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("") 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)