-
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