1020 lines
35 KiB
Python
1020 lines
35 KiB
Python
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()
|
|
print(exams)
|
|
|
|
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("<div role='alert' class='alert alert-info'>Exam completed</div>")
|
|
|
|
|
|
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)
|