Enhance self-review functionality with new detail views, improved UI, and dynamic case management

This commit is contained in:
Ross
2026-04-27 11:43:15 +01:00
parent 95793c78c9
commit 36a77981c8
7 changed files with 398 additions and 84 deletions
@@ -1,78 +1,139 @@
{% extends 'base.html' %}
{% block content %}
{% if cid is not None %}
CID: {{cid}}
{% endif %}
<h2>Collection: {{collection.name}}
{% if collection.in_review_mode or cid_user_exam.completed %}
<span class="stamp-white">REVIEW</span>
{% endif %}
</h2>
{% if not collection.review_only %}
{{answer_count}} out of {{collection_length}} cases answered. Click to go to case.
{% endif %}
<div class="sba-finish-list">
<ul>
{% for question, answer, self_review in question_answer_tuples %}
<li> <span class="{% if not collection.review_only and not answer %}unanswered{% endif %}">
<a href="
{% if cid is not None %}
{% url 'atlas:collection_case_view_take' pk=collection.id case_number=forloop.counter0 cid=cid passcode=passcode %}
{% else %}
{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}
{% endif %}
">
Case: {{forloop.counter}}
{% if answer.completed or cid_user_exam.completed %}
<span title="You have completed this case"><i class="bi bi-check-square-fill"></i>
</span>
{% endif %}
{% if self_review %} <span class="self-review-complete" title="You have reviewed this case."> <i class="bi-card-checklist"></i></span>{% endif %}
</a>
<br/>
{% if not collection.review_only %}
{% if not answer %}
No answer
{% else %}
<details><summary>Answered</summary>
{% if aswer.answer %}
{{answer.answer}}
{% else %}
{{answer.json_answer}}
{% endif %}
</details>
{% endif %}
{% endif %}
</span>
</li>
{% endfor %}
</ul>
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h4 mb-1">
{{ collection.name }}
{% if collection.in_review_mode or cid_user_exam.completed %}
<span class="badge bg-light text-dark ms-2">Review</span>
{% endif %}
</h2>
<div class="text-muted small">
{% if cid is not None %}
CID: <strong>{{ cid }}</strong>
{% else %}
Your collection overview
{% endif %}
</div>
</div>
<div class="card border-secondary" style="min-width: 280px;">
<div class="card-body py-2 px-3">
<div class="small text-muted">Progress</div>
{% if not collection.review_only %}
<div class="fw-semibold">{{ answer_count }} / {{ collection_length }} answered</div>
<div class="progress mt-2" style="height: 8px;">
<div class="progress-bar" role="progressbar" style="width: {% widthratio answer_count collection_length 100 %}%"></div>
</div>
{% else %}
<div class="fw-semibold">Review-only collection</div>
{% endif %}
<div class="mt-2 small">Started: {{ cid_user_exam.start_time }}</div>
</div>
</div>
</div>
<div class="list-group mb-3">
{% for question, answer, self_review in question_answer_tuples %}
<div class="list-group-item {% if not answer %}bg-danger-subtle border-danger-subtle{% endif %}">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">
Case {{ forloop.counter }}{% if collection.show_title_post %}: {{ question.case.title|default:question.case.pk|truncatechars:70 }}{% endif %}
{% if answer and answer.completed or cid_user_exam.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% elif answer %}
<span class="badge bg-warning-subtle text-warning-emphasis ms-2">Saved</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis ms-2">Not answered</span>
{% endif %}
{% if self_review %}
<span class="badge bg-info-subtle text-info-emphasis ms-1">Self review added</span>
{% endif %}
</div>
{% if collection.show_title_post %}
<div class="small text-muted">
{{ question.case }}
</div>
{% endif %}
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-primary"
href="{% if cid is not None %}{% url 'atlas:collection_case_view_take' pk=collection.id case_number=forloop.counter0 cid=cid passcode=passcode %}{% else %}{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}{% endif %}">
Open Case
</a>
{% if cid is None %}
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'atlas:collection_case_view_take_user_answers' pk=collection.id case_number=forloop.counter0 %}">
View Answer / Report
</a>
{% else %}
<a class="btn btn-sm btn-outline-secondary"
href="{% url 'atlas:collection_case_view_take' pk=collection.id case_number=forloop.counter0 cid=cid passcode=passcode %}">
View Answer / Report
</a>
{% endif %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:add_self_review' cid_user_exam.id question.case.id %}">
{% if self_review %}Add New Self Review{% else %}Add Self Review{% endif %}
</a>
</div>
</div>
Start time: {{cid_user_exam.start_time}}
{% if self_review %}
<div class="mt-2 pt-2 border-top">
<div class="small fw-semibold mb-1">Your self reviews</div>
<div class="d-flex flex-wrap gap-2">
{% for review in self_review %}
<a class="btn btn-sm btn-outline-info"
href="{% url 'atlas:self_review_detail' cid_user_exam.id question.case.id review.id %}">
View review {{ forloop.counter }}
</a>
<a class="btn btn-sm btn-outline-warning"
href="{% url 'atlas:self_review_update' cid_user_exam.id question.case.id review.id %}">
Edit review {{ forloop.counter }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if not collection.review_only %}
{% if answer %}
<details class="mt-2">
<summary class="small text-muted">Preview saved answer/report</summary>
<div class="small mt-2 p-2 bg-body-tertiary rounded">
{% if answer.answer %}
{{ answer.answer|linebreaksbr }}
{% else %}
{{ answer.json_answer }}
{% endif %}
</div>
</details>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{% if not collection.review_only and not cid_user_exam.completed %}
<p>Completed: <span id="completed-state">{{cid_user_exam.completed}}</span></p>
<form hx-post=""
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete
hx-confirm="Finish session?"
hx-target="#completed-state"
_="on htmx:afterOnLoad remove me"
>
<button type="submit" name="finish" value="finish">Finish</button>
</form>
<div class="card border-warning-subtle">
<div class="card-body d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<div class="fw-semibold">Ready to submit this session?</div>
<div class="small text-muted">Completed: <span id="completed-state">{{ cid_user_exam.completed }}</span></div>
</div>
<form hx-post=""
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete
hx-confirm="Finish session?"
hx-target="#completed-state"
_="on htmx:afterOnLoad remove me"
class="m-0">
<button type="submit" name="finish" value="finish" class="btn btn-warning">Finish Session</button>
</form>
</div>
</div>
{% endif %}
@@ -1,11 +1,31 @@
{% for cd in casedetails %}
{% with idx=forloop.counter0 %}
{% if cid %}
<a class="dropdown-item {% if idx == current %}active{% endif %}" href="{% url 'atlas:collection_case_view_take' pk=collection.pk case_number=idx cid=cid passcode=passcode %}">
{% else %}
<a class="dropdown-item {% if idx == current %}active{% endif %}" href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=idx %}">
{% endif %}
Case {{ forloop.counter }}: {{ cd.case.title|default:cd.case.pk|truncatechars:60 }}
</a>
{% endwith %}
{% for item in jump_items %}
<div class="dropdown-item-text {% if not item.has_answer %}bg-danger-subtle border border-danger-subtle rounded{% elif item.idx == current %}bg-primary-subtle rounded{% endif %} py-2 px-2 mb-1">
<div class="d-flex justify-content-between align-items-start gap-2">
<a class="text-decoration-none small fw-semibold"
href="{{ item.case_url }}">
Case {{ item.idx|add:1 }}: {{ item.cd.case.title|default:item.cd.case.pk|truncatechars:45 }}
</a>
<div class="d-flex gap-1">
{% if item.has_answer %}
<span class="badge bg-success-subtle text-success-emphasis">Answered</span>
{% else %}
<span class="badge bg-danger-subtle text-danger-emphasis">Not answered</span>
{% endif %}
{% if item.has_self_review %}
<span class="badge bg-info-subtle text-info-emphasis">Self review</span>
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-1 mt-2">
<a class="btn btn-outline-secondary btn-sm py-0 px-2" href="{{ item.answer_url }}">Answer</a>
{% if item.self_review_view_url %}
<a class="btn btn-outline-info btn-sm py-0 px-2" href="{{ item.self_review_view_url }}">View review</a>
<a class="btn btn-outline-warning btn-sm py-0 px-2" href="{{ item.self_review_edit_url }}">Edit</a>
{% else %}
<a class="btn btn-outline-info btn-sm py-0 px-2" href="{{ item.self_review_add_url }}">Add review</a>
{% endif %}
</div>
</div>
{% empty %}
<div class="dropdown-item-text text-muted small px-2 py-2">No cases available.</div>
{% endfor %}
@@ -24,20 +24,30 @@
<div class="small text-muted">
Updated: {{ review.review_update_date|date:"Y-m-d H:i" }}
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Edit self review">
<a class="btn btn-outline-primary"
<div class="d-flex flex-wrap gap-1" role="group" aria-label="Self review actions">
<a class="btn btn-outline-primary btn-sm"
href="{% url 'atlas:self_review_detail' cid_user_exam.id case.id review.id %}">
View
</a>
<button type="button" class="btn btn-outline-info btn-sm"
onclick="openSelfReviewPopup('{% url 'atlas:self_review_detail' cid_user_exam.id case.id review.id %}')">
View popup
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
onclick="openSelfReviewSidebar('{% url 'atlas:self_review_detail' cid_user_exam.id case.id review.id %}')">
View sidebar
</button>
<a class="btn btn-outline-warning btn-sm"
href="{% url 'atlas:self_review_update' cid_user_exam.id case.id review.id %}">
Edit
</a>
<button type="button"
class="btn btn-outline-info"
<button type="button" class="btn btn-outline-warning btn-sm"
onclick="openSelfReviewPopup('{% url 'atlas:self_review_update' cid_user_exam.id case.id review.id %}')">
Popup
Edit popup
</button>
<button type="button"
class="btn btn-outline-secondary"
<button type="button" class="btn btn-outline-warning btn-sm"
onclick="openSelfReviewSidebar('{% url 'atlas:self_review_update' cid_user_exam.id case.id review.id %}')">
Sidebar
Edit sidebar
</button>
</div>
</div>
@@ -0,0 +1,52 @@
<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">
<div class="small text-muted mb-2">Case: <strong>{{ case }}</strong></div>
<div class="row g-2 mb-3">
<div class="col-6">
<div class="p-2 border rounded">
<div class="small text-muted">Findings</div>
<div class="fw-semibold">{{ review.findings|default:"-" }}/5</div>
</div>
</div>
<div class="col-6">
<div class="p-2 border rounded">
<div class="small text-muted">Interpretation</div>
<div class="fw-semibold">{{ review.interpretation|default:"-" }}/5</div>
</div>
</div>
</div>
<div class="mb-3">
<div class="small text-muted mb-1">Comments</div>
<div class="p-2 border rounded bg-body-tertiary">
{% if review.comments %}
{{ review.comments|linebreaksbr }}
{% else %}
<span class="text-muted">No comments added.</span>
{% endif %}
</div>
</div>
<div class="small text-muted mb-3">
Updated: {{ review.review_update_date }}
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-outline-warning btn-sm"
href="{% url 'atlas:self_review_update' user_exam.id case.id review.id %}?embed=1">
Edit in sidebar
</a>
<a class="btn btn-outline-secondary btn-sm"
href="{% url 'atlas:self_review_detail' user_exam.id case.id review.id %}">
Open full page
</a>
</div>
</div>
</div>
</div>
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
<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">
<div class="small text-muted mb-3">Case: <strong>{{ case }}</strong></div>
<div class="row g-2 mb-3">
<div class="col-sm-6">
<div class="p-2 border rounded">
<div class="small text-muted">Findings</div>
<div class="fw-semibold">{{ review.findings|default:"-" }}/5</div>
</div>
</div>
<div class="col-sm-6">
<div class="p-2 border rounded">
<div class="small text-muted">Interpretation</div>
<div class="fw-semibold">{{ review.interpretation|default:"-" }}/5</div>
</div>
</div>
</div>
<div class="mb-3">
<div class="small text-muted mb-1">Comments</div>
<div class="p-2 border rounded bg-body-tertiary">
{% if review.comments %}
{{ review.comments|linebreaksbr }}
{% else %}
<span class="text-muted">No comments added.</span>
{% endif %}
</div>
</div>
<div class="small text-muted mb-3">
Created: {{ review.review_date }}<br>
Updated: {{ review.review_update_date }}
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-outline-warning"
href="{% url 'atlas:self_review_update' user_exam.id case.id review.id %}">
Edit Self Review
</a>
<a class="btn btn-outline-secondary" href="{{ review.get_absolute_url }}">
Back to case
</a>
</div>
</div>
</div>
</div>
{% endblock %}
+5
View File
@@ -25,6 +25,11 @@ urlpatterns = [
views.SelfReviewUpdate.as_view(),
name="self_review_update",
),
path(
"self_review/<int:user_exam_id>/<int:case_id>/view/<int:pk>",
views.SelfReviewDetail.as_view(),
name="self_review_detail",
),
path(
"self_review/<int:user_exam_id>/<int:case_id>/delete/<int:pk>",
views.SelfReviewDelete.as_view(),
+114
View File
@@ -1884,11 +1884,94 @@ def collection_case_jump_partial(request, pk: int):
cid = request.GET.get('cid')
passcode = request.GET.get('passcode')
try:
cid_value = int(cid) if cid is not None else None
except Exception:
cid_value = None
cid_user_exam = collection.get_or_create_cid_user_exam(
cid=cid_value,
user_user=request.user,
)
if cid_value is not None:
answers = CidReportAnswer.objects.filter(cid=cid_value, question__in=casedetails)
else:
answers = UserReportAnswer.objects.filter(user=request.user, question__in=casedetails)
answer_case_ids = {
ans.question.case_id
for ans in answers
if (ans.answer or ans.json_answer)
}
latest_reviews = (
cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails])
.select_related("case")
.order_by("case_id", "-review_update_date")
)
latest_review_by_case = {}
for review in latest_reviews:
if review.case_id not in latest_review_by_case:
latest_review_by_case[review.case_id] = review
jump_items = []
for idx, cd in enumerate(casedetails):
if cid is not None:
case_url = reverse(
"atlas:collection_case_view_take",
kwargs={"pk": collection.pk, "case_number": idx, "cid": cid, "passcode": passcode},
)
answer_url = case_url
else:
case_url = reverse(
"atlas:collection_case_view_take_user",
kwargs={"pk": collection.pk, "case_number": idx},
)
answer_url = reverse(
"atlas:collection_case_view_take_user_answers",
kwargs={"pk": collection.pk, "case_number": idx},
)
add_url = reverse(
"atlas:add_self_review",
kwargs={"user_exam_id": cid_user_exam.id, "case_id": cd.case_id},
)
review = latest_review_by_case.get(cd.case_id)
review_view_url = None
review_edit_url = None
if review is not None:
review_view_url = reverse(
"atlas:self_review_detail",
kwargs={"user_exam_id": cid_user_exam.id, "case_id": cd.case_id, "pk": review.id},
)
review_edit_url = reverse(
"atlas:self_review_update",
kwargs={"user_exam_id": cid_user_exam.id, "case_id": cd.case_id, "pk": review.id},
)
jump_items.append(
{
"idx": idx,
"cd": cd,
"case_url": case_url,
"answer_url": answer_url,
"self_review_add_url": add_url,
"self_review_view_url": review_view_url,
"self_review_edit_url": review_edit_url,
"has_answer": cd.case_id in answer_case_ids,
"has_self_review": review is not None,
}
)
html = render_to_string(
'atlas/partials/_collection_case_jump.html',
{
'collection': collection,
'casedetails': casedetails,
'jump_items': jump_items,
'current': current,
'cid': cid,
'passcode': passcode,
@@ -5644,6 +5727,37 @@ class SelfReviewDelete(DeleteView):
return self.user_exam.exam.get_case_take_url(self.case)
class SelfReviewDetail(DetailView):
"""Read-only self-review view for a specific user exam and case.
This is the default viewing mode for self reviews. Add ``?embed=1`` to
render the compact sidebar-friendly template.
"""
model = SelfReview
def get_template_names(self):
if self.request.GET.get("embed") == "1":
return ["atlas/partials/_selfreview_detail_embed.html"]
return ["atlas/selfreview_detail.html"]
def get_object(self, queryset=None):
return get_object_or_404(
SelfReview,
pk=self.kwargs["pk"],
user_exam_id=self.kwargs["user_exam_id"],
case_id=self.kwargs["case_id"],
)
def get_context_data(self, **kwargs):
context = super(SelfReviewDetail, self).get_context_data(**kwargs)
context["review"] = self.object
context["user_exam"] = self.object.user_exam
context["case"] = self.object.case
context["embed"] = self.request.GET.get("embed") == "1"
return context
class SelfReviewUpdate(UpdateView):
"""Edit an existing self-review entry for a case attempt.