Refactor exam review templates and views to improve response summary display and handle unanswered responses
This commit is contained in:
@@ -6,9 +6,6 @@
|
|||||||
<div id="review-root">
|
<div id="review-root">
|
||||||
{# The #review-content element is replaced by HTMX when navigating between questions. #}
|
{# The #review-content element is replaced by HTMX when navigating between questions. #}
|
||||||
<div id="review-content">
|
<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 %}
|
{% include fragment_template %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-2"><span class="badge bg-dark">SBAs</span></div>
|
|
||||||
<h5 class="card-title">Question</h5>
|
<h5 class="card-title">Question</h5>
|
||||||
<p class="lead">{{ question|safe }}</p>
|
<p class="lead">{{ question|safe }}</p>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,12 @@
|
|||||||
{# Choice A #}
|
{# Choice A #}
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||||
<div>A</div>
|
<div>
|
||||||
|
<div class="fw-bold">A</div>
|
||||||
|
{% if exam_response_texts.a %}
|
||||||
|
<div class="small text-muted">{{ exam_response_texts.a|safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<span class="me-2">{{ exam_response_counts.a|default:0 }}</span>
|
<span class="me-2">{{ exam_response_counts.a|default:0 }}</span>
|
||||||
<small class="text-muted">{{ exam_response_pcts.a|default:0 }}%</small>
|
<small class="text-muted">{{ exam_response_pcts.a|default:0 }}%</small>
|
||||||
@@ -35,7 +40,12 @@
|
|||||||
{# Choice B #}
|
{# Choice B #}
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||||
<div>B</div>
|
<div>
|
||||||
|
<div class="fw-bold">B</div>
|
||||||
|
{% if exam_response_texts.b %}
|
||||||
|
<div class="small text-muted">{{ exam_response_texts.b|safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<span class="me-2">{{ exam_response_counts.b|default:0 }}</span>
|
<span class="me-2">{{ exam_response_counts.b|default:0 }}</span>
|
||||||
<small class="text-muted">{{ exam_response_pcts.b|default:0 }}%</small>
|
<small class="text-muted">{{ exam_response_pcts.b|default:0 }}%</small>
|
||||||
@@ -51,7 +61,12 @@
|
|||||||
{# Choice C #}
|
{# Choice C #}
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||||
<div>C</div>
|
<div>
|
||||||
|
<div class="fw-bold">C</div>
|
||||||
|
{% if exam_response_texts.c %}
|
||||||
|
<div class="small text-muted">{{ exam_response_texts.c|safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<span class="me-2">{{ exam_response_counts.c|default:0 }}</span>
|
<span class="me-2">{{ exam_response_counts.c|default:0 }}</span>
|
||||||
<small class="text-muted">{{ exam_response_pcts.c|default:0 }}%</small>
|
<small class="text-muted">{{ exam_response_pcts.c|default:0 }}%</small>
|
||||||
@@ -67,7 +82,12 @@
|
|||||||
{# Choice D #}
|
{# Choice D #}
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||||
<div>D</div>
|
<div>
|
||||||
|
<div class="fw-bold">D</div>
|
||||||
|
{% if exam_response_texts.d %}
|
||||||
|
<div class="small text-muted">{{ exam_response_texts.d|safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<span class="me-2">{{ exam_response_counts.d|default:0 }}</span>
|
<span class="me-2">{{ exam_response_counts.d|default:0 }}</span>
|
||||||
<small class="text-muted">{{ exam_response_pcts.d|default:0 }}%</small>
|
<small class="text-muted">{{ exam_response_pcts.d|default:0 }}%</small>
|
||||||
@@ -83,7 +103,12 @@
|
|||||||
{# Choice E #}
|
{# Choice E #}
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||||
<div>E</div>
|
<div>
|
||||||
|
<div class="fw-bold">E</div>
|
||||||
|
{% if exam_response_texts.e %}
|
||||||
|
<div class="small text-muted">{{ exam_response_texts.e|safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<span class="me-2">{{ exam_response_counts.e|default:0 }}</span>
|
<span class="me-2">{{ exam_response_counts.e|default:0 }}</span>
|
||||||
<small class="text-muted">{{ exam_response_pcts.e|default:0 }}%</small>
|
<small class="text-muted">{{ exam_response_pcts.e|default:0 }}%</small>
|
||||||
@@ -96,6 +121,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{# Unanswered / other responses (blank, None, or unexpected labels) #}
|
||||||
|
{% if exam_response_counts.unanswered|default:0 %}
|
||||||
|
<div class="list-group-item">
|
||||||
|
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||||
|
<div>No answer / Other</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span class="me-2">{{ exam_response_counts.unanswered|default:0 }}</span>
|
||||||
|
<small class="text-muted">{{ exam_response_pcts.unanswered|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_pcts.unanswered|default:0 }}%;"
|
||||||
|
aria-valuenow="{{ exam_response_pcts.unanswered|default:0 }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="small text-muted">No responses recorded for this question in the exam yet.</div>
|
<div class="small text-muted">No responses recorded for this question in the exam yet.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+24
-2
@@ -138,13 +138,24 @@ def exam_review_question_summary(request, pk: int, q_index: int):
|
|||||||
ua_qs = question.cid_user_answers.filter(exam=exam)
|
ua_qs = question.cid_user_answers.filter(exam=exam)
|
||||||
total_responses = ua_qs.count()
|
total_responses = ua_qs.count()
|
||||||
|
|
||||||
|
# Track counts for the five canonical choices and aggregate any
|
||||||
|
# empty / unexpected answers into an 'unanswered' bucket so the
|
||||||
|
# template can show them explicitly and percentages use the same
|
||||||
|
# denominator (total_responses).
|
||||||
counts = {"a": 0, "b": 0, "c": 0, "d": 0, "e": 0}
|
counts = {"a": 0, "b": 0, "c": 0, "d": 0, "e": 0}
|
||||||
|
other_count = 0
|
||||||
for ans in ua_qs.values_list("answer", flat=True):
|
for ans in ua_qs.values_list("answer", flat=True):
|
||||||
|
# Normalize None/empty strings to be considered 'unanswered'
|
||||||
|
if not ans:
|
||||||
|
other_count += 1
|
||||||
|
continue
|
||||||
if ans in counts:
|
if ans in counts:
|
||||||
counts[ans] += 1
|
counts[ans] += 1
|
||||||
else:
|
else:
|
||||||
# Unexpected answer labels — store them as-is (rare)
|
# Unexpected answer labels — treat as 'other/unanswered'
|
||||||
counts[ans] = counts.get(ans, 0) + 1
|
other_count += 1
|
||||||
|
# Expose 'unanswered' so templates can report it
|
||||||
|
counts['unanswered'] = other_count
|
||||||
|
|
||||||
pcts = {}
|
pcts = {}
|
||||||
if total_responses:
|
if total_responses:
|
||||||
@@ -161,6 +172,16 @@ def exam_review_question_summary(request, pk: int, q_index: int):
|
|||||||
if total_responses:
|
if total_responses:
|
||||||
correct_pct = round(100.0 * correct_count / total_responses, 1)
|
correct_pct = round(100.0 * correct_count / total_responses, 1)
|
||||||
|
|
||||||
|
# Provide the actual answer text for each canonical choice so the
|
||||||
|
# summary template can display the full answer alongside the letter.
|
||||||
|
exam_response_texts = {
|
||||||
|
"a": (getattr(question, "a_answer", None) or "") ,
|
||||||
|
"b": (getattr(question, "b_answer", None) or "") ,
|
||||||
|
"c": (getattr(question, "c_answer", None) or "") ,
|
||||||
|
"d": (getattr(question, "d_answer", None) or "") ,
|
||||||
|
"e": (getattr(question, "e_answer", None) or "") ,
|
||||||
|
}
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"exam": exam,
|
"exam": exam,
|
||||||
"question": question,
|
"question": question,
|
||||||
@@ -171,6 +192,7 @@ def exam_review_question_summary(request, pk: int, q_index: int):
|
|||||||
"exam_response_total": total_responses,
|
"exam_response_total": total_responses,
|
||||||
"exam_response_correct_count": correct_count,
|
"exam_response_correct_count": correct_count,
|
||||||
"exam_response_correct_pct": correct_pct,
|
"exam_response_correct_pct": correct_pct,
|
||||||
|
"exam_response_texts": exam_response_texts,
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "sbas/partials/exam_review_question_summary_fragment.html", context)
|
return render(request, "sbas/partials/exam_review_question_summary_fragment.html", context)
|
||||||
|
|||||||
Reference in New Issue
Block a user