.
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 = [
|
||||
('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),
|
||||
),
|
||||
]
|
||||
+14
-14
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
{% for ss in case.subspecialty.all %}
|
||||
{% if ss.name %}<span class="badge bg-secondary" title="Subspecialty: {{ ss.name }}">{{ ss.name }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
{% with case.get_series_info as series_info %}
|
||||
{% if series_info.in_series %}
|
||||
<span class="badge bg-info text-dark ms-1" title="Linked case series position: {{ series_info.position }} of {{ series_info.total }}">
|
||||
Series ({{ series_info.position }}/{{ series_info.total }})
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<p class="mb-1 text-truncate">{% if case.description %}{{ case.description|truncatechars:140 }}{% endif %}</p>
|
||||
</div>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user