Compare commits

...
21 Commits
Author SHA1 Message Date
Ross 3c31246636 Update progress bar colors to use bg-danger for incorrect answers in exam review summary 2025-11-09 22:27:28 +00:00
Ross dd55f999f7 Remove unnecessary check for defined exam_user_answers in responses fragment 2025-11-09 22:23:33 +00:00
Ross a6f5ed4c88 Enhance exam review UI with detailed response summaries and progress bars for answer choices 2025-11-09 22:21:08 +00:00
Ross d482b77430 Add HTMX-based response summary and user responses display for exam questions 2025-11-09 22:12:18 +00:00
Ross 3e9a96d99f Add HTMX-based aggregated response summary for exam questions 2025-11-09 22:09:23 +00:00
Ross 092c4a1be6 Add aggregated response summary and user answer filtering in exam review 2025-11-09 22:06:56 +00:00
Ross ae720662f7 Implement HTMX-based loading of exam question responses and add corresponding view and URL routing 2025-11-09 22:01:57 +00:00
Ross c89c14b89e Add display of user responses with correctness indication in exam review 2025-11-09 21:54:51 +00:00
Ross e6a01cb1b5 Enhance exam review question fragment to display detailed answers and feedback with correct answer indication 2025-11-09 21:52:41 +00:00
Ross fd8edd3cc3 Refactor exam review fragment inclusion to support app-specific templates and improve HTMX handling 2025-11-09 21:41:02 +00:00
Ross 2142749db6 Refactor exam review URLs and streamline HTMX request handling in views 2025-11-09 21:36:22 +00:00
Ross 1e1abb2e7f Refactor HTMX request detection to use request.htmx for improved compatibility 2025-11-09 21:18:31 +00:00
Ross 133a63cecc Improve HTMX request detection by using request.headers and falling back to META for compatibility 2025-11-08 22:15:03 +00:00
Ross 8ad47ffc91 Change hx-swap attribute from outerHTML to innerHTML for navigation buttons in exam review question fragment 2025-11-08 22:13:11 +00:00
Ross bf240af678 Change hx-swap attribute from innerHTML to outerHTML for navigation buttons in exam review template 2025-11-08 22:12:07 +00:00
Ross 0f571d8f5c Refactor exam review question template to support HTMX navigation and create a partial for question details 2025-11-08 22:10:21 +00:00
Ross 321c7ef895 Add user access check for editing/reviewing exams in GenericViewBase 2025-11-08 22:07:28 +00:00
Ross d26cac04a7 Add exam review links to navigation in multiple templates 2025-11-08 22:05:52 +00:00
Ross 5613bf6b9f Add exam review functionality with navigation for questions 2025-11-08 21:56:08 +00:00
Ross 1a45f11fc2 test updating view 2025-11-08 21:48:27 +00:00
Ross 4d830bb8e7 . 2025-11-08 21:16:23 +00:00
29 changed files with 1196 additions and 14 deletions
+1
View File
@@ -11,5 +11,6 @@
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> / <a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> / <a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
{% endif %} {% endif %}
<a href="{% url 'anatomy:exam_review_start' pk=exam.pk %}">Review</a>
{% comment %} <a href="{% url 'anatomy:question_create_exam' pk=exam.pk %}" title="Add a new question directly into the exam.">Add New Question</a> {% endcomment %} {% comment %} <a href="{% url 'anatomy:question_create_exam' pk=exam.pk %}" title="Add a new question directly into the exam.">Add New Question</a> {% endcomment %}
{% endblock %} {% endblock %}
@@ -0,0 +1,121 @@
<div class="card mb-3">
<div class="card-body">
<div class="mb-2"><span class="badge bg-dark">Anatomy</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="mt-3">
<h6>Marked answers</h6>
<ul class="list-group">
{% for answer in question.answers.all|dictsortreversed:"status" %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if answer.status == '2' %} list-group-item-success{% elif answer.status == '1' %} list-group-item-warning{% elif answer.status == '0' %} list-group-item-secondary{% endif %}">
<div>
{% if answer.proposed %}
<small class="text-muted me-2">(proposed)</small>
{% endif %}
<span>{{ answer.answer }}</span>
</div>
<div>
{% if answer.status == '2' %}
<span class="badge bg-success">Correct</span>
{% elif answer.status == '1' %}
<span class="badge bg-warning text-dark">Half mark</span>
{% elif answer.status == '0' %}
<span class="badge bg-secondary">Incorrect</span>
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
{# Aggregated summary placeholder — auto-load via HTMX #}
<div class="mt-3">
<h6>Response summary</h6>
<div id="anatomy-response-summary"
hx-get="{% url 'anatomy:exam_review_question_summary' exam.pk q_index %}"
hx-trigger="load"
hx-swap="innerHTML">
<div class="small text-muted">Loading summary…</div>
</div>
</div>
{# Responses: load on demand #}
<div class="mt-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Responses in this exam</h6>
<div>
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'anatomy:exam_review_question_responses' exam.pk q_index %}"
hx-target="#anatomy-responses-container"
hx-swap="innerHTML">Show responses</button>
</div>
</div>
<div class="mt-2" id="anatomy-responses-container">
<div class="text-muted small">Responses are hidden — click “Show responses” to load.</div>
</div>
</div>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<div class="mb-2"><span class="badge bg-info">Anatomy</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
@@ -0,0 +1,35 @@
{# Responses partial for anatomy question within an exam. HTMX-loadable. #}
<div id="anatomy-responses-container">
<h6>Responses in this exam</h6>
<div class="small text-muted mb-2">List of candidate answers recorded for this question in the current exam.</div>
<ul class="list-group">
{% for ua in exam_user_answers %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
{% if ua.user %}
<strong>{{ ua.user.get_full_name|default:ua.user.username }}</strong>
{% else %}
<strong>Anonymous</strong>
{% endif %}
<div>Answer: <strong>{{ ua.answer }}</strong></div>
{% if ua.created_date %}
<div class="small text-muted">Answered: {{ ua.created_date }}</div>
{% endif %}
</div>
<div>
{% if ua.score == '2' %}
<span class="badge bg-success">Correct</span>
{% elif ua.score == '1' %}
<span class="badge bg-warning text-dark">Half mark</span>
{% elif ua.score == '0' %}
<span class="badge bg-secondary">Incorrect</span>
{% else %}
<span class="badge bg-light text-muted">Unmarked</span>
{% endif %}
</div>
</li>
{% empty %}
<li class="list-group-item">No responses recorded for this exam.</li>
{% endfor %}
</ul>
</div>
@@ -0,0 +1,83 @@
{# Aggregated response summary partial for anatomy questions (HTMX-loadable) #}
<div id="anatomy-response-summary-content">
{% if exam_response_total %}
<div class="small text-muted mb-2">Total responses: {{ exam_response_total }}</div>
<div class="row mb-2">
<div class="col-auto">Correct:</div>
<div class="col">
{% if exam_response_correct_count is not None %}
<strong>{{ exam_response_correct_count }}</strong>
{% if exam_response_correct_pct is not None %}
<span class="text-muted">({{ exam_response_correct_pct }}%)</span>
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</div>
</div>
<div class="list-group mb-2">
{# Correct (2) #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>Correct</div>
<div class="text-end"><strong>{{ exam_response_score_counts.2|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts.2|default:0 }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-success" role="progressbar" style="width: {{ exam_response_score_pcts.2|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.2|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{# Half mark (1) #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>Half mark</div>
<div class="text-end"><strong>{{ exam_response_score_counts.1|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts.1|default:0 }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-warning" role="progressbar" style="width: {{ exam_response_score_pcts.1|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.1|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{# Incorrect (0) #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>Incorrect</div>
<div class="text-end"><strong>{{ exam_response_score_counts.0|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts.0|default:0 }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ exam_response_score_pcts.0|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.0|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{# Unmarked (empty) #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>Unmarked</div>
<div class="text-end"><strong>{{ exam_response_score_counts.empty|default:0 }}</strong> <small class="text-muted">{{ exam_response_score_pcts.empty|default:0 }}%</small></div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ exam_response_score_pcts.empty|default:0 }}%;" aria-valuenow="{{ exam_response_score_pcts.empty|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
<div>
<h6 class="mb-1">Top submitted answers</h6>
{% if top_answers %}
<ul class="list-group">
{% for a in top_answers %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>{{ a.answer_compare }}</div>
<div class="small text-muted">{{ a.count }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="small text-muted">No submitted answers yet.</div>
{% endif %}
</div>
{% else %}
<div class="small text-muted">No responses recorded for this question in the exam yet.</div>
{% endif %}
</div>
+10
View File
@@ -92,6 +92,16 @@ urlpatterns = [
views.UserAnswerDelete.as_view(), views.UserAnswerDelete.as_view(),
name="user_answer_delete", name="user_answer_delete",
), ),
path(
"exam/<int:pk>/review/<int:q_index>/responses",
views.exam_review_question_responses,
name="exam_review_question_responses",
),
path(
"exam/<int:pk>/review/<int:q_index>/summary",
views.exam_review_question_summary,
name="exam_review_question_summary",
),
] ]
urlpatterns.extend(generic_view_urls(views.GenericViews)) urlpatterns.extend(generic_view_urls(views.GenericViews))
+88
View File
@@ -80,6 +80,7 @@ from helpers.images import image_as_base64
from django.template.defaulttags import register from django.template.defaulttags import register
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
from django.db.models import Count
class AuthorOrCheckerRequiredMixin(object): class AuthorOrCheckerRequiredMixin(object):
@@ -259,6 +260,93 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
n = sk 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 and normalise unmarked -> 'empty'
score_counts = {str(row["score"]) if row["score"] is not None else "empty": 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
# compute percentage distribution for score buckets for the summary bar chart
score_keys = ["2", "1", "0", "empty"]
score_pcts = {}
if total_responses:
for k in score_keys:
score_pcts[k] = round(100.0 * score_counts.get(k, 0) / total_responses, 1)
else:
for k in score_keys:
score_pcts[k] = 0.0
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_score_pcts": score_pcts,
"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 = { question_details = {
"total": len(questions), "total": len(questions),
"current": n + 1, "current": n + 1,
+3 -1
View File
@@ -7,7 +7,9 @@
{% if collection %} {% if collection %}
{% include "atlas/collection_headers.html" %} {% include "atlas/collection_headers.html" %}
{% endif %} {% endif %}
{% if exam %}
<a href="{% url 'atlas:exam_review_start' pk=exam.pk %}">Review</a> /
{% endif %}
{% comment %} Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=exam.pk %}">Overview</a> / {% comment %} Collection: {{collection.name}}-> <a href="{% url 'atlas:collection_detail' pk=exam.pk %}">Overview</a> /
<a href="{% url 'atlas:collection_mark_overview' exam.pk %}">Mark</a> / <a href="{% url 'atlas:collection_mark_overview' exam.pk %}">Mark</a> /
<a href="{% url 'atlas:collection_scores_cid' exam.pk %}">Scores</a> / <a href="{% url 'atlas:collection_scores_cid' exam.pk %}">Scores</a> /
@@ -0,0 +1,33 @@
<div class="card mb-3">
<div class="card-body">
<div class="mb-2"><span class="badge bg-secondary">Atlas</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
+5 -4
View File
@@ -828,10 +828,11 @@ class UserAutocomplete(ModelAutocomplete):
search_attrs = ["first_name", "last_name", "username"] search_attrs = ["first_name", "last_name", "username"]
class UsersAutocompleteForm(Form): class UsersAutocompleteForm(Form):
users = ModelChoiceField(queryset=User.objects.all(), widget=AutocompleteWidget( # Use a ModelMultipleChoiceField so the widget's multiselect option maps to a multi-value field
ac_class=UserAutocomplete, options={"multiselect": True} users = ModelMultipleChoiceField(
queryset=User.objects.all(),
)) widget=AutocompleteWidget(ac_class=UserAutocomplete, options={"multiselect": True}),
)
autocomplete_register(UserAutocomplete) autocomplete_register(UserAutocomplete)
@@ -0,0 +1,8 @@
{% extends 'generic/examcollection_base.html' %}
{% block content %}
<div class="container my-4">
<div class="alert alert-info">Review complete for exam: {{ exam.name }}</div>
<p><a class="btn btn-secondary" href="{% url app_name|add:':exam_overview' exam.pk %}">Back to exam overview</a></p>
</div>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends 'generic/examcollection_base.html' %}
{% block content %}
<div class="container my-4">
<h2>Reviewing: {{ exam.name }} (Question {{ q_index|add:1 }} of {{ total }})</h2>
<div id="review-root">
{# The #review-content element is replaced by HTMX when navigating between questions. #}
<div id="review-content">
{# The view selects an app-specific fragment if present and passes its name
in `fragment_template`. Include that fragment here so the full-page
render and the HTMX partials both use the same template. #}
{% include fragment_template %}
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,32 @@
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
@@ -0,0 +1,21 @@
{% load crispy_forms_tags %}
{{ form.media }}
<form hx-post="{% url 'generic:exam_collection_add_author' collection_id %}" hx-target="#add-user" hx-swap="innerHTML" hx-indicator="#add-author-indicator" action="" method="post" enctype="multipart/form-data" id="user-form" class="inline-form">
{% csrf_token %}
{{ form|crispy }}
{% comment %} Use a normal submit so the form data is included in the HTMX request {% endcomment %}
<button type="submit" class="btn btn-primary">Submit</button>
<span id="add-author-indicator" class="ms-2" aria-hidden="true">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</span>
</form>
<style>
/* Hide indicator by default; HTMX will add the `htmx-request` class during requests */
#add-author-indicator { display: none; }
#add-author-indicator.htmx-request { display: inline-flex; align-items: center; }
</style>
+10
View File
@@ -353,6 +353,16 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
generic_exam_view.exam_question_detail, generic_exam_view.exam_question_detail,
name="exam_question_detail", name="exam_question_detail",
), ),
path(
"exam/<int:pk>/review/start",
generic_exam_view.exam_review_start,
name="exam_review_start",
),
path(
"exam/<int:pk>/review/<int:q_index>",
generic_exam_view.exam_review_question,
name="exam_review_question",
),
# path( # path(
# "exam/<int:pk>/question/<int:sk>/answer/<str:user_or_cid>", # "exam/<int:pk>/question/<int:sk>/answer/<str:user_or_cid>",
# generic_exam_view.exam_question_user_answer, # generic_exam_view.exam_question_user_answer,
+197 -8
View File
@@ -1163,6 +1163,134 @@ class ExamViews(View, LoginRequiredMixin):
data = {"status": "error"} data = {"status": "error"}
return JsonResponse(data, status=400) return JsonResponse(data, status=400)
@method_decorator(login_required)
def exam_review_start(self, request, pk):
"""Start a per-question review for an exam. Redirects to the first question."""
# Ensure the user can edit the exam (authors only)
if not self.check_user_edit_access(request.user, exam_id=pk):
raise PermissionDenied
exam = get_object_or_404(self.Exam, pk=pk)
# Materialise ordered questions
questions = list(exam.get_questions())
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)
@method_decorator(login_required)
def exam_review_question(self, request, pk, q_index=0):
"""Render a single question from an exam for review with navigation."""
if not self.check_user_edit_access(request.user, exam_id=pk):
raise PermissionDenied
exam = get_object_or_404(self.Exam, pk=pk)
questions = list(exam.get_questions())
total = len(questions)
try:
q_index = int(q_index)
except Exception:
q_index = 0
if q_index < 0 or q_index >= total:
# Out of range — render complete
return render(request, "generic/exam_review_complete.html", {"exam": exam, "app_name": self.app_name})
question = questions[q_index]
prev_index = q_index - 1 if q_index > 0 else None
next_index = q_index + 1 if q_index < total - 1 else None
context = {
"exam": exam,
"question": question,
"q_index": q_index,
"total": total,
"prev_index": prev_index,
"next_index": next_index,
"app_name": self.app_name,
"can_edit": self.check_user_edit_access(request.user, exam_id=pk),
}
# Compute aggregated response summary for this question within the current exam.
# Add counts and percentages to the context so fragments (e.g. SBAS) can display them.
try:
if hasattr(question, "cid_user_answers"):
ua_qs = question.cid_user_answers.filter(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
# Compute percentages per answer key
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
# If the question exposes a best_answer, compute correct count
correct_count = None
if hasattr(question, "best_answer") and question.best_answer:
correct_count = ua_qs.filter(answer=question.best_answer).count()
else:
correct_count = None
# percent correct
correct_pct = None
if correct_count is not None and total_responses:
try:
correct_pct = round(100.0 * correct_count / total_responses, 1)
except Exception:
correct_pct = None
context.update(
{
"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,
}
)
except Exception:
# Best-effort: don't fail the review page if aggregation errors occur
pass
# Choose an app-specific fragment if present, otherwise fall back to
# the generic fragment. Expose the chosen fragment name in the
# context so the full-page render can include it; return the fragment
# directly for HTMX requests.
from django.template import loader
fragment_candidates = [
f"{self.app_name}/partials/exam_review_question_fragment.html",
"generic/partials/exam_review_question_fragment.html",
]
template = loader.select_template(fragment_candidates)
fragment_template_name = template.template.name
context["fragment_template"] = fragment_template_name
# Detect HTMX: prefer the request.htmx attribute when available, otherwise
# check the HX-Request header.
is_htmx = getattr(request, "htmx", False)
if not is_htmx:
try:
is_htmx = request.headers.get("HX-Request", "").lower() == "true"
except Exception:
is_htmx = request.META.get("HTTP_HX_REQUEST", "") == "true"
if is_htmx:
return render(request, fragment_template_name, context)
return render(request, "generic/exam_review_question.html", context)
@method_decorator(login_required) @method_decorator(login_required)
def exam_json_recreate(self, request, pk): def exam_json_recreate(self, request, pk):
exam = get_object_or_404(self.Exam, pk=pk) exam = get_object_or_404(self.Exam, pk=pk)
@@ -2816,16 +2944,22 @@ class ExamViews(View, LoginRequiredMixin):
if self.app_name in ("physics", "sbas", "longs", "shorts"): if self.app_name in ("physics", "sbas", "longs", "shorts"):
cached_scores = False cached_scores = False
questions = exam.get_questions() questions_qs = exam.get_questions()
else: else:
questions = exam.get_questions().prefetch_related("answers") questions_qs = exam.get_questions().prefetch_related("answers")
# We could prefect the UserAnswers.answers here (if we didn't cache them) # Materialize questions into a list so we can index and build a question->index map
cid_user_answers = self.UserAnswer.objects.select_related("question").filter( questions = list(questions_qs)
question__in=questions, exam__id=pk question_index_map = {q: i for i, q in enumerate(questions)}
# We could prefetch the UserAnswers.answers here (if we didn't cache them)
# Also select_related the user to avoid per-answer user lookups in the loop
cid_user_answers = (
self.UserAnswer.objects.select_related("question", "user")
.filter(question__in=questions, exam__id=pk)
) )
question_number = questions.count() question_number = len(questions)
cids = set() cids = set()
@@ -2869,7 +3003,9 @@ class ExamViews(View, LoginRequiredMixin):
answer_score = s.get_answer_score() answer_score = s.get_answer_score()
if answer_score == "unmarked": if answer_score == "unmarked":
index = exam.get_question_index(q) # Use precomputed map to avoid repeated scanning/indexing of questions
index = question_index_map.get(q)
if index is not None:
unmarked.add(index) unmarked.add(index)
# user_answers[cid].append(ans) # user_answers[cid].append(ans)
user_answers_marks[cid].append(answer_score) user_answers_marks[cid].append(answer_score)
@@ -3062,6 +3198,58 @@ class GenericViewBase:
self.checker_group = g[app_name] self.checker_group = g[app_name]
def check_user_edit_access(self, user, exam_id=None):
"""Check if a user should be able to access editing/reviewing an exam.
This mirrors the older permission helper used elsewhere in the file but
references `self.exam_object` and `self.app_name` (since GenericViewBase
is instantiated per app).
"""
if user.is_superuser:
return True
# If a user is an exam author they should have access
if exam_id is not None:
exam = get_object_or_404(self.exam_object, pk=exam_id)
if user in exam.get_author_objects():
return True
if getattr(exam, "authors_only", False):
return False
# App-specific group checks (require checker role for edit/review)
if (
self.app_name == "rapids"
and not user.groups.filter(name="rapid_checker").exists()
):
return False
if (
self.app_name == "anatomy"
and not user.groups.filter(name="anatomy_checker").exists()
):
return False
if (
self.app_name == "longs"
and not user.groups.filter(name__in=("long_checker",)).exists()
):
return False
if (
self.app_name == "physics"
and not user.groups.filter(name="physics_checker").exists()
):
return False
if (
self.app_name == "sbas"
and not user.groups.filter(name="sba_checker").exists()
):
return False
if (
self.app_name == "casecollection"
and not user.groups.filter(name="casecollection_checker").exists()
):
return False
return False
def help(self, request): def help(self, request):
return render(request, f"{self.app_name}/help.html", {"app_name": self.app_name}) return render(request, f"{self.app_name}/help.html", {"app_name": self.app_name})
@@ -5165,9 +5353,10 @@ def exam_collection_add_author(request, collection_id):
if request.method != "POST": if request.method != "POST":
form = UsersAutocompleteForm() form = UsersAutocompleteForm()
# Render the fixed form which properly includes form data in HTMX POSTs
return render( return render(
request, request,
"generic/user_form.html", "generic/user_form_fixed.html",
{"form": form, "collection_id": collection_id}, {"form": form, "collection_id": collection_id},
) )
return return
+1
View File
@@ -9,4 +9,5 @@
<a href="{% url 'longs:exam_scores_all' pk=exam.pk %}">Scores</a> / <a href="{% url 'longs:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'longs:exam_cids' exam_id=exam.pk %}">Candidates</a> / <a href="{% url 'longs:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'longs:exam_stats' exam_id=exam.pk %}">Stats</a> / <a href="{% url 'longs:exam_stats' exam_id=exam.pk %}">Stats</a> /
<a href="{% url 'longs:exam_review_start' pk=exam.pk %}">Review</a> /
{% endblock %} {% endblock %}
@@ -0,0 +1,33 @@
<div class="card mb-3">
<div class="card-body">
<div class="mb-2"><span class="badge bg-secondary">Longs</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
+1
View File
@@ -7,4 +7,5 @@ Exams: {{exam}}-> <a href="{% url 'physics:exam_overview' pk=exam.pk %}">Overvie
<a href="{% url 'physics:exam_scores_all' pk=exam.pk %}">Scores</a> / <a href="{% url 'physics:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'physics:exam_cids' exam_id=exam.pk %}">Candidates</a> / <a href="{% url 'physics:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'physics:exam_stats' exam_id=exam.pk %}">Stats</a> / <a href="{% url 'physics:exam_stats' exam_id=exam.pk %}">Stats</a> /
<a href="{% url 'physics:exam_review_start' pk=exam.pk %}">Review</a> /
{% endblock %} {% endblock %}
@@ -0,0 +1,33 @@
<div class="card mb-3">
<div class="card-body">
<div class="mb-2"><span class="badge bg-primary">Physics</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
+1
View File
@@ -12,4 +12,5 @@
<a href="{% url 'rapids:exam_stats' exam_id=exam.pk %}">Stats</a> / <a href="{% url 'rapids:exam_stats' exam_id=exam.pk %}">Stats</a> /
{% endif %} {% endif %}
<a href="{% url 'rapids:question_create_exam' pk=exam.pk %}">Add New Question</a> <a href="{% url 'rapids:question_create_exam' pk=exam.pk %}">Add New Question</a>
<a href="{% url 'rapids:exam_review_start' pk=exam.pk %}">Review</a>
{% endblock %} {% endblock %}
@@ -0,0 +1,33 @@
<div class="card mb-3">
<div class="card-body">
<div class="mb-2"><span class="badge bg-warning text-dark">Rapids</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
+1
View File
@@ -8,4 +8,5 @@
<a href="{% url 'sbas:exam_scores_all' pk=exam.pk %}">Scores</a> / <a href="{% url 'sbas:exam_scores_all' pk=exam.pk %}">Scores</a> /
<a href="{% url 'sbas:exam_cids' exam_id=exam.pk %}">Candidates</a> / <a href="{% url 'sbas:exam_cids' exam_id=exam.pk %}">Candidates</a> /
<a href="{% url 'sbas:exam_stats' exam_id=exam.pk %}">Stats</a> / <a href="{% url 'sbas:exam_stats' exam_id=exam.pk %}">Stats</a> /
<a href="{% url 'sbas:exam_review_start' pk=exam.pk %}">Review</a> /
{% endblock %} {% endblock %}
@@ -0,0 +1,159 @@
<div class="card mb-3">
<div class="card-body">
<div class="mb-2"><span class="badge bg-dark">SBAs</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
{# Use per-field answers (a..e) from sbas Question model and highlight the best answer #}
<div class="mt-3">
<h6>Answers</h6>
<ul class="list-group">
{% if question.a_answer %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if question.best_answer == 'a' %} list-group-item-success{% endif %}">
<div>
<strong class="me-2">A.</strong>
<span>{{ question.a_answer }}</span>
{% if question.a_feedback %}
<div class="small text-muted">Feedback: {{ question.a_feedback }}</div>
{% endif %}
</div>
<div>
{% if question.best_answer == 'a' %}
<span class="badge bg-success">Correct</span>
{% else %}
<span class="badge bg-light text-muted">&nbsp;</span>
{% endif %}
</div>
</li>
{% endif %}
{% if question.b_answer %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if question.best_answer == 'b' %} list-group-item-success{% endif %}">
<div>
<strong class="me-2">B.</strong>
<span>{{ question.b_answer }}</span>
{% if question.b_feedback %}
<div class="small text-muted">Feedback: {{ question.b_feedback }}</div>
{% endif %}
</div>
<div>
{% if question.best_answer == 'b' %}
<span class="badge bg-success">Correct</span>
{% else %}
<span class="badge bg-light text-muted">&nbsp;</span>
{% endif %}
</div>
</li>
{% endif %}
{% if question.c_answer %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if question.best_answer == 'c' %} list-group-item-success{% endif %}">
<div>
<strong class="me-2">C.</strong>
<span>{{ question.c_answer }}</span>
{% if question.c_feedback %}
<div class="small text-muted">Feedback: {{ question.c_feedback }}</div>
{% endif %}
</div>
<div>
{% if question.best_answer == 'c' %}
<span class="badge bg-success">Correct</span>
{% else %}
<span class="badge bg-light text-muted">&nbsp;</span>
{% endif %}
</div>
</li>
{% endif %}
{% if question.d_answer %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if question.best_answer == 'd' %} list-group-item-success{% endif %}">
<div>
<strong class="me-2">D.</strong>
<span>{{ question.d_answer }}</span>
{% if question.d_feedback %}
<div class="small text-muted">Feedback: {{ question.d_feedback }}</div>
{% endif %}
</div>
<div>
{% if question.best_answer == 'd' %}
<span class="badge bg-success">Correct</span>
{% else %}
<span class="badge bg-light text-muted">&nbsp;</span>
{% endif %}
</div>
</li>
{% endif %}
{% if question.e_answer %}
<li class="list-group-item d-flex justify-content-between align-items-start{% if question.best_answer == 'e' %} list-group-item-success{% endif %}">
<div>
<strong class="me-2">E.</strong>
<span>{{ question.e_answer }}</span>
{% if question.e_feedback %}
<div class="small text-muted">Feedback: {{ question.e_feedback }}</div>
{% endif %}
</div>
<div>
{% if question.best_answer == 'e' %}
<span class="badge bg-success">Correct</span>
{% else %}
<span class="badge bg-light text-muted">&nbsp;</span>
{% endif %}
</div>
</li>
{% endif %}
</ul>
</div>
<p class="mt-2">
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
{# Aggregated summary: HTMX-loaded automatically on fragment load #}
<div class="mt-3">
<h6>Response summary</h6>
<div id="response-summary"
hx-get="{% url 'sbas:exam_review_question_summary' exam.pk q_index %}"
hx-trigger="load"
hx-swap="innerHTML">
<div class="small text-muted">Loading summary…</div>
</div>
</div>
{# Responses partial: load on demand via HTMX. Button fetches partial into #responses-container #}
<div class="mt-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'sbas:exam_review_question_responses' exam.pk q_index %}"
hx-target="#responses-container"
hx-swap="innerHTML">Show responses</button>
</div>
</div>
<div class="mt-2" id="responses-container">
<div class="text-muted small">Responses are hidden — click “Show responses” to load.</div>
</div>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>
@@ -0,0 +1,30 @@
{# Partial: responses for a question within an exam. Rendered server-side or via HTMX. #}
<div id="responses-container">
<h6>Responses in this exam</h6>
<div class="small text-muted mb-2">List of candidate answers recorded for this question in the current exam.</div>
<ul class="list-group">
<ul class="list-group">
{% for ua in exam_user_answers %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div>
{% if ua.user %}
<strong>{{ ua.user.get_full_name|default:ua.user.username }}</strong>
{% else %}
<strong>Anonymous</strong>
{% endif %}
<div>Answer: <strong>{{ ua.answer }}</strong></div>
{% if ua.created_date %}
<div class="small text-muted">Answered: {{ ua.created_date }}</div>
{% endif %}
</div>
<div>
{% if ua.answer == question.best_answer %}
<span class="badge bg-success">Correct</span>
{% else %}
<span class="badge bg-secondary">Incorrect</span>
{% endif %}
</div>
</li>
{% empty %}
<li class="list-group-item">No responses recorded for this exam.</li>
{% endfor %}
@@ -0,0 +1,102 @@
{# Partial: aggregated response summary for a question within an exam. HTMX-loadable. #}
<div id="response-summary-content">
{% if exam_response_total %}
<div class="small text-muted mb-2">Total responses: {{ exam_response_total }}</div>
<div class="row mb-2">
<div class="col-auto">Correct:</div>
<div class="col">
{% if exam_response_correct_count is not None %}
<strong>{{ exam_response_correct_count }}</strong>
{% if exam_response_correct_pct is not None %}
<span class="text-muted">({{ exam_response_correct_pct }}%)</span>
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</div>
</div>
<div class="list-group">
{# Choice A #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>A</div>
<div class="text-end">
<span class="me-2">{{ exam_response_counts.a|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.a|default:0 }}%</small>
</div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar {% if question.best_answer == 'a' %}bg-success{% else %}bg-danger{% endif %}" role="progressbar"
style="width: {{ exam_response_pcts.a|default:0 }}%;"
aria-valuenow="{{ exam_response_pcts.a|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{# Choice B #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>B</div>
<div class="text-end">
<span class="me-2">{{ exam_response_counts.b|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.b|default:0 }}%</small>
</div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar {% if question.best_answer == 'b' %}bg-success{% else %}bg-danger{% endif %}" role="progressbar"
style="width: {{ exam_response_pcts.b|default:0 }}%;"
aria-valuenow="{{ exam_response_pcts.b|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{# Choice C #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>C</div>
<div class="text-end">
<span class="me-2">{{ exam_response_counts.c|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.c|default:0 }}%</small>
</div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar {% if question.best_answer == 'c' %}bg-success{% else %}bg-danger{% endif %}" role="progressbar"
style="width: {{ exam_response_pcts.c|default:0 }}%;"
aria-valuenow="{{ exam_response_pcts.c|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{# Choice D #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>D</div>
<div class="text-end">
<span class="me-2">{{ exam_response_counts.d|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.d|default:0 }}%</small>
</div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar {% if question.best_answer == 'd' %}bg-success{% else %}bg-danger{% endif %}" role="progressbar"
style="width: {{ exam_response_pcts.d|default:0 }}%;"
aria-valuenow="{{ exam_response_pcts.d|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
{# Choice E #}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between align-items-center">
<div>E</div>
<div class="text-end">
<span class="me-2">{{ exam_response_counts.e|default:0 }}</span>
<small class="text-muted">{{ exam_response_pcts.e|default:0 }}%</small>
</div>
</div>
<div class="progress mt-2" style="height:12px;">
<div class="progress-bar {% if question.best_answer == 'e' %}bg-success{% else %}bg-danger{% endif %}" role="progressbar"
style="width: {{ exam_response_pcts.e|default:0 }}%;"
aria-valuenow="{{ exam_response_pcts.e|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
{% else %}
<div class="small text-muted">No responses recorded for this question in the exam yet.</div>
{% endif %}
</div>
+15
View File
@@ -117,6 +117,21 @@ urlpatterns = [
), ),
path("search/", views.question_search_page, name="question_search_page"), path("search/", views.question_search_page, name="question_search_page"),
path("search/questions/", views.question_search_partial, name="question_search_partial"), path("search/questions/", views.question_search_partial, name="question_search_partial"),
path(
"exam/<int:pk>/review/<int:q_index>/responses",
views.exam_review_question_responses,
name="exam_review_question_responses",
),
path(
"exam/<int:pk>/review/<int:q_index>/summary",
views.exam_review_question_summary,
name="exam_review_question_summary",
),
path(
"exam/<int:pk>/review/<int:q_index>/responses",
views.exam_review_question_responses,
name="exam_review_question_responses",
),
path("question/<int:pk>/toggle_frcr/", views.toggle_frcr_appropriate, name="toggle_frcr"), path("question/<int:pk>/toggle_frcr/", views.toggle_frcr_appropriate, name="toggle_frcr"),
path("category/merge/", views.merge_category, name="merge_category"), path("category/merge/", views.merge_category, name="merge_category"),
#path( #path(
+89
View File
@@ -87,6 +87,95 @@ from .forms import (
) )
@login_required
def exam_review_question_responses(request, pk: int, q_index: int):
"""Return the responses partial for a given exam question (HTMX-friendly).
pk: exam pk
q_index: question index within the exam
"""
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": "sbas",
}
# 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()
context["exam_user_answers"] = exam_user_answers
return render(request, "sbas/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).
Computes counts and percentages for choices a..e and returns the summary partial.
"""
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()
counts = {"a": 0, "b": 0, "c": 0, "d": 0, "e": 0}
for ans in ua_qs.values_list("answer", flat=True):
if ans in counts:
counts[ans] += 1
else:
# Unexpected answer labels — store them as-is (rare)
counts[ans] = counts.get(ans, 0) + 1
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
correct_count = None
correct_pct = None
if hasattr(question, "best_answer") and question.best_answer:
correct_count = ua_qs.filter(answer=question.best_answer).count()
if total_responses:
correct_pct = round(100.0 * correct_count / total_responses, 1)
context = {
"exam": exam,
"question": question,
"q_index": q_index,
"app_name": "sbas",
"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,
}
return render(request, "sbas/partials/exam_review_question_summary_fragment.html", context)
class AuthorOrCheckerRequiredMixin(object): class AuthorOrCheckerRequiredMixin(object):
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs) obj = super().get_object(*args, **kwargs)
+1
View File
@@ -12,4 +12,5 @@
<a href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a> / <a href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a> /
{% endif %} {% endif %}
<a href="{% url 'shorts:question_create_exam' pk=exam.pk %}">Add New Question</a> <a href="{% url 'shorts:question_create_exam' pk=exam.pk %}">Add New Question</a>
<a href="{% url 'shorts:exam_review_start' pk=exam.pk %}">Review</a>
{% endblock %} {% endblock %}
@@ -0,0 +1,33 @@
<div class="card mb-3">
<div class="card-body">
<div class="mb-2"><span class="badge bg-success">Shorts</span></div>
<h5 class="card-title">Question</h5>
<p class="lead">{{ question }}</p>
<p>
<a class="btn btn-sm btn-outline-secondary" href="{% url app_name|add:':question_detail' question.pk %}" target="_blank">View full question details</a>
</p>
<div class="d-flex justify-content-between mt-3">
{% if prev_index is not None %}
<a class="btn btn-outline-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk prev_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">&larr; Previous</a>
{% else %}
<span></span>
{% endif %}
{% if next_index is not None %}
<a class="btn btn-primary"
hx-get="{% url app_name|add:':exam_review_question' exam.pk next_index %}"
hx-target="#review-content"
hx-swap="innerHTML"
hx-push-url="true">Next &rarr;</a>
{% else %}
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
{% endif %}
</div>
</div>
</div>