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",
|
||||
"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.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"> </span></div>
|
||||
<div><strong>Case view:</strong> <span id="question-timer" aria-live="polite"> </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
@@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user