This commit is contained in:
Ross
2026-07-06 22:06:58 +01:00
parent 50b80fd0c7
commit fa98541e31
24 changed files with 1258 additions and 98 deletions
+325 -1
View File
@@ -19,6 +19,7 @@ from django.urls import reverse_lazy, reverse
from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django.utils.safestring import mark_safe
from django.db.models import Count
from .forms import (
ExamAuthorForm,
@@ -1053,4 +1054,327 @@ def question_add_exam(request, question_id: int):
return HttpResponse(mark_safe(html))
raise PermissionDenied()
raise PermissionDenied()
@login_required
def mark2(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False):
"""Improved marking UI (mark2) for rapids."""
exam = get_object_or_404(Exam, pk=exam_pk)
if not GenericExamViews.check_user_marker_access(request.user, exam_pk):
raise PermissionDenied
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
mark_url = "rapids:mark2"
if review:
mark_url = "rapids:mark2"
questions = exam.get_questions()
n = sk
question_details = {
"total": len(questions),
"current": n + 1,
}
try:
question = questions[sk]
except IndexError:
raise Http404("Exam question does not exist")
if request.method == "POST":
form = MarkRapidQuestionForm(request.POST)
if form.is_valid():
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"))
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)
else:
form = MarkRapidQuestionForm()
correct_answers = []
half_mark_answers = []
incorrect_answers = []
review_user_answers = []
unmarked_answers_bool = False
unmarked_user_answers = []
if question.normal:
incorrect_answers = question.get_user_answers(exam_pk=exam_pk)
if "" in incorrect_answers:
incorrect_answers.remove("")
if "normal" in incorrect_answers:
incorrect_answers.remove("normal")
else:
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)
if review and not question.normal:
for ans in unmarked_user_answers:
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))
context = {
"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,
}
return render(request, "rapids/mark2.html", context)
@login_required
def mark2_overview(request, pk):
"""Overview page for mark2 workflow: lists questions and unmarked counts."""
exam = get_object_or_404(Exam, pk=pk)
if not GenericExamViews.check_user_marker_access(request.user, pk):
raise PermissionDenied
if not exam.exam_mode:
raise Http404("Packet not in exam mode")
questions = exam.get_questions()
question_unmarked_map = []
for q in questions:
count = int(q.get_unmarked_user_answer_count(exam_pk=exam.pk))
question_unmarked_map.append((q, count, count))
return render(request, "rapids/mark2_overview.html", {"exam": exam, "question_unmarked_map": question_unmarked_map})
@login_required
def mark2_toggle_answer(request):
"""Endpoint to toggle/set a single answer mark quickly."""
if request.method != "POST":
return JsonResponse({"status": "error", "message": "POST required"}, status=400)
question_pk = request.POST.get("question_pk") or request.POST.get("question")
answer_text = request.POST.get("answer")
newmark = request.POST.get("newmark")
if not question_pk or not answer_text or not newmark:
return JsonResponse({"status": "error", "message": "missing parameters"}, status=400)
try:
question = Rapid.objects.get(pk=question_pk)
except Rapid.DoesNotExist:
return JsonResponse({"status": "error", "message": "question not found"}, status=404)
exams = list(question.exams.all())
if exams:
exam_pk = exams[0].pk
if not GenericExamViews.check_user_marker_access(request.user, exam_pk):
return JsonResponse({"status": "error", "message": "forbidden"}, status=403)
mark_map = {
"correct": Answer.MarkOptions.CORRECT,
"half-correct": Answer.MarkOptions.HALF_MARK,
"incorrect": Answer.MarkOptions.INCORRECT,
}
exam_pk = request.POST.get('exam_pk')
sk = request.POST.get('sk')
if newmark == 'clear':
a = Answer.objects.filter(answer_compare__iexact=answer_text, question_id=question.pk).first()
if a is not None:
a.delete()
if request.htmx and exam_pk and sk is not None:
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return JsonResponse({"status": "ok"})
return JsonResponse({"status": "ok"})
if newmark not in mark_map:
return JsonResponse({"status": "error", "message": "invalid mark"}, status=400)
status_val = mark_map[newmark]
a = Answer.objects.filter(answer_compare__iexact=answer_text, question_id=question.pk).first()
if a is None:
a = Answer()
a.question_id = question.pk
a.answer = answer_text
a.status = status_val
a.save()
if request.htmx and exam_pk and sk is not None:
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return JsonResponse({"status": "ok"})
return JsonResponse({"status": "ok"})
@login_required
def mark2_exam_marked(request, exam_pk, sk):
"""Return a partial listing of answers submitted for this question within the given exam."""
exam = get_object_or_404(Exam, pk=exam_pk)
questions = exam.get_questions()
try:
question = questions[sk]
except Exception:
raise Http404("Question not found in exam")
ua_qs = question.cid_user_answers.filter(exam=exam)
order = request.GET.get('order', 'count')
grouped = ua_qs.values('answer_compare').annotate(count=Count('id'))
items = []
for row in grouped:
ans = row['answer_compare']
if ans == 'normal' or ans == '':
continue
cnt = row['count']
a = question.answers.filter(answer_compare__iexact=ans).first()
mark = None
if a is not None:
if a.status == Answer.MarkOptions.CORRECT:
mark = 'correct'
elif a.status == Answer.MarkOptions.HALF_MARK:
mark = 'half-correct'
elif a.status == Answer.MarkOptions.INCORRECT:
mark = 'incorrect'
items.append({'answer': ans, 'count': cnt, 'mark': mark})
if order == 'mark':
weight = {'correct': 3, 'half-correct': 2, 'incorrect': 1, None: 0}
items.sort(key=lambda x: (weight.get(x.get('mark')), x.get('count', 0)), reverse=True)
else:
items.sort(key=lambda x: x.get('count', 0), reverse=True)
context = {'exam': exam, 'question': question, 'items': items, 'sk': sk}
return render(request, 'rapids/partials/_mark2_exam_marked_fragment.html', context)
@login_required
def mark2_edit_answer(request):
"""HTMX-friendly endpoint to edit/create a stored Answer for a question."""
if request.method == 'GET' and request.htmx:
question_pk = request.GET.get('question_pk')
original = request.GET.get('original', '')
exam_pk = request.GET.get('exam_pk')
sk = request.GET.get('sk')
question = get_object_or_404(Rapid, pk=question_pk)
existing = question.answers.filter(answer_compare__iexact=original).first()
context = {
'question': question,
'original': original,
'existing': existing,
'exam_pk': exam_pk,
'sk': sk,
}
return render(request, 'rapids/partials/_mark2_edit_answer_fragment.html', context)
if request.method == 'POST' and request.htmx:
question_pk = request.POST.get('question_pk')
original = request.POST.get('original', '')
new_text = request.POST.get('new_answer', '').strip()
exam_pk = request.POST.get('exam_pk')
sk = request.POST.get('sk')
if not question_pk or not new_text:
return HttpResponse('Missing parameters', status=400)
question = get_object_or_404(Rapid, pk=question_pk)
a = question.answers.filter(answer_compare__iexact=original).first()
if a is None:
a = Answer()
a.question = question
a.answer = new_text
else:
a.answer = new_text
a.save()
if exam_pk and sk is not None:
try:
return mark2_exam_marked(request, int(exam_pk), int(sk))
except Exception:
return HttpResponse('OK')
return HttpResponse('OK')
raise PermissionDenied()
@login_required
def mark2_question_marked(request, pk):
"""Return a partial listing of all stored Answer choices for this question (grouped by status)."""
question = get_object_or_404(Rapid, pk=pk)
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)
context = {
'question': question,
'correct_answers': correct_answers,
'half_mark_answers': half_mark_answers,
'incorrect_answers': incorrect_answers,
}
return render(request, 'rapids/partials/_mark2_question_marked_fragment.html', context)