Add prerequisites handling to CaseCollection and implement user access checks
This commit is contained in:
@@ -216,6 +216,7 @@ class CaseCollectionForm(ModelForm):
|
|||||||
"collection_type",
|
"collection_type",
|
||||||
"viewer_mode",
|
"viewer_mode",
|
||||||
"question_time_limit",
|
"question_time_limit",
|
||||||
|
"prerequisites",
|
||||||
),
|
),
|
||||||
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
|
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
|
||||||
Div(
|
Div(
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-10-13 12:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('atlas', '0078_cidreportanswer_started_at_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='casecollection',
|
||||||
|
name='prerequisites',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='Collections that must be completed before this collection can be taken', related_name='dependents', to='atlas.casecollection'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -71,6 +71,7 @@ from django.utils import timezone
|
|||||||
import reversion
|
import reversion
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
|
|
||||||
@@ -948,6 +949,15 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
|||||||
help_text="Time limit for answering questions in seconds."
|
help_text="Time limit for answering questions in seconds."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Collections that must be completed before this collection can be taken
|
||||||
|
prerequisites = models.ManyToManyField(
|
||||||
|
"self",
|
||||||
|
blank=True,
|
||||||
|
symmetrical=False,
|
||||||
|
related_name="dependents",
|
||||||
|
help_text="Collections that must be completed before this collection can be taken",
|
||||||
|
)
|
||||||
|
|
||||||
class COLLECTION_TYPE_CHOICES(models.TextChoices):
|
class COLLECTION_TYPE_CHOICES(models.TextChoices):
|
||||||
REVIEW = (
|
REVIEW = (
|
||||||
"REV",
|
"REV",
|
||||||
@@ -1105,6 +1115,49 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
|||||||
kwargs={"pk": self.pk, "case_number": cases.index(case)},
|
kwargs={"pk": self.pk, "case_number": cases.index(case)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def check_user_can_take(self, cid, passcode, user=None, active_only=True):
|
||||||
|
"""
|
||||||
|
Extend base check_user_can_take to also require completion of any
|
||||||
|
prerequisite collections.
|
||||||
|
"""
|
||||||
|
# Perform the normal access checks first
|
||||||
|
super().check_user_can_take(cid, passcode, user=user, active_only=active_only)
|
||||||
|
|
||||||
|
# If there are prerequisites, the user (or CID) must have completed them
|
||||||
|
if not self.prerequisites.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
for prereq in self.prerequisites.all():
|
||||||
|
# Look up any existing exam record for this user/cid on the prerequisite
|
||||||
|
ct = ContentType.objects.get_for_model(prereq)
|
||||||
|
exam_record = None
|
||||||
|
if cid is not None:
|
||||||
|
# Find CidUser by cid
|
||||||
|
try:
|
||||||
|
cid_user = CidUser.objects.filter(cid=cid).first()
|
||||||
|
except Exception:
|
||||||
|
cid_user = None
|
||||||
|
|
||||||
|
if cid_user is None:
|
||||||
|
exam_record = None
|
||||||
|
else:
|
||||||
|
exam_record = CidUserExam.objects.filter(
|
||||||
|
content_type=ct, object_id=prereq.pk, cid_user=cid_user
|
||||||
|
).first()
|
||||||
|
else:
|
||||||
|
# Check for a normal user_user exam record
|
||||||
|
exam_record = CidUserExam.objects.filter(
|
||||||
|
content_type=ct, object_id=prereq.pk, user_user=user
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if exam_record is None or not getattr(exam_record, "completed", False):
|
||||||
|
# Not allowed to take this collection until prereq completed
|
||||||
|
# Raise the PrerequisiteRequired exception including the prereq object
|
||||||
|
raise PrerequisiteRequired(
|
||||||
|
f"Collection not available until prerequisite '{prereq.name}' is completed.",
|
||||||
|
prereq=prereq,
|
||||||
|
)
|
||||||
|
|
||||||
def get_ohif_dicom_json(self, case_title_as_patient_name=True):
|
def get_ohif_dicom_json(self, case_title_as_patient_name=True):
|
||||||
studies = []
|
studies = []
|
||||||
for n, case in enumerate(self.cases.all()):
|
for n, case in enumerate(self.cases.all()):
|
||||||
@@ -1737,3 +1790,15 @@ class QuestionSchema(models.Model, AuthorMixin):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return "{}".format(self.name)
|
return "{}".format(self.name)
|
||||||
|
|
||||||
|
|
||||||
|
class PrerequisiteRequired(Exception):
|
||||||
|
"""Raised when a user attempts to access a collection but has not completed a prerequisite.
|
||||||
|
|
||||||
|
The exception stores an optional `prereq` attribute pointing to the prerequisite
|
||||||
|
CaseCollection instance so views can render a helpful page linking to it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message=None, *, prereq=None):
|
||||||
|
super().__init__(message or "Prerequisite required")
|
||||||
|
self.prereq = prereq
|
||||||
@@ -404,6 +404,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If time has already elapsed when the page loads, lock the question and do not autosubmit.
|
||||||
|
if (remaining <= 0) {
|
||||||
|
console.debug('Timer: time already expired on load, locking without autosubmit');
|
||||||
|
lockQuestion();
|
||||||
|
$timer.text(formatTime(0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(s) {
|
function formatTime(s) {
|
||||||
var mins = Math.floor(s / 60);
|
var mins = Math.floor(s / 60);
|
||||||
var secs = s % 60;
|
var secs = s % 60;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends 'atlas/base.html' %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Collection requires completion of another collection</h2>
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
{% if prereq %}
|
||||||
|
<p>
|
||||||
|
You must complete the collection: <a href="{% url 'atlas:collection_take_start' prereq.pk %}">{{ prereq.name }}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>
|
||||||
|
If you think this is an error, contact the course administrator.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -105,6 +105,7 @@ from .models import (
|
|||||||
SeriesImage,
|
SeriesImage,
|
||||||
UncategorisedDicom,
|
UncategorisedDicom,
|
||||||
UserReportAnswer,
|
UserReportAnswer,
|
||||||
|
PrerequisiteRequired
|
||||||
)
|
)
|
||||||
from .tables import (
|
from .tables import (
|
||||||
CaseCollectionTable,
|
CaseCollectionTable,
|
||||||
@@ -2197,6 +2198,16 @@ def collection_take_start(request, pk, cid=None, passcode=None):
|
|||||||
valid_user = True
|
valid_user = True
|
||||||
except Http404:
|
except Http404:
|
||||||
valid_user = False
|
valid_user = False
|
||||||
|
except PrerequisiteRequired as e:
|
||||||
|
# Show a friendly page linking to the required collection rather than 404
|
||||||
|
prereq = getattr(e, 'prereq', None)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
'atlas/prerequisite_required.html',
|
||||||
|
{'message': str(e), 'prereq': prereq},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#valid_user = collection.check_logged_in_user(request)
|
#valid_user = collection.check_logged_in_user(request)
|
||||||
|
|
||||||
|
|
||||||
@@ -2611,7 +2622,11 @@ def collection_take_overview(
|
|||||||
"""
|
"""
|
||||||
collection = get_object_or_404(CaseCollection, pk=pk)
|
collection = get_object_or_404(CaseCollection, pk=pk)
|
||||||
|
|
||||||
|
try:
|
||||||
collection.check_user_can_take(cid, passcode, request.user)
|
collection.check_user_can_take(cid, passcode, request.user)
|
||||||
|
except PrerequisiteRequired as e:
|
||||||
|
prereq = getattr(e, 'prereq', None)
|
||||||
|
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq})
|
||||||
|
|
||||||
cid_user_exam = collection.get_or_create_cid_user_exam(
|
cid_user_exam = collection.get_or_create_cid_user_exam(
|
||||||
cid=cid, user_user=request.user
|
cid=cid, user_user=request.user
|
||||||
@@ -2684,7 +2699,11 @@ def collection_case_view_take_answers(
|
|||||||
""" """
|
""" """
|
||||||
collection = get_object_or_404(CaseCollection, pk=pk)
|
collection = get_object_or_404(CaseCollection, pk=pk)
|
||||||
|
|
||||||
|
try:
|
||||||
collection.check_user_can_take(cid, passcode, request.user)
|
collection.check_user_can_take(cid, passcode, request.user)
|
||||||
|
except PrerequisiteRequired as e:
|
||||||
|
prereq = getattr(e, 'prereq', None)
|
||||||
|
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq})
|
||||||
|
|
||||||
case, case_count = collection.get_case_by_index(case_number, case_count=True)
|
case, case_count = collection.get_case_by_index(case_number, case_count=True)
|
||||||
|
|
||||||
@@ -2733,7 +2752,11 @@ def collection_case_view_take(
|
|||||||
form = None
|
form = None
|
||||||
answer: None | CidReportAnswer | UserReportAnswer = None
|
answer: None | CidReportAnswer | UserReportAnswer = None
|
||||||
|
|
||||||
|
try:
|
||||||
collection.check_user_can_take(cid, passcode, request.user)
|
collection.check_user_can_take(cid, passcode, request.user)
|
||||||
|
except PrerequisiteRequired as e:
|
||||||
|
prereq = getattr(e, 'prereq', None)
|
||||||
|
return render(request, 'atlas/prerequisite_required.html', {'message': str(e), 'prereq': prereq})
|
||||||
|
|
||||||
case, case_count = collection.get_case_by_index(case_number, case_count=True)
|
case, case_count = collection.get_case_by_index(case_number, case_count=True)
|
||||||
|
|
||||||
@@ -2818,6 +2841,18 @@ def collection_case_view_take(
|
|||||||
# If this was a timed-out submission, mark as completed
|
# If this was a timed-out submission, mark as completed
|
||||||
if request.POST.get('timed_out') == '1':
|
if request.POST.get('timed_out') == '1':
|
||||||
answer.completed = True
|
answer.completed = True
|
||||||
|
## Also mark the overall exam as completed for this user/CID
|
||||||
|
#try:
|
||||||
|
# cid_user_exam.completed = True
|
||||||
|
# cid_user_exam.save()
|
||||||
|
# # Record an exam status entry
|
||||||
|
# collection.exam_user_status.create(
|
||||||
|
# cid_user_exam=cid_user_exam,
|
||||||
|
# status="submitted",
|
||||||
|
# extra="autosubmit",
|
||||||
|
# )
|
||||||
|
#except Exception:
|
||||||
|
# logger.exception("Failed to mark cid_user_exam completed on autosubmit")
|
||||||
|
|
||||||
answer.save()
|
answer.save()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user