Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c31246636 | ||
|
|
dd55f999f7 | ||
|
|
a6f5ed4c88 | ||
|
|
d482b77430 | ||
|
|
3e9a96d99f | ||
|
|
092c4a1be6 | ||
|
|
ae720662f7 | ||
|
|
c89c14b89e | ||
|
|
e6a01cb1b5 | ||
|
|
fd8edd3cc3 | ||
|
|
2142749db6 | ||
|
|
1e1abb2e7f | ||
|
|
133a63cecc | ||
|
|
8ad47ffc91 | ||
|
|
bf240af678 | ||
|
|
0f571d8f5c | ||
|
|
321c7ef895 | ||
|
|
d26cac04a7 | ||
|
|
5613bf6b9f | ||
|
|
1a45f11fc2 | ||
|
|
4d830bb8e7 |
@@ -11,5 +11,6 @@
|
||||
<a href="{% url 'anatomy:exam_cids' exam_id=exam.pk %}">Candidates</a> /
|
||||
<a href="{% url 'anatomy:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
||||
{% 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 %}
|
||||
{% 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">← 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 →</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">← 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 →</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>
|
||||
@@ -92,6 +92,16 @@ urlpatterns = [
|
||||
views.UserAnswerDelete.as_view(),
|
||||
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))
|
||||
|
||||
@@ -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,93 @@ 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 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 = {
|
||||
"total": len(questions),
|
||||
"current": n + 1,
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
{% if collection %}
|
||||
{% include "atlas/collection_headers.html" %}
|
||||
{% 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> /
|
||||
<a href="{% url 'atlas:collection_mark_overview' exam.pk %}">Mark</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">← 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 →</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
@@ -828,10 +828,11 @@ class UserAutocomplete(ModelAutocomplete):
|
||||
search_attrs = ["first_name", "last_name", "username"]
|
||||
|
||||
class UsersAutocompleteForm(Form):
|
||||
users = ModelChoiceField(queryset=User.objects.all(), widget=AutocompleteWidget(
|
||||
ac_class=UserAutocomplete, options={"multiselect": True}
|
||||
|
||||
))
|
||||
# Use a ModelMultipleChoiceField so the widget's multiselect option maps to a multi-value field
|
||||
users = ModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
widget=AutocompleteWidget(ac_class=UserAutocomplete, options={"multiselect": True}),
|
||||
)
|
||||
|
||||
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">← 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 →</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>
|
||||
@@ -353,6 +353,16 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
|
||||
generic_exam_view.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(
|
||||
# "exam/<int:pk>/question/<int:sk>/answer/<str:user_or_cid>",
|
||||
# generic_exam_view.exam_question_user_answer,
|
||||
|
||||
+197
-8
@@ -1163,6 +1163,134 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
data = {"status": "error"}
|
||||
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)
|
||||
def exam_json_recreate(self, request, 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"):
|
||||
cached_scores = False
|
||||
questions = exam.get_questions()
|
||||
questions_qs = exam.get_questions()
|
||||
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)
|
||||
cid_user_answers = self.UserAnswer.objects.select_related("question").filter(
|
||||
question__in=questions, exam__id=pk
|
||||
# Materialize questions into a list so we can index and build a question->index map
|
||||
questions = list(questions_qs)
|
||||
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()
|
||||
|
||||
@@ -2869,7 +3003,9 @@ class ExamViews(View, LoginRequiredMixin):
|
||||
|
||||
answer_score = s.get_answer_score()
|
||||
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)
|
||||
# user_answers[cid].append(ans)
|
||||
user_answers_marks[cid].append(answer_score)
|
||||
@@ -3062,6 +3198,58 @@ class GenericViewBase:
|
||||
|
||||
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):
|
||||
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":
|
||||
form = UsersAutocompleteForm()
|
||||
|
||||
# Render the fixed form which properly includes form data in HTMX POSTs
|
||||
return render(
|
||||
request,
|
||||
"generic/user_form.html",
|
||||
"generic/user_form_fixed.html",
|
||||
{"form": form, "collection_id": collection_id},
|
||||
)
|
||||
return
|
||||
|
||||
@@ -9,4 +9,5 @@
|
||||
<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_stats' exam_id=exam.pk %}">Stats</a> /
|
||||
<a href="{% url 'longs:exam_review_start' pk=exam.pk %}">Review</a> /
|
||||
{% 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">← 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 →</a>
|
||||
{% else %}
|
||||
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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_cids' exam_id=exam.pk %}">Candidates</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 %}
|
||||
@@ -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">← 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 →</a>
|
||||
{% else %}
|
||||
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,4 +12,5 @@
|
||||
<a href="{% url 'rapids:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
||||
{% endif %}
|
||||
<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 %}
|
||||
|
||||
@@ -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">← 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 →</a>
|
||||
{% else %}
|
||||
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,4 +8,5 @@
|
||||
<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_stats' exam_id=exam.pk %}">Stats</a> /
|
||||
<a href="{% url 'sbas:exam_review_start' pk=exam.pk %}">Review</a> /
|
||||
{% 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"> </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"> </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"> </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"> </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"> </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">← 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 →</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>
|
||||
@@ -117,6 +117,21 @@ urlpatterns = [
|
||||
),
|
||||
path("search/", views.question_search_page, name="question_search_page"),
|
||||
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("category/merge/", views.merge_category, name="merge_category"),
|
||||
#path(
|
||||
|
||||
@@ -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):
|
||||
def get_object(self, *args, **kwargs):
|
||||
obj = super().get_object(*args, **kwargs)
|
||||
|
||||
@@ -12,4 +12,5 @@
|
||||
<a href="{% url 'shorts:exam_stats' exam_id=exam.pk %}">Stats</a> /
|
||||
{% endif %}
|
||||
<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 %}
|
||||
|
||||
@@ -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">← 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 →</a>
|
||||
{% else %}
|
||||
<a class="btn btn-success" href="{% url app_name|add:':exam_overview' exam.pk %}">Complete review</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user