Implement self-review functionality with enhanced UI and dynamic scoring options
This commit is contained in:
@@ -1062,6 +1062,14 @@ class AddCollectionToCaseForm(Form):
|
||||
|
||||
|
||||
class SelfReviewForm(ModelForm):
|
||||
SCORE_CHOICES = [
|
||||
(1, "1 - Poor"),
|
||||
(2, "2 - Limited"),
|
||||
(3, "3 - Adequate"),
|
||||
(4, "4 - Good"),
|
||||
(5, "5 - Excellent"),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
model = SelfReview
|
||||
fields = ["user_exam", "case", "comments", "findings", "interpretation"]
|
||||
@@ -1076,6 +1084,44 @@ class SelfReviewForm(ModelForm):
|
||||
ModelForm.__init__(self, *args, **kwargs)
|
||||
super(SelfReviewForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Render scoring fields as explicit 1-5 selector choices for cleaner UX.
|
||||
self.fields["findings"] = forms.TypedChoiceField(
|
||||
choices=self.SCORE_CHOICES,
|
||||
coerce=int,
|
||||
empty_value=None,
|
||||
required=False,
|
||||
widget=RadioSelect,
|
||||
label="Findings",
|
||||
help_text="How well did you identify key findings?",
|
||||
)
|
||||
self.fields["interpretation"] = forms.TypedChoiceField(
|
||||
choices=self.SCORE_CHOICES,
|
||||
coerce=int,
|
||||
empty_value=None,
|
||||
required=False,
|
||||
widget=RadioSelect,
|
||||
label="Interpretation",
|
||||
help_text="How strong was your overall interpretation?",
|
||||
)
|
||||
|
||||
self.fields["comments"].widget.attrs.update(
|
||||
{
|
||||
"rows": 5,
|
||||
"placeholder": "What went well? What would you do differently next time?",
|
||||
}
|
||||
)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_method = "post"
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
Field("user_exam"),
|
||||
Field("case"),
|
||||
Field("comments"),
|
||||
Field("findings"),
|
||||
Field("interpretation"),
|
||||
)
|
||||
|
||||
|
||||
class UncategorisedDicomForm(ModelForm):
|
||||
class Meta:
|
||||
|
||||
@@ -214,9 +214,23 @@
|
||||
|
||||
{% if collection.self_review %}
|
||||
<div>
|
||||
<p>
|
||||
<a href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}"><button type="button">Add self review</button></a>
|
||||
</p>
|
||||
<div class="self-review-actions mb-3">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Self review actions">
|
||||
<a class="btn btn-primary" href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}">
|
||||
<i class="bi bi-card-checklist"></i> Add Self Review
|
||||
</a>
|
||||
<button type="button"
|
||||
class="btn btn-outline-info"
|
||||
onclick="openSelfReviewPopup('{% url 'atlas:add_self_review' cid_user_exam.id case.id %}')">
|
||||
Popup
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="openSelfReviewSidebar('{% url 'atlas:add_self_review' cid_user_exam.id case.id %}')">
|
||||
Sidebar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if self_review %}
|
||||
<h4>Self Feedback</h4>
|
||||
|
||||
@@ -263,9 +277,23 @@
|
||||
{% if question_completed %}
|
||||
<div>
|
||||
{% if collection.self_review %}
|
||||
<p>
|
||||
<a target="_blank" title="Add self review, this will open in a new page / tab" href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}"><button type="button">Add self review</button></a>
|
||||
</p>
|
||||
<div class="self-review-actions mb-3">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Self review actions">
|
||||
<a class="btn btn-primary" href="{% url 'atlas:add_self_review' cid_user_exam.id case.id %}">
|
||||
<i class="bi bi-card-checklist"></i> Add Self Review
|
||||
</a>
|
||||
<button type="button"
|
||||
class="btn btn-outline-info"
|
||||
onclick="openSelfReviewPopup('{% url 'atlas:add_self_review' cid_user_exam.id case.id %}')">
|
||||
Popup
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="openSelfReviewSidebar('{% url 'atlas:add_self_review' cid_user_exam.id case.id %}')">
|
||||
Sidebar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if self_review %}
|
||||
<h4>Self Feedback</h4>
|
||||
|
||||
@@ -306,6 +334,17 @@
|
||||
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside id="selfReviewSidebar" class="self-review-sidebar" aria-live="polite" aria-label="Self review sidebar">
|
||||
<div class="self-review-sidebar-header">
|
||||
<h5 class="mb-0" id="selfReviewSidebarLabel">Self Review</h5>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="closeSelfReviewSidebar()">Close</button>
|
||||
</div>
|
||||
<div id="selfReviewSidebarBody" class="self-review-sidebar-body">
|
||||
<div class="p-3 text-muted small">Choose Sidebar to load the self-review form here.</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
label {
|
||||
vertical-align: top;
|
||||
@@ -376,6 +415,44 @@
|
||||
background-color: darkblue;
|
||||
|
||||
}
|
||||
|
||||
.self-review-actions .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.self-review-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: min(760px, 94vw);
|
||||
height: 100vh;
|
||||
background: var(--bs-body-bg);
|
||||
border-left: 1px solid var(--bs-border-color);
|
||||
box-shadow: -8px 0 18px rgba(0, 0, 0, 0.35);
|
||||
z-index: 1050;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.self-review-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.self-review-sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
background: var(--bs-secondary-bg);
|
||||
}
|
||||
|
||||
.self-review-sidebar-body {
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
@@ -590,6 +667,89 @@
|
||||
}
|
||||
});
|
||||
|
||||
window.openSelfReviewPopup = function (url) {
|
||||
var features = 'width=980,height=840,resizable=yes,scrollbars=yes';
|
||||
window.open(url, 'selfReviewPopup', features);
|
||||
};
|
||||
|
||||
window.selfReviewCurrentEmbedUrl = null;
|
||||
|
||||
window.bindSelfReviewEmbedForm = function () {
|
||||
var root = document.getElementById('selfReviewSidebarBody');
|
||||
if (!root) return;
|
||||
|
||||
var form = root.querySelector('form.self-review-embed-form');
|
||||
if (!form) return;
|
||||
|
||||
if (form.dataset.bound === '1') return;
|
||||
form.dataset.bound = '1';
|
||||
|
||||
form.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
var formData = new FormData(form);
|
||||
var csrftokenInput = form.querySelector('input[name="csrfmiddlewaretoken"]');
|
||||
var csrftoken = csrftokenInput ? csrftokenInput.value : '';
|
||||
|
||||
try {
|
||||
var resp = await fetch(window.selfReviewCurrentEmbedUrl || window.location.href, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRFToken': csrftoken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (resp.redirected) {
|
||||
root.innerHTML = '<div class="p-3"><div class="alert alert-success mb-0">Self review saved. You can continue reviewing this case.</div></div>';
|
||||
setTimeout(function () { window.closeSelfReviewSidebar(); }, 700);
|
||||
return;
|
||||
}
|
||||
|
||||
var html = await resp.text();
|
||||
root.innerHTML = html;
|
||||
window.bindSelfReviewEmbedForm();
|
||||
} catch (e) {
|
||||
var msg = root.querySelector('.self-review-embed-message');
|
||||
if (msg) {
|
||||
msg.innerHTML = '<div class="alert alert-danger py-2">Unable to save self review. Please try again.</div>';
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.closeSelfReviewSidebar = function () {
|
||||
var sidebar = document.getElementById('selfReviewSidebar');
|
||||
if (sidebar) {
|
||||
sidebar.classList.remove('open');
|
||||
}
|
||||
};
|
||||
|
||||
window.openSelfReviewSidebar = async function (url) {
|
||||
var sidebar = document.getElementById('selfReviewSidebar');
|
||||
var body = document.getElementById('selfReviewSidebarBody');
|
||||
if (!sidebar || !body) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
var embedUrl = url + (url.indexOf('?') === -1 ? '?embed=1' : '&embed=1');
|
||||
window.selfReviewCurrentEmbedUrl = embedUrl;
|
||||
body.innerHTML = '<div class="p-3 text-muted small">Loading self-review form...</div>';
|
||||
sidebar.classList.add('open');
|
||||
|
||||
try {
|
||||
var resp = await fetch(embedUrl, { credentials: 'same-origin' });
|
||||
var html = await resp.text();
|
||||
body.innerHTML = html;
|
||||
window.bindSelfReviewEmbedForm();
|
||||
} catch (e) {
|
||||
body.innerHTML = '<div class="p-3"><div class="alert alert-danger py-2 mb-0">Could not load self-review sidebar content. Use Popup as fallback.</div></div>';
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
{% endblock js %}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<div class="p-3">
|
||||
<div class="card border-secondary shadow-sm mb-2">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h2 class="h6 mb-0">Self Review</h2>
|
||||
<span class="badge bg-primary-subtle text-primary-emphasis">{{ user_exam.exam }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-2">
|
||||
Record a quick reflection while reviewing this case.
|
||||
</p>
|
||||
<div class="small text-muted mb-3">
|
||||
Case: <strong>{{ case }}</strong>
|
||||
</div>
|
||||
|
||||
<form method="post" class="self-review-embed-form" action="">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
{{ form.user_exam }}
|
||||
{{ form.case }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.comments|as_crispy_field }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold d-block">{{ form.findings.label }}</label>
|
||||
<div class="btn-group" role="group" aria-label="Findings self rating">
|
||||
{% for value, label in form.fields.findings.choices %}
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="{{ form.findings.html_name }}"
|
||||
id="id_embed_findings_{{ forloop.counter0 }}"
|
||||
value="{{ value }}"
|
||||
{% if form.findings.value|stringformat:'s' == value|stringformat:'s' %}checked{% endif %}
|
||||
>
|
||||
<label class="btn btn-outline-info" for="id_embed_findings_{{ forloop.counter0 }}">{{ label }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.findings.help_text %}
|
||||
<div class="form-text">{{ form.findings.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.findings.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.findings.errors|striptags }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold d-block">{{ form.interpretation.label }}</label>
|
||||
<div class="btn-group" role="group" aria-label="Interpretation self rating">
|
||||
{% for value, label in form.fields.interpretation.choices %}
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="{{ form.interpretation.html_name }}"
|
||||
id="id_embed_interpretation_{{ forloop.counter0 }}"
|
||||
value="{{ value }}"
|
||||
{% if form.interpretation.value|stringformat:'s' == value|stringformat:'s' %}checked{% endif %}
|
||||
>
|
||||
<label class="btn btn-outline-warning" for="id_embed_interpretation_{{ forloop.counter0 }}">{{ label }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.interpretation.help_text %}
|
||||
<div class="form-text">{{ form.interpretation.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.interpretation.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.interpretation.errors|striptags }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save Self Review</button>
|
||||
{% if review %}
|
||||
<a href="{% url 'atlas:self_review_delete' user_exam.id case.id review.id %}" class="btn btn-outline-danger btn-sm">Delete</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
<div class="self-review-embed-message mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,38 +1,88 @@
|
||||
|
||||
{% extends "base.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h2>Add Self Review</h2>
|
||||
<div class="alert alert-info" role="alert">
|
||||
Adding self feedback for {{user_exam.exam}}/{{case}}
|
||||
|
||||
</div>
|
||||
This form allows you to record your own feedback for the question / case. This can take the form of a note or a ranking (1-5) of your findings and interpretation.
|
||||
<form class="highlight" action="" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
<div class="container px-0" style="max-width: 920px;">
|
||||
<div class="card border-secondary shadow-sm mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h2 class="h5 mb-0">Self Review</h2>
|
||||
<span class="badge bg-primary-subtle text-primary-emphasis">{{ user_exam.exam }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-3">
|
||||
Reflect on this case and score your own performance. These scores are personal and intended to support learning.
|
||||
</p>
|
||||
<div class="small text-muted mb-3">
|
||||
Case: <strong>{{ case }}</strong>
|
||||
</div>
|
||||
|
||||
{{ form }}
|
||||
{% comment %} <div class="fieldWrapper">
|
||||
{{ form.content_type.errors }}
|
||||
{{ form.content_type }}
|
||||
<form method="post" class="needs-validation" novalidate>
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
{{ form.user_exam }}
|
||||
{{ form.case }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.comments|as_crispy_field }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold d-block">{{ form.findings.label }}</label>
|
||||
<div class="btn-group" role="group" aria-label="Findings self rating">
|
||||
{% for value, label in form.fields.findings.choices %}
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="{{ form.findings.html_name }}"
|
||||
id="id_findings_{{ forloop.counter0 }}"
|
||||
value="{{ value }}"
|
||||
{% if form.findings.value|stringformat:'s' == value|stringformat:'s' %}checked{% endif %}
|
||||
>
|
||||
<label class="btn btn-outline-info" for="id_findings_{{ forloop.counter0 }}">{{ label }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.findings.help_text %}
|
||||
<div class="form-text">{{ form.findings.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.findings.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.findings.errors|striptags }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold d-block">{{ form.interpretation.label }}</label>
|
||||
<div class="btn-group" role="group" aria-label="Interpretation self rating">
|
||||
{% for value, label in form.fields.interpretation.choices %}
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="{{ form.interpretation.html_name }}"
|
||||
id="id_interpretation_{{ forloop.counter0 }}"
|
||||
value="{{ value }}"
|
||||
{% if form.interpretation.value|stringformat:'s' == value|stringformat:'s' %}checked{% endif %}
|
||||
>
|
||||
<label class="btn btn-outline-warning" for="id_interpretation_{{ forloop.counter0 }}">{{ label }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.interpretation.help_text %}
|
||||
<div class="form-text">{{ form.interpretation.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.interpretation.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.interpretation.errors|striptags }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-primary">Save Self Review</button>
|
||||
<a href="{{ case.get_absolute_url }}" class="btn btn-outline-secondary">Open Case</a>
|
||||
{% if review %}
|
||||
<a href="{% url 'atlas:self_review_delete' user_exam.id case.id review.id %}" class="btn btn-outline-danger">Delete Review</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fieldWrapper">
|
||||
{{ form.object_id.errors }}
|
||||
{{ form.object_id }}
|
||||
</div>
|
||||
<div class="fieldWrapper">
|
||||
{{ form.note_type.errors }}
|
||||
<label for="{{ form.note_type.id_for_label }}">Note type</label>
|
||||
{{ form.note_type }}
|
||||
</div>
|
||||
<div class="fieldWrapper">
|
||||
{{ form.note.errors }}
|
||||
<label for="{{ form.note.id_for_label }}">Additional information</label><br/>
|
||||
{{ form.note }}
|
||||
</div> {% endcomment %}
|
||||
<input type="submit" value="Submit">
|
||||
</form>
|
||||
{% if review %}<br/><a href="{% url 'atlas:self_review_delete' user_exam.id case.id review.id %}"><button>Delete review</button></a>{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5613,6 +5613,11 @@ class CollectionClone(CollectionCloneMixin, CaseCollectionCreate):
|
||||
|
||||
|
||||
class SelfReviewDelete(DeleteView):
|
||||
"""Delete a user's self-review entry for a specific case attempt.
|
||||
|
||||
The view resolves ``user_exam``, ``case`` and ``review`` from URL params and,
|
||||
on success, returns the learner to the case take page.
|
||||
"""
|
||||
model = SelfReview
|
||||
template_name = "confirm_delete.html"
|
||||
# success_url = reverse_lazy("atlas:finding_view")
|
||||
@@ -5640,9 +5645,18 @@ class SelfReviewDelete(DeleteView):
|
||||
|
||||
|
||||
class SelfReviewUpdate(UpdateView):
|
||||
"""Edit an existing self-review entry for a case attempt.
|
||||
|
||||
Uses ``SelfReviewForm`` and redirects back to the case take page after save.
|
||||
"""
|
||||
model = SelfReview
|
||||
form_class = SelfReviewForm
|
||||
|
||||
def get_template_names(self):
|
||||
if self.request.GET.get("embed") == "1":
|
||||
return ["atlas/partials/_selfreview_form_embed.html"]
|
||||
return ["atlas/selfreview_form.html"]
|
||||
|
||||
def get_initial(self):
|
||||
user_exam_id = self.kwargs["user_exam_id"]
|
||||
case_id = self.kwargs["case_id"]
|
||||
@@ -5659,6 +5673,7 @@ class SelfReviewUpdate(UpdateView):
|
||||
context["user_exam"] = self.user_exam
|
||||
context["case"] = self.case
|
||||
context["review"] = self.review
|
||||
context["embed"] = self.request.GET.get("embed") == "1"
|
||||
return context
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
@@ -5677,9 +5692,19 @@ class SelfReviewUpdate(UpdateView):
|
||||
|
||||
|
||||
class AddSelfReview(CreateView):
|
||||
"""Create a new self-review entry linked to a user exam and case.
|
||||
|
||||
The form captures free-text comments and 1-5 self-assessment scores for
|
||||
findings and interpretation, then returns the learner to the case take page.
|
||||
"""
|
||||
model = SelfReview
|
||||
form_class = SelfReviewForm
|
||||
|
||||
def get_template_names(self):
|
||||
if self.request.GET.get("embed") == "1":
|
||||
return ["atlas/partials/_selfreview_form_embed.html"]
|
||||
return ["atlas/selfreview_form.html"]
|
||||
|
||||
# fields = ("author", "note_type", "note")
|
||||
|
||||
def get_form_kwargs(self):
|
||||
@@ -5702,6 +5727,7 @@ class AddSelfReview(CreateView):
|
||||
context = super(AddSelfReview, self).get_context_data(**kwargs)
|
||||
context["user_exam"] = self.user_exam
|
||||
context["case"] = self.case
|
||||
context["embed"] = self.request.GET.get("embed") == "1"
|
||||
return context
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
|
||||
Reference in New Issue
Block a user