diff --git a/anatomy/templates/anatomy/exam_scores.html b/anatomy/templates/anatomy/exam_scores.html index bd6018a4..8ed7ba1b 100644 --- a/anatomy/templates/anatomy/exam_scores.html +++ b/anatomy/templates/anatomy/exam_scores.html @@ -10,10 +10,12 @@ {% endfor %} {% endcomment %} {% for cid in cids %} {% with by_question|get_item:question|get_item:cid as ans_score %} - {{ans_score.0}} {% endwith %} diff --git a/generic/templates/generic/exam_scores_base.html b/generic/templates/generic/exam_scores_base.html index 995de8a5..1e516cc0 100644 --- a/generic/templates/generic/exam_scores_base.html +++ b/generic/templates/generic/exam_scores_base.html @@ -3,9 +3,6 @@ {% block content %}

{{ exam }}

- {% if cached_scores %} - User answer scores are cached, if you update or change an answer you will need to refresh the scores. - {% endif %} {% if missing_cids or missing_users %}
@@ -38,18 +35,31 @@ {% endif %}
-
{{plot|safe}}
-
-
Stats
-
Candidates: {{cids|length}}
-
Questions: {{question_number}}
-
Available marks: {{max_score}}
-
Mean: {{mean}}, Median {{median}}, Mode {{mode}}
-
Top score: {{exam.stats_max}}
+ +
+
+
+
+ Loading... +
+
Loading statistics...
+
+
+ + +
+
+ +
+
+
- +
@@ -58,7 +68,7 @@ {% endif %} {% for cid in cids %} - + {% if cid|slice:":1" == "u" %} {% else %} @@ -136,19 +146,48 @@ {% block js %} +{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/rapids/templates/rapids/mark2_overview.html b/rapids/templates/rapids/mark2_overview.html new file mode 100644 index 00000000..59568242 --- /dev/null +++ b/rapids/templates/rapids/mark2_overview.html @@ -0,0 +1,79 @@ +{% extends 'rapids/exams.html' %} + +{% block content %} +
+
+
+

Mark2 — Marking: {{ exam }}

+
+
+
+ + +
+ Start mark2 +
+
+ +
+
+
    + {% for question, unmarked_count, unmarked_count2 in question_unmarked_map %} +
  • +
    + Question {{ forloop.counter }}: +
    {{ question }}
    +
    +
    +
    Unmarked
    + {% if unmarked_count > 0 %} + {{ unmarked_count }} + {% else %} + {{ unmarked_count }} + {% endif %} +
    +
  • + {% empty %} +
  • No questions found.
  • + {% endfor %} +
+
+
+
+ + + +{% endblock %} diff --git a/rapids/templates/rapids/partials/_mark2_edit_answer_fragment.html b/rapids/templates/rapids/partials/_mark2_edit_answer_fragment.html new file mode 100644 index 00000000..4907893b --- /dev/null +++ b/rapids/templates/rapids/partials/_mark2_edit_answer_fragment.html @@ -0,0 +1,35 @@ +
+
+
Edit stored answer
+
+ {% csrf_token %} + + + {% if exam_pk %} + + {% endif %} + {% if sk is not None %} + + {% endif %} + +
+ +
{{ original }}
+
+ +
+ + +
+ +
+ + {% if exam_pk and sk is not None %} + + {% else %} + + {% endif %} +
+ +
+
diff --git a/rapids/templates/rapids/partials/_mark2_exam_marked_fragment.html b/rapids/templates/rapids/partials/_mark2_exam_marked_fragment.html new file mode 100644 index 00000000..1ba4fa43 --- /dev/null +++ b/rapids/templates/rapids/partials/_mark2_exam_marked_fragment.html @@ -0,0 +1,78 @@ +{% comment %}List of distinct answers submitted within this exam for the question, with counts and mark badges{% endcomment %} +
+
+ +
+
Answers submitted in this exam
+
+
+ + +
+ +
+
+ {% if not items %} +
No answers submitted in this exam.
+ {% else %} +
    + {% for it in items %} +
  • +
    {{ it.answer }}
    +
    + {# Stored mark badge #} + {% if it.mark == 'correct' %} + 2 + {% elif it.mark == 'half-correct' %} + 1 + {% elif it.mark == 'incorrect' %} + 0 + {% else %} + - + {% endif %} + + {# Submission count badge #} + {{ it.count }} + +
    + {% csrf_token %} + + + + + + + + + + + +
    +
  • + {% endfor %} +
+ {% endif %} +
+
+ + diff --git a/rapids/templates/rapids/partials/_mark2_marked_list_fragment.html b/rapids/templates/rapids/partials/_mark2_marked_list_fragment.html new file mode 100644 index 00000000..9a7f5768 --- /dev/null +++ b/rapids/templates/rapids/partials/_mark2_marked_list_fragment.html @@ -0,0 +1,43 @@ +{% comment %}Renders only the list items for already-marked answers (used as a partial){% endcomment %} +{% for answer in correct_answers %} + {% with answer_url=answer|urlencode:'' %} +
  • +
    +
    {{ answer }}
    +
    + 🔍 + + 🔗 +
    +
    +
  • + {% endwith %} +{% endfor %} +{% for answer in half_mark_answers %} + {% with answer_url=answer|urlencode:'' %} +
  • +
    +
    {{ answer }}
    +
    + 🔍 + + 🔗 +
    +
    +
  • + {% endwith %} +{% endfor %} +{% for answer in incorrect_answers %} + {% with answer_url=answer|urlencode:'' %} +
  • +
    +
    {{ answer }}
    +
    + 🔍 + + 🔗 +
    +
    +
  • + {% endwith %} +{% endfor %} diff --git a/rapids/templates/rapids/partials/_mark2_question_marked_fragment.html b/rapids/templates/rapids/partials/_mark2_question_marked_fragment.html new file mode 100644 index 00000000..aa5b34c0 --- /dev/null +++ b/rapids/templates/rapids/partials/_mark2_question_marked_fragment.html @@ -0,0 +1,41 @@ +{% comment %}All stored Answer choices for this question grouped by status{% endcomment %} +
    +
    +
    All stored answers for this question
    + +
    +
    +
    2 marks
    +
      + {% for answer in correct_answers %} +
    • {{ answer.answer }}
    • + {% empty %} +
    • None
    • + {% endfor %} +
    +
    +
    +
    1 mark
    +
      + {% for answer in half_mark_answers %} +
    • {{ answer.answer }}
    • + {% empty %} +
    • None
    • + {% endfor %} +
    +
    +
    +
    0 marks
    +
      + {% for answer in incorrect_answers %} +
    • {{ answer.answer }}
    • + {% empty %} +
    • None
    • + {% endfor %} +
    +
    +
    +
    +
    diff --git a/rapids/templates/rapids/partials/_mark2_unmarked_list_fragment.html b/rapids/templates/rapids/partials/_mark2_unmarked_list_fragment.html new file mode 100644 index 00000000..4685e53e --- /dev/null +++ b/rapids/templates/rapids/partials/_mark2_unmarked_list_fragment.html @@ -0,0 +1,15 @@ +{% comment %}Renders only the list items for unmarked answers (used as a partial){% endcomment %} +{% for answer in user_answers %} + {% with answer_url=answer|urlencode:'' %} +
  • +
    +
    {{ answer }}
    +
    + 🔍 + + 🔗 +
    +
    +
  • + {% endwith %} +{% endfor %} diff --git a/rapids/tests/test_urls.py b/rapids/tests/test_urls.py index 04f1c080..ff46de5c 100644 --- a/rapids/tests/test_urls.py +++ b/rapids/tests/test_urls.py @@ -372,3 +372,31 @@ def test_cookies(db, client): #assert "csrftoken" in [i.text for i in soup.find_all("th")] #assert "sessionid" in [i.text for i in soup.find_all("th")] + +def test_rapids_mark2_views(db, client, create_superuser): + client.force_login(create_superuser) + from rapids.models import Exam, Rapid + exam = Exam.objects.create(name="Test Exam", exam_mode=True) + rapid = Rapid.objects.create(laterality=Rapid.NONE, normal=True) + exam.exam_questions.add(rapid) + + # 1. Test mark2_overview + url_overview = reverse("rapids:mark2_overview", kwargs={"pk": exam.pk}) + resp = client.get(url_overview) + assert resp.status_code == 200 + + # 2. Test mark2 question view + url_mark2 = reverse("rapids:mark2", kwargs={"exam_pk": exam.pk, "sk": 0}) + resp = client.get(url_mark2) + assert resp.status_code == 200 + + # 3. Test mark2_exam_marked partial + url_exam_marked = reverse("rapids:mark2_exam_marked", kwargs={"exam_pk": exam.pk, "sk": 0}) + resp = client.get(url_exam_marked) + assert resp.status_code == 200 + + # 4. Test mark2_question_marked partial + url_question_marked = reverse("rapids:mark2_question_marked", kwargs={"pk": rapid.pk}) + resp = client.get(url_question_marked) + assert resp.status_code == 200 + diff --git a/rapids/urls.py b/rapids/urls.py index 70a00c5d..23d8c782 100755 --- a/rapids/urls.py +++ b/rapids/urls.py @@ -57,6 +57,12 @@ urlpatterns = [ path( "exam///mark/review", views.mark_review, name="mark_review" ), + path("exam///mark2", views.mark2, name="mark2"), + path("exam//mark2", views.mark2_overview, name="mark2_overview"), + path("exam/mark2/toggle", views.mark2_toggle_answer, name="mark2_toggle_answer"), + path("exam///mark2/exam_marked", views.mark2_exam_marked, name="mark2_exam_marked"), + path("question//mark2/question_marked", views.mark2_question_marked, name="mark2_question_marked"), + path("exam/mark2/edit_answer", views.mark2_edit_answer, name="mark2_edit_answer"), path("exam//markers", views.ExamMarkersUpdate.as_view(), name="exam_markers"), path("exam//groups", views.ExamGroupsUpdate.as_view(), name="exam_groups_edit"), path("exam//authors", views.ExamAuthorUpdate.as_view(), name="exam_authors"), diff --git a/rapids/views.py b/rapids/views.py index 9613ad3e..a0d08c90 100755 --- a/rapids/views.py +++ b/rapids/views.py @@ -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() \ No newline at end of file + 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) \ No newline at end of file diff --git a/sbas/templates/sbas/exam_scores.html b/sbas/templates/sbas/exam_scores.html index e0372c38..7b1a5488 100644 --- a/sbas/templates/sbas/exam_scores.html +++ b/sbas/templates/sbas/exam_scores.html @@ -11,7 +11,7 @@ {% endfor %} {% endcomment %} {% for cid in cids %} {% with by_question|get_item:question|get_item:cid as ans_score %} - + {% endwith %} {% endfor %} diff --git a/shorts/templates/shorts/exam_scores.html b/shorts/templates/shorts/exam_scores.html index 6aeffefb..a94bf806 100644 --- a/shorts/templates/shorts/exam_scores.html +++ b/shorts/templates/shorts/exam_scores.html @@ -10,7 +10,7 @@ {% endfor %} {% endcomment %} {% for cid in cids %} {% with by_question|get_item:question|get_item:cid as ans_score %} - + {% endwith %} {% endfor %} diff --git a/todo b/todo index 02e5f17f..e0320346 100644 --- a/todo +++ b/todo @@ -1,7 +1,5 @@ -Remove the cache links such as "refresh the scores" if they are no longer required. +It should be posssible to set the pass mark for an exam. this should be updatable from the scores page. -Load the stats panels as htmx partials. please also add additional stats that would be useful and style it more nicely. +when calculating stats we should be able to exclude users (such as those who have not completed the exam) from the stats calculations. -the anatomy app features a new marking mode mark2. we want to port a similar new marking mode to the rapids app. it should be styled in a similar way to the mark2 mode in anatomy and bring across any new features (that are applicable) -Both the candidate list and the answers table should allow filtering by candidates to allow them to be quickly viewed. \ No newline at end of file
    Candidate ID Score
    {{cids_user_id_map|get_item:cid}} {{ans_score.0}}{{ans_score.0}}
    {{ans_score.1|default_if_none:"Unmarked"}}{{ans_score.1|default_if_none:"Unmarked"}}