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)
|
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):
|
def save(self, *args, **kwargs):
|
||||||
self.clean()
|
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.">
|
<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>
|
<strong>Time remaining:</strong>
|
||||||
<span id="question-timer" aria-live="polite"> </span>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -338,12 +343,12 @@
|
|||||||
try {
|
try {
|
||||||
var timeLimit = {{ collection.question_time_limit|default:'null' }};
|
var timeLimit = {{ collection.question_time_limit|default:'null' }};
|
||||||
var questionCompleted = {{ question_completed|yesno:"true,false" }};
|
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, questionCompleted: questionCompleted});
|
||||||
|
|
||||||
if (!timeLimit || questionCompleted) {
|
if (!timeLimit || questionCompleted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var $timer = $('#question-timer');
|
var $timer = $('#question-timer');
|
||||||
var $form = $('form.post-form');
|
var $form = $('form.post-form');
|
||||||
|
|
||||||
@@ -352,7 +357,19 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute remaining based on the canonical start time (if provided)
|
||||||
var remaining = parseInt(timeLimit, 10);
|
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) {
|
function formatTime(s) {
|
||||||
var mins = Math.floor(s / 60);
|
var mins = Math.floor(s / 60);
|
||||||
@@ -360,13 +377,40 @@
|
|||||||
return mins + ':' + (secs < 10 ? '0' + secs : secs);
|
return mins + ':' + (secs < 10 ? '0' + secs : secs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var $progressInner = $('#question-timer-progress-inner');
|
||||||
$timer.text(formatTime(remaining));
|
$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 () {
|
var intervalId = setInterval(function () {
|
||||||
remaining -= 1;
|
remaining -= 1;
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
$timer.text('0:00');
|
$timer.text('0:00');
|
||||||
|
$progressInner.css('width', '0%');
|
||||||
|
$progressInner.css('background', '#dc3545');
|
||||||
var $next = $form.find('button[name="next"]');
|
var $next = $form.find('button[name="next"]');
|
||||||
var $finish = $form.find('button[name="finish"]');
|
var $finish = $form.find('button[name="finish"]');
|
||||||
|
|
||||||
@@ -381,6 +425,7 @@
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$timer.text(formatTime(remaining));
|
$timer.text(formatTime(remaining));
|
||||||
|
updateProgress();
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -2737,6 +2737,29 @@ def collection_case_view_take(
|
|||||||
).first()
|
).first()
|
||||||
ReportAnswerForm = UserQuestionAnswerForm
|
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 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:
|
||||||
@@ -2754,6 +2777,8 @@ def collection_case_view_take(
|
|||||||
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)
|
||||||
answer.question = case_detail
|
answer.question = case_detail
|
||||||
|
# Record submission timestamp
|
||||||
|
answer.submitted_at = timezone.now()
|
||||||
# answer.published_date = timezone.now()
|
# answer.published_date = timezone.now()
|
||||||
|
|
||||||
if "complete_case" in request.POST:
|
if "complete_case" in request.POST:
|
||||||
@@ -2872,6 +2897,11 @@ def collection_case_view_take(
|
|||||||
"cid_user_exam": cid_user_exam,
|
"cid_user_exam": cid_user_exam,
|
||||||
"question_completed": question_completed,
|
"question_completed": question_completed,
|
||||||
"self_review": self_review,
|
"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