diff --git a/anatomy/migrations/0031_exam_pass_mark.py b/anatomy/migrations/0031_exam_pass_mark.py new file mode 100644 index 00000000..56e9e59d --- /dev/null +++ b/anatomy/migrations/0031_exam_pass_mark.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-07-13 08:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('anatomy', '0030_useranswer_time_answered'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='pass_mark', + field=models.FloatField(blank=True, help_text='Pass mark for the exam. If unset, defaults to 50% of the maximum score.', null=True), + ), + ] diff --git a/atlas/forms.py b/atlas/forms.py index 469df42b..7ccca19c 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -842,23 +842,23 @@ class CaseForm(ModelForm): self.save_m2m = save_m2m # Do we need to save all changes now? - # if commit: - instance.save() - self.save_m2m() + if commit: + instance.save() + self.save_m2m() - logger.debug(f"{self.collection=}") - - if self.collection is not None: logger.debug(f"{self.collection=}") - case_no = self.collection.cases.count() + 1 - logger.debug(f"{case_no=}") - logger.debug(f"{instance=}") - # Create through model - # cd = CaseDetail(case=instance, collection=exam, sort_order=case_no) - self.collection.cases.add( - instance, through_defaults={"sort_order": case_no} - ) + if self.collection is not None: + logger.debug(f"{self.collection=}") + case_no = self.collection.cases.count() + 1 + logger.debug(f"{case_no=}") + logger.debug(f"{instance=}") + + # Create through model + # cd = CaseDetail(case=instance, collection=exam, sort_order=case_no) + self.collection.cases.add( + instance, through_defaults={"sort_order": case_no} + ) return instance diff --git a/atlas/models.py b/atlas/models.py index b6f75ab6..e3845045 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -847,6 +847,50 @@ class Case(models.Model, AuthorMixin, QuestionMixin): help_text="Optional manually overridden study date for linked case series.", ) + def get_series_info(self): + try: + has_next = hasattr(self, 'next_case') and self.next_case is not None + except Case.DoesNotExist: + has_next = False + + if not self.previous_case_id and not has_next: + return {"in_series": False} + + # Find the start of the chain (the first case) + first_case = self + visited = {self.pk} + while first_case.previous_case: + if first_case.previous_case.pk in visited: + break + first_case = first_case.previous_case + visited.add(first_case.pk) + + # Traverse the chain from the first case to find positions and count + chain = [] + current = first_case + visited_forward = set() + while current: + if current.pk in visited_forward: + break + chain.append(current) + visited_forward.add(current.pk) + try: + current = current.next_case if hasattr(current, 'next_case') else None + except Case.DoesNotExist: + current = None + + total = len(chain) + try: + position = chain.index(self) + 1 + except ValueError: + position = 1 + + return { + "in_series": True, + "position": position, + "total": total, + } + def get_ordered_series_details(self): cached = getattr(self, "_ordered_series_details_cache", None) if cached is not None: diff --git a/atlas/templates/atlas/partials/_case_search_item.html b/atlas/templates/atlas/partials/_case_search_item.html index ee334ba2..a1a1ab0f 100644 --- a/atlas/templates/atlas/partials/_case_search_item.html +++ b/atlas/templates/atlas/partials/_case_search_item.html @@ -16,6 +16,13 @@ {% for ss in case.subspecialty.all %} {% if ss.name %}{{ ss.name }}{% endif %} {% endfor %} + {% with case.get_series_info as series_info %} + {% if series_info.in_series %} + + Series ({{ series_info.position }}/{{ series_info.total }}) + + {% endif %} + {% endwith %}

{% if case.description %}{{ case.description|truncatechars:140 }}{% endif %}

diff --git a/generic/migrations/0034_ciduserexam_exclude_from_stats.py b/generic/migrations/0034_ciduserexam_exclude_from_stats.py new file mode 100644 index 00000000..fa9299e8 --- /dev/null +++ b/generic/migrations/0034_ciduserexam_exclude_from_stats.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-07-13 08:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('generic', '0033_userhiddenitem'), + ] + + operations = [ + migrations.AddField( + model_name='ciduserexam', + name='exclude_from_stats', + field=models.BooleanField(default=False, help_text='Exclude this candidate from exam statistics.'), + ), + ] diff --git a/generic/models.py b/generic/models.py index 3e0e1ad2..d72dd12c 100644 --- a/generic/models.py +++ b/generic/models.py @@ -1050,6 +1050,12 @@ class ExamBase(ExamOrCollectionGenericBase): default=False, ) + pass_mark = models.FloatField( + null=True, + blank=True, + help_text="Pass mark for the exam. If unset, defaults to 50% of the maximum score.", + ) + # randomise_question_order = models.BooleanField( # help_text="If an exam should randomise the order of questions in RTS.", # ) @@ -1710,6 +1716,11 @@ class CidUserExam(models.Model): manual_submission = models.BooleanField(default=False) + exclude_from_stats = models.BooleanField( + default=False, + help_text="Exclude this candidate from exam statistics." + ) + # TODO switch to json field? results_emailed_status = models.CharField(max_length=255, blank=True) diff --git a/generic/templates/generic/exam_scores_base.html b/generic/templates/generic/exam_scores_base.html index 1e516cc0..6506bd7f 100644 --- a/generic/templates/generic/exam_scores_base.html +++ b/generic/templates/generic/exam_scores_base.html @@ -35,10 +35,29 @@ {% endif %} + {% if can_edit %} +
+
+
+ {% csrf_token %} +
+ +
+
+ +
+
+ +
+
+
+
+ {% endif %} +
@@ -66,6 +85,10 @@ {% if exam.app_name == "longs" or exam.app_name == "rapids" %} Normalised Score {% endif %} + Status + {% if can_edit %} + Stats + {% endif %} {% for cid in cids %} @@ -86,6 +109,18 @@ {% if exam.app_name == "longs" or exam.app_name == "rapids" %} {{user_scores_normalised|get_item:cid}} {% endif %} + + {% if pass_status|get_item:cid == 'Pass' %} + Pass + {% else %} + Fail + {% endif %} + + {% if can_edit %} + + {% include "generic/partials/_candidate_stats_checkbox.html" with cid=cid is_excluded=excluded_map|get_item:cid exam=exam %} + + {% endif %} {% endfor %} diff --git a/generic/templates/generic/partials/_candidate_stats_checkbox.html b/generic/templates/generic/partials/_candidate_stats_checkbox.html new file mode 100644 index 00000000..c92fbe71 --- /dev/null +++ b/generic/templates/generic/partials/_candidate_stats_checkbox.html @@ -0,0 +1,5 @@ + diff --git a/generic/templates/generic/partials/_exam_scores_stats.html b/generic/templates/generic/partials/_exam_scores_stats.html index bd4048ad..6320a29b 100644 --- a/generic/templates/generic/partials/_exam_scores_stats.html +++ b/generic/templates/generic/partials/_exam_scores_stats.html @@ -13,7 +13,7 @@
-
Pass Rate (≥ 50%)
+
Pass Rate (≥ {{ pass_mark_threshold }})
{{ pass_rate }}%
{{ passed_candidates_count }} of {{ cids|length }} candidates
diff --git a/generic/tests/test_scores_stats.py b/generic/tests/test_scores_stats.py new file mode 100644 index 00000000..e1f0966d --- /dev/null +++ b/generic/tests/test_scores_stats.py @@ -0,0 +1,139 @@ +from django.test import TestCase, Client +from django.urls import reverse +from django.contrib.auth.models import User +from generic.models import CidUserExam, CidUser +from physics.models import Exam, Question, UserAnswer +from atlas.models import Case +from atlas.forms import CaseForm + +class ScoresStatsTests(TestCase): + def setUp(self): + self.admin = User.objects.create_superuser( + username="admin", + email="admin@example.com", + password="adminpassword" + ) + self.client = Client() + self.client.force_login(self.admin) + + def test_physics_stats_with_pass_mark_and_exclusion(self): + # Create Exam + exam = Exam.objects.create(name="Physics Test Exam", exam_mode=True) + exam.author.add(self.admin) + + # Create Questions (max score will be 10 points) + q1 = Question.objects.create( + stem="stem1", + a="a", a_answer=True, + b="b", b_answer=False, + c="c", c_answer=True, + d="d", d_answer=False, + e="e", e_answer=True, + ) + q1.exams.add(exam) + q2 = Question.objects.create( + stem="stem2", + a="a", a_answer=True, + b="b", b_answer=False, + c="c", c_answer=True, + d="d", d_answer=False, + e="e", e_answer=True, + ) + q2.exams.add(exam) + + # Create two candidates: cid=1 (score=5/10), cid=2 (score=3/10) + UserAnswer.objects.create( + question=q1, exam=exam, cid=1, + a=True, b=False, c=True, d=False, e=True + ) + UserAnswer.objects.create( + question=q2, exam=exam, cid=1, + a=False, b=True, c=False, d=True, e=False + ) + + UserAnswer.objects.create( + question=q1, exam=exam, cid=2, + a=True, b=True, c=True, d=True, e=False + ) + + # 1. Test stats calculations without custom pass mark (defaults to 50% = 5) + url = reverse("physics:exam_scores_all", kwargs={"pk": exam.pk}) + response = self.client.get(f"{url}?stats_only=true") + self.assertEqual(response.status_code, 200) + context = response.context + self.assertEqual(context["pass_mark_threshold"], 5.0) + self.assertEqual(context["passed_candidates_count"], 1) + self.assertEqual(context["pass_rate"], 50.0) + + # 2. Update pass mark to 3.0 via HTMX update_pass_mark url + update_url = reverse("physics:update_pass_mark", kwargs={"pk": exam.pk}) + update_response = self.client.post(update_url, {"pass_mark": "3.0"}, HTTP_HX_REQUEST="true") + self.assertEqual(update_response.status_code, 302) + exam.refresh_from_db() + self.assertEqual(exam.pass_mark, 3.0) + + # Fetch stats again and verify updated pass mark threshold and pass rate + response = self.client.get(f"{url}?stats_only=true") + context = response.context + self.assertEqual(context["pass_mark_threshold"], 3.0) + self.assertEqual(context["passed_candidates_count"], 2) + self.assertEqual(context["pass_rate"], 100.0) + + # 3. Exclude cid=2 from stats + exclude_url = reverse("physics:toggle_candidate_stats_exclusion", kwargs={"pk": exam.pk}) + exclude_response = self.client.post(f"{exclude_url}?candidate=c/2") + self.assertEqual(exclude_response.status_code, 200) + + # Check database status + cid_user = CidUser.objects.get(cid=2) + cux = CidUserExam.objects.get(object_id=exam.pk, cid_user=cid_user) + self.assertTrue(cux.exclude_from_stats) + + # Fetch stats again and verify cid=2 is excluded + response = self.client.get(f"{url}?stats_only=true") + context = response.context + self.assertEqual(len(context["cids"]), 1) + self.assertIn("c/1", context["cids"]) + self.assertNotIn("c/2", context["cids"]) + self.assertEqual(context["mean"], 5.0) + self.assertEqual(context["passed_candidates_count"], 1) + self.assertEqual(context["pass_rate"], 100.0) + + def test_case_form_save_commit_false_does_not_persist(self): + form_data = { + "title": "Unsaved Case Form Test", + "description": "Unsaved description", + "diagnostic_certainty": 3, + "open_access": True, + } + form = CaseForm(data=form_data, user=self.admin) + self.assertTrue(form.is_valid()) + case_inst = form.save(commit=False) + self.assertIsNone(case_inst.pk) + self.assertEqual(Case.objects.filter(title="Unsaved Case Form Test").count(), 0) + + def test_case_linked_series_info(self): + # Create a chain of 3 cases: c1 -> c2 -> c3 + c1 = Case.objects.create(title="Case 1") + c2 = Case.objects.create(title="Case 2", previous_case=c1) + c3 = Case.objects.create(title="Case 3", previous_case=c2) + + # Verify positions and totals + info1 = c1.get_series_info() + self.assertTrue(info1["in_series"]) + self.assertEqual(info1["position"], 1) + self.assertEqual(info1["total"], 3) + + info2 = c2.get_series_info() + self.assertTrue(info2["in_series"]) + self.assertEqual(info2["position"], 2) + self.assertEqual(info2["total"], 3) + + info3 = c3.get_series_info() + self.assertTrue(info3["in_series"]) + self.assertEqual(info3["position"], 3) + self.assertEqual(info3["total"], 3) + + c4 = Case.objects.create(title="Case 4") + info4 = c4.get_series_info() + self.assertFalse(info4["in_series"]) diff --git a/generic/urls.py b/generic/urls.py index 1dc95e06..f861f309 100755 --- a/generic/urls.py +++ b/generic/urls.py @@ -485,6 +485,16 @@ def generic_exam_urls(generic_exam_view: GenericExamViews): generic_exam_view.exam_scores_all, name="exam_scores_all", ), + path( + "exam//update_pass_mark", + generic_exam_view.update_pass_mark, + name="update_pass_mark", + ), + path( + "exam//toggle_candidate_stats_exclusion", + generic_exam_view.toggle_candidate_stats_exclusion, + name="toggle_candidate_stats_exclusion", + ), path( "exam//stats", generic_exam_view.exam_stats, diff --git a/generic/views.py b/generic/views.py index 34388740..8e55acb6 100644 --- a/generic/views.py +++ b/generic/views.py @@ -4038,8 +4038,21 @@ class ExamViews(View, LoginRequiredMixin): max_score = self.get_max_score(questions) + from django.contrib.contenttypes.models import ContentType + from generic.models import CidUserExam + ct = ContentType.objects.get_for_model(self.Exam) + + excluded_keys = set() + for cux in CidUserExam.objects.filter(content_type=ct, object_id=exam.pk, exclude_from_stats=True): + if cux.user_user_id: + excluded_keys.add(f"u/{cux.user_user_id}") + elif cux.cid_user_id: + excluded_keys.add(f"c/{cux.cid_user.cid}") + + pass_mark_threshold = exam.pass_mark if exam.pass_mark is not None else (max_score / 2) + if stats_only: - user_scores_list = [i for i in user_scores.values() if i > 0] + user_scores_list = [score for user_key, score in user_scores.items() if user_key not in excluded_keys and score > 0] mean = 0 median = 0 mode = 0 @@ -4080,8 +4093,9 @@ class ExamViews(View, LoginRequiredMixin): min_score = round(min(user_scores_list), 2) max_score_achieved = round(max(user_scores_list), 2) - passed_candidates_count = sum(1 for score in user_scores.values() if score >= max_score / 2) - pass_rate = round((passed_candidates_count / len(cids)) * 100, 1) if cids else 0.0 + passed_candidates_count = sum(1 for user_key, score in user_scores.items() if user_key not in excluded_keys and score >= pass_mark_threshold) + stats_cids = [c for c in cids if c not in excluded_keys] + pass_rate = round((passed_candidates_count / len(stats_cids)) * 100, 1) if stats_cids else 0.0 df = user_scores_list fig = px.histogram( @@ -4120,8 +4134,9 @@ class ExamViews(View, LoginRequiredMixin): exam.user_scores = user_data exam.save() - expected_answer_count = len(cids) * question_number - user_answer_count_total = sum(user_answer_count.values()) + stats_cids = [c for c in cids if c not in excluded_keys] + expected_answer_count = len(stats_cids) * question_number + user_answer_count_total = sum(user_answer_count[u] for u in stats_cids if u in user_answer_count) completion_rate = round((user_answer_count_total / expected_answer_count) * 100, 1) if expected_answer_count > 0 else 0.0 if hasattr(self.Answer, "MarkOptions") and hasattr(self.UserAnswer, "score"): @@ -4135,7 +4150,7 @@ class ExamViews(View, LoginRequiredMixin): context = { "exam": exam, - "cids": sorted(cids), + "cids": sorted(stats_cids), "question_number": question_number, "max_score": max_score, "mean": mean, @@ -4153,9 +4168,20 @@ class ExamViews(View, LoginRequiredMixin): "completion_rate": completion_rate, "unmarked_count": unmarked_count, "plot": fig_html, + "pass_mark_threshold": pass_mark_threshold, } return render(request, "generic/partials/_exam_scores_stats.html", context) + pass_status = {} + for cid, score in user_scores.items(): + try: + val = float(score) + pass_status[cid] = "Pass" if val >= pass_mark_threshold else "Fail" + except (ValueError, TypeError): + pass_status[cid] = "Fail" + + excluded_map = {k: True for k in excluded_keys} + template_variables = { "cids": sorted(cids), "missing_cids": valid_cid_users - plain_cids, @@ -4172,6 +4198,10 @@ class ExamViews(View, LoginRequiredMixin): "max_score": max_score, "can_edit": exam.check_user_can_edit(request.user), "base_template": "generic/exam_scores_partial_base.html" if table_only else "generic/exam_scores_base.html", + "excluded_map": excluded_map, + "pass_status": pass_status, + "pass_mark_threshold": pass_mark_threshold, + "default_pass_mark": max_score / 2, } if self.app_name == "rapids": @@ -4202,6 +4232,87 @@ class ExamViews(View, LoginRequiredMixin): template_variables, ) + def update_pass_mark(self, request, pk): + exam = get_object_or_404(self.Exam, pk=pk) + if not exam.check_user_can_edit(request.user): + raise PermissionDenied + + pass_mark_str = request.POST.get("pass_mark") + if pass_mark_str == "" or pass_mark_str is None: + exam.pass_mark = None + else: + try: + exam.pass_mark = float(pass_mark_str) + except ValueError: + return HttpResponse("Invalid pass mark", status=400) + exam.save() + + from django.urls import reverse + from django.http import HttpResponseRedirect + url = reverse(f"{self.app_name}:exam_scores_all", kwargs={"pk": pk}) + response = HttpResponseRedirect(url) + if request.htmx: + response["HX-Redirect"] = url + return response + + def toggle_candidate_stats_exclusion(self, request, pk): + exam = get_object_or_404(self.Exam, pk=pk) + if not exam.check_user_can_edit(request.user): + raise PermissionDenied + + candidate_key = request.GET.get("candidate") + if not candidate_key: + return HttpResponse("Candidate ID required", status=400) + + from django.contrib.contenttypes.models import ContentType + from generic.models import CidUserExam, CidUser + + ct = ContentType.objects.get_for_model(self.Exam) + + cux = None + if candidate_key.startswith("u/"): + try: + user_id = int(candidate_key[2:]) + cux, created = CidUserExam.objects.get_or_create( + content_type=ct, + object_id=exam.pk, + user_user_id=user_id, + ) + except ValueError: + return HttpResponse("Invalid user ID", status=400) + elif candidate_key.startswith("c/"): + try: + cid = int(candidate_key[2:]) + cid_user = CidUser.objects.filter(cid=cid).first() + if cid_user: + cux, created = CidUserExam.objects.get_or_create( + content_type=ct, + object_id=exam.pk, + cid_user=cid_user, + ) + except ValueError: + return HttpResponse("Invalid CID", status=400) + else: + return HttpResponse("Invalid candidate key format", status=400) + + if cux: + cux.exclude_from_stats = not cux.exclude_from_stats + cux.save() + + response = render( + request, + "generic/partials/_candidate_stats_checkbox.html", + { + "cid": candidate_key, + "is_excluded": cux.exclude_from_stats, + "exam": exam, + } + ) + response["HX-Trigger"] = "refreshStats" + return response + + return HttpResponse("Candidate not found", status=404) + class GenericViewBase: def __init__(self, app_name, QuestionObject, UserAnswerObject, ExamObject): diff --git a/longs/migrations/0040_exam_pass_mark.py b/longs/migrations/0040_exam_pass_mark.py new file mode 100644 index 00000000..fea45dcd --- /dev/null +++ b/longs/migrations/0040_exam_pass_mark.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-07-13 08:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('longs', '0039_useranswer_marker_feedback'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='pass_mark', + field=models.FloatField(blank=True, help_text='Pass mark for the exam. If unset, defaults to 50% of the maximum score.', null=True), + ), + ] diff --git a/physics/migrations/0022_exam_pass_mark.py b/physics/migrations/0022_exam_pass_mark.py new file mode 100644 index 00000000..3110f074 --- /dev/null +++ b/physics/migrations/0022_exam_pass_mark.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-07-13 08:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('physics', '0021_useranswer_time_answered'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='pass_mark', + field=models.FloatField(blank=True, help_text='Pass mark for the exam. If unset, defaults to 50% of the maximum score.', null=True), + ), + ] diff --git a/rapids/migrations/0021_exam_pass_mark.py b/rapids/migrations/0021_exam_pass_mark.py new file mode 100644 index 00000000..bbcea48e --- /dev/null +++ b/rapids/migrations/0021_exam_pass_mark.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-07-13 08:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rapids', '0020_useranswer_time_answered'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='pass_mark', + field=models.FloatField(blank=True, help_text='Pass mark for the exam. If unset, defaults to 50% of the maximum score.', null=True), + ), + ] diff --git a/sbas/migrations/0027_exam_pass_mark.py b/sbas/migrations/0027_exam_pass_mark.py new file mode 100644 index 00000000..38f40c45 --- /dev/null +++ b/sbas/migrations/0027_exam_pass_mark.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-07-13 08:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sbas', '0026_useranswer_time_answered'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='pass_mark', + field=models.FloatField(blank=True, help_text='Pass mark for the exam. If unset, defaults to 50% of the maximum score.', null=True), + ), + ] diff --git a/shorts/migrations/0016_exam_pass_mark.py b/shorts/migrations/0016_exam_pass_mark.py new file mode 100644 index 00000000..d94df8d2 --- /dev/null +++ b/shorts/migrations/0016_exam_pass_mark.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-07-13 08:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shorts', '0015_useranswer_marker_feedback'), + ] + + operations = [ + migrations.AddField( + model_name='exam', + name='pass_mark', + field=models.FloatField(blank=True, help_text='Pass mark for the exam. If unset, defaults to 50% of the maximum score.', null=True), + ), + ] diff --git a/todo b/todo index e0320346..1adc2382 100644 --- a/todo +++ b/todo @@ -1,5 +1,7 @@ -It should be posssible to set the pass mark for an exam. this should be updatable from the scores page. +It should be posssible to set the pass mark for an exam. this should be updatable from the scores page once an exam has been taken. we should add detials to the candidate table about pass / fail status. -when calculating stats we should be able to exclude users (such as those who have not completed the exam) from the stats calculations. +when calculating stats we should be able to exclude users (such as those who have not completed the exam) from the stats calculations. we should be able to do this from the scores page. +when importing multiple cases as a case series from the uploads page the cases are correctly imported and linked, however there is an addition case that is created (and not linked). +when searhing for a case as part of the case_search_widged we need to show if it is part of a linked case series (and if so its position in the series) \ No newline at end of file