diff --git a/atlas/forms.py b/atlas/forms.py index 42741947..e0412dc0 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -216,6 +216,7 @@ class CaseCollectionForm(ModelForm): "collection_type", "viewer_mode", "question_time_limit", + "prerequisites", ), Fieldset("Valid User Groups", *user_fields) if user_fields else None, Div( diff --git a/atlas/migrations/0079_casecollection_prerequisites.py b/atlas/migrations/0079_casecollection_prerequisites.py new file mode 100644 index 00000000..91d76f8f --- /dev/null +++ b/atlas/migrations/0079_casecollection_prerequisites.py @@ -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'), + ), + ] diff --git a/atlas/models.py b/atlas/models.py index 64289d04..65eb12e5 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -71,6 +71,7 @@ from django.utils import timezone import reversion from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.validators import MaxValueValidator, MinValueValidator @@ -948,6 +949,15 @@ class CaseCollection(ExamOrCollectionGenericBase): 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): REVIEW = ( "REV", @@ -1105,6 +1115,49 @@ class CaseCollection(ExamOrCollectionGenericBase): 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): studies = [] for n, case in enumerate(self.cases.all()): @@ -1736,4 +1789,16 @@ class QuestionSchema(models.Model, AuthorMixin): return json.dumps(self.schema) def __str__(self) -> str: - return "{}".format(self.name) \ No newline at end of file + 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 \ No newline at end of file diff --git a/atlas/templates/atlas/collection_case_view_take.html b/atlas/templates/atlas/collection_case_view_take.html index a1bda017..f056b317 100644 --- a/atlas/templates/atlas/collection_case_view_take.html +++ b/atlas/templates/atlas/collection_case_view_take.html @@ -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) { var mins = Math.floor(s / 60); var secs = s % 60; diff --git a/atlas/templates/atlas/prerequisite_required.html b/atlas/templates/atlas/prerequisite_required.html new file mode 100644 index 00000000..cc13e0a9 --- /dev/null +++ b/atlas/templates/atlas/prerequisite_required.html @@ -0,0 +1,14 @@ +{% extends 'atlas/base.html' %} + +{% block content %} +

Collection requires completion of another collection

+

{{ message }}

+ {% if prereq %} +

+ You must complete the collection: {{ prereq.name }} +

+ {% endif %} +

+ If you think this is an error, contact the course administrator. +

+{% endblock %} diff --git a/atlas/views.py b/atlas/views.py index 4e736044..3f109380 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -105,6 +105,7 @@ from .models import ( SeriesImage, UncategorisedDicom, UserReportAnswer, + PrerequisiteRequired ) from .tables import ( CaseCollectionTable, @@ -2192,11 +2193,21 @@ def collection_take_start(request, pk, cid=None, passcode=None): collection = get_object_or_404(CaseCollection, pk=pk) print(f"Collection: {collection}") - try: + try: collection.check_user_can_take(cid, passcode, request.user) valid_user = True except Http404: 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) @@ -2611,7 +2622,11 @@ def collection_take_overview( """ collection = get_object_or_404(CaseCollection, pk=pk) - collection.check_user_can_take(cid, passcode, request.user) + try: + 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=cid, user_user=request.user @@ -2684,7 +2699,11 @@ def collection_case_view_take_answers( """ """ collection = get_object_or_404(CaseCollection, pk=pk) - collection.check_user_can_take(cid, passcode, request.user) + try: + 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) @@ -2733,7 +2752,11 @@ def collection_case_view_take( form = None answer: None | CidReportAnswer | UserReportAnswer = None - collection.check_user_can_take(cid, passcode, request.user) + try: + 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) @@ -2818,6 +2841,18 @@ def collection_case_view_take( # If this was a timed-out submission, mark as completed if request.POST.get('timed_out') == '1': 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()