Enhance exam review overview page to display per-question summaries with response metrics and color coding for quick triage

This commit is contained in:
Ross
2025-11-10 12:26:12 +00:00
parent cf09449d16
commit e7c15a75a0
2 changed files with 161 additions and 3 deletions
+111 -3
View File
@@ -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):