feat: implement HTMX support for exam question navigation and flagging functionality
This commit is contained in:
+185
-1
@@ -38,6 +38,8 @@ from django.db import transaction
|
||||
import re
|
||||
import logging
|
||||
|
||||
from generic.models import Flag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
import uuid
|
||||
|
||||
@@ -521,16 +523,48 @@ def exam_take(request, pk: int, sk: int, cid: int = None, passcode: str = None):
|
||||
take_url = "sbas:exam_take_user"
|
||||
finish_url = "sbas:exam_take_overview_user"
|
||||
|
||||
# HTMX detection: header 'HX-Request' or META 'HTTP_HX_REQUEST'
|
||||
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:
|
||||
if is_htmx:
|
||||
try:
|
||||
url = reverse(finish_url, kwargs=kwargs)
|
||||
except Exception:
|
||||
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:
|
||||
if is_htmx:
|
||||
resp = exam_take_fragment(request, pk=pk, sk=pos, cid=cid, passcode=passcode)
|
||||
try:
|
||||
resp['HX-Trigger'] = json.dumps({'saved': True})
|
||||
except Exception:
|
||||
resp['HX-Trigger'] = 'saved'
|
||||
return resp
|
||||
return redirect(take_url, sk=pos, **kwargs)
|
||||
elif "goto" in request.POST:
|
||||
return redirect(take_url, sk=int(request.POST.get("goto")), **kwargs)
|
||||
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)
|
||||
|
||||
if is_htmx:
|
||||
return exam_take_fragment(request, pk=pk, sk=pos, cid=cid, passcode=passcode)
|
||||
else:
|
||||
form = UserAnswerForm(instance=answer)
|
||||
|
||||
@@ -557,6 +591,156 @@ def exam_take(request, pk: int, sk: int, cid: int = None, passcode: str = None):
|
||||
)
|
||||
|
||||
|
||||
def exam_take_fragment(request, pk: int, sk: int, cid: int = None, passcode: str = None):
|
||||
"""Return an HTMX partial for SBAs question form and controls."""
|
||||
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)
|
||||
|
||||
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.answer
|
||||
else:
|
||||
form = UserAnswerForm()
|
||||
saved_answer = None
|
||||
|
||||
# compute flagged state
|
||||
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 answered/flagged sets for menu
|
||||
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, "sbas/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,
|
||||
"passcode": passcode,
|
||||
"cid_user_exam": cid_user_exam,
|
||||
"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]),
|
||||
})
|
||||
|
||||
|
||||
|
||||
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, "sbas/partials/exam_flag_button.html", {"flagged": flagged, "exam": exam, "pos": index, "cid": cid, "passcode": passcode})
|
||||
|
||||
|
||||
GenericExamViews = ExamViews(Exam, Question, None, UserAnswer, "sbas", "sbas")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user