Add timestamps for answer tracking in report models and views; enhance timer display in templates
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-13 08:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0077_alter_casecollection_question_time_limit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cidreportanswer',
|
||||
name='started_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cidreportanswer',
|
||||
name='submitted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userreportanswer',
|
||||
name='started_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userreportanswer',
|
||||
name='submitted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1376,6 +1376,10 @@ class BaseReportAnswer(models.Model):
|
||||
)
|
||||
|
||||
completed = models.BooleanField(default=False)
|
||||
# Timestamp when the user first loaded the question (started answering)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
# Timestamp when the answer was submitted/saved
|
||||
submitted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
<div id="question-timer-block" style="margin-top:8px; margin-bottom:8px;" title="This question has a time limit of {{ collection.question_time_limit }} seconds. The timer will start when the page loads.">
|
||||
<strong>Time remaining:</strong>
|
||||
<span id="question-timer" aria-live="polite"> </span>
|
||||
<div id="question-timer-progress" style="display:inline-block; vertical-align: middle; width: 200px; margin-left:12px;">
|
||||
<div id="question-timer-progress-outer" style="background:#e9ecef; border-radius:6px; height:10px; overflow:hidden;">
|
||||
<div id="question-timer-progress-inner" style="width:100%; height:100%; background:var(--timer-color, #28a745);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -338,12 +343,12 @@
|
||||
try {
|
||||
var timeLimit = {{ collection.question_time_limit|default:'null' }};
|
||||
var questionCompleted = {{ question_completed|yesno:"true,false" }};
|
||||
var answerStartedAtIso = "{{ answer_started_at_iso|default:'null' }}";
|
||||
console.debug('Timer init:', {timeLimit: timeLimit, questionCompleted: questionCompleted});
|
||||
|
||||
if (!timeLimit || questionCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $timer = $('#question-timer');
|
||||
var $form = $('form.post-form');
|
||||
|
||||
@@ -352,7 +357,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute remaining based on the canonical start time (if provided)
|
||||
var remaining = parseInt(timeLimit, 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);
|
||||
} catch (e) {
|
||||
console.debug('Timer: invalid started_at iso, falling back to full timeLimit', e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(s) {
|
||||
var mins = Math.floor(s / 60);
|
||||
@@ -360,13 +377,40 @@
|
||||
return mins + ':' + (secs < 10 ? '0' + secs : secs);
|
||||
}
|
||||
|
||||
var $progressInner = $('#question-timer-progress-inner');
|
||||
$timer.text(formatTime(remaining));
|
||||
|
||||
function updateProgress() {
|
||||
var pct = Math.max(0, Math.min(100, Math.round((remaining / timeLimit) * 100)));
|
||||
var widthPct = pct;
|
||||
$progressInner.css('width', widthPct + '%');
|
||||
|
||||
// Smooth color transition: green (120) -> orange (30) -> red (0)
|
||||
// We use a two-stage interpolation so the midpoint (~50%) is orange.
|
||||
var hue = 0;
|
||||
if (pct > 50) {
|
||||
// interpolate from orange (30) to green (120)
|
||||
var t = (pct - 50) / 50.0; // 0..1
|
||||
hue = 30 + t * (120 - 30);
|
||||
} else {
|
||||
// interpolate from red (0) to orange (30)
|
||||
var t = pct / 50.0; // 0..1
|
||||
hue = 0 + t * (30 - 0);
|
||||
}
|
||||
|
||||
var color = 'hsl(' + Math.round(hue) + ', 75%, 40%)';
|
||||
$progressInner.css('background', color);
|
||||
}
|
||||
|
||||
updateProgress();
|
||||
|
||||
var intervalId = setInterval(function () {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(intervalId);
|
||||
$timer.text('0:00');
|
||||
$progressInner.css('width', '0%');
|
||||
$progressInner.css('background', '#dc3545');
|
||||
var $next = $form.find('button[name="next"]');
|
||||
var $finish = $form.find('button[name="finish"]');
|
||||
|
||||
@@ -381,6 +425,7 @@
|
||||
}
|
||||
} else {
|
||||
$timer.text(formatTime(remaining));
|
||||
updateProgress();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
|
||||
@@ -2737,6 +2737,29 @@ def collection_case_view_take(
|
||||
).first()
|
||||
ReportAnswerForm = UserQuestionAnswerForm
|
||||
|
||||
# If we have an answer object but it doesn't have a started_at, set it now.
|
||||
if answer and getattr(answer, 'started_at', None) is None:
|
||||
try:
|
||||
answer.started_at = timezone.now()
|
||||
answer.save()
|
||||
except Exception:
|
||||
# Non-fatal: if saving fails, continue without blocking the user
|
||||
logger.exception("Failed to set started_at on answer", exc_info=True)
|
||||
|
||||
# If no answer exists yet, create a placeholder so we have a started_at timestamp
|
||||
if not answer and collection.collection_type in ("REP", "QUE"):
|
||||
try:
|
||||
if cid is not None:
|
||||
answer = CidReportAnswer(question=case_detail, cid=cid)
|
||||
else:
|
||||
answer = UserReportAnswer(question=case_detail, user=request.user)
|
||||
answer.started_at = timezone.now()
|
||||
answer.save()
|
||||
except Exception:
|
||||
# If creation fails, leave answer as None; the normal flow will handle form creation
|
||||
answer = None
|
||||
logger.exception("Failed to create initial answer object", exc_info=True)
|
||||
|
||||
if request.method == "POST":
|
||||
if collection.collection_type in ("REP", "QUE"):
|
||||
if not collection.publish_results:
|
||||
@@ -2754,6 +2777,8 @@ def collection_case_view_take(
|
||||
answer = form.save(commit=False)
|
||||
answer.set_cid_or_user(cid=cid, user=request.user)
|
||||
answer.question = case_detail
|
||||
# Record submission timestamp
|
||||
answer.submitted_at = timezone.now()
|
||||
# answer.published_date = timezone.now()
|
||||
|
||||
if "complete_case" in request.POST:
|
||||
@@ -2872,6 +2897,11 @@ def collection_case_view_take(
|
||||
"cid_user_exam": cid_user_exam,
|
||||
"question_completed": question_completed,
|
||||
"self_review": self_review,
|
||||
# Provide a canonical start timestamp (prefer answer.started_at, then cid_user_exam.start_time)
|
||||
"answer_started_at_iso": (
|
||||
(answer.started_at.isoformat() if getattr(answer, 'started_at', None) else None)
|
||||
or (cid_user_exam.start_time.isoformat() if getattr(cid_user_exam, 'start_time', None) else None)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user