This commit is contained in:
Ross
2026-07-13 10:03:33 +01:00
parent 89bf6eedc0
commit 3a3ec75b1d
18 changed files with 514 additions and 24 deletions
@@ -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.'),
),
]
+11
View File
@@ -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)
@@ -35,10 +35,29 @@
{% endif %}
</div>
{% if can_edit %}
<div class="card mb-3 bg-dark text-light border-secondary shadow-sm col-md-6 col-lg-4">
<div class="card-body py-2 px-3">
<form hx-post="{% url exam.app_name|add:':update_pass_mark' exam.pk %}" class="row g-2 align-items-center">
{% csrf_token %}
<div class="col-auto">
<label for="pass_mark_input" class="col-form-label col-form-label-sm">Pass Mark:</label>
</div>
<div class="col">
<input type="number" step="any" id="pass_mark_input" name="pass_mark" class="form-control form-control-sm bg-dark text-light border-secondary" placeholder="Default: {{ default_pass_mark }}" value="{{ exam.pass_mark|default_if_none:'' }}">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-outline-secondary">Set</button>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Stats Container Lazy-Loader -->
<div id="stats-container"
hx-get="{% url exam.app_name|add:':exam_scores_all' exam.pk %}?stats_only=true"
hx-trigger="load"
hx-trigger="load, refreshStats from:body"
hx-swap="outerHTML">
<div class="card mb-3 bg-dark text-light border-secondary shadow-sm">
<div class="card-body text-center py-4">
@@ -66,6 +85,10 @@
{% if exam.app_name == "longs" or exam.app_name == "rapids" %}
<th>Normalised Score</th>
{% endif %}
<th>Status</th>
{% if can_edit %}
<th title="Include in stats calculations">Stats</th>
{% endif %}
</tr>
{% for cid in cids %}
<tr class="candidate-row" data-answer-count={{user_answer_count|get_item:cid}} data-cid-name="{{ cids_user_id_map|get_item:cid|lower }}" data-cid-val="{{ cid|lower }}">
@@ -86,6 +109,18 @@
{% if exam.app_name == "longs" or exam.app_name == "rapids" %}
<td>{{user_scores_normalised|get_item:cid}}</td>
{% endif %}
<td>
{% if pass_status|get_item:cid == 'Pass' %}
<span class="badge bg-success">Pass</span>
{% else %}
<span class="badge bg-danger">Fail</span>
{% endif %}
</td>
{% if can_edit %}
<td>
{% include "generic/partials/_candidate_stats_checkbox.html" with cid=cid is_excluded=excluded_map|get_item:cid exam=exam %}
</td>
{% endif %}
</tr>
{% endfor %}
</table>
@@ -0,0 +1,5 @@
<input type="checkbox"
hx-post="{% url exam.app_name|add:':toggle_candidate_stats_exclusion' exam.pk %}?candidate={{ cid }}"
hx-swap="outerHTML"
title="Check to include in statistics calculations"
{% if not is_excluded %}checked{% endif %}>
@@ -13,7 +13,7 @@
<div class="col-md-4 col-sm-6">
<div class="card bg-dark text-light border-secondary h-100 shadow-sm">
<div class="card-body">
<div class="text-uppercase text-muted small fw-semibold mb-1">Pass Rate (&ge; 50%)</div>
<div class="text-uppercase text-muted small fw-semibold mb-1">Pass Rate (&ge; {{ pass_mark_threshold }})</div>
<div class="fs-3 fw-bold text-info">{{ pass_rate }}%</div>
<div class="small text-muted mt-1">{{ passed_candidates_count }} of {{ cids|length }} candidates</div>
</div>
+139
View File
@@ -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"])
+10
View File
@@ -485,6 +485,16 @@ def generic_exam_urls(generic_exam_view: GenericExamViews):
generic_exam_view.exam_scores_all,
name="exam_scores_all",
),
path(
"exam/<int:pk>/update_pass_mark",
generic_exam_view.update_pass_mark,
name="update_pass_mark",
),
path(
"exam/<int:pk>/toggle_candidate_stats_exclusion",
generic_exam_view.toggle_candidate_stats_exclusion,
name="toggle_candidate_stats_exclusion",
),
path(
"exam/<int:exam_id>/stats",
generic_exam_view.exam_stats,
+117 -6
View File
@@ -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):