feat: Enhance case collection and detail forms with timing overrides and custom screens

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Ross
2026-04-30 20:31:16 +01:00
co-authored by Copilot
parent 74cdf14af3
commit 6575c50507
10 changed files with 453 additions and 48 deletions
+34 -2
View File
@@ -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"),
),
)
@@ -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",
),
),
]
@@ -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.",
),
),
]
+59
View File
@@ -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",)
@@ -36,26 +36,40 @@
</div>
</div>
{% if not question_completed and collection.question_time_limit is not None %}
<div id="question-timer-block" class="mb-3" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
{% if not question_completed and effective_question_time_limit is not None %}
<div id="question-timer-block" class="mb-3" title="This case has a view time limit of {{ effective_question_time_limit }} seconds.">
<div class="d-flex align-items-center gap-3">
<div><strong>Time remaining:</strong> <span id="question-timer" aria-live="polite">&nbsp;</span></div>
<div><strong>Case view:</strong> <span id="question-timer" aria-live="polite">&nbsp;</span></div>
<div style="flex:1; max-width:320px;">
<div class="progress" style="height:10px;">
<div id="question-timer-progress-inner" class="progress-bar" role="progressbar" style="width:100%; background:var(--timer-color, #28a745);"></div>
</div>
</div>
</div>
{% if effective_answer_entry_grace_period %}
<div id="answer-window-info" class="small text-muted mt-1 {% if not case_view_locked or answer_entry_closed %}d-none{% endif %}">
Answer window remaining: <span id="answer-window-timer">{{ effective_answer_entry_grace_period }}s</span>
</div>
{% endif %}
<div id="timer-htmx-target" style="display:none;"></div>
<div id="autosubmit-toast-container" style="position:fixed; top:16px; right:16px; z-index:10500;"></div>
</div>
{% endif %}
<div id="case-view-locked-alert" class="alert alert-warning small {% if not case_view_locked %}d-none{% 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 %}
</div>
{% comment %} <details>
<summary class="opacity-50">Help <i class="bi bi-info-circle"></i></summary>
</details> {% endcomment %}
<div id="case-view-content" {% if case_view_locked %}style="display:none;"{% endif %}>
{% if not question_completed %}
{% if resources %}
<h5 class="mt-3">Resources</h5>
@@ -175,6 +189,7 @@
</div>
</details>
{% endif %}
</div>
{% if question_completed %}
@@ -235,7 +250,7 @@
{{form.json.errors}}
<div class="form-contents">
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
<fieldset {% if question_completed or answer_entry_closed %}disabled="disabled"{% endif %}>
{{form}}
</fieldset>
</div>
@@ -245,7 +260,7 @@
{% elif collection.collection_type == "REP" %}
{{form.json.errors}}
<div class="form-contents">
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
<fieldset {% if question_completed or answer_entry_closed %}disabled="disabled"{% endif %}>
{{form | crispy}}
</fieldset>
</div>
@@ -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);
@@ -3,6 +3,25 @@
{% block content %}
<h2>Collection: {{exam}}</h2>
{% if collection.start_screen_content %}
<div class="card mb-3">
<div class="card-body">
{{ collection.start_screen_content|safe }}
</div>
</div>
{% endif %}
{% if collection.start_screen_resources.exists %}
<div class="card mb-3">
<div class="card-body">
<h6>Resources</h6>
{% for resource in collection.start_screen_resources.all %}
<div class="small mb-1">{{ resource.get_display }}</div>
{% endfor %}
</div>
</div>
{% endif %}
{% comment %} <ul>
{% for case in collection.cases.all %}
<li><a href="{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}">Case {{forloop.counter}}</a></li>
@@ -52,7 +52,7 @@
</div>
<div class="list-group mb-3">
{% for question, answer, self_review, review_stats in question_answer_tuples %}
{% for question, answer, self_review, review_stats, timing_state in question_answer_tuples %}
<div class="list-group-item {% if review_stats.has_outstanding_feedback %}border-warning border-2{% elif not answer %}bg-danger-subtle border-danger-subtle{% endif %}">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
@@ -68,6 +68,9 @@
{% if self_review %}
<span class="badge bg-info-subtle text-info-emphasis ms-1">Self review added</span>
{% endif %}
{% if timing_state.effective_time_limit is not None and timing_state.case_view_locked %}
<span class="badge bg-dark ms-1">Time expired</span>
{% endif %}
{% if collection.case_query_messaging_enabled %}
{% if review_stats.has_outstanding_feedback %}
<span class="badge bg-danger ms-1">{{ review_stats.outstanding_feedback_count }} feedback item{{ review_stats.outstanding_feedback_count|pluralize }} to review</span>
@@ -159,8 +162,29 @@
</div>
{% endif %}
{% if collection.end_overview_content %}
<div class="card mt-3">
<div class="card-body">
{{ collection.end_overview_content|safe }}
</div>
</div>
{% endif %}
{% if end_overview_resources %}
<div class="card mt-3">
<div class="card-body">
<h6 class="mb-2">Further Resources</h6>
<div class="d-grid gap-2">
{% for resource in end_overview_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{# Sharing panel only for registered-user attempts (not CID candidates) #}
{% if cid_user_exam.user_user_id %}
{% if collection.show_results_sharing_information and cid_user_exam.user_user_id %}
<div hx-get="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
hx-trigger="load"
hx-swap="innerHTML">
@@ -44,6 +44,23 @@
<p class="mb-0 mt-2 text-body-secondary">Open the collection and begin or resume your session.</p>
</section>
{% if collection.start_screen_content %}
<section class="take-start-card mb-3">
{{ collection.start_screen_content|safe }}
</section>
{% endif %}
{% if start_screen_resources %}
<section class="take-start-card mb-3">
<h6 class="mb-2">Resources</h6>
<div class="d-grid gap-2">
{% for resource in start_screen_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if request.user.is_authenticated and valid_user %}
<section class="take-start-card">
<dl class="take-start-meta mb-3">
@@ -75,7 +92,7 @@
</section>
{# ---- Who can see your results ---- #}
{% if sharing_summary %}
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
@@ -3,25 +3,17 @@
<div class="d-flex justify-content-between align-items-start gap-2">
<a class="text-decoration-none small fw-semibold"
href="{{ item.case_url }}">
Case {{ item.idx|add:1 }}: {{ item.cd.case.title|default:item.cd.case.pk|truncatechars:45 }}
Case {{ item.idx|add:1 }}
</a>
<div class="d-flex gap-1">
{% if item.is_time_expired %}
<span class="badge bg-dark">Time expired</span>
{% endif %}
{% if item.has_self_review %}
<span class="badge bg-info-subtle text-info-emphasis">Self review</span>
{% endif %}
</div>
</div>
<div class="d-flex flex-wrap gap-1 mt-2">
<a class="btn btn-outline-secondary btn-sm py-0 px-2" href="{{ item.answer_url }}">Answer</a>
{% if question_completed%}
{% if item.self_review_view_url %}
<a class="btn btn-outline-info btn-sm py-0 px-2" href="{{ item.self_review_view_url }}">View review</a>
<a class="btn btn-outline-warning btn-sm py-0 px-2" href="{{ item.self_review_edit_url }}">Edit</a>
{% else %}
<a class="btn btn-outline-info btn-sm py-0 px-2" href="{{ item.self_review_add_url }}">Add review</a>
{% endif %}
{% endif %}
</div>
</div>
{% empty %}
<div class="dropdown-item-text text-muted small px-2 py-2">No cases available.</div>
+110 -8
View File
@@ -1909,6 +1909,10 @@ def collection_case_jump_partial(request, pk: int):
for ans in answers
if (ans.answer or ans.json_answer)
}
answer_by_case_id = {
ans.question.case_id: ans
for ans in answers
}
latest_reviews = (
cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails])
@@ -1928,16 +1932,11 @@ def collection_case_jump_partial(request, pk: int):
"atlas:collection_case_view_take",
kwargs={"pk": collection.pk, "case_number": idx, "cid": cid, "passcode": passcode},
)
answer_url = case_url
else:
case_url = reverse(
"atlas:collection_case_view_take_user",
kwargs={"pk": collection.pk, "case_number": idx},
)
answer_url = reverse(
"atlas:collection_case_view_take_user_answers",
kwargs={"pk": collection.pk, "case_number": idx},
)
add_url = reverse(
"atlas:add_self_review",
@@ -1945,6 +1944,11 @@ def collection_case_jump_partial(request, pk: int):
)
review = latest_review_by_case.get(cd.case_id)
timing_state = _build_case_timing_state(
collection,
cd,
answer_by_case_id.get(cd.case_id),
)
review_view_url = None
review_edit_url = None
if review is not None:
@@ -1962,12 +1966,12 @@ def collection_case_jump_partial(request, pk: int):
"idx": idx,
"cd": cd,
"case_url": case_url,
"answer_url": answer_url,
"self_review_add_url": add_url,
"self_review_view_url": review_view_url,
"self_review_edit_url": review_edit_url,
"has_answer": cd.case_id in answer_case_ids,
"has_self_review": review is not None,
"is_time_expired": timing_state["effective_time_limit"] is not None and timing_state["case_view_locked"],
}
)
@@ -5029,6 +5033,7 @@ def collection_take_start(request, pk, cid=None, passcode=None):
"valid_user": valid_user,
"cid_exam": cid_exam,
"sharing_summary": sharing_summary,
"start_screen_resources": collection.start_screen_resources.all(),
}
match collection.collection_type:
@@ -5690,13 +5695,15 @@ def collection_take_overview(
for cd in casedetails:
self_review = cid_user_exam.selfreview_set.filter(case=cd.case)
review_stats = case_review_stats.get(cd.case_id, {})
answer_obj = answer_question_map.get(cd)
timing_state = _build_case_timing_state(collection, cd, answer_obj)
if cd in answer_question_map and (
answer_question_map[cd].answer or answer_question_map[cd].json_answer
):
question_answer_tuples.append((cd, answer_question_map[cd], self_review, review_stats))
question_answer_tuples.append((cd, answer_question_map[cd], self_review, review_stats, timing_state))
answer_count += 1
else:
question_answer_tuples.append((cd, None, self_review, review_stats))
question_answer_tuples.append((cd, None, self_review, review_stats, timing_state))
return render(
request,
@@ -5711,10 +5718,61 @@ def collection_take_overview(
"cid_user_exam": cid_user_exam,
"outstanding_case_count": outstanding_case_count,
"total_outstanding_feedback": total_outstanding_feedback,
"end_overview_resources": collection.end_overview_resources.all(),
},
)
def _build_case_timing_state(collection, casedetail, answer):
"""Return computed timing state for a collection case attempt.
- `effective_time_limit`: seconds until case view is locked (None means disabled)
- `effective_answer_grace_period`: extra answer-only seconds after view lock
- `case_view_locked`: user can no longer view case content/viewer
- `answer_entry_closed`: user can no longer submit/edit answer
"""
effective_time_limit = collection.get_effective_case_time_limit(casedetail)
effective_answer_grace_period = collection.get_effective_answer_entry_grace_period(
casedetail
)
if effective_time_limit is None:
return {
"effective_time_limit": None,
"effective_answer_grace_period": 0,
"case_view_locked": False,
"answer_entry_closed": False,
"elapsed_seconds": 0,
"case_view_seconds_remaining": None,
"answer_entry_seconds_remaining": None,
}
started_at = getattr(answer, "started_at", None)
elapsed_seconds = 0
if started_at is not None:
elapsed_seconds = max(0, int((timezone.now() - started_at).total_seconds()))
case_view_locked = elapsed_seconds >= effective_time_limit
answer_entry_closed = elapsed_seconds >= (
effective_time_limit + (effective_answer_grace_period or 0)
)
case_view_seconds_remaining = max(0, effective_time_limit - elapsed_seconds)
answer_entry_seconds_remaining = max(
0,
(effective_time_limit + (effective_answer_grace_period or 0)) - elapsed_seconds,
)
return {
"effective_time_limit": effective_time_limit,
"effective_answer_grace_period": effective_answer_grace_period or 0,
"case_view_locked": case_view_locked,
"answer_entry_closed": answer_entry_closed,
"elapsed_seconds": elapsed_seconds,
"case_view_seconds_remaining": case_view_seconds_remaining,
"answer_entry_seconds_remaining": answer_entry_seconds_remaining,
}
def _collection_sharing_summary(collection, user):
"""Return a dict describing who a registered user's results will be shared with
for the given collection, before an attempt is created.
@@ -6221,6 +6279,14 @@ def collection_case_view_take(
answer = None
logger.exception("Failed to create initial answer object", exc_info=True)
timing_state = _build_case_timing_state(collection, casedetail, answer)
if request.method != "POST" and timing_state["answer_entry_closed"] and answer and not answer.completed:
answer.completed = True
if not answer.submitted_at:
answer.submitted_at = timezone.now()
answer.save()
if request.method == "POST":
if collection.collection_type in ("REP", "QUE"):
if not collection.publish_results:
@@ -6231,9 +6297,32 @@ def collection_case_view_take(
else:
form = ReportAnswerForm(request.POST, casedetail=casedetail)
timed_out_submission = request.POST.get('timed_out') == '1'
if not cid_user_exam.completed and not (
answer is not None and answer.completed
):
if timing_state["answer_entry_closed"] and not timed_out_submission:
if answer is not None and not answer.completed:
answer.completed = True
if not answer.submitted_at:
answer.submitted_at = timezone.now()
answer.save()
is_ajax = (
request.headers.get('HX-Request', '').lower() == 'true'
or request.headers.get('x-requested-with') == 'XMLHttpRequest'
or getattr(request, 'htmx', False)
)
if is_ajax:
return JsonResponse(
{
'status': 'closed',
'locked': True,
'submitted_at': answer.submitted_at.isoformat() if answer and getattr(answer, 'submitted_at', None) else None,
}
)
if form.is_valid():
answer = form.save(commit=False)
answer.set_cid_or_user(cid=cid, user=request.user)
@@ -6374,6 +6463,14 @@ def collection_case_view_take(
casedetails=[casedetail],
messaging_enabled=_collection_case_query_messaging_enabled(collection),
)
case_view_locked = (
not question_completed and timing_state["case_view_locked"]
)
answer_entry_closed = (
not question_completed and timing_state["answer_entry_closed"]
)
return render(
request,
"atlas/collection_case_view_take.html",
@@ -6403,6 +6500,11 @@ def collection_case_view_take(
"question_completed": question_completed,
"self_review": self_review,
"answer_started_at_iso": answer.started_at.isoformat() if answer and getattr(answer, 'started_at', None) else None,
"effective_question_time_limit": timing_state["effective_time_limit"],
"effective_answer_entry_grace_period": timing_state["effective_answer_grace_period"],
"case_view_locked": case_view_locked,
"answer_entry_closed": answer_entry_closed,
"start_screen_resources": collection.start_screen_resources.all(),
},
)