Anatomy
diff --git a/anatomy/templates/anatomy/partials/exam_review_question_responses_fragment.html b/anatomy/templates/anatomy/partials/exam_review_question_responses_fragment.html
new file mode 100644
index 00000000..b65d8a93
--- /dev/null
+++ b/anatomy/templates/anatomy/partials/exam_review_question_responses_fragment.html
@@ -0,0 +1,35 @@
+{# Responses partial for anatomy question within an exam. HTMX-loadable. #}
+
+
Responses in this exam
+
List of candidate answers recorded for this question in the current exam.
+
+
diff --git a/anatomy/templates/anatomy/partials/exam_review_question_summary_fragment.html b/anatomy/templates/anatomy/partials/exam_review_question_summary_fragment.html
new file mode 100644
index 00000000..d6da29dc
--- /dev/null
+++ b/anatomy/templates/anatomy/partials/exam_review_question_summary_fragment.html
@@ -0,0 +1,56 @@
+{# Aggregated response summary partial for anatomy 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 %}
+
+
+
+
+
+
Correct
+
{{ exam_response_score_counts.2|default:0 }}
+
+
+
Half mark
+
{{ exam_response_score_counts.1|default:0 }}
+
+
+
Incorrect
+
{{ exam_response_score_counts.0|default:0 }}
+
+
+
Unmarked
+
{{ exam_response_score_counts.empty|default:0 }}
+
+
+
+
+
Top submitted answers
+ {% if top_answers %}
+
+ {% for a in top_answers %}
+ -
+
{{ a.answer_compare }}
+ {{ a.count }}
+
+ {% endfor %}
+
+ {% else %}
+
No submitted answers yet.
+ {% endif %}
+
+ {% else %}
+
No responses recorded for this question in the exam yet.
+ {% endif %}
+
diff --git a/anatomy/urls.py b/anatomy/urls.py
index 5871d41a..c71ebd61 100644
--- a/anatomy/urls.py
+++ b/anatomy/urls.py
@@ -92,6 +92,16 @@ urlpatterns = [
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",
+ ),
]
urlpatterns.extend(generic_view_urls(views.GenericViews))
diff --git a/anatomy/views.py b/anatomy/views.py
index 5e8f5c30..13a3a06d 100644
--- a/anatomy/views.py
+++ b/anatomy/views.py
@@ -80,6 +80,7 @@ from helpers.images import image_as_base64
from django.template.defaulttags import register
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
+from django.db.models import Count
class AuthorOrCheckerRequiredMixin(object):
@@ -259,6 +260,82 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
n = sk
+
+
+@login_required
+def exam_review_question_responses(request, pk: int, q_index: int):
+ """Return the responses partial for a given anatomy 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")
+
+ try:
+ exam_user_answers = question.cid_user_answers.filter(exam=exam).select_related("user")
+ except Exception:
+ exam_user_answers = question.cid_user_answers.all()
+
+ context = {
+ "exam": exam,
+ "question": question,
+ "q_index": q_index,
+ "app_name": "anatomy",
+ "exam_user_answers": exam_user_answers,
+ }
+
+ return render(request, "anatomy/partials/exam_review_question_responses_fragment.html", context)
+
+
+@login_required
+def exam_review_question_summary(request, pk: int, q_index: int):
+ """Return aggregated response summary partial for anatomy 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")
+
+ ua_qs = question.cid_user_answers.filter(exam=exam)
+ total_responses = ua_qs.count()
+
+ # counts by score values ('2','1','0','')
+ score_counts = {}
+ for s, c in ua_qs.values_list("score").annotate(count=Count("id")) if False else []:
+ pass
+
+ # simpler: compute via values
+ score_counts = {row["score"] if row["score"] is not None else "": row["count"] for row in ua_qs.values("score").annotate(count=Count("id"))}
+
+ # top submitted answers (by answer_compare)
+ top_answers = list(ua_qs.values("answer_compare").annotate(count=Count("id")).order_by("-count")[:10])
+
+ correct_count = None
+ correct_pct = None
+ # compute correct based on matching marked answers if available
+ if total_responses:
+ # count scores equal to '2' as correct
+ correct_count = score_counts.get("2", 0)
+ correct_pct = round(100.0 * correct_count / total_responses, 1) if total_responses else None
+
+ context = {
+ "exam": exam,
+ "question": question,
+ "q_index": q_index,
+ "app_name": "anatomy",
+ "exam_response_total": total_responses,
+ "exam_response_score_counts": score_counts,
+ "exam_response_correct_count": correct_count,
+ "exam_response_correct_pct": correct_pct,
+ "top_answers": top_answers,
+ }
+
+ return render(request, "anatomy/partials/exam_review_question_summary_fragment.html", context)
+
question_details = {
"total": len(questions),
"current": n + 1,