from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from generic.models import CidUser, ExamUserStatus, CidUserExam, Flag
from physics.decorators import user_is_author_or_physics_checker
from physics.filters import QuestionFilter, UserAnswerFilter
from generic.mixins import CheckCanEditMixin, SuperuserRequiredMixin
from physics.tables import QuestionTable, UserAnswerTable
from django.shortcuts import render, get_object_or_404, redirect
from django.views.decorators.csrf import csrf_exempt
from django import forms
# from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.models import User
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.detail import DetailView
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.views.generic import ListView
from django.db.models.functions import Lower
from django.core.cache import cache
from django.urls import reverse_lazy, reverse
from django.http import Http404, HttpResponseBadRequest, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django_filters.views import FilterView
from django_tables2.views import SingleTableMixin
from reversion.views import RevisionMixin
from .models import Question, Category, Exam, UserAnswer
from collections import defaultdict
import os
import base64
import mimetypes
import json
import statistics
import plotly.express as px
from generic.views import (
AuthorRequiredMixin,
ExamCloneMixin,
ExamCreateBase,
ExamDeleteBase,
ExamGroupsUpdateBase,
ExamUpdateBase,
ExamViews,
GenericViewBase,
exam_inactive,
)
from django.core.exceptions import PermissionDenied
from .forms import (
ExamGroupsForm,
ExamMarkerForm,
UserAnswerForm,
ExamAuthorForm,
ExamForm,
QuestionForm,
)
from .decorators import (
user_is_author_or_physics_checker,
user_is_exam_author_or_physics_checker,
)
from loguru import logger
class AuthorOrCheckerRequiredMixin(object):
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)
if self.request.user.groups.filter(name="physics_checker").exists():
return obj
if self.request.user not in obj.author.all():
raise PermissionDenied() # or Http404
return obj
def question_list(request):
questions = Question.objects.all()
return render(request, "physics/question_list.html", {"questions": questions})
@login_required
@user_is_author_or_physics_checker
def question_detail(request, pk):
question = get_object_or_404(Question, pk=pk)
return render(request, "physics/question_detail.html", {"question": question})
@login_required
def active_exams(request):
exams = Exam.objects.all()
active_exams = []
for exam in exams:
if exam.active:
active_exams.append(exam)
return render(request, "physics/available_exam_list.html", {"exams": active_exams})
def exam_take_old(request, pk):
exam = get_object_or_404(Exam, pk=pk)
if not exam.active:
raise exam_inactive(request)
questions = exam.get_questions()
return render(
request,
"physics/exam_take_old.html",
{
"exam": exam,
"questions": questions,
},
)
def exam_start(request, pk):
exam = get_object_or_404(Exam, pk=pk)
if not exam.active:
return exam_inactive(request, context={"exam": exam})
return render(
request,
"physics/exam_start.html",
{
"exam": exam,
# This may be overly comlicated but if the logged in user
# does not have access we give the option to take it as a cid user
"valid_user": exam.check_logged_in_user(request),
},
)
def exam_complete(request, pk, cid=None, passcode=None):
exam = get_object_or_404(Exam, pk=pk)
exam.check_user_can_take(cid, passcode, request.user)
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
cid_user_exam.complete_exam()
try:
ct = ContentType.objects.get_for_model(exam)
ExamUserStatus.objects.create(
content_type=ct,
object_id=exam.pk,
cid_user_exam=cid_user_exam,
status="completed",
extra="physics",
)
except Exception:
logger.error("Failed to log exam completion status")
# avoid crashing the user flow if logging fails
pass
return HttpResponse("
Exam completed
")
def exam_take_overview(request, pk, cid=None, passcode=None):
exam = get_object_or_404(Exam, pk=pk)
exam.check_user_can_take(cid, passcode, request.user)
questions = exam.get_questions()
if cid is not None:
answers = UserAnswer.objects.filter(cid=cid, exam=exam)
else:
answers = UserAnswer.objects.filter(user=request.user, exam=exam)
# Map answers by question id for quick lookup
answer_question_map = {ans.question_id: ans for ans in answers}
# Prepare bulk flagged set to avoid per-question queries
q_ids = [q.pk for q in questions]
try:
ct = ContentType.objects.get_for_model(Question)
if cid is not None:
flags_qs = Flag.objects.filter(content_type=ct, object_id__in=q_ids, cid_user__cid=cid)
else:
flags_qs = Flag.objects.filter(content_type=ct, object_id__in=q_ids, user=request.user)
flagged_set = set(flags_qs.values_list('object_id', flat=True))
except Exception:
flagged_set = set()
question_answer_tuples = []
answer_count = 0
for q in questions:
ans = answer_question_map.get(q.pk)
if ans is not None:
answer_count += 1
flagged = q.pk in flagged_set
question_answer_tuples.append((q, ans, flagged))
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
return render(
request,
"physics/exam_take_overview.html",
{
"exam": exam,
"cid": cid,
"question_answer_tuples": question_answer_tuples,
"answer_count": answer_count,
"exam_length": len(questions),
"passcode": passcode,
"cid_user_exam": cid_user_exam,
},
)
def exam_take(request, pk: int, sk: int, cid: str | None = None, passcode: str | None = None):
"""Display and handle answers for a single question in a physics exam.
Behavior and invariants:
- Uses a single canonical list of questions (from `exam.get_questions()`) for
selection, navigation and sizing so indexes cannot drift.
- Validates the incoming question index and raises 404 for invalid values.
- Supports candidate (cid) and logged-in user answers.
"""
exam = get_object_or_404(Exam, pk=pk)
if not exam.active:
return exam_inactive(request, context={"exam": exam})
exam.check_user_can_take(cid, passcode, request.user)
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
# Canonical questions list for consistent indexing/navigation
questions = list(exam.get_questions())
# Normalize and validate provided index
try:
index = int(sk)
except Exception:
raise Http404("Invalid question index")
if index < 0 or index >= len(questions):
raise Http404("Question not found in exam")
question = questions[index]
exam_length = len(questions)
# local numeric position equals the validated index
pos = index
if cid is not None:
answer = question.cid_user_answers.filter(cid=cid, exam=exam).first()
else:
answer = question.cid_user_answers.filter(user=request.user, exam=exam).first()
previous = -1
if sk > 0:
previous = sk - 1
next = sk + 1
if sk == exam_length - 1:
next = False
if request.method == "POST":
if answer:
form = UserAnswerForm(request.POST, instance=answer)
else:
form = UserAnswerForm(request.POST)
if form.is_valid() and not exam.publish_results and not cid_user_exam.completed:
answer = form.save(commit=False)
if cid is not None:
answer.cid = cid
else:
answer.user = request.user
answer.question = question
answer.exam = exam
# answer.published_date = timezone.now()
answer.save()
cid_user_exam.end_time = timezone.now()
cid_user_exam.save()
if cid is not None:
kwargs = {"pk": pk, "cid": cid, "passcode": passcode}
take_url = "physics:exam_take"
finish_url = "physics:exam_take_overview"
else:
kwargs = {"pk": pk}
take_url = "physics:exam_take_user"
finish_url = "physics:exam_take_overview_user"
# Support HTMX: if this is an HX request, return the fragment HTML for the
# destination question instead of issuing a full-page redirect. This lets
# the client swap only the question fragment.
is_htmx = request.headers.get("HX-Request") == "true" or request.META.get("HTTP_HX_REQUEST")
if "next" in request.POST:
if not next:
return HttpResponseBadRequest("Invalid request")
if is_htmx:
return exam_take_fragment(request, pk=pk, sk=pos + 1, cid=cid, passcode=passcode)
return redirect(take_url, sk=pos + 1, **kwargs)
elif "previous" in request.POST:
if is_htmx:
return exam_take_fragment(request, pk=pk, sk=pos - 1, cid=cid, passcode=passcode)
return redirect(take_url, sk=pos - 1, **kwargs)
elif "finish" in request.POST:
# The overview is a full page. For HTMX clients, instruct the
# browser to perform a full navigation by returning an HX-Redirect
# header (htmx will set window.location). For non-HTMX clients,
# perform a normal redirect.
if is_htmx:
try:
url = reverse(finish_url, kwargs=kwargs)
except Exception:
# Fallback: build a simple redirect response
resp = HttpResponse()
resp["HX-Redirect"] = ""
return resp
resp = HttpResponse()
resp["HX-Redirect"] = url
return resp
return redirect(finish_url, **kwargs)
elif "save" in request.POST:
# Save action should not trigger a full-page load for HTMX clients.
if is_htmx:
# Re-render the same fragment so the client can swap it in-place
# and set an HX-Trigger header so the client can show a confirmation
resp = exam_take_fragment(request, pk=pk, sk=pos, cid=cid, passcode=passcode)
try:
# Use a small JSON payload so client listeners can access details if needed
resp['HX-Trigger'] = json.dumps({'saved': True})
except Exception:
# Fallback to a simple event name
resp['HX-Trigger'] = 'saved'
return resp
# Non-HTMX clients get redirected back to the same question page
return redirect(take_url, sk=pos, **kwargs)
elif "goto" in request.POST:
dest = int(request.POST.get("goto"))
if is_htmx:
return exam_take_fragment(request, pk=pk, sk=dest, cid=cid, passcode=passcode)
return redirect(take_url, sk=dest, **kwargs)
# For HTMX POSTs that don't match a navigation action (eg validation errors),
# return the fragment so the client receives only the partial HTML.
if is_htmx:
return exam_take_fragment(request, pk=pk, sk=pos, cid=cid, passcode=passcode)
else:
form = UserAnswerForm(instance=answer)
saved_answer = False
if answer is not None:
saved_answer = [answer.a, answer.b, answer.c, answer.d, answer.e]
# compute flagged state for this question+actor
try:
ct = ContentType.objects.get_for_model(question)
flags_qs = Flag.objects.filter(content_type=ct, object_id=question.pk)
if cid is not None:
flags_qs = flags_qs.filter(cid_user__cid=cid)
else:
flags_qs = flags_qs.filter(user=request.user)
flagged = flags_qs.exists()
except Exception:
flagged = False
# Precompute answered and flagged sets in bulk to avoid repeated queries
q_ids = [q.pk for q in questions]
if cid is not None:
answered_set = set(UserAnswer.objects.filter(cid=cid, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, cid_user__cid=cid).values_list('object_id', flat=True))
else:
answered_set = set(UserAnswer.objects.filter(user=request.user, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, user=request.user).values_list('object_id', flat=True))
return render(
request,
"physics/exam_take.html",
{
"form": form,
"cid": cid,
"exam": exam,
"question": question,
"next": next,
"previous": previous,
"exam_length": exam_length,
"pos": pos,
"saved_answer": saved_answer,
"passcode": passcode,
"cid_user_exam": cid_user_exam,
"flagged": flagged,
# minimal per-question status for client-side menu: answered/flagged lists
# compute answered/flagged sets in bulk to avoid N+1 queries
"answered_json": json.dumps([q.pk in answered_set for q in questions]),
"flagged_json": json.dumps([q.pk in flagged_set for q in questions]),
},
)
def exam_take_fragment(request, pk: int, sk: int, cid: str | None = None, passcode: str | None = None):
"""Return an HTMX partial containing the question form and navigation controls.
This view mirrors the GET rendering logic of `exam_take` but returns a fragment
suitable for being loaded via HTMX into the page. It intentionally does not
process POSTs (the main `exam_take` endpoint handles form submissions and
redirects) to keep fragment logic focused on rendering.
"""
exam = get_object_or_404(Exam, pk=pk)
if not exam.active:
return exam_inactive(request, context={"exam": exam})
exam.check_user_can_take(cid, passcode, request.user)
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
# Canonical questions list for consistent indexing/navigation
questions = list(exam.get_questions())
try:
index = int(sk)
except Exception:
raise Http404("Invalid question index")
if index < 0 or index >= len(questions):
raise Http404("Question not found in exam")
question = questions[index]
exam_length = len(questions)
pos = index
if cid is not None:
answer = question.cid_user_answers.filter(cid=cid, exam=exam).first()
else:
answer = question.cid_user_answers.filter(user=request.user, exam=exam).first()
if answer is not None:
form = UserAnswerForm(instance=answer)
saved_answer = [answer.a, answer.b, answer.c, answer.d, answer.e]
else:
form = UserAnswerForm()
saved_answer = False
# compute flagged state for this question+actor so fragment renders correctly
try:
ct = ContentType.objects.get_for_model(question)
flags_qs = Flag.objects.filter(content_type=ct, object_id=question.pk)
if cid is not None:
flags_qs = flags_qs.filter(cid_user__cid=cid)
else:
flags_qs = flags_qs.filter(user=request.user)
flagged = flags_qs.exists()
except Exception:
flagged = False
previous = -1
if sk > 0:
previous = sk - 1
next = sk + 1
if sk == exam_length - 1:
next = False
# Bulk compute answered and flagged sets for the fragment
q_ids = [q.pk for q in questions]
if cid is not None:
frag_answered_set = set(UserAnswer.objects.filter(cid=cid, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
frag_flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, cid_user__cid=cid).values_list('object_id', flat=True))
else:
frag_answered_set = set(UserAnswer.objects.filter(user=request.user, exam=exam, question_id__in=q_ids).values_list('question_id', flat=True))
frag_flagged_set = set(Flag.objects.filter(content_type=ContentType.objects.get_for_model(Question), object_id__in=q_ids, user=request.user).values_list('object_id', flat=True))
return render(
request,
"physics/partials/exam_take_fragment.html",
{
"form": form,
"cid": cid,
"exam": exam,
"question": question,
"next": next,
"previous": previous,
"exam_length": exam_length,
"pos": pos,
"saved_answer": saved_answer,
"answer": answer,
"flagged": flagged,
"answered_json": json.dumps([q.pk in frag_answered_set for q in questions]),
"flagged_json": json.dumps([q.pk in frag_flagged_set for q in questions]),
"passcode": passcode,
"cid_user_exam": cid_user_exam,
},
)
#@login_required
def exam_toggle_flag(request, pk: int, sk: int, cid: str | None = None, passcode: str | None = None):
"""Toggle the flagged state for the current user's answer to a question.
Returns the small flag-button partial so HTMX clients can swap it in-place.
"""
exam = get_object_or_404(Exam, pk=pk)
if not exam.active:
return exam_inactive(request, context={"exam": exam})
exam.check_user_can_take(cid, passcode, request.user)
# canonical questions list
questions = list(exam.get_questions())
try:
index = int(sk)
except Exception:
raise Http404("Invalid question index")
if index < 0 or index >= len(questions):
raise Http404("Question not found in exam")
question = questions[index]
# Determine actor: either a CidUser (for cid flows) or the logged-in user
cid_user_obj = None
if cid is not None:
try:
cid_user_obj = CidUser.objects.filter(cid=cid).first()
except Exception:
cid_user_obj = None
# Find existing flag for this question+actor
ct = ContentType.objects.get_for_model(question)
flags_qs = Flag.objects.filter(content_type=ct, object_id=question.pk)
if cid_user_obj is not None:
flags_qs = flags_qs.filter(cid_user=cid_user_obj)
else:
flags_qs = flags_qs.filter(user=request.user)
flagged = flags_qs.exists()
if request.method == "POST":
# set param explicitly controls state; otherwise toggle
set_val = request.POST.get("set")
if set_val is None:
# toggle
if flagged:
flags_qs.delete()
flagged = False
else:
Flag.objects.create(content_type=ct, object_id=question.pk, user=(None if cid_user_obj else request.user), cid_user=cid_user_obj)
flagged = True
else:
desired = str(set_val).lower() in ("1", "true", "yes")
if desired and not flagged:
Flag.objects.create(content_type=ct, object_id=question.pk, user=(None if cid_user_obj else request.user), cid_user=cid_user_obj)
flagged = True
elif not desired and flagged:
flags_qs.delete()
flagged = False
# Render the partial button for the current state
return render(request, "physics/partials/exam_flag_button.html", {"flagged": flagged, "exam": exam, "pos": index, "cid": cid, "passcode": passcode})
# def loadJsonAnswer(answer):
# # As access is not restricted make sure the data appears valid
# if (not isinstance(answer["cid"], int)) or (not isinstance(answer["eid"], int)):
# return False, JsonResponse({"success": False, "error": "invalid"})
#
# # The model should catch invalid data but this should be less intensive
# max_int = 2147483647
# if answer["cid"] > max_int or answer["eid"] > max_int:
# return False, JsonResponse({"success": False, "error": "invalid candidate id"})
#
# exam = get_object_or_404(Exam, pk=answer["eid"])
#
# # if not exam.active:
# # return False, JsonResponse(
# # {"success": False, "error": "No active exam: {}".format(answer["eid"])}
# # )
#
# questions = defaultdict(dict)
#
# for q, n, a in answer["ans"]:
# questions[q][n] = a
# pass
#
# for qid in questions:
# exiting_answers = UserAnswer.objects.filter(
# question__id=qid, exam__id=answer["eid"], cid=answer["cid"]
# )
#
# a = questions[qid]["a"]
# b = questions[qid]["b"]
# c = questions[qid]["c"]
# d = questions[qid]["d"]
# e = questions[qid]["e"]
#
# if not exiting_answers:
# ans = UserAnswer(a=a, b=b, c=c, d=d, e=e, cid=answer["cid"])
# ans.question_id = qid
# ans.exam_id = answer["eid"]
#
# ans.full_clean()
#
# ans.save()
# else:
# # Update an existing answer
# # should never be more than one (famous last words)
# ans = exiting_answers[0]
# ans.a = a
# ans.b = b
# ans.c = c
# ans.d = d
# ans.e = e
# ans.full_clean()
# ans.save()
#
# return True, None
GenericExamViews = ExamViews(Exam, Question, None, UserAnswer, "physics", "physics")
GenericViews = GenericViewBase("physics", Question, UserAnswer, Exam)
class ExamCreate(ExamCreateBase):
model = Exam
form_class = ExamForm
class ExamUpdate(ExamUpdateBase, AuthorOrCheckerRequiredMixin):
model = Exam
form_class = ExamForm
class ExamDelete(AuthorOrCheckerRequiredMixin, ExamDeleteBase):
model = Exam
class ExamClone(AuthorOrCheckerRequiredMixin, ExamCloneMixin, ExamCreate):
"""Clone exam view"""
class ExamAuthorUpdate(
RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView
):
model = Exam
form_class = ExamAuthorForm
template_name = "author_form.html"
class ExamMarkersUpdate(
RevisionMixin, CheckCanEditMixin, LoginRequiredMixin, AuthorRequiredMixin, UpdateView
):
model = Exam
form_class = ExamMarkerForm
template_name = "markers_form.html"
class ExamGroupsUpdate(ExamGroupsUpdateBase):
model = Exam
form_class = ExamGroupsForm
class QuestionView(
LoginRequiredMixin, SingleTableMixin, FilterView, AuthorOrCheckerRequiredMixin
):
model = Question
table_class = QuestionTable
template_name = "question_table_view.html"
filterset_class = QuestionFilter
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["app_name"] = "physics"
return context
class QuestionCreateBase(RevisionMixin, LoginRequiredMixin, CreateView):
model = Question
form_class = QuestionForm
def get_form_kwargs(self):
kwargs = super(QuestionCreateBase, self).get_form_kwargs()
kwargs.update({"user": self.request.user})
return kwargs
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.save()
# add the current user as an author
form.instance.author.add(self.request.user.id)
response = super().form_valid(form)
return response
class QuestionCreate(QuestionCreateBase):
def get_initial(self):
if "pk" in self.kwargs:
initial = super().get_initial()
exam = get_object_or_404(Exam, pk=self.kwargs["pk"])
# When creating a question from an exam context we don't have an
# 'exams' field on the ModelForm (exams is a reverse relation).
# Store nothing in the initial data here; attachment is handled
# in form_valid below.
return initial
def form_valid(self, form):
# Let the base class save the object and set the author
response = super().form_valid(form)
# If the create URL included an exam pk, add the new question to that exam
if "pk" in self.kwargs:
try:
exam = get_object_or_404(Exam, pk=self.kwargs["pk"])
# exam.exam_questions is a ManyToMany through relation; add is fine
exam.exam_questions.add(self.object)
except Exception:
# Don't fail the whole request if attaching to the exam fails;
# the question has already been created and author set.
pass
return response
class UserAnswerView(AuthorOrCheckerRequiredMixin, LoginRequiredMixin, DetailView):
model = UserAnswer
class UserAnswerTableView(
AuthorOrCheckerRequiredMixin, LoginRequiredMixin, SingleTableMixin, FilterView
):
model = UserAnswer
table_class = UserAnswerTable
template_name = "physics/user_answer_question_view.html"
filterset_class = UserAnswerFilter
class UserAnswerDelete(SuperuserRequiredMixin, DeleteView):
model = UserAnswer
template_name = "user_answer_delete.html"
success_url = reverse_lazy("physics:user_answer_table_view")
@login_required
def exam_review_question_responses(request, pk: int, q_index: int):
"""Return the responses partial for a given exam question (HTMX-friendly)."""
exam = get_object_or_404(Exam, pk=pk)
questions = exam.get_questions()
try:
question = questions[q_index]
except Exception:
raise Http404("Question not found in exam")
context = {
"exam": exam,
"question": question,
"q_index": q_index,
"app_name": "physics",
}
# Prefilter user answers for this exam to avoid template-side filtering
try:
exam_user_answers = question.cid_user_answers.filter(exam=exam).select_related("user")
except Exception:
exam_user_answers = question.cid_user_answers.all()
# Default: anonymise unless reveal=1 is provided
reveal_flag = str(request.GET.get("reveal", "")).lower() in ("1", "true", "on")
context["reveal_respondents"] = reveal_flag
context["exam_user_answers"] = exam_user_answers
return render(request, "physics/partials/exam_review_question_responses_fragment.html", context)
@login_required
def exam_review_question_summary(request, pk: int, q_index: int):
"""Return the aggregated response summary partial for a given exam question (HTMX-friendly)."""
exam = get_object_or_404(Exam, pk=pk)
questions = exam.get_questions()
try:
question = questions[q_index]
except Exception:
raise Http404("Question not found in exam")
# Aggregate user answers for this question & exam
ua_qs = question.cid_user_answers.filter(exam=exam)
total_responses = ua_qs.count()
# Physics answers may be stored as tuples/lists or named fields; attempt to aggregate
counts = defaultdict(int)
other_count = 0
# Try simple 'answer' field aggregation first; if not present, fall
# back to iterating objects and using get_answer() or composing parts.
try:
for ans in ua_qs.values_list("answer", flat=True):
if not ans:
other_count += 1
continue
if isinstance(ans, (list, tuple)):
key = ", ".join([str(x) for x in ans])
else:
key = str(ans)
counts[key] += 1
except Exception:
for ua in ua_qs:
ans_val = None
try:
if hasattr(ua, "get_answer"):
ans_val = ua.get_answer()
except Exception:
ans_val = None
if not ans_val:
parts = []
for fld in ("a", "b", "c", "d", "e"):
if hasattr(ua, fld):
v = getattr(ua, fld)
if v is None:
continue
if isinstance(v, (list, tuple)):
parts.extend([str(x) for x in v])
else:
parts.append(str(v))
if parts:
ans_val = ", ".join(parts)
if not ans_val:
other_count += 1
continue
counts[ans_val] += 1
counts["unanswered"] = other_count
pcts = {}
if total_responses:
for k, v in counts.items():
pcts[k] = round(100.0 * v / total_responses, 1)
else:
for k in counts.keys():
pcts[k] = 0.0
# Provide an explicit iterable of structured items for templates to consume.
# Each item includes the original key, count, pct and optional per-part
# breakdown (list of {value, is_correct}) for physics multi-part answers.
exam_response_items = []
parts_names = ["a", "b", "c", "d", "e"]
for k, v in sorted(counts.items(), key=lambda x: x[1], reverse=True):
item = {"key": k, "count": v, "pct": pcts.get(k, 0.0), "parts": None}
# Attempt to parse comma-separated boolean-like keys into parts
try:
if isinstance(k, str) and "," in k:
parts_raw = [p.strip() for p in k.split(",")]
if len(parts_raw) == 5:
parts_details = []
for idx, sval in enumerate(parts_raw):
sval_l = sval.lower()
if sval_l in ("true", "t", "1", "yes"):
val = True
elif sval_l in ("false", "f", "0", "no"):
val = False
elif sval_l in ("none", "", "not answered", "not_answered"):
val = None
else:
# leave as string fallback
val = sval
# determine correctness where possible
is_correct = None
try:
correct_val = getattr(question, f"{parts_names[idx]}_answer")
if isinstance(val, bool) and isinstance(correct_val, bool):
is_correct = val == correct_val
except Exception:
is_correct = None
parts_details.append({"value": val, "is_correct": is_correct})
item["parts"] = parts_details
except Exception:
item["parts"] = None
exam_response_items.append(item)
# Compute percent correct using per-answer scoring
correct_count = None
correct_pct = None
try:
# For physics, count true/false sub-questions individually rather than
# requiring a full-match across all parts.
if total_responses:
correct_subitems = 0
total_subitems = 0
for ua in ua_qs:
try:
score = ua.get_answer_score()
except Exception:
continue
if isinstance(score, (list, tuple)) and score:
for s in score:
if isinstance(s, (int, float)) and s > 0:
correct_subitems += 1
total_subitems += len(score)
else:
if isinstance(score, (int, float)):
if score > 0:
correct_subitems += 1
total_subitems += 1
correct_count = correct_subitems
if total_subitems:
correct_pct = round(100.0 * correct_subitems / total_subitems, 1)
else:
correct_pct = 0.0
else:
correct_count = 0
correct_pct = 0.0
except Exception:
correct_count = None
correct_pct = None
# Per-part (a-e) true/false aggregation. Each physics question has 5 parts
# stored as boolean fields on UserAnswer (a,b,c,d,e). Build counts and
# percentages for each part so templates can render them as separate TF
# sub-questions.
per_part_stats = []
try:
parts = ["a", "b", "c", "d", "e"]
# initialize counters
for idx, part in enumerate(parts):
per_part_stats.append({
"part": part,
"label": getattr(question, part).strip() if getattr(question, part, None) else f"Part {idx+1}",
"true_count": 0,
"false_count": 0,
"total": 0,
"correct_count": 0,
"correct_pct": 0.0,
"pct_true": 0.0,
})
for ua in ua_qs:
for idx, part in enumerate(parts):
try:
val = getattr(ua, part)
except Exception:
continue
if val is True:
per_part_stats[idx]["true_count"] += 1
elif val is False:
per_part_stats[idx]["false_count"] += 1
# count only explicit booleans as total
if isinstance(val, bool):
per_part_stats[idx]["total"] += 1
# correct counting
try:
correct_val = getattr(question, f"{part}_answer")
except Exception:
correct_val = None
if isinstance(val, bool) and isinstance(correct_val, bool):
if val == correct_val:
per_part_stats[idx]["correct_count"] += 1
# compute percentages
for stats in per_part_stats:
if stats["total"]:
stats["pct_true"] = round(100.0 * stats["true_count"] / stats["total"], 1)
stats["correct_pct"] = round(100.0 * stats["correct_count"] / stats["total"], 1)
else:
stats["pct_true"] = 0.0
stats["correct_pct"] = 0.0
except Exception:
per_part_stats = []
# Provide a best-effort correct answer display
correct_answer = None
try:
correct_answer = question.get_answers()
if isinstance(correct_answer, (list, tuple)):
correct_answer = ", ".join([str(x) for x in correct_answer])
else:
correct_answer = str(correct_answer)
except Exception:
correct_answer = None
context = {
"exam": exam,
"question": question,
"q_index": q_index,
"app_name": "physics",
"exam_response_counts": counts,
"exam_response_items": exam_response_items,
"exam_response_pcts": pcts,
"exam_response_total": total_responses,
"exam_response_correct_count": correct_count,
"exam_response_correct_pct": correct_pct,
"exam_response_texts": {},
"correct_answer": correct_answer,
"per_part_stats": per_part_stats,
}
return render(request, "physics/partials/exam_review_question_summary_fragment.html", context)