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:
+34
-2
@@ -289,9 +289,21 @@ class CaseCollectionForm(ModelForm):
|
|||||||
"self_review",
|
"self_review",
|
||||||
"feedback_once_collection_complete",
|
"feedback_once_collection_complete",
|
||||||
"case_query_messaging_enabled",
|
"case_query_messaging_enabled",
|
||||||
|
"show_results_sharing_information",
|
||||||
"viewer_mode",
|
"viewer_mode",
|
||||||
"question_time_limit",
|
"question_time_limit",
|
||||||
|
"answer_entry_grace_period",
|
||||||
"prerequisites",
|
"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,
|
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
|
||||||
Div(
|
Div(
|
||||||
@@ -1285,7 +1297,12 @@ class CaseQuestionForm(ModelForm):
|
|||||||
class CaseDetailForm(ModelForm):
|
class CaseDetailForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CaseDetail
|
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):
|
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("rows", 6)
|
||||||
self.fields["override_history"].widget.attrs.setdefault("class", "form-control")
|
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
|
# Build crispy helper/layout embedding the original history and buttons tied to override_history
|
||||||
self.helper = FormHelper()
|
self.helper = FormHelper()
|
||||||
self.helper.form_tag = False
|
self.helper.form_tag = False
|
||||||
@@ -1345,7 +1372,12 @@ class CaseDetailForm(ModelForm):
|
|||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
HTML(history_block),
|
HTML(history_block),
|
||||||
Field("override_history"),
|
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.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1360,6 +1360,10 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
|||||||
default=True,
|
default=True,
|
||||||
help_text="Enable per-case query and messaging for this collection.",
|
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(
|
question_time_limit = models.PositiveIntegerField(
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -1367,6 +1371,39 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
|||||||
help_text="Time limit for answering questions in seconds."
|
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
|
# Collections that must be completed before this collection can be taken
|
||||||
prerequisites = models.ManyToManyField(
|
prerequisites = models.ManyToManyField(
|
||||||
"self",
|
"self",
|
||||||
@@ -1480,6 +1517,16 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
|||||||
"""Returns the cases in the collection in order of the CaseDetail sort_order"""
|
"""Returns the cases in the collection in order of the CaseDetail sort_order"""
|
||||||
return self.cases.all().order_by("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):
|
def get_next_case(self, case):
|
||||||
cases = list(self.get_cases())
|
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."
|
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:
|
class Meta:
|
||||||
ordering = ("sort_order",)
|
ordering = ("sort_order",)
|
||||||
|
|
||||||
|
|||||||
@@ -36,26 +36,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not question_completed and collection.question_time_limit is not None %}
|
{% if not question_completed and effective_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.">
|
<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 class="d-flex align-items-center gap-3">
|
||||||
<div><strong>Time remaining:</strong> <span id="question-timer" aria-live="polite"> </span></div>
|
<div><strong>Case view:</strong> <span id="question-timer" aria-live="polite"> </span></div>
|
||||||
<div style="flex:1; max-width:320px;">
|
<div style="flex:1; max-width:320px;">
|
||||||
<div class="progress" style="height:10px;">
|
<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 id="question-timer-progress-inner" class="progress-bar" role="progressbar" style="width:100%; background:var(--timer-color, #28a745);"></div>
|
||||||
</div>
|
</div>
|
||||||
</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="timer-htmx-target" style="display:none;"></div>
|
||||||
<div id="autosubmit-toast-container" style="position:fixed; top:16px; right:16px; z-index:10500;"></div>
|
<div id="autosubmit-toast-container" style="position:fixed; top:16px; right:16px; z-index:10500;"></div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
{% comment %} <details>
|
||||||
<summary class="opacity-50">Help <i class="bi bi-info-circle"></i></summary>
|
<summary class="opacity-50">Help <i class="bi bi-info-circle"></i></summary>
|
||||||
</details> {% endcomment %}
|
</details> {% endcomment %}
|
||||||
|
|
||||||
|
|
||||||
|
<div id="case-view-content" {% if case_view_locked %}style="display:none;"{% endif %}>
|
||||||
{% if not question_completed %}
|
{% if not question_completed %}
|
||||||
{% if resources %}
|
{% if resources %}
|
||||||
<h5 class="mt-3">Resources</h5>
|
<h5 class="mt-3">Resources</h5>
|
||||||
@@ -175,6 +189,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if question_completed %}
|
{% if question_completed %}
|
||||||
|
|
||||||
@@ -235,7 +250,7 @@
|
|||||||
|
|
||||||
{{form.json.errors}}
|
{{form.json.errors}}
|
||||||
<div class="form-contents">
|
<div class="form-contents">
|
||||||
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
|
<fieldset {% if question_completed or answer_entry_closed %}disabled="disabled"{% endif %}>
|
||||||
{{form}}
|
{{form}}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,7 +260,7 @@
|
|||||||
{% elif collection.collection_type == "REP" %}
|
{% elif collection.collection_type == "REP" %}
|
||||||
{{form.json.errors}}
|
{{form.json.errors}}
|
||||||
<div class="form-contents">
|
<div class="form-contents">
|
||||||
<fieldset {% if question_completed %}disabled="disabled"{% endif %}>
|
<fieldset {% if question_completed or answer_entry_closed %}disabled="disabled"{% endif %}>
|
||||||
{{form | crispy}}
|
{{form | crispy}}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,6 +455,22 @@
|
|||||||
// Question time limit countdown + auto-submit
|
// Question time limit countdown + auto-submit
|
||||||
$(function () {
|
$(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() {
|
function lockQuestion() {
|
||||||
// Only disable save buttons to prevent further saves
|
// Only disable save buttons to prevent further saves
|
||||||
$form = $('form.post-form');
|
$form = $('form.post-form');
|
||||||
@@ -453,15 +484,17 @@
|
|||||||
$('#question-timer').text('Locked');
|
$('#question-timer').text('Locked');
|
||||||
$progressInner.css('background', '#dc3545');
|
$progressInner.css('background', '#dc3545');
|
||||||
$progressInner.css('width', '0%');
|
$progressInner.css('width', '0%');
|
||||||
|
closeAnswerWindow();
|
||||||
|
|
||||||
$("#question-timer-progress-outer").hide();
|
$("#question-timer-progress-outer").hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 questionCompleted = {{ question_completed|yesno:"true,false" }};
|
||||||
var answerStartedAtIso = "{{ answer_started_at_iso|default:'null' }}";
|
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) {
|
if (!timeLimit || questionCompleted) {
|
||||||
return;
|
return;
|
||||||
@@ -483,25 +516,32 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute remaining based on the canonical start time (if provided)
|
// Compute remaining values based on the canonical start time (if provided)
|
||||||
var remaining = parseInt(timeLimit, 10);
|
var remainingToLock = parseInt(timeLimit, 10);
|
||||||
|
var remainingToClose = parseInt(timeLimit, 10) + parseInt(answerGrace || 0, 10);
|
||||||
if (answerStartedAtIso) {
|
if (answerStartedAtIso) {
|
||||||
try {
|
try {
|
||||||
var started = new Date(answerStartedAtIso);
|
var started = new Date(answerStartedAtIso);
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
var elapsed = Math.floor((now - started) / 1000);
|
var elapsed = Math.floor((now - started) / 1000);
|
||||||
remaining = Math.max(0, remaining - elapsed);
|
remainingToLock = Math.max(0, remainingToLock - elapsed);
|
||||||
console.debug('Timer: using started_at, elapsed seconds:', elapsed, 'remaining:', remaining);
|
remainingToClose = Math.max(0, remainingToClose - elapsed);
|
||||||
|
console.debug('Timer: using started_at, elapsed seconds:', elapsed, 'remainingToLock:', remainingToLock, 'remainingToClose:', remainingToClose);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.debug('Timer: invalid started_at iso, falling back to full timeLimit', 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 case-view time has elapsed on load, hide case content.
|
||||||
if (remaining <= 0) {
|
if (remainingToLock <= 0) {
|
||||||
console.debug('Timer: time already expired on load, locking without autosubmit');
|
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();
|
lockQuestion();
|
||||||
$timer.text(formatTime(0));
|
$timer.text('Locked');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,10 +552,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var $progressInner = $('#question-timer-progress-inner');
|
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() {
|
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;
|
var widthPct = pct;
|
||||||
$progressInner.css('width', widthPct + '%');
|
$progressInner.css('width', widthPct + '%');
|
||||||
|
|
||||||
@@ -539,10 +585,31 @@
|
|||||||
updateProgress();
|
updateProgress();
|
||||||
|
|
||||||
var intervalId = setInterval(function () {
|
var intervalId = setInterval(function () {
|
||||||
remaining -= 1;
|
remainingToLock -= 1;
|
||||||
if (remaining <= 0) {
|
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);
|
clearInterval(intervalId);
|
||||||
$timer.text('0:00');
|
$timer.text('Locked');
|
||||||
$progressInner.css('width', '0%');
|
$progressInner.css('width', '0%');
|
||||||
$progressInner.css('background', '#dc3545');
|
$progressInner.css('background', '#dc3545');
|
||||||
//var $next = $form.find('button[name="next"]');
|
//var $next = $form.find('button[name="next"]');
|
||||||
@@ -629,9 +696,6 @@
|
|||||||
target: "#timer-htmx-target",
|
target: "#timer-htmx-target",
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
} else {
|
|
||||||
$timer.text(formatTime(remaining));
|
|
||||||
updateProgress();
|
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,25 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Collection: {{exam}}</h2>
|
<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>
|
{% comment %} <ul>
|
||||||
{% for case in collection.cases.all %}
|
{% 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>
|
<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>
|
||||||
|
|
||||||
<div class="list-group mb-3">
|
<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="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 class="d-flex flex-wrap justify-content-between align-items-start gap-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -68,6 +68,9 @@
|
|||||||
{% if self_review %}
|
{% if self_review %}
|
||||||
<span class="badge bg-info-subtle text-info-emphasis ms-1">Self review added</span>
|
<span class="badge bg-info-subtle text-info-emphasis ms-1">Self review added</span>
|
||||||
{% endif %}
|
{% 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 collection.case_query_messaging_enabled %}
|
||||||
{% if review_stats.has_outstanding_feedback %}
|
{% 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>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% 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) #}
|
{# 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 %}"
|
<div hx-get="{% url 'atlas:collection_exam_sharing_panel' cid_user_exam.pk %}"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="innerHTML">
|
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>
|
<p class="mb-0 mt-2 text-body-secondary">Open the collection and begin or resume your session.</p>
|
||||||
</section>
|
</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 %}
|
{% if request.user.is_authenticated and valid_user %}
|
||||||
<section class="take-start-card">
|
<section class="take-start-card">
|
||||||
<dl class="take-start-meta mb-3">
|
<dl class="take-start-meta mb-3">
|
||||||
@@ -75,7 +92,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{# ---- Who can see your results ---- #}
|
{# ---- Who can see your results ---- #}
|
||||||
{% if sharing_summary %}
|
{% if collection.show_results_sharing_information and sharing_summary %}
|
||||||
<section class="take-start-card mt-3">
|
<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>
|
<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">
|
<ul class="list-unstyled mb-0 small">
|
||||||
|
|||||||
@@ -3,25 +3,17 @@
|
|||||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||||
<a class="text-decoration-none small fw-semibold"
|
<a class="text-decoration-none small fw-semibold"
|
||||||
href="{{ item.case_url }}">
|
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>
|
</a>
|
||||||
<div class="d-flex gap-1">
|
<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 %}
|
{% if item.has_self_review %}
|
||||||
<span class="badge bg-info-subtle text-info-emphasis">Self review</span>
|
<span class="badge bg-info-subtle text-info-emphasis">Self review</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="dropdown-item-text text-muted small px-2 py-2">No cases available.</div>
|
<div class="dropdown-item-text text-muted small px-2 py-2">No cases available.</div>
|
||||||
|
|||||||
+110
-8
@@ -1909,6 +1909,10 @@ def collection_case_jump_partial(request, pk: int):
|
|||||||
for ans in answers
|
for ans in answers
|
||||||
if (ans.answer or ans.json_answer)
|
if (ans.answer or ans.json_answer)
|
||||||
}
|
}
|
||||||
|
answer_by_case_id = {
|
||||||
|
ans.question.case_id: ans
|
||||||
|
for ans in answers
|
||||||
|
}
|
||||||
|
|
||||||
latest_reviews = (
|
latest_reviews = (
|
||||||
cid_user_exam.selfreview_set.filter(case__in=[cd.case for cd in casedetails])
|
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",
|
"atlas:collection_case_view_take",
|
||||||
kwargs={"pk": collection.pk, "case_number": idx, "cid": cid, "passcode": passcode},
|
kwargs={"pk": collection.pk, "case_number": idx, "cid": cid, "passcode": passcode},
|
||||||
)
|
)
|
||||||
answer_url = case_url
|
|
||||||
else:
|
else:
|
||||||
case_url = reverse(
|
case_url = reverse(
|
||||||
"atlas:collection_case_view_take_user",
|
"atlas:collection_case_view_take_user",
|
||||||
kwargs={"pk": collection.pk, "case_number": idx},
|
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(
|
add_url = reverse(
|
||||||
"atlas:add_self_review",
|
"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)
|
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_view_url = None
|
||||||
review_edit_url = None
|
review_edit_url = None
|
||||||
if review is not None:
|
if review is not None:
|
||||||
@@ -1962,12 +1966,12 @@ def collection_case_jump_partial(request, pk: int):
|
|||||||
"idx": idx,
|
"idx": idx,
|
||||||
"cd": cd,
|
"cd": cd,
|
||||||
"case_url": case_url,
|
"case_url": case_url,
|
||||||
"answer_url": answer_url,
|
|
||||||
"self_review_add_url": add_url,
|
"self_review_add_url": add_url,
|
||||||
"self_review_view_url": review_view_url,
|
"self_review_view_url": review_view_url,
|
||||||
"self_review_edit_url": review_edit_url,
|
"self_review_edit_url": review_edit_url,
|
||||||
"has_answer": cd.case_id in answer_case_ids,
|
"has_answer": cd.case_id in answer_case_ids,
|
||||||
"has_self_review": review is not None,
|
"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,
|
"valid_user": valid_user,
|
||||||
"cid_exam": cid_exam,
|
"cid_exam": cid_exam,
|
||||||
"sharing_summary": sharing_summary,
|
"sharing_summary": sharing_summary,
|
||||||
|
"start_screen_resources": collection.start_screen_resources.all(),
|
||||||
}
|
}
|
||||||
|
|
||||||
match collection.collection_type:
|
match collection.collection_type:
|
||||||
@@ -5690,13 +5695,15 @@ def collection_take_overview(
|
|||||||
for cd in casedetails:
|
for cd in casedetails:
|
||||||
self_review = cid_user_exam.selfreview_set.filter(case=cd.case)
|
self_review = cid_user_exam.selfreview_set.filter(case=cd.case)
|
||||||
review_stats = case_review_stats.get(cd.case_id, {})
|
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 (
|
if cd in answer_question_map and (
|
||||||
answer_question_map[cd].answer or answer_question_map[cd].json_answer
|
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
|
answer_count += 1
|
||||||
else:
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
@@ -5711,10 +5718,61 @@ def collection_take_overview(
|
|||||||
"cid_user_exam": cid_user_exam,
|
"cid_user_exam": cid_user_exam,
|
||||||
"outstanding_case_count": outstanding_case_count,
|
"outstanding_case_count": outstanding_case_count,
|
||||||
"total_outstanding_feedback": total_outstanding_feedback,
|
"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):
|
def _collection_sharing_summary(collection, user):
|
||||||
"""Return a dict describing who a registered user's results will be shared with
|
"""Return a dict describing who a registered user's results will be shared with
|
||||||
for the given collection, before an attempt is created.
|
for the given collection, before an attempt is created.
|
||||||
@@ -6221,6 +6279,14 @@ def collection_case_view_take(
|
|||||||
answer = None
|
answer = None
|
||||||
logger.exception("Failed to create initial answer object", exc_info=True)
|
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 request.method == "POST":
|
||||||
if collection.collection_type in ("REP", "QUE"):
|
if collection.collection_type in ("REP", "QUE"):
|
||||||
if not collection.publish_results:
|
if not collection.publish_results:
|
||||||
@@ -6231,9 +6297,32 @@ def collection_case_view_take(
|
|||||||
else:
|
else:
|
||||||
form = ReportAnswerForm(request.POST, casedetail=casedetail)
|
form = ReportAnswerForm(request.POST, casedetail=casedetail)
|
||||||
|
|
||||||
|
timed_out_submission = request.POST.get('timed_out') == '1'
|
||||||
|
|
||||||
if not cid_user_exam.completed and not (
|
if not cid_user_exam.completed and not (
|
||||||
answer is not None and answer.completed
|
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():
|
if form.is_valid():
|
||||||
answer = form.save(commit=False)
|
answer = form.save(commit=False)
|
||||||
answer.set_cid_or_user(cid=cid, user=request.user)
|
answer.set_cid_or_user(cid=cid, user=request.user)
|
||||||
@@ -6374,6 +6463,14 @@ def collection_case_view_take(
|
|||||||
casedetails=[casedetail],
|
casedetails=[casedetail],
|
||||||
messaging_enabled=_collection_case_query_messaging_enabled(collection),
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/collection_case_view_take.html",
|
"atlas/collection_case_view_take.html",
|
||||||
@@ -6403,6 +6500,11 @@ def collection_case_view_take(
|
|||||||
"question_completed": question_completed,
|
"question_completed": question_completed,
|
||||||
"self_review": self_review,
|
"self_review": self_review,
|
||||||
"answer_started_at_iso": answer.started_at.isoformat() if answer and getattr(answer, 'started_at', None) else None,
|
"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(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user