Compare commits

...
19 Commits
Author SHA1 Message Date
Ross 70430b2593 Refactor mark view to improve question handling and user answer management, enhancing overall exam experience. 2025-11-10 22:35:24 +00:00
Ross e989a18d31 Enhance create_anatomy_questions management command with optional image generation and improved argument handling 2025-11-10 22:32:30 +00:00
Ross e39539fb99 Add management commands for creating anatomy questions with customizable options 2025-11-10 22:24:44 +00:00
Ross 44ca49899a Refactor button styles for previous and next actions to use secondary color, enhancing visual consistency across the exam interface. 2025-11-10 22:21:43 +00:00
Ross 4246713ee8 Refactor styles for physics finish list and overview question buttons to improve responsiveness and layout on various screen sizes. 2025-11-10 22:09:02 +00:00
Ross 5a4792ea43 Enhance exam take fragment to update global question header with current question number and stem, improving synchronization and user experience. 2025-11-10 22:04:46 +00:00
Ross 94d5771a26 Refactor exam overview to use anchor elements for questions, improving HTML validity and accessibility; enhance styles for better layout and responsiveness. 2025-11-10 22:01:15 +00:00
Ross 200a6e2a1c Enhance exam take functionality to support HX-Redirect for HTMX clients, ensuring full navigation experience 2025-11-10 21:58:12 +00:00
Ross 2ff857621a Refactor exam take functionality to redirect to full overview page instead of returning HTMX fragment 2025-11-10 21:56:41 +00:00
Ross 52c4f27e2b Add HTMX support for exam overview fragment to enhance user experience 2025-11-10 21:52:46 +00:00
Ross c1eff140f8 Add global listener for HTMX save confirmations and trigger event 2025-11-10 21:46:16 +00:00
Ross dce72f9abe Enhance exam take functionality to support HTMX save action and partial HTML responses 2025-11-10 21:43:19 +00:00
Ross 738752c030 Enhance exam take functionality to support HTMX for dynamic question loading and navigation 2025-11-10 21:39:36 +00:00
Ross 41fc7050ad Refactor exam take functionality to support HTMX for dynamic question loading and navigation 2025-11-10 21:30:45 +00:00
Ross 6eec40c43b Style: Enhance overview button appearance and add hover effects for improved user experience 2025-11-10 21:25:47 +00:00
Ross 7782ac55ca Refactor CSS styles for exam layout to improve responsiveness and alignment of question inputs and labels 2025-11-10 21:22:23 +00:00
Ross 9cf7ad6da7 Refactor exam_take function to use a canonical list of questions for consistent indexing and improved error handling for question navigation 2025-11-10 21:18:58 +00:00
Ross 922ca5053f Refactor exam_take function to use a canonical list of questions for consistent indexing and improved error handling for question navigation 2025-11-10 21:01:02 +00:00
Ross 10739f517f Add command to generate sample physics questions with customizable options 2025-11-10 20:56:18 +00:00
12 changed files with 1140 additions and 340 deletions
+1
View File
@@ -1,4 +1,5 @@
media/
mediafiles/
__pycache__
.vscode/
+1
View File
@@ -0,0 +1 @@
"""Management package for anatomy app."""
+1
View File
@@ -0,0 +1 @@
"""Management commands package for anatomy app."""
@@ -0,0 +1,180 @@
from __future__ import annotations
import os
import io
from uuid import uuid4
from typing import List, Tuple
from django.core.management.base import BaseCommand, CommandError
from django.core.files import File
from django.contrib.auth import get_user_model
from PIL import Image
from anatomy.models import AnatomyQuestion, Exam, ExamQuestionDetail, QuestionType, Structure
from generic.models import Modality as GenericModality # type: ignore
class Command(BaseCommand):
help = (
"Create anatomy questions and assign them to an exam."
"Requires --exam-id and --image-path (path readable inside the container)."
)
def add_arguments(self, parser):
parser.add_argument("--exam-id", type=int, required=False, help="ID of the anatomy.Exam to assign questions to (if omitted a new exam will be created)")
parser.add_argument("--exam-name", type=str, default=None, help="Name to use when creating a new exam if --exam-id omitted")
parser.add_argument("--count", type=int, default=1, help="Number of questions to create")
parser.add_argument("--image-path", type=str, required=False, help="Path to image file to use for each created question")
parser.add_argument("--generate-image", action="store_true", help="Generate a placeholder image automatically if --image-path is not provided or not found")
parser.add_argument("--image-size", type=str, default="800x600", help="Generated image size WIDTHxHEIGHT, e.g. 800x600")
parser.add_argument("--image-color", type=str, default="#777777", help="Background color for generated image (hex) e.g. #ffffff")
parser.add_argument("--description", type=str, default="Generated question {i}", help="Description template; use {i} for index")
parser.add_argument("--question-type-id", type=int, default=None, help="Optional QuestionType id to assign")
parser.add_argument("--modality-id", type=int, default=None, help="Optional Modality id to assign (generic.Modality id)")
parser.add_argument("--structure-id", type=int, default=None, help="Optional Structure id to assign")
parser.add_argument("--author-id", type=int, action="append", help="Author user id; repeatable")
parser.add_argument("--start-sort", type=int, default=1, help="Starting sort_order for the first question")
def handle(self, *args, **options):
exam_id = options.get("exam_id")
count = options["count"]
image_path = options.get("image_path")
generate_image = options.get("generate_image")
image_size_str = options.get("image_size") or "800x600"
image_color = options.get("image_color") or "#777777"
description_template = options["description"]
qtype_id = options.get("question_type_id")
modality_id = options.get("modality_id")
structure_id = options.get("structure_id")
author_ids: List[int] = options.get("author_id") or []
sort = options.get("start_sort") or 1
exam = None
if exam_id:
try:
exam = Exam.objects.get(pk=exam_id)
except Exam.DoesNotExist:
raise CommandError(f"Exam with id {exam_id} does not exist")
else:
# create new exam
exam_name = options.get("exam_name") or f"Auto Anatomy Exam {uuid4().hex[:8]}"
exam = Exam(name=exam_name, active=True, open_access=True)
exam.save()
self.stdout.write(self.style.SUCCESS(f"Created exam id={exam.id} name='{exam.name}'"))
# validate image path if provided
if image_path and not os.path.exists(image_path):
if not generate_image:
raise CommandError(f"Image file not found at path: {image_path}")
else:
image_path = None
# Resolve optional foreign keys
qtype = None
if qtype_id:
qtype = QuestionType.objects.filter(pk=qtype_id).first()
if not qtype:
raise CommandError(f"QuestionType id {qtype_id} not found")
modality = None
if modality_id:
# The project stores Modality in generic.models.Modality
try:
from generic.models import Modality as GModality
modality = GModality.objects.filter(pk=modality_id).first()
if not modality:
raise CommandError(f"Modality id {modality_id} not found in generic.Modality")
except Exception as e:
raise CommandError(str(e))
structure = None
if structure_id:
from atlas.models import Structure as AtlasStructure
structure = AtlasStructure.objects.filter(pk=structure_id).first()
if not structure:
raise CommandError(f"Structure id {structure_id} not found")
User = get_user_model()
authors = []
for uid in author_ids:
u = User.objects.filter(pk=uid).first()
if not u:
raise CommandError(f"Author user id {uid} not found")
authors.append(u)
# parse image size
try:
w, h = image_size_str.lower().split("x")
image_size: Tuple[int, int] = (int(w), int(h))
except Exception:
raise CommandError("Invalid --image-size; expected WIDTHxHEIGHT, e.g. 800x600")
created = []
for i in range(1, count + 1):
desc = description_template.format(i=i)
# determine image source: either provided path or generated image
unique_name = f"{uuid4().hex}_image.jpg"
if image_path:
# use provided file
with open(image_path, "rb") as f:
django_file = File(f, name=unique_name)
img_file_for_save = django_file
# create and save question
q = AnatomyQuestion(
question_type=qtype,
description=desc,
answer_help="",
)
if modality:
q.modality = modality
if structure:
q.structure = structure
q.image.save(unique_name, img_file_for_save, save=False)
q.save()
else:
# generate an image in memory
buf = io.BytesIO()
img = Image.new("RGB", image_size, image_color)
img.save(buf, format="JPEG")
buf.seek(0)
django_file = File(buf, name=unique_name)
q = AnatomyQuestion(
question_type=qtype,
description=desc,
answer_help="",
)
if modality:
q.modality = modality
if structure:
q.structure = structure
q.image.save(unique_name, django_file, save=False)
q.save()
# authors
if authors:
q.author.set(authors)
# add to exam via the many-to-many through relationship
try:
exam.exam_questions.add(q, through_defaults={"sort_order": sort})
except TypeError:
ExamQuestionDetail.objects.create(exam=exam, anatomyquestion=q, sort_order=sort)
self.stdout.write(self.style.SUCCESS(f"Created question id={q.id} and added to exam id={exam.id} with sort_order={sort}"))
created.append(q)
sort += 1
self.stdout.write(self.style.SUCCESS(f"Finished: created {len(created)} question(s)"))
+151 -151
View File
@@ -294,6 +294,157 @@ def mark(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
n = sk
question_details = {
"total": len(questions),
"current": n + 1,
}
try:
question: AnatomyQuestion = questions[sk]
except IndexError:
raise Http404("Exam question does not exist")
if request.method == "POST":
form = MarkAnatomyQuestionForm(request.POST)
# *******************************
# TODO: convert to JSON request
# *******************************
if form.is_valid():
# If skip button is pressed skip the question without marking
if "skip" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
cd = form.cleaned_data
marked_answers = json.loads(cd.get("marked_answers"))
# This should probably use json (or something else...)
# ajax.....
to_run = (
("correct", Answer.MarkOptions.CORRECT),
("half-correct", Answer.MarkOptions.HALF_MARK),
("incorrect", Answer.MarkOptions.INCORRECT),
)
for status_string, status in to_run:
for ans in marked_answers[status_string]:
ans = ans.strip()
if ans == "":
continue
a = Answer.objects.filter(
answer__iexact=ans, question_id=question.pk
).first()
if a is None:
a = Answer()
a.question_id = question.pk
a.answer = ans
a.status = status
a.save()
if "next" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
elif "previous" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n - 1)
# Reset user answers (relies on the functional javascript)
unmarked_user_answers = set()
else:
form = MarkAnatomyQuestionForm()
# correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
# half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK)
# incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
correct_answers = []
half_mark_answers = []
incorrect_answers = []
review_user_answers = []
unmarked_answers_bool = False
unmarked_user_answers = []
if review:
unmarked_user_answers = question.get_user_answers(
exam_pk=exam_pk, include_normal=False
)
elif unmarked_exam_answers_only:
unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk)
else:
unmarked_user_answers = question.get_unmarked_user_answers()
if not unmarked_exam_answers_only:
correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK)
incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
answer_suggest_incorrect: list = []
if review:
for ans in unmarked_user_answers:
# duplicated from user answer model....
marked_ans = question.answers.filter(answer_compare__iexact=ans).first()
mark = "unmarked"
if marked_ans is not None:
if marked_ans.status == Answer.MarkOptions.CORRECT:
mark = "correct"
elif marked_ans.status == Answer.MarkOptions.HALF_MARK:
mark = "half-correct"
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
mark = "incorrect"
if mark == "unmarked":
unmarked_answers_bool = True
review_user_answers.append((ans, mark))
else:
for ans in unmarked_user_answers:
for suggest_incorrect in question.answer_suggest_incorrect:
if suggest_incorrect in ans:
answer_suggest_incorrect.append(ans)
break
words_present: set = set()
words_present_in_correct: set = set()
for ans in correct_answers:
answer_words = set(ans.get_constituent_words())
words_present.update(answer_words)
words_present_in_correct.update(answer_words)
for ans in half_mark_answers:
answer_words = set(ans.get_constituent_words())
words_present.update(answer_words)
words_present_in_correct.update(answer_words)
for ans in incorrect_answers:
answer_words = set(ans.get_constituent_words())
words_present.update(answer_words)
words_suggest_incorrect = words_present - words_present_in_correct - set(question.answer_suggest_incorrect)
return render(
request,
"anatomy/mark.html",
{
"exam": exam,
"form": form,
"review": review,
"question": question,
"question_number": n,
"question_details": question_details,
"unmarked_answers_bool": unmarked_answers_bool,
"user_answers": unmarked_user_answers,
"review_user_answers": review_user_answers,
"correct_answers": correct_answers,
"half_mark_answers": half_mark_answers,
"incorrect_answers": incorrect_answers,
"unmarked_exam_answers_only": unmarked_exam_answers_only,
"answer_suggest_incorrect": answer_suggest_incorrect,
"words_suggest_incorrect": words_suggest_incorrect,
},
)
@login_required
@@ -472,157 +623,6 @@ def exam_review_question_summary(request, pk: int, q_index: int):
return render(request, "anatomy/partials/exam_review_question_summary_fragment.html", context)
question_details = {
"total": len(questions),
"current": n + 1,
}
try:
question: AnatomyQuestion = questions[sk]
except IndexError:
raise Http404("Exam question does not exist")
if request.method == "POST":
form = MarkAnatomyQuestionForm(request.POST)
# *******************************
# TODO: convert to JSON request
# *******************************
if form.is_valid():
# If skip button is pressed skip the question without marking
if "skip" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
cd = form.cleaned_data
marked_answers = json.loads(cd.get("marked_answers"))
# This should probably use json (or something else...)
# ajax.....
to_run = (
("correct", Answer.MarkOptions.CORRECT),
("half-correct", Answer.MarkOptions.HALF_MARK),
("incorrect", Answer.MarkOptions.INCORRECT),
)
for status_string, status in to_run:
for ans in marked_answers[status_string]:
ans = ans.strip()
if ans == "":
continue
a = Answer.objects.filter(
answer__iexact=ans, question_id=question.pk
).first()
if a is None:
a = Answer()
a.question_id = question.pk
a.answer = ans
a.status = status
a.save()
if "next" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n + 1)
elif "previous" in request.POST:
return redirect(mark_url, exam_pk=exam_pk, sk=n - 1)
# Reset user answers (relies on the functional javascript)
unmarked_user_answers = set()
else:
form = MarkAnatomyQuestionForm()
# correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
# half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK)
# incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
correct_answers = []
half_mark_answers = []
incorrect_answers = []
review_user_answers = []
unmarked_answers_bool = False
unmarked_user_answers = []
if review:
unmarked_user_answers = question.get_user_answers(
exam_pk=exam_pk, include_normal=False
)
elif unmarked_exam_answers_only:
unmarked_user_answers = question.get_unmarked_user_answers(exam_pk=exam_pk)
else:
unmarked_user_answers = question.get_unmarked_user_answers()
if not unmarked_exam_answers_only:
correct_answers = question.answers.filter(status=Answer.MarkOptions.CORRECT)
half_mark_answers = question.answers.filter(status=Answer.MarkOptions.HALF_MARK)
incorrect_answers = question.answers.filter(status=Answer.MarkOptions.INCORRECT)
answer_suggest_incorrect: list = []
if review:
for ans in unmarked_user_answers:
# duplicated from user answer model....
marked_ans = question.answers.filter(answer_compare__iexact=ans).first()
mark = "unmarked"
if marked_ans is not None:
if marked_ans.status == Answer.MarkOptions.CORRECT:
mark = "correct"
elif marked_ans.status == Answer.MarkOptions.HALF_MARK:
mark = "half-correct"
elif marked_ans.status == Answer.MarkOptions.INCORRECT:
mark = "incorrect"
if mark == "unmarked":
unmarked_answers_bool = True
review_user_answers.append((ans, mark))
else:
for ans in unmarked_user_answers:
for suggest_incorrect in question.answer_suggest_incorrect:
if suggest_incorrect in ans:
answer_suggest_incorrect.append(ans)
break
words_present: set = set()
words_present_in_correct: set = set()
for ans in correct_answers:
answer_words = set(ans.get_constituent_words())
words_present.update(answer_words)
words_present_in_correct.update(answer_words)
for ans in half_mark_answers:
answer_words = set(ans.get_constituent_words())
words_present.update(answer_words)
words_present_in_correct.update(answer_words)
for ans in incorrect_answers:
answer_words = set(ans.get_constituent_words())
words_present.update(answer_words)
words_suggest_incorrect = words_present - words_present_in_correct - set(question.answer_suggest_incorrect)
return render(
request,
"anatomy/mark.html",
{
"exam": exam,
"form": form,
"review": review,
"question": question,
"question_number": n,
"question_details": question_details,
"unmarked_answers_bool": unmarked_answers_bool,
"user_answers": unmarked_user_answers,
"review_user_answers": review_user_answers,
"correct_answers": correct_answers,
"half_mark_answers": half_mark_answers,
"incorrect_answers": incorrect_answers,
"unmarked_exam_answers_only": unmarked_exam_answers_only,
"answer_suggest_incorrect": answer_suggest_incorrect,
"words_suggest_incorrect": words_suggest_incorrect,
},
)
#@login_required
@@ -0,0 +1,129 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.db import transaction
import random
from physics.models import Question, Category, Exam
User = get_user_model()
class Command(BaseCommand):
help = "Generate a number of sample physics questions for development/testing."
def add_arguments(self, parser):
parser.add_argument(
"--count",
type=int,
default=10,
help="Number of sample questions to create (default: 10)",
)
parser.add_argument(
"--category",
type=str,
default=None,
help="Category name to assign to created questions (created if missing)",
)
parser.add_argument(
"--author",
type=str,
default=None,
help="Username of an existing user to add as author for created questions",
)
parser.add_argument(
"--seed",
type=int,
default=None,
help="Optional random seed for reproducible generation",
)
parser.add_argument(
"--create-exam",
dest="create_exam",
type=str,
default=None,
help="If provided, create an exam with this name and add generated questions to it",
)
parser.add_argument(
"--activate",
action="store_true",
dest="activate",
help="If set when creating an exam, mark the exam active",
)
def handle(self, *args, **options):
count = options.get("count") or 10
category_name = options.get("category")
author_username = options.get("author")
seed = options.get("seed")
if seed is not None:
random.seed(seed)
category = None
if category_name:
category, _ = Category.objects.get_or_create(category=category_name)
created_exam = None
create_exam_name = options.get("create_exam")
activate = options.get("activate")
if create_exam_name:
created_exam = Exam.objects.create(name=create_exam_name, active=bool(activate))
author_user = None
if author_username:
try:
author_user = User.objects.get(username=author_username)
except User.DoesNotExist:
self.stdout.write(self.style.WARNING(f"Author username '{author_username}' not found; continuing without author"))
author_user = None
created = 0
with transaction.atomic():
for i in range(count):
qnum = Question.objects.count() + 1
stem = f"Sample physics question #{qnum}: Identify which of the following statements are true."
# simple option texts
opts = [
f"Statement A for question {qnum}",
f"Statement B for question {qnum}",
f"Statement C for question {qnum}",
f"Statement D for question {qnum}",
f"Statement E for question {qnum}",
]
# randomly decide true/false for each option but ensure at least one True
answers = [random.choice([True, False]) for _ in range(5)]
if not any(answers):
answers[random.randrange(5)] = True
q = Question.objects.create(
stem=stem,
a=opts[0],
a_answer=answers[0],
a_feedback="",
b=opts[1],
b_answer=answers[1],
b_feedback="",
c=opts[2],
c_answer=answers[2],
c_feedback="",
d=opts[3],
d_answer=answers[3],
d_feedback="",
e=opts[4],
e_answer=answers[4],
e_feedback="",
category=category,
)
# If we created an exam, add this question to it
if created_exam:
created_exam.exam_questions.add(q)
if author_user:
q.author.add(author_user)
created += 1
self.stdout.write(self.style.SUCCESS(f"Created {created} sample physics question(s)."))
+235 -170
View File
@@ -3,17 +3,24 @@
{% block content %}
<span id="user-id">
{% if request.user.is_authenticated %}
User: {{request.user}}
User: {{ request.user }}
{% else %}
CID: {{cid}}
CID: {{ cid }}
{% endif %}
</span>
{% include "exam_clock.html" %}
<div class="no-select">
<h2>{{exam}}: Question [<span id="question-number">{{pos|add:1}}</span>/<span id="exam-length">{{exam_length}}</span>]</h2>
<h2>
{{ exam }}: Question
[<span id="question-number">{{ pos|add:1 }}</span>/<span id="exam-length">{{ exam_length }}</span>]
</h2>
{% if exam.publish_results %}
<div class="alert alert-primary review-mode-alert" role="alert">
Exam is in review mode. Add question feedback <a href="#" onclick="return window.create_popup_window('{% url 'feedback_create' question_type='physics' pk=question.pk %}')">here</a>.
Exam is in review mode. Add question feedback
<a href="#" onclick="return window.create_popup_window('{% url "feedback_create" question_type="physics" pk=question.pk %}')">here</a>.
</div>
{% elif cid_user_exam.completed %}
<div class="alert alert-primary review-mode-alert" role="alert">
@@ -22,201 +29,259 @@
{% endif %}
<div>
<p><span id="question-stem">{{question.stem|safe}}</span></p>
<p><span id="question-stem">{{ question.stem|safe }}</span></p>
</div>
<div>
<form method="POST" class="post-form">{% csrf_token %}
{{ form.non_field_errors }}
<div class="form-contents">
<ol class="physics-answer-list">
<li data-ans="a">
<div class="fieldWrapper flex-container">
{{ form.a.errors }}
<label for="{{ form.a.id_for_label }}" class="flex-8 question-text">{{question.a|safe}}</label>
<span class="flex-1">{{ form.a }}<span class="postinput"></span></span>
</div>
{% if exam.publish_results and question.a_feedback %}
<span class="feedback">Feedback: {{question.a_feedback}}</span>
{% endif %}
</li>
<li data-ans="b">
<div class="fieldWrapper flex-container">
{{ form.b.errors }}
<label for="{{ form.b.id_for_label }}" class="flex-8 question-text">{{question.b|safe}}</label>
<span class="flex-1">{{ form.b }}<span class="postinput"></span></span>
</div>
</li>
<li data-ans="c">
<div class="fieldWrapper flex-container">
{{ form.c.errors }}
<label for="{{ form.c.id_for_label }}" class="flex-8 question-text">{{question.c|safe}}</label>
<span class="flex-1">{{ form.c }}<span class="postinput"></span></span>
</div>
</li>
<li data-ans="d">
<div class="fieldWrapper flex-container">
{{ form.d.errors }}
<label for="{{ form.d.id_for_label }}" class="flex-8 question-text">{{question.d|safe}}</label>
<span class="flex-1">{{ form.d }}<span class="postinput"></span></span>
</div>
</li>
<li data-ans="e">
<div class="fieldWrapper flex-container">
{{ form.e.errors }}
<label for="{{ form.e.id_for_label }}" class="flex-8 question-text">{{question.e|safe}}</label>
<span class="flex-1">{{ form.e }}<span class="postinput"></span></span>
</div>
</li>
</ol>
</div>
{% if previous > -1 %}
<button type="submit" name="previous" class="save btn btn-default" title="Click to save your answer(s) and go to the previous question">Previous</button>
{% endif %}
{% if next %}
<button type="submit" name="next" class="save btn btn-default" title="Click to save your answer(s) and go to the next quesiton">Next</button>
{% else %}
{% if not exam.publish_results %}
<button type="submit" name="save" class="save btn btn-default" title="Click to save your current answer(s)">Save</button>
{% endif %}
{% endif %}
<br />
<button type="submit" name="finish" class="save btn btn-default" title="Click to go to the overview page (your answers will be saved).">Overview</button>
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
</form>
<div id="question-fragment"
hx-trigger="load"
hx-swap="innerHTML"
hx-get="{% if cid %}{% url 'physics:exam_take_fragment' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_fragment_user' pk=exam.pk sk=pos %}{% endif %}">
<!-- HTMX will load the question form + controls here -->
</div>
</div>
<h4>Questions</h4>
<div id="menu-list">
<div id="menu-list"></div>
</div>
{% include "physics/exam_take_help.html" %}
{% endblock %}
{% endblock %}
{% block js %}
<script>
$(document).ready(() => {
/* beautify ignore:start */
{% if not exam.publish_results and not cid_user_exam.completed %}
{# HTMX fragment will load question-specific JS; nothing required here #}
let time_limit = '{{exam.time_limit}}'
if (time_limit != "None") {
let end_time = new Date({{cid_user_exam.start_time|date:"U"}}*1000+parseInt('{{exam.time_limit}}')*1000);
initializeClock("clockdiv", end_time);
}
{% elif exam.publish_results %}
$(`ol.physics-answer-list input`).each((n, el) => { el.disabled = true; });
{{question.get_answers_js}}.forEach((el, n) => {
console.log(el)
li_el = $(`ol.physics-answer-list li:eq(${n})`)//.addClass(`answer-${el}`)
if (el) {
li_el.addClass("answer-true");
} else {
li_el.addClass("answer-false");
}
if (el == li_el.find("input").get(0).checked) {
li_el.addClass("answer-correct");
} else {
li_el.addClass("answer-incorrect");
}
});
{% elif cid_user_exam.completed %}
$(`ol.physics-answer-list input`).each((n, el) => { el.disabled = true; });
//$("ul.physics-answer-list li[data-ans='{{question.best_answer}}']").addClass("correct");
{% endif %}
for (let i = 0; i < {{ exam_length }}; i++) {
qbutton = $(`<button class="question-menu-item" name="goto-${i}" data-qn="${i}">${i+1}</button>`)
if (i == {{pos}}) {qbutton.addClass("current-question")}
$("#menu-list").append(qbutton);
}
$("button.question-menu-item").on("click", (e) => {
console.log(e.currentTarget.dataset.qn);
document.getElementById("goto-button").value = e.currentTarget.dataset.qn;
$("#goto-button").click();
});
/* beautify ignore:end */
})
</script>
{% endblock %}
{% block css %}
{% block css %}
<style type="text/css">
/* .form-contents {
display: none;
} */
.flex-container {
display: flex;
/* Color variables and dark-mode support
- Uses CSS variables for easy theming
- Honors system preference via prefers-color-scheme
- Also supports a manual `.dark-mode` class on body or a parent
*/
:root {
--bg: #ffffff;
--text: #212529;
--muted: #6c757d;
--secondary: #6c757d;
--card-bg: #ffffff;
--card-border: #e9ecef;
--primary: #0d6efd;
--success: #198754;
--danger: #dc3545;
--feedback-bg: #f8f9fa;
--shadow: 0 1px 2px rgba(0,0,0,0.03);
}
.flex-1 {
flex: 1;
@media (prefers-color-scheme: dark) {
:root {
--bg: #050607;
--text: #e6eef6;
--muted: #9aa4ad;
--card-bg: #0f1720;
--card-border: #1f2a33;
--primary: #6ea8ff;
--success: #36d07a;
--danger: #ff6b6b;
--feedback-bg: #071322;
--shadow: 0 2px 6px rgba(0,0,0,0.6);
}
body, .exam-take-container { background: var(--bg); color: var(--text); }
}
.flex-8 {
flex: 8;
}
/* Manual override: add `dark-mode` to body or a parent to force dark theme */
body.dark-mode, .dark-mode { color: var(--text); background: var(--bg); }
.selected {
border: 1px solid purple;
}
/* Layout and responsiveness */
.exam-take-container { max-width: 980px; margin: 0 auto; padding: 1rem; }
.no-select { user-select: none; }
.answer-true::before {
content: "[Correct answer: True]";
color: darkgray;
float: right;
opacity: 50%;
.flex-container { display: flex; align-items: center; gap: 0.75rem; }
/* fieldWrapper is used around each label + input; ensure it stretches the full width */
.fieldWrapper { width: 100%; display: flex; align-items: center; gap: 0.75rem; }
}
.answer-false::before {
content: "[Correct answer: False]";
color: darkgray;
float: right;
opacity: 50%;
/* We keep two main columns inside each fieldWrapper:
- the label (question-text) which should take the bulk of space and sit to the left on wide screens
- the input container (.flex-1) which holds the input and a post-input label; the post-input should float right
*/
.flex-8 { flex: 8; }
.flex-1 { flex: 1; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
}
.answer-correct {
border: 1px dotted darkgreen;
}
.answer-incorrect {
border: 1px dashed darkred;
}
/* Left-align label text on wide screens so the question text appears on the left, input between, and the
post-input label floats to the right. Padding keeps visual separation. */
.question-text { margin: 0; text-align: left; padding-right: 0.5rem; }
/* Answer items styled as cards */
.physics-answer-list { list-style: none; padding: 0; margin: 0; }
.physics-answer-list li {
margin-top: 5px;
margin-bottom: 5px;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid var(--card-border);
border-radius: 0.5rem;
background: var(--card-bg);
box-shadow: var(--shadow);
color: var(--text);
}
.physics-answer-list li + li { margin-top: 0.75rem; }
.question-text { margin: 0; }
/* Post-input badge (shows True/False text next to inputs) */
.postinput { display: inline-block; min-width: 3rem; text-align: right; color: var(--muted); }
.physics-answer-list input { margin: 0 0.25rem 0 0; }
/* show a compact post-label on the right of the input container */
.physics-answer-list input:checked + .postinput::after {
content: 'True'; color: var(--success); font-weight: 600; margin-left: 0.4rem;
}
.physics-answer-list input:not(:checked) + .postinput::after {
content: 'False'; color: var(--muted); font-weight: 600; margin-left: 0.4rem;
}
.physics-answer-list input:checked+.postinput::after {
margin-left: 20px;
content: "True"
}
.physics-answer-list input:not(:checked)+.postinput::after {
margin-left: 20px;
content: "False"
/* Correctness badges and highlights */
.answer-true::before,
.answer-false::before {
display: inline-block;
margin-left: auto;
padding: 0.18rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
color: #fff;
}
.answer-true::before { content: "Correct"; background: var(--success); }
.answer-false::before { content: "Not correct"; background: var(--muted); }
.answer-correct { background-color: rgba(25,135,84,0.06); border-color: rgba(25,135,84,0.18); }
.answer-incorrect { background-color: rgba(220,53,69,0.04); border-color: rgba(220,53,69,0.12); }
/* Feedback block */
.feedback {
padding: 20px;
padding: 0.85rem 1rem;
display: block;
margin-top: 0.5rem;
border-left: 4px solid var(--primary);
background: var(--feedback-bg);
color: var(--text);
border-radius: 0.25rem;
}
/* Question menu (jump buttons) */
#menu-list { margin-top: 1rem; display:flex; flex-wrap:wrap; gap:0.4rem; }
button.question-menu-item {
padding: 0.35rem 0.6rem;
border-radius: 0.35rem;
border: 1px solid var(--card-border);
background: var(--card-bg);
color: var(--text);
cursor: pointer;
font-size: 0.9rem;
}
button.question-menu-item.current-question {
background: var(--primary); color: #fff; border-color: var(--primary);
box-shadow: 0 1px 3px rgba(13,110,253,0.18);
}
/* Control buttons spacing */
.mt-2 .save { margin-right: 0.5rem; }
.mt-2 .save.btn.btn-default { background: var(--feedback-bg); border: 1px solid var(--card-border); color: var(--text); }
/* Overview button - make it stand out using the primary color */
#overview-button { background: var(--primary); border-color: var(--primary); color: #fff; }
#overview-button:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); }
/* Previous / Next button colors */
.mt-2 button[name="previous"] { background: var(--info); border-color: var(--info); color: #fff; }
.mt-2 button[name="previous"]:hover { filter: brightness(0.95); box-shadow: 0 2px 6px rgba(0,0,0,0.08); }
.mt-2 button[name="next"] { background: var(--info); border-color: var(--info); color: #fff; }
.mt-2 button[name="next"]:hover { filter: brightness(0.95); box-shadow: 0 3px 8px rgba(13,110,253,0.18); }
/* Small helper: make labels wrap nicely on small screens */
@media (max-width: 576px) {
/* On small screens, stack the label and input for readability */
.flex-container { flex-direction: column; align-items: stretch; }
.fieldWrapper { flex-direction: column; align-items: stretch; }
.question-text { text-align: left; padding-right: 0; margin-bottom: 0.4rem; }
.flex-1 { justify-content: flex-start; gap: 0.5rem; }
.postinput { margin-top: 0.4rem; text-align: left; }
.physics-answer-list li { padding: 0.6rem; }
}
</style>
{% endblock %}
{% endblock %}
{% block js %}
<script>
/* HTMX swap helpers: preserve scroll and container height to avoid page jumping
Listens for htmx:beforeSwap and htmx:afterSwap on the question fragment container.
*/
(function(){
function getTarget(evt){
return (evt.detail && (evt.detail.target || evt.detail.elt)) || evt.target;
}
document.addEventListener('htmx:beforeSwap', function(evt){
try{
var target = getTarget(evt);
if(!target || target.id !== 'question-fragment') return;
// save scroll position
target.__savedScrollY = window.scrollY || window.pageYOffset || 0;
// preserve current height to avoid layout shift while fragment is swapping
target.style.minHeight = target.offsetHeight + 'px';
}catch(e){ console && console.error && console.error(e); }
});
document.addEventListener('htmx:afterSwap', function(evt){
try{
var target = getTarget(evt);
if(!target || target.id !== 'question-fragment') return;
var y = target.__savedScrollY;
if(typeof y === 'number') window.scrollTo(0, y);
// remove the minHeight after a short delay to allow content to layout
setTimeout(function(){ target.style.minHeight = ''; }, 60);
// notify fragment-loaded so fragment init code can listen if required
target.dispatchEvent(new CustomEvent('fragment:loaded', { bubbles: true }));
}catch(e){ console && console.error && console.error(e); }
});
})();
</script>
<script>
// Global listener for server-triggered save confirmations.
// The view sets an `HX-Trigger` header named `saved` when an HTMX save occurs.
document.addEventListener('saved', function(evt){
try {
// Prefer toastr if available
if (typeof toastr !== 'undefined' && toastr && typeof toastr.success === 'function') {
toastr.success('Saved');
return;
}
// Lightweight fallback toast
const id = 'htmx-save-toast';
let el = document.getElementById(id);
if (!el) {
el = document.createElement('div');
el.id = id;
el.style.position = 'fixed';
el.style.top = '1rem';
el.style.right = '1rem';
el.style.zIndex = 2000;
el.style.padding = '0.6rem 0.9rem';
el.style.background = 'rgba(40,167,69,0.95)';
el.style.color = '#fff';
el.style.borderRadius = '0.4rem';
el.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
el.style.fontWeight = '600';
el.style.opacity = '0';
el.style.transition = 'opacity 220ms ease-in-out';
document.body.appendChild(el);
}
el.textContent = 'Saved';
// show
requestAnimationFrame(function(){ el.style.opacity = '1'; });
// hide after 1.6s
setTimeout(function(){ el.style.opacity = '0'; }, 1600);
} catch (e) { console && console.error && console.error(e); }
});
</script>
{% endblock %}
@@ -39,14 +39,26 @@
{% endif %}
<div class="overview-text">{{answer_count}} out of {{exam_length}} questions answered. Unanswered questions are shown in red. <p>Click to go to a question.</p></div>
<div class="physics-finish-list">
<div class="overview-text">{{answer_count}} out of {{exam_length}} questions answered. Unanswered questions are shown in red. <span class="d-block">Click any tile to jump to that question.</span></div>
<div class="physics-finish-list" role="list">
{% for question, answer in question_answer_tuples %}
{% comment %} Use an anchor styled as a button rather than nesting button inside anchor (invalid HTML). Keep link targets unchanged. {% endcomment %}
{% if not cid %}
<a href="{% url 'physics:exam_take_user' pk=exam.id sk=forloop.counter0 %}"><button {% if not answer %}class="unanswered" title="You have not answered this question"{% endif %}>{{forloop.counter}}: {{answer.answer}}</button></a>
<a role="listitem" href="{% url 'physics:exam_take_user' pk=exam.id sk=forloop.counter0 %}"
class="overview-question-btn btn btn-outline-secondary"
aria-label="Go to question {{ forloop.counter }}"
{% if not answer %}title="You have not answered this question" aria-current="false"{% endif %}>
{{forloop.counter}}{% if answer and answer.answer %}: {{answer.answer}}{% endif %}
</a>
{% else %}
<a href="{% url 'physics:exam_take' pk=exam.id sk=forloop.counter0 cid=cid passcode=passcode %}"><button {% if not answer %}class="unanswered" title="You have not answered this question"{% endif %}>{{forloop.counter}}: {{answer.answer}}</button></a>
<a role="listitem" href="{% url 'physics:exam_take' pk=exam.id sk=forloop.counter0 cid=cid passcode=passcode %}"
class="overview-question-btn btn btn-outline-secondary"
aria-label="Go to question {{ forloop.counter }}"
{% if not answer %}title="You have not answered this question" aria-current="false"{% endif %}>
{{forloop.counter}}{% if answer and answer.answer %}: {{answer.answer}}{% endif %}
</a>
{% endif %}
{% endfor %}
</div>
@@ -58,9 +70,9 @@
{% if not cid_user_exam.completed %}
{% if not cid %}
<button hx-get="{% url 'physics:exam_complete_user' pk=exam.id %}" hx-swap="outerHTML" hx-confirm="Finish exam?">Finish Exam</button>
<button class="btn btn-primary mt-3" hx-get="{% url 'physics:exam_complete_user' pk=exam.id %}" hx-swap="outerHTML" hx-confirm="Finish exam?">Finish Exam</button>
{% else %}
<button hx-get="{% url 'physics:exam_complete' pk=exam.id cid=cid passcode=passcode %}" hx-swap="outerHTML" hx-confirm="Finish exam?">Finish Exam</button>
<button class="btn btn-primary mt-3" hx-get="{% url 'physics:exam_complete' pk=exam.id cid=cid passcode=passcode %}" hx-swap="outerHTML" hx-confirm="Finish exam?">Finish Exam</button>
{% endif %}
{% endif %}
@@ -83,3 +95,64 @@
})
</script>
{% endblock %}
{% block css %}
<style>
/* Overview page specific styles */
.overview-text {
margin-bottom: 1rem;
font-weight: 500;
}
.physics-finish-list {
display: grid;
/* Limit the maximum width of each tile so buttons don't stretch too wide on large screens.
Each tile will be between 80px and 140px. The grid will wrap responsively. */
grid-template-columns: repeat(auto-fit, minmax(80px, 140px));
justify-content: start;
gap: .5rem;
align-items: stretch;
margin-bottom: 1rem;
}
.overview-question-btn {
/* Fill the grid cell but the cell itself is capped by the grid-template-columns above. */
width: 100%;
box-sizing: border-box;
text-align: left;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: .5rem .75rem;
display: flex;
align-items: center;
justify-content: flex-start;
}
/* Unanswered questions should draw attention */
.overview-question-btn[title] {
border-color: #dc3545 !important;
color: #dc3545 !important;
background: rgba(220,53,69,0.03);
}
#time-details {
font-size: .95rem;
color: #9aa0a6;
margin-top: .5rem;
}
/* Make the finish button stand out on small screens */
.btn-primary.mt-3 {
min-width: 160px;
}
@media (max-width: 575.98px) {
.physics-finish-list {
/* On very small screens allow slightly narrower tiles but still wrap */
grid-template-columns: repeat(auto-fit, minmax(70px, 110px));
}
.overview-question-btn { font-size: .95rem }
}
</style>
{% endblock %}
@@ -0,0 +1,133 @@
<div class="exam-question-fragment">
<form method="POST" class="post-form"
hx-post="{% if cid %}{% url 'physics:exam_take' pk=exam.pk sk=pos cid=cid passcode=passcode %}{% else %}{% url 'physics:exam_take_user' pk=exam.pk sk=pos %}{% endif %}"
hx-target="#question-fragment" hx-swap="innerHTML">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="form-contents">
<!-- Mobile/fragment-visible copy of the question stem (kept hidden on md+ because main page shows it) -->
<div class="question-stem-fragment d-md-none mb-2">{{ question.stem|safe }}</div>
<ol class="physics-answer-list">
<li data-ans="a">
<div class="fieldWrapper flex-container">
{{ form.a.errors }}
<label for="{{ form.a.id_for_label }}" class="flex-8 question-text">{{ question.a|safe }}</label>
<span class="flex-1">{{ form.a }}<span class="postinput"></span></span>
</div>
{% if exam.publish_results and question.a_feedback %}
<span class="feedback">Feedback: {{ question.a_feedback }}</span>
{% endif %}
</li>
<li data-ans="b">
<div class="fieldWrapper flex-container">
{{ form.b.errors }}
<label for="{{ form.b.id_for_label }}" class="flex-8 question-text">{{ question.b|safe }}</label>
<span class="flex-1">{{ form.b }}<span class="postinput"></span></span>
</div>
</li>
<li data-ans="c">
<div class="fieldWrapper flex-container">
{{ form.c.errors }}
<label for="{{ form.c.id_for_label }}" class="flex-8 question-text">{{ question.c|safe }}</label>
<span class="flex-1">{{ form.c }}<span class="postinput"></span></span>
</div>
</li>
<li data-ans="d">
<div class="fieldWrapper flex-container">
{{ form.d.errors }}
<label for="{{ form.d.id_for_label }}" class="flex-8 question-text">{{ question.d|safe }}</label>
<span class="flex-1">{{ form.d }}<span class="postinput"></span></span>
</div>
</li>
<li data-ans="e">
<div class="fieldWrapper flex-container">
{{ form.e.errors }}
<label for="{{ form.e.id_for_label }}" class="flex-8 question-text">{{ question.e|safe }}</label>
<span class="flex-1">{{ form.e }}<span class="postinput"></span></span>
</div>
</li>
</ol>
</div>
<div class="mt-2">
{% if previous > -1 %}
<button type="submit" name="previous" class="save btn btn-secondary" title="Click to save your answer(s) and go to the previous question">Previous</button>
{% endif %}
{% if next %}
<button type="submit" name="next" class="save btn btn-secondary" title="Click to save your answer(s) and go to the next question">Next</button>
{% else %}
{% if not exam.publish_results %}
<button type="submit" name="save" class="save btn btn-default" title="Click to save your current answer(s)">Save</button>
{% endif %}
{% endif %}
<br />
<button type="submit" id="overview-button" name="finish" class="save btn btn-default" title="Click to go to the overview page (your answers will be saved).">Overview</button>
<button type="submit" id="goto-button" value="test" name="goto" class="hide">goto</button>
</div>
</form>
<script>
(function () {
/* run after fragment is loaded */
// Update the global page header question number and stem so the top of the page stays in sync
try {
var qnum = document.getElementById('question-number');
if (qnum) { qnum.textContent = '{{ pos|add:"1" }}'; }
var qstem = document.getElementById('question-stem');
if (qstem) { qstem.innerHTML = '{{ question.stem|escapejs }}'; }
} catch (err) {
console.warn('Could not update global question header from fragment', err);
}
{% if not exam.publish_results and not cid_user_exam.completed %}
let time_limit = '{{ exam.time_limit }}';
if (time_limit != 'None') {
let end_time = new Date({{ cid_user_exam.start_time|date:"U" }} * 1000 + parseInt('{{ exam.time_limit }}') * 1000);
if (typeof initializeClock === 'function') {
initializeClock('clockdiv', end_time);
}
}
{% elif exam.publish_results %}
$('ol.physics-answer-list input').each((n, el) => { el.disabled = true; });
{{ question.get_answers_js }}.forEach((el, n) => {
const li_el = $(`ol.physics-answer-list li:eq(${n})`);
if (el) {
li_el.addClass('answer-true');
} else {
li_el.addClass('answer-false');
}
if (el == li_el.find('input').get(0).checked) {
li_el.addClass('answer-correct');
} else {
li_el.addClass('answer-incorrect');
}
});
{% elif cid_user_exam.completed %}
$('ol.physics-answer-list input').each((n, el) => { el.disabled = true; });
{% endif %}
// build the question menu locally so it is available after fragment swaps
$('#menu-list').empty();
for (let i = 0; i < {{ exam_length }}; i++) {
const qbutton = $(`<button class="question-menu-item" name="goto-${i}" data-qn="${i}">${i+1}</button>`);
if (i == {{ pos }}) { qbutton.addClass('current-question'); }
$('#menu-list').append(qbutton);
}
$('button.question-menu-item').on('click', (e) => {
document.getElementById('goto-button').value = e.currentTarget.dataset.qn;
$('#goto-button').click();
});
})();
</script>
</div>
@@ -0,0 +1,68 @@
<div class="exam-overview-fragment">
<h3>Overview: {{ exam }}</h3>
<p>{{ answer_count }} / {{ exam_length }} answered</p>
<div class="overview-list">
<ol class="overview-questions">
{% for q, ans in question_answer_tuples %}
<li class="overview-item" data-qn-index="{{ forloop.counter0 }}">
<div style="display:flex;align-items:center;gap:0.75rem;">
<button class="btn btn-sm btn-outline-secondary goto-from-overview" data-qn="{{ forloop.counter0 }}">{{ forloop.counter }}</button>
<div style="flex:1; padding-left:0.5rem;">{{ q.stem|truncatechars:140|safe }}</div>
<div style="min-width:6rem; text-align:right;">
{% if ans %}
{% if ans.a or ans.b or ans.c or ans.d or ans.e %}
<span class="badge bg-success">Answered</span>
{% else %}
<span class="badge bg-secondary">Unanswered</span>
{% endif %}
{% else %}
<span class="badge bg-secondary">Unanswered</span>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ol>
</div>
<div style="margin-top:1rem;">
<button id="overview-back-to-question" class="btn btn-primary">Back to current question</button>
</div>
<script>
(function(){
// When a question button is clicked, trigger the hidden goto button in the
// main fragment so the server navigates to that question via the normal flow.
document.querySelectorAll('.goto-from-overview').forEach(function(btn){
btn.addEventListener('click', function(e){
var q = e.currentTarget.dataset.qn;
// Find the form in the currently loaded fragment (it may be the overview itself)
var form = document.querySelector('#question-fragment form.post-form');
if (!form) return;
var goto = form.querySelector('button[name="goto"]') || form.querySelector('#goto-button');
if (!goto) return;
// Set the value and submit
if (goto.tagName.toLowerCase() === 'input' || goto.tagName.toLowerCase() === 'button') {
goto.value = q;
// submit via HTMX by clicking the hidden goto button
goto.click();
}
});
});
// Back button simply reloads the current question fragment by triggering a
// click on the menu item for the current question (if available) or reloads
// via HTMX load of the fragment.
document.getElementById('overview-back-to-question').addEventListener('click', function(){
var menuCurrent = document.querySelector('#menu-list .current-question');
if (menuCurrent) { menuCurrent.click(); return; }
// fallback: force HTMX to reload the current fragment
var frag = document.getElementById('question-fragment');
if (frag && frag.getAttribute('hx-get')) {
htmx.trigger(frag, 'load');
}
});
})();
</script>
</div>
+11
View File
@@ -22,6 +22,17 @@ urlpatterns.extend(
views.exam_take,
name="exam_take_user",
),
# HTMX fragment endpoint for loading the question+controls via AJAX
path(
"exam/<int:pk>/<int:sk>/fragment",
views.exam_take_fragment,
name="exam_take_fragment_user",
),
path(
"exam/<int:pk>/<int:sk>/<str:cid>/<str:passcode>/fragment",
views.exam_take_fragment,
name="exam_take_fragment",
),
path("exam/<int:pk>/start", views.exam_start, name="exam_start"),
path(
"exam/<int:pk>/<str:cid>/<str:passcode>/finish",
+143 -5
View File
@@ -188,7 +188,16 @@ def exam_take_overview(request, pk, cid=None, passcode=None):
)
def exam_take(request, pk: int, sk: int, cid: str| None = None, passcode: str| None = None):
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:
@@ -198,11 +207,23 @@ def exam_take(request, pk: int, sk: int, cid: str| None = None, passcode: str| N
cid_user_exam = exam.get_or_create_cid_user_exam(cid=cid, user_user=request.user)
question = exam.get_questions()[sk]
# Canonical questions list for consistent indexing/navigation
questions = list(exam.get_questions())
exam_length = len(exam.exam_questions.all())
# Normalize and validate provided index
try:
index = int(sk)
except Exception:
raise Http404("Invalid question index")
pos = exam.get_question_index(question)
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()
@@ -245,16 +266,62 @@ def exam_take(request, pk: int, sk: int, cid: str| None = None, passcode: str| N
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:
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)
# 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)
@@ -281,6 +348,77 @@ def exam_take(request, pk: int, sk: int, cid: str| None = None, passcode: str| N
)
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
previous = -1
if sk > 0:
previous = sk - 1
next = sk + 1
if sk == exam_length - 1:
next = False
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,
"passcode": passcode,
"cid_user_exam": cid_user_exam,
},
)
# 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)):