Implement self-review functionality with enhanced UI and dynamic scoring options

This commit is contained in:
Ross
2026-04-27 11:07:09 +01:00
parent e82b353ad9
commit b864f3d3fd
5 changed files with 401 additions and 36 deletions
+46
View File
@@ -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>
+80 -30
View File
@@ -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 %}
+26
View File
@@ -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: