From 6575c5050777880c87290d7d7bf776e7714e762f Mon Sep 17 00:00:00 2001 From: Ross Date: Thu, 30 Apr 2026 20:31:16 +0100 Subject: [PATCH] feat: Enhance case collection and detail forms with timing overrides and custom screens Co-authored-by: Copilot --- atlas/forms.py | 36 +++++- ...9_casecollection_workflow_customisation.py | 77 ++++++++++++ ...ection_show_results_sharing_information.py | 19 +++ atlas/models.py | 59 +++++++++ .../atlas/collection_case_view_take.html | 110 ++++++++++++---- .../atlas/collection_review_start.html | 19 +++ .../atlas/collection_take_overview.html | 28 ++++- .../atlas/collection_take_start.html | 19 ++- .../atlas/partials/_collection_case_jump.html | 16 +-- atlas/views.py | 118 ++++++++++++++++-- 10 files changed, 453 insertions(+), 48 deletions(-) create mode 100644 atlas/migrations/0099_casecollection_workflow_customisation.py create mode 100644 atlas/migrations/0100_casecollection_show_results_sharing_information.py diff --git a/atlas/forms.py b/atlas/forms.py index 421b9d5d..0bf69ada 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -289,9 +289,21 @@ class CaseCollectionForm(ModelForm): "self_review", "feedback_once_collection_complete", "case_query_messaging_enabled", + "show_results_sharing_information", "viewer_mode", "question_time_limit", + "answer_entry_grace_period", "prerequisites", + Fieldset( + "Custom Start Screen", + "start_screen_content", + "start_screen_resources", + ), + Fieldset( + "Custom End Overview", + "end_overview_content", + "end_overview_resources", + ), ), Fieldset("Valid User Groups", *user_fields) if user_fields else None, Div( @@ -1285,7 +1297,12 @@ class CaseQuestionForm(ModelForm): class CaseDetailForm(ModelForm): class Meta: model = CaseDetail - fields = ["redact_history", "override_history"] + fields = [ + "redact_history", + "override_history", + "question_time_limit_override", + "answer_entry_grace_period_override", + ] def __init__(self, *args, case_history: str = None, **kwargs): """ @@ -1308,6 +1325,16 @@ class CaseDetailForm(ModelForm): self.fields["override_history"].widget.attrs.setdefault("rows", 6) self.fields["override_history"].widget.attrs.setdefault("class", "form-control") + if "question_time_limit_override" in self.fields: + self.fields["question_time_limit_override"].help_text = ( + "Leave blank to use the collection default answer time limit." + ) + + if "answer_entry_grace_period_override" in self.fields: + self.fields["answer_entry_grace_period_override"].help_text = ( + "Leave blank to use the collection default answer-only grace period." + ) + # Build crispy helper/layout embedding the original history and buttons tied to override_history self.helper = FormHelper() self.helper.form_tag = False @@ -1345,7 +1372,12 @@ class CaseDetailForm(ModelForm): self.helper.layout = Layout( HTML(history_block), Field("override_history"), - Field("redact_history") + Field("redact_history"), + Fieldset( + "Case Timing Overrides", + Field("question_time_limit_override"), + Field("answer_entry_grace_period_override"), + ), ) diff --git a/atlas/migrations/0099_casecollection_workflow_customisation.py b/atlas/migrations/0099_casecollection_workflow_customisation.py new file mode 100644 index 00000000..6904636c --- /dev/null +++ b/atlas/migrations/0099_casecollection_workflow_customisation.py @@ -0,0 +1,77 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("atlas", "0098_casecollection_case_query_messaging_enabled"), + ] + + operations = [ + migrations.AddField( + model_name="casedetail", + name="answer_entry_grace_period_override", + field=models.PositiveIntegerField( + blank=True, + help_text="Optional per-case override for additional answer-only time in seconds after case view lock.", + null=True, + ), + ), + migrations.AddField( + model_name="casedetail", + name="question_time_limit_override", + field=models.PositiveIntegerField( + blank=True, + help_text="Optional per-case override for the answer time limit in seconds.", + null=True, + ), + ), + migrations.AddField( + model_name="casecollection", + name="answer_entry_grace_period", + field=models.PositiveIntegerField( + blank=True, + default=0, + help_text="Additional seconds after the case view is locked during which answers can still be entered.", + null=True, + ), + ), + migrations.AddField( + model_name="casecollection", + name="end_overview_content", + field=models.TextField( + blank=True, + help_text="Optional HTML content shown on the collection end overview screen.", + null=True, + ), + ), + migrations.AddField( + model_name="casecollection", + name="end_overview_resources", + field=models.ManyToManyField( + blank=True, + help_text="Optional resources shown on the collection end overview screen.", + related_name="end_overview_collections", + to="atlas.resource", + ), + ), + migrations.AddField( + model_name="casecollection", + name="start_screen_content", + field=models.TextField( + blank=True, + help_text="Optional HTML content shown on the collection start screen (for example embedded resources).", + null=True, + ), + ), + migrations.AddField( + model_name="casecollection", + name="start_screen_resources", + field=models.ManyToManyField( + blank=True, + help_text="Optional resources shown on the collection start screen.", + related_name="start_screen_collections", + to="atlas.resource", + ), + ), + ] diff --git a/atlas/migrations/0100_casecollection_show_results_sharing_information.py b/atlas/migrations/0100_casecollection_show_results_sharing_information.py new file mode 100644 index 00000000..8f4178cd --- /dev/null +++ b/atlas/migrations/0100_casecollection_show_results_sharing_information.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("atlas", "0099_casecollection_workflow_customisation"), + ] + + operations = [ + migrations.AddField( + model_name="casecollection", + name="show_results_sharing_information", + field=models.BooleanField( + default=True, + help_text="If true, show result-sharing information on candidate start and overview screens.", + ), + ), + ] diff --git a/atlas/models.py b/atlas/models.py index 2d4dd0d6..1bb8b3fb 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -1360,6 +1360,10 @@ class CaseCollection(ExamOrCollectionGenericBase): default=True, help_text="Enable per-case query and messaging for this collection.", ) + show_results_sharing_information = models.BooleanField( + default=True, + help_text="If true, show result-sharing information on candidate start and overview screens.", + ) question_time_limit = models.PositiveIntegerField( blank=True, @@ -1367,6 +1371,39 @@ class CaseCollection(ExamOrCollectionGenericBase): help_text="Time limit for answering questions in seconds." ) + answer_entry_grace_period = models.PositiveIntegerField( + blank=True, + null=True, + default=0, + help_text="Additional seconds after the case view is locked during which answers can still be entered." + ) + + start_screen_content = models.TextField( + blank=True, + null=True, + help_text="Optional HTML content shown on the collection start screen (for example embedded resources).", + ) + + start_screen_resources = models.ManyToManyField( + "Resource", + blank=True, + related_name="start_screen_collections", + help_text="Optional resources shown on the collection start screen.", + ) + + end_overview_content = models.TextField( + blank=True, + null=True, + help_text="Optional HTML content shown on the collection end overview screen.", + ) + + end_overview_resources = models.ManyToManyField( + "Resource", + blank=True, + related_name="end_overview_collections", + help_text="Optional resources shown on the collection end overview screen.", + ) + # Collections that must be completed before this collection can be taken prerequisites = models.ManyToManyField( "self", @@ -1480,6 +1517,16 @@ class CaseCollection(ExamOrCollectionGenericBase): """Returns the cases in the collection in order of the CaseDetail sort_order""" return self.cases.all().order_by("casedetail__sort_order") + def get_effective_case_time_limit(self, casedetail): + if casedetail.question_time_limit_override is not None: + return casedetail.question_time_limit_override + return self.question_time_limit + + def get_effective_answer_entry_grace_period(self, casedetail): + if casedetail.answer_entry_grace_period_override is not None: + return casedetail.answer_entry_grace_period_override + return self.answer_entry_grace_period or 0 + def get_next_case(self, case): cases = list(self.get_cases()) @@ -1707,6 +1754,18 @@ class CaseDetail(models.Model): help_text="This will override the case history for the purpose of the exam/collection." ) + question_time_limit_override = models.PositiveIntegerField( + null=True, + blank=True, + help_text="Optional per-case override for the answer time limit in seconds.", + ) + + answer_entry_grace_period_override = models.PositiveIntegerField( + null=True, + blank=True, + help_text="Optional per-case override for additional answer-only time in seconds after case view lock.", + ) + class Meta: ordering = ("sort_order",) diff --git a/atlas/templates/atlas/collection_case_view_take.html b/atlas/templates/atlas/collection_case_view_take.html index cd4677df..ba21fa45 100644 --- a/atlas/templates/atlas/collection_case_view_take.html +++ b/atlas/templates/atlas/collection_case_view_take.html @@ -36,26 +36,40 @@ - {% if not question_completed and collection.question_time_limit is not None %} -
+ {% if not question_completed and effective_question_time_limit is not None %} +
-
Time remaining:  
+
Case view:  
+ {% if effective_answer_entry_grace_period %} +
+ Answer window remaining: {{ effective_answer_entry_grace_period }}s +
+ {% endif %}
{% endif %} +
+ {% if answer_entry_closed %} + Case view is locked and the answer window has closed. + {% else %} + Case view is now locked. You can continue entering your answer until the answer window closes. + {% endif %} +
+ {% comment %}
Help
{% endcomment %} +
{% if not question_completed %} {% if resources %}
Resources
@@ -175,6 +189,7 @@
{% endif %} +
{% if question_completed %} @@ -235,7 +250,7 @@ {{form.json.errors}}
-
+
{{form}}
@@ -245,7 +260,7 @@ {% elif collection.collection_type == "REP" %} {{form.json.errors}}
-
+
{{form | crispy}}
@@ -440,6 +455,22 @@ // Question time limit countdown + auto-submit $(function () { + function lockCaseView() { + var $view = $('#case-view-content'); + if ($view.length) { + $view.hide(); + } + $('#case-view-locked-alert').removeClass('d-none'); + $('#answer-window-info').removeClass('d-none'); + } + + function closeAnswerWindow() { + $('#answer-window-info').addClass('d-none'); + $('#case-view-locked-alert') + .removeClass('d-none') + .text('Case view is locked and the answer window has closed.'); + } + function lockQuestion() { // Only disable save buttons to prevent further saves $form = $('form.post-form'); @@ -453,15 +484,17 @@ $('#question-timer').text('Locked'); $progressInner.css('background', '#dc3545'); $progressInner.css('width', '0%'); + closeAnswerWindow(); $("#question-timer-progress-outer").hide(); } try { - var timeLimit = {{ collection.question_time_limit|default:'null' }}; + var timeLimit = {{ effective_question_time_limit|default:'null' }}; + var answerGrace = {{ effective_answer_entry_grace_period|default:'0' }}; var questionCompleted = {{ question_completed|yesno:"true,false" }}; var answerStartedAtIso = "{{ answer_started_at_iso|default:'null' }}"; - console.debug('Timer init:', {timeLimit: timeLimit, questionCompleted: questionCompleted}); + console.debug('Timer init:', {timeLimit: timeLimit, answerGrace: answerGrace, questionCompleted: questionCompleted}); if (!timeLimit || questionCompleted) { return; @@ -483,25 +516,32 @@ return; } - // Compute remaining based on the canonical start time (if provided) - var remaining = parseInt(timeLimit, 10); + // Compute remaining values based on the canonical start time (if provided) + var remainingToLock = parseInt(timeLimit, 10); + var remainingToClose = parseInt(timeLimit, 10) + parseInt(answerGrace || 0, 10); if (answerStartedAtIso) { try { var started = new Date(answerStartedAtIso); var now = new Date(); var elapsed = Math.floor((now - started) / 1000); - remaining = Math.max(0, remaining - elapsed); - console.debug('Timer: using started_at, elapsed seconds:', elapsed, 'remaining:', remaining); + remainingToLock = Math.max(0, remainingToLock - elapsed); + remainingToClose = Math.max(0, remainingToClose - elapsed); + console.debug('Timer: using started_at, elapsed seconds:', elapsed, 'remainingToLock:', remainingToLock, 'remainingToClose:', remainingToClose); } catch (e) { console.debug('Timer: invalid started_at iso, falling back to full timeLimit', e); } } - // 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'); + // If case-view time has elapsed on load, hide case content. + if (remainingToLock <= 0) { + lockCaseView(); + } + + // If answer-entry time has elapsed on load, fully lock. + if (remainingToClose <= 0) { + console.debug('Timer: answer-entry window already expired on load, locking without autosubmit'); lockQuestion(); - $timer.text(formatTime(0)); + $timer.text('Locked'); return; } @@ -512,10 +552,16 @@ } var $progressInner = $('#question-timer-progress-inner'); - $timer.text(formatTime(remaining)); + $timer.text(remainingToLock > 0 ? formatTime(remainingToLock) : 'Locked'); + if ($('#answer-window-timer').length && remainingToLock <= 0 && remainingToClose > 0) { + $('#answer-window-info').removeClass('d-none'); + $('#answer-window-timer').text(formatTime(remainingToClose)); + } else if ($('#answer-window-timer').length) { + $('#answer-window-info').addClass('d-none'); + } function updateProgress() { - var pct = Math.max(0, Math.min(100, Math.round((remaining / timeLimit) * 100))); + var pct = Math.max(0, Math.min(100, Math.round((remainingToLock / timeLimit) * 100))); var widthPct = pct; $progressInner.css('width', widthPct + '%'); @@ -539,10 +585,31 @@ updateProgress(); var intervalId = setInterval(function () { - remaining -= 1; - if (remaining <= 0) { + remainingToLock -= 1; + remainingToClose -= 1; + + if (remainingToLock <= 0) { + lockCaseView(); + $timer.text('Locked'); + $progressInner.css('width', '0%'); + $progressInner.css('background', '#dc3545'); + } else { + $timer.text(formatTime(remainingToLock)); + updateProgress(); + } + + if ($('#answer-window-timer').length) { + if (remainingToLock <= 0 && remainingToClose > 0) { + $('#answer-window-info').removeClass('d-none'); + $('#answer-window-timer').text(formatTime(Math.max(0, remainingToClose))); + } else { + $('#answer-window-info').addClass('d-none'); + } + } + + if (remainingToClose <= 0) { clearInterval(intervalId); - $timer.text('0:00'); + $timer.text('Locked'); $progressInner.css('width', '0%'); $progressInner.css('background', '#dc3545'); //var $next = $form.find('button[name="next"]'); @@ -629,9 +696,6 @@ target: "#timer-htmx-target", }); })(); - } else { - $timer.text(formatTime(remaining)); - updateProgress(); } }, 1000); diff --git a/atlas/templates/atlas/collection_review_start.html b/atlas/templates/atlas/collection_review_start.html index 8e7d69ff..1273b539 100644 --- a/atlas/templates/atlas/collection_review_start.html +++ b/atlas/templates/atlas/collection_review_start.html @@ -3,6 +3,25 @@ {% block content %}

Collection: {{exam}}

+ {% if collection.start_screen_content %} +
+
+ {{ collection.start_screen_content|safe }} +
+
+ {% endif %} + + {% if collection.start_screen_resources.exists %} +
+
+
Resources
+ {% for resource in collection.start_screen_resources.all %} +
{{ resource.get_display }}
+ {% endfor %} +
+
+ {% endif %} + {% comment %}