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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user