.
This commit is contained in:
@@ -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.'),
|
||||
),
|
||||
]
|
||||
@@ -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 (≥ 50%)</div>
|
||||
<div class="text-uppercase text-muted small fw-semibold mb-1">Pass Rate (≥ {{ 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>
|
||||
|
||||
@@ -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"])
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user