diff --git a/generic/templates/generic/exam_review_start.html b/generic/templates/generic/exam_review_start.html new file mode 100644 index 00000000..19d4fdb9 --- /dev/null +++ b/generic/templates/generic/exam_review_start.html @@ -0,0 +1,50 @@ +{% extends 'generic/examcollection_base.html' %} + +{% block content %} +
+
+

Review overview: {{ exam.name }}

+
+ Start sequential review +
+
+ +

Colour indicates likely attention needed: green (good), yellow (check), red (needs attention). Hover for details.

+ +
+ {% for qs in question_summaries %} +
+
+
+
+
Question {{ qs.q_index|add:1 }}: {{ qs.question }}
+ {% 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 %} +
+ +

Responses: {{ qs.total_responses }}{% if qs.top_answer %} — top: "{{ qs.top_answer }}"{% endif %}

+ + + +
+ Answer breakdown +
    + {% if qs.breakdown %} + {% for ans in qs.breakdown %} +
  • {{ ans.0 }}: {{ ans.1 }} ({{ ans.2 }}%)
  • + {% endfor %} + {% else %} +
  • No responses yet
  • + {% endif %} +
+
+
+
+
+ {% endfor %} +
+
+ +{% endblock %} diff --git a/generic/views.py b/generic/views.py index 4d87829a..1fe96f2b 100644 --- a/generic/views.py +++ b/generic/views.py @@ -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):