From 1997d013d84207b026624c430950729235e1b7d6 Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 11 Nov 2025 11:34:05 +0000 Subject: [PATCH] Implement improved marking UI with new endpoints and templates for enhanced user experience --- anatomy/templates/anatomy/mark2.html | 248 ++++++++++++++++++ .../partials/mark2_marked_list_fragment.html | 10 + .../mark2_unmarked_list_fragment.html | 8 + anatomy/urls.py | 3 + anatomy/views.py | 212 +++++++++++++++ 5 files changed, 481 insertions(+) create mode 100644 anatomy/templates/anatomy/mark2.html create mode 100644 anatomy/templates/anatomy/partials/mark2_marked_list_fragment.html create mode 100644 anatomy/templates/anatomy/partials/mark2_unmarked_list_fragment.html diff --git a/anatomy/templates/anatomy/mark2.html b/anatomy/templates/anatomy/mark2.html new file mode 100644 index 00000000..82e2be11 --- /dev/null +++ b/anatomy/templates/anatomy/mark2.html @@ -0,0 +1,248 @@ +{% extends 'anatomy/exams.html' %} + +{% block content %} +
+
+
+
+
+
+
Marking question {{ question_details.current }} of {{ question_details.total }}
+
+ View + Edit + {% if request.user.is_superuser %} + Admin Edit + {% endif %} +
+
{{ question.question_type }}
+ {% if question.structure %} +
Structure: {{ question.structure }}
+ {% endif %} +
Primary answer: {{ question.get_primary_answer }}
+
+
+ {% if question.answer_help %} + + {% endif %} +
+
+ {% if question.answer_help %} +
+
+ {{ question.answer_help|safe }} +
+
+ {% endif %} + +
+
{% csrf_token %} +

Click each answer to toggle through marks awarded (as per colour). This UI writes marks immediately.

+
+
Key: 2 Marks, 1 Mark, 0 Marks
+
+ {% if question_details.current > 1 %} + Previous + {% endif %} + + {% if question_details.current < question_details.total %} + Next + Classic mark + {% endif %} +
+
+ +
+
+
+
+
Unmarked
+
    + {% include 'anatomy/partials/mark2_unmarked_list_fragment.html' %} +
+
+
+
Marked
+
    + {% include 'anatomy/partials/mark2_marked_list_fragment.html' %} +
+
+
+
+
+ + {{ form.as_p }} +
+
+
+
+
+ +
+
+
+
+{% endblock %} + +{% block css %} + +{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/anatomy/templates/anatomy/partials/mark2_marked_list_fragment.html b/anatomy/templates/anatomy/partials/mark2_marked_list_fragment.html new file mode 100644 index 00000000..af1ea860 --- /dev/null +++ b/anatomy/templates/anatomy/partials/mark2_marked_list_fragment.html @@ -0,0 +1,10 @@ +{% comment %}Renders only the list items for already-marked answers (used as a partial){% endcomment %} +{% for answer in correct_answers %} +
  • {{ answer }}
  • +{% endfor %} +{% for answer in half_mark_answers %} +
  • {{ answer }}
  • +{% endfor %} +{% for answer in incorrect_answers %} +
  • {{ answer }}
  • +{% endfor %} diff --git a/anatomy/templates/anatomy/partials/mark2_unmarked_list_fragment.html b/anatomy/templates/anatomy/partials/mark2_unmarked_list_fragment.html new file mode 100644 index 00000000..fcb015e3 --- /dev/null +++ b/anatomy/templates/anatomy/partials/mark2_unmarked_list_fragment.html @@ -0,0 +1,8 @@ +{% comment %}Renders only the list items for unmarked answers (used as a partial){% endcomment %} +{% for answer in user_answers %} + {% if answer in answer_suggest_incorrect %} +
  • {{ answer }}
  • + {% else %} +
  • {{ answer }}
  • + {% endif %} +{% endfor %} diff --git a/anatomy/urls.py b/anatomy/urls.py index c71ebd61..c933e2d0 100644 --- a/anatomy/urls.py +++ b/anatomy/urls.py @@ -102,6 +102,9 @@ urlpatterns = [ views.exam_review_question_summary, name="exam_review_question_summary", ), + # New improved marking UI (mark2) and small toggle endpoint used by JS/HTMX + path("exam///mark2", views.mark2, name="mark2"), + path("exam/mark2/toggle", views.mark2_toggle_answer, name="mark2_toggle_answer"), ] urlpatterns.extend(generic_view_urls(views.GenericViews)) diff --git a/anatomy/views.py b/anatomy/views.py index ef2327ec..cd4f121c 100644 --- a/anatomy/views.py +++ b/anatomy/views.py @@ -624,6 +624,218 @@ def exam_review_question_summary(request, pk: int, q_index: int): return render(request, "anatomy/partials/exam_review_question_summary_fragment.html", context) +@login_required +def mark2(request, exam_pk, sk, unmarked_exam_answers_only=True, review=False): + """Improved marking UI (mark2). + + Same core behaviour as `mark` but renders a modern, HTMX-friendly template + and exposes a small toggle endpoint for quick per-answer marking. + """ + 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 = "anatomy:mark2" + if review: + mark_url = "anatomy:mark2" + + questions = exam.get_questions().prefetch_related("answers") + + n = sk + + question_details = { + "total": len(questions), + "current": n + 1, + } + + try: + question: AnatomyQuestion = questions[sk] + except IndexError: + raise Http404("Exam question does not exist") + + # For consistency with the existing mark view we accept a standard POST form + # but the preferred workflow is immediate HTMX/ajax toggles via mark2_toggle_answer. + if request.method == "POST": + form = MarkAnatomyQuestionForm(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 = MarkAnatomyQuestionForm() + + # Reuse existing collections for display + 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: + 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) + + 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, + "answer_suggest_incorrect": answer_suggest_incorrect, + "words_suggest_incorrect": words_suggest_incorrect, + } + + return render(request, "anatomy/mark2.html", context) + + +@login_required +def mark2_toggle_answer(request): + """Endpoint to toggle/set a single answer mark quickly. + + Expects POST with form fields: question_pk, answer (text), newmark ("correct"|"half-correct"|"incorrect"). + Returns JSON status. + """ + 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 = AnatomyQuestion.objects.get(pk=question_pk) + except AnatomyQuestion.DoesNotExist: + return JsonResponse({"status": "error", "message": "question not found"}, status=404) + + # permission checks: ensure user can mark the exam(s) this question belongs to + # We can't easily infer exam_pk here, but ask GenericExamViews.check_user_marker_access + # using any exam that contains this question is adequate if present. + 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) + + # Map mark strings to model constants + mark_map = { + "correct": Answer.MarkOptions.CORRECT, + "half-correct": Answer.MarkOptions.HALF_MARK, + "incorrect": Answer.MarkOptions.INCORRECT, + } + + if newmark not in mark_map: + return JsonResponse({"status": "error", "message": "invalid mark"}, status=400) + + status_val = mark_map[newmark] + + # Find existing Answer by answer_compare or create + 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() + + return JsonResponse({"status": "ok"}) + + #@login_required #def exam_scores_refresh(request, pk):