Enhance exam review overview page to display per-question summaries with response metrics and color coding for quick triage
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
{% extends 'generic/examcollection_base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container my-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>Review overview: {{ exam.name }}</h2>
|
||||
<div>
|
||||
<a class="btn btn-primary" href="{% url app_name|add:':exam_review_question' exam.pk 0 %}">Start sequential review</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted small">Colour indicates likely attention needed: green (good), yellow (check), red (needs attention). Hover for details.</p>
|
||||
|
||||
<div class="row">
|
||||
{% for qs in question_summaries %}
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card border-{{ qs.colour }} h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<h5 class="card-title">Question {{ qs.q_index|add:1 }}: {{ qs.question }}</h5>
|
||||
<span class="badge bg-{{ qs.colour }} align-self-start">{% if qs.correct_pct is not None %}{{ qs.correct_pct }}% correct{% elif qs.answered_pct is not None %}{{ qs.answered_pct }}% answered{% else %}No data{% endif %}</span>
|
||||
</div>
|
||||
|
||||
<p class="card-text small text-muted mb-2">Responses: {{ qs.total_responses }}{% if qs.top_answer %} — top: "{{ qs.top_answer }}"{% endif %}</p>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-sm btn-outline-primary" href="{% url app_name|add:':exam_review_question' exam.pk qs.q_index %}">Review</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="#" onclick="document.location.href='{% url app_name|add:':question_detail' qs.question.pk %}';return false;">Question detail</a>
|
||||
</div>
|
||||
|
||||
<details class="mt-2">
|
||||
<summary class="small">Answer breakdown</summary>
|
||||
<ul class="list-unstyled small mt-2">
|
||||
{% if qs.breakdown %}
|
||||
{% for ans in qs.breakdown %}
|
||||
<li>{{ ans.0 }}: {{ ans.1 }} ({{ ans.2 }}%)</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li class="text-muted">No responses yet</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
+111
-3
@@ -1165,7 +1165,14 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
|
||||
@method_decorator(login_required)
|
||||
def exam_review_start(self, request, pk):
|
||||
"""Start a per-question review for an exam. Redirects to the first question."""
|
||||
"""Render an overview page for per-question review.
|
||||
|
||||
This shows a compact summary for every question in the exam (response counts,
|
||||
percent answered / percent correct where applicable) and colour-codes each
|
||||
question so the reviewer can quickly triage items. From here the reviewer
|
||||
can jump to an individual question's review page or start the sequential
|
||||
review (existing behavior).
|
||||
"""
|
||||
# Ensure the user can edit the exam (authors only)
|
||||
if not self.check_user_edit_access(request.user, exam_id=pk):
|
||||
raise PermissionDenied
|
||||
@@ -1177,8 +1184,109 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
if not questions:
|
||||
return render(request, "generic/exam_review_complete.html", {"exam": exam, "app_name": self.app_name})
|
||||
|
||||
# Redirect (render) the first question
|
||||
return self.exam_review_question(request, pk, q_index=0)
|
||||
# Build per-question summary metrics
|
||||
question_summaries = []
|
||||
|
||||
# Candidate pool size (used to compute percent answered)
|
||||
candidate_total = 0
|
||||
try:
|
||||
candidate_total = (
|
||||
exam.valid_cid_users.count() + exam.valid_user_users.count()
|
||||
)
|
||||
except Exception:
|
||||
candidate_total = 0
|
||||
|
||||
for i, q in enumerate(questions):
|
||||
try:
|
||||
ua_qs = q.cid_user_answers.filter(exam=exam)
|
||||
except Exception:
|
||||
# If relationship is different for some app types, try a fallback
|
||||
ua_qs = self.UserAnswer.objects.filter(question=q, exam=exam)
|
||||
|
||||
total_responses = ua_qs.count()
|
||||
|
||||
counts = {}
|
||||
for ans in ua_qs.values_list("answer", flat=True):
|
||||
counts[ans] = counts.get(ans, 0) + 1
|
||||
|
||||
if total_responses:
|
||||
pcts = {k: round(100.0 * v / total_responses, 1) for k, v in counts.items()}
|
||||
else:
|
||||
pcts = {k: 0.0 for k in counts.keys()}
|
||||
|
||||
# Compute percent correct where a best_answer exists
|
||||
correct_pct = None
|
||||
correct_count = None
|
||||
if hasattr(q, "best_answer") and getattr(q, "best_answer", None):
|
||||
try:
|
||||
correct_count = ua_qs.filter(answer=q.best_answer).count()
|
||||
correct_pct = round(100.0 * correct_count / total_responses, 1) if total_responses else 0.0
|
||||
except Exception:
|
||||
correct_pct = None
|
||||
|
||||
# Percent answered relative to candidate pool (if known)
|
||||
answered_pct = None
|
||||
if candidate_total:
|
||||
try:
|
||||
answered_pct = round(100.0 * total_responses / candidate_total, 1)
|
||||
except Exception:
|
||||
answered_pct = None
|
||||
|
||||
# Pick a simple colour classification: prefer percent-correct when available,
|
||||
# otherwise percent-answered.
|
||||
pct_for_colour = None
|
||||
if correct_pct is not None:
|
||||
pct_for_colour = correct_pct
|
||||
elif answered_pct is not None:
|
||||
pct_for_colour = answered_pct
|
||||
|
||||
colour = "secondary"
|
||||
if pct_for_colour is not None:
|
||||
if pct_for_colour >= 75:
|
||||
colour = "success"
|
||||
elif pct_for_colour >= 50:
|
||||
colour = "warning"
|
||||
else:
|
||||
colour = "danger"
|
||||
|
||||
# Compute top answer (most common)
|
||||
top_answer = None
|
||||
top_count = 0
|
||||
for k, v in counts.items():
|
||||
if v > top_count:
|
||||
top_count = v
|
||||
top_answer = k
|
||||
# Build a simple breakdown list of (answer, count, pct) for template rendering
|
||||
breakdown = []
|
||||
for k, v in counts.items():
|
||||
pct_val = pcts.get(k, 0.0) if isinstance(pcts, dict) else 0.0
|
||||
breakdown.append((k, v, pct_val))
|
||||
|
||||
question_summaries.append(
|
||||
{
|
||||
"question": q,
|
||||
"q_index": i,
|
||||
"total_responses": total_responses,
|
||||
"counts": counts,
|
||||
"pcts": pcts,
|
||||
"correct_pct": correct_pct,
|
||||
"answered_pct": answered_pct,
|
||||
"colour": colour,
|
||||
"top_answer": top_answer,
|
||||
"breakdown": breakdown,
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"generic/exam_review_start.html",
|
||||
{
|
||||
"exam": exam,
|
||||
"question_summaries": question_summaries,
|
||||
"question_number": len(questions),
|
||||
"app_name": self.app_name,
|
||||
},
|
||||
)
|
||||
|
||||
@method_decorator(login_required)
|
||||
def exam_review_question(self, request, pk, q_index=0):
|
||||
|
||||
Reference in New Issue
Block a user