From bd81fdcf419eb59607f6a5ac01d6d01287498578 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 10 Nov 2025 12:49:10 +0000 Subject: [PATCH] Add exam review question responses and summary views with HTMX support --- .../templates/anatomy/exam_scores_user.html | 2 +- .../templates/generic/exam_review_start.html | 2 +- ...am_review_question_responses_fragment.html | 50 +++++++ ...exam_review_question_summary_fragment.html | 52 +++++++ physics/urls.py | 10 ++ physics/views.py | 131 ++++++++++++++++++ 6 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 physics/templates/physics/partials/exam_review_question_responses_fragment.html create mode 100644 physics/templates/physics/partials/exam_review_question_summary_fragment.html diff --git a/anatomy/templates/anatomy/exam_scores_user.html b/anatomy/templates/anatomy/exam_scores_user.html index e2720d60..b15d6ec8 100644 --- a/anatomy/templates/anatomy/exam_scores_user.html +++ b/anatomy/templates/anatomy/exam_scores_user.html @@ -23,7 +23,7 @@
{{ans}}
{% if exam.publish_results or view_all_results%} - {{score}} + Marks: {{score}} {% endif %} View diff --git a/generic/templates/generic/exam_review_start.html b/generic/templates/generic/exam_review_start.html index 944029c0..2645beb3 100644 --- a/generic/templates/generic/exam_review_start.html +++ b/generic/templates/generic/exam_review_start.html @@ -1,4 +1,4 @@ -{% extends 'generic/examcollection_base.html' %} +{% extends exam.app_name|add:'/exams.html' %} {% block content %}
diff --git a/physics/templates/physics/partials/exam_review_question_responses_fragment.html b/physics/templates/physics/partials/exam_review_question_responses_fragment.html new file mode 100644 index 00000000..2deee9f7 --- /dev/null +++ b/physics/templates/physics/partials/exam_review_question_responses_fragment.html @@ -0,0 +1,50 @@ +{# Responses partial for physics question within an exam. HTMX-loadable. #} +
+
Responses in this exam
+
+
List of candidate answers recorded for this question in the current exam.
+
+ {% if reveal_respondents %} + + {% else %} + + {% endif %} +
+
+ +
    + {% for ua in exam_user_answers %} +
  • +
    + {% if reveal_respondents %} + {% if ua.user %} + {{ ua.user.get_full_name|default:ua.user.username }} + {% else %} + Anonymous + {% endif %} + {% else %} + Respondent {{ forloop.counter }} + {% endif %} +
    Answer: {{ ua.answer }}
    + {% if ua.created_date %} +
    Answered: {{ ua.created_date }}
    + {% endif %} +
    +
    + {# Physics scoring may be numeric; show a simple badge if fully correct according to score field where present #} + {% if ua.score %} + {% if ua.score|stringformat:'s' == '5' %} + Correct + {% else %} + Score: {{ ua.score }} + {% endif %} + {% else %} + Unmarked + {% endif %} +
    +
  • + {% empty %} +
  • No responses recorded for this exam.
  • + {% endfor %} +
+
diff --git a/physics/templates/physics/partials/exam_review_question_summary_fragment.html b/physics/templates/physics/partials/exam_review_question_summary_fragment.html new file mode 100644 index 00000000..3580c841 --- /dev/null +++ b/physics/templates/physics/partials/exam_review_question_summary_fragment.html @@ -0,0 +1,52 @@ +{# Aggregated response summary partial for physics questions (HTMX-loadable) #} +
+ {% if exam_response_total %} +
Total responses: {{ exam_response_total }}
+
+
Correct:
+
+ {% if exam_response_correct_count is not None %} + {{ exam_response_correct_count }} + {% if exam_response_correct_pct is not None %} + ({{ exam_response_correct_pct }}%) + {% endif %} + {% else %} + N/A + {% endif %} +
+
+ +
+ {# Render top-level answer breakdown from exam_response_counts and pcts #} + {% for key, count in exam_response_counts.items %} +
+
+
{{ key }}
+
{{ count }} {{ exam_response_pcts|get_item:key|default:0 }}%
+
+
+
+
+
+ {% endfor %} +
+ +
+
+
Correct answer
+ +
+
+ {% if correct_answer %} +
{{ correct_answer }}
+ {% else %} +
No correct answer available.
+ {% endif %} +
+
+ {% else %} +
No responses recorded for this question in the exam yet.
+ {% endif %} +
diff --git a/physics/urls.py b/physics/urls.py index 0558e301..a420e4bd 100644 --- a/physics/urls.py +++ b/physics/urls.py @@ -71,6 +71,16 @@ urlpatterns.extend( views.UserAnswerDelete.as_view(), name="user_answer_delete", ), + path( + "exam//review//responses", + views.exam_review_question_responses, + name="exam_review_question_responses", + ), + path( + "exam//review//summary", + views.exam_review_question_summary, + name="exam_review_question_summary", + ), # TODO: consider merging with generic... #path( # "exam//scores///", diff --git a/physics/views.py b/physics/views.py index e005a3ad..af7fbb4f 100644 --- a/physics/views.py +++ b/physics/views.py @@ -414,3 +414,134 @@ class UserAnswerDelete(SuperuserRequiredMixin, DeleteView): model = UserAnswer template_name = "user_answer_delete.html" success_url = reverse_lazy("physics:user_answer_table_view") + + +@login_required +def exam_review_question_responses(request, pk: int, q_index: int): + """Return the responses partial for a given exam question (HTMX-friendly).""" + exam = get_object_or_404(Exam, pk=pk) + + questions = exam.get_questions() + try: + question = questions[q_index] + except Exception: + raise Http404("Question not found in exam") + + context = { + "exam": exam, + "question": question, + "q_index": q_index, + "app_name": "physics", + } + + # Prefilter user answers for this exam to avoid template-side filtering + try: + exam_user_answers = question.cid_user_answers.filter(exam=exam).select_related("user") + except Exception: + exam_user_answers = question.cid_user_answers.all() + + # Default: anonymise unless reveal=1 is provided + reveal_flag = str(request.GET.get("reveal", "")).lower() in ("1", "true", "on") + context["reveal_respondents"] = reveal_flag + context["exam_user_answers"] = exam_user_answers + + return render(request, "physics/partials/exam_review_question_responses_fragment.html", context) + + +@login_required +def exam_review_question_summary(request, pk: int, q_index: int): + """Return the aggregated response summary partial for a given exam question (HTMX-friendly).""" + exam = get_object_or_404(Exam, pk=pk) + + questions = exam.get_questions() + try: + question = questions[q_index] + except Exception: + raise Http404("Question not found in exam") + + # Aggregate user answers for this question & exam + ua_qs = question.cid_user_answers.filter(exam=exam) + total_responses = ua_qs.count() + + # Physics answers may be stored as tuples/lists or named fields; attempt to aggregate + counts = defaultdict(int) + other_count = 0 + for ans in ua_qs.values_list("answer", flat=True): + if not ans: + other_count += 1 + continue + # normalize iterable answers to a string key + if isinstance(ans, (list, tuple)): + key = ", ".join([str(x) for x in ans]) + else: + key = str(ans) + counts[key] += 1 + + counts["unanswered"] = other_count + + pcts = {} + if total_responses: + for k, v in counts.items(): + pcts[k] = round(100.0 * v / total_responses, 1) + else: + for k in counts.keys(): + pcts[k] = 0.0 + + # Compute percent correct using per-answer scoring + correct_count = None + correct_pct = None + try: + # per-question max for physics is 5 (see generic.get_max_score mapping) + per_question_max = 5 + if total_responses: + cnt = 0 + for ua in ua_qs: + try: + score = ua.get_answer_score() + except Exception: + continue + if isinstance(score, (list, tuple)): + try: + ssum = sum([s for s in score if isinstance(s, (int, float))]) + except Exception: + continue + if ssum == per_question_max: + cnt += 1 + else: + if isinstance(score, (int, float)) and score == per_question_max: + cnt += 1 + correct_count = cnt + correct_pct = round(100.0 * correct_count / total_responses, 1) if total_responses else 0.0 + else: + correct_count = 0 + correct_pct = 0.0 + except Exception: + correct_count = None + correct_pct = None + + # Provide a best-effort correct answer display + correct_answer = None + try: + correct_answer = question.get_answers() + if isinstance(correct_answer, (list, tuple)): + correct_answer = ", ".join([str(x) for x in correct_answer]) + else: + correct_answer = str(correct_answer) + except Exception: + correct_answer = None + + context = { + "exam": exam, + "question": question, + "q_index": q_index, + "app_name": "physics", + "exam_response_counts": counts, + "exam_response_pcts": pcts, + "exam_response_total": total_responses, + "exam_response_correct_count": correct_count, + "exam_response_correct_pct": correct_pct, + "exam_response_texts": {}, + "correct_answer": correct_answer, + } + + return render(request, "physics/partials/exam_review_question_summary_fragment.html", context)