Compare commits
19
Commits
eebbe8b759
...
70430b2593
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70430b2593 | ||
|
|
e989a18d31 | ||
|
|
e39539fb99 | ||
|
|
44ca49899a | ||
|
|
4246713ee8 | ||
|
|
5a4792ea43 | ||
|
|
94d5771a26 | ||
|
|
200a6e2a1c | ||
|
|
2ff857621a | ||
|
|
52c4f27e2b | ||
|
|
c1eff140f8 | ||
|
|
dce72f9abe | ||
|
|
738752c030 | ||
|
|
41fc7050ad | ||
|
|
6eec40c43b | ||
|
|
7782ac55ca | ||
|
|
9cf7ad6da7 | ||
|
|
922ca5053f | ||
|
|
10739f517f |
@@ -1,4 +1,5 @@
|
||||
media/
|
||||
mediafiles/
|
||||
|
||||
__pycache__
|
||||
.vscode/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Management package for anatomy app."""
|
||||
@@ -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
@@ -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)."))
|
||||
@@ -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)
|
||||
{% block css %}
|
||||
<style type="text/css">
|
||||
/* 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
|
||||
*/
|
||||
|
||||
li_el = $(`ol.physics-answer-list li:eq(${n})`)//.addClass(`answer-${el}`)
|
||||
: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);
|
||||
}
|
||||
|
||||
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 %}
|
||||
<style type="text/css">
|
||||
/* .form-contents {
|
||||
display: none;
|
||||
} */
|
||||
.flex-container {
|
||||
display: flex;
|
||||
@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-1 {
|
||||
flex: 1;
|
||||
}
|
||||
/* 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); }
|
||||
|
||||
.flex-8 {
|
||||
flex: 8;
|
||||
}
|
||||
/* Layout and responsiveness */
|
||||
.exam-take-container { max-width: 980px; margin: 0 auto; padding: 1rem; }
|
||||
.no-select { user-select: none; }
|
||||
|
||||
.selected {
|
||||
border: 1px solid purple;
|
||||
}
|
||||
.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-true::before {
|
||||
content: "[Correct answer: True]";
|
||||
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-false::before {
|
||||
content: "[Correct answer: False]";
|
||||
color: darkgray;
|
||||
float: right;
|
||||
opacity: 50%;
|
||||
/* 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-correct {
|
||||
border: 1px dotted darkgreen;
|
||||
}
|
||||
.answer-incorrect {
|
||||
border: 1px dashed darkred;
|
||||
}
|
||||
/* Answer items styled as cards */
|
||||
.physics-answer-list { list-style: none; padding: 0; margin: 0; }
|
||||
.physics-answer-list li {
|
||||
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; }
|
||||
|
||||
.physics-answer-list li {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.question-text { margin: 0; }
|
||||
|
||||
.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"
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
padding: 20px;
|
||||
display: block;
|
||||
}
|
||||
/* 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); }
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
.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: 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 %}
|
||||
|
||||
{% 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>
|
||||
@@ -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
@@ -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)):
|
||||
|
||||
Reference in New Issue
Block a user