Add question time limit to CaseCollection model and update related template for countdown display
This commit is contained in:
@@ -215,6 +215,7 @@ class CaseCollectionForm(ModelForm):
|
|||||||
"feedback_once_collection_complete",
|
"feedback_once_collection_complete",
|
||||||
"collection_type",
|
"collection_type",
|
||||||
"viewer_mode",
|
"viewer_mode",
|
||||||
|
"question_time_limit",
|
||||||
),
|
),
|
||||||
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
|
Fieldset("Valid User Groups", *user_fields) if user_fields else None,
|
||||||
Div(
|
Div(
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-10-13 08:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('atlas', '0075_casedetail_override_history_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='casecollection',
|
||||||
|
name='question_time_limit',
|
||||||
|
field=models.PositiveIntegerField(help_text='Time limit for answering questions in seconds.', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-10-13 08:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('atlas', '0076_casecollection_question_time_limit'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='casecollection',
|
||||||
|
name='question_time_limit',
|
||||||
|
field=models.PositiveIntegerField(blank=True, help_text='Time limit for answering questions in seconds.', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -942,6 +942,12 @@ class CaseCollection(ExamOrCollectionGenericBase):
|
|||||||
|
|
||||||
feedback_once_collection_complete = models.BooleanField(default=True, help_text="If true feedback is only given once the collection is complete. If false feedback is given after each case.")
|
feedback_once_collection_complete = models.BooleanField(default=True, help_text="If true feedback is only given once the collection is complete. If false feedback is given after each case.")
|
||||||
|
|
||||||
|
question_time_limit = models.PositiveIntegerField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text="Time limit for answering questions in seconds."
|
||||||
|
)
|
||||||
|
|
||||||
class COLLECTION_TYPE_CHOICES(models.TextChoices):
|
class COLLECTION_TYPE_CHOICES(models.TextChoices):
|
||||||
REVIEW = (
|
REVIEW = (
|
||||||
"REV",
|
"REV",
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
{% if not question_completed and collection.question_time_limit is not None %}
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% 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 %}
|
||||||
@@ -51,9 +58,9 @@
|
|||||||
<div>
|
<div>
|
||||||
|
|
||||||
{% if question_completed %}
|
{% if question_completed %}
|
||||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}"></iframe>
|
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}"></iframe>
|
||||||
{% else %}
|
{% else %}
|
||||||
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}"></iframe>
|
<iframe id="viewer" style="width: 100%; height: 100%; border: none; padding: 0px; min-height: 700px" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}"></iframe>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -63,9 +70,9 @@
|
|||||||
{% if collection.show_ohif_viewer_link %}
|
{% if collection.show_ohif_viewer_link %}
|
||||||
<div>
|
<div>
|
||||||
{% if question_completed %}
|
{% if question_completed %}
|
||||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json_review' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% url 'atlas:collection_case_dicom_json' collection.pk case.pk %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -325,107 +332,66 @@
|
|||||||
{{ form.media }}
|
{{ form.media }}
|
||||||
{% comment %} <script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
|
{% comment %} <script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> {% endcomment %}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
window.images = {
|
|
||||||
{% comment %} {% for series in series_list %}
|
|
||||||
{{ forloop.counter0 }}: ["{{ series.get_image_url_array_not_json }}"],
|
|
||||||
{% endfor %} {% endcomment %}
|
|
||||||
{% for series, prior, relation in series_to_load %}
|
|
||||||
{{ forloop.counter0 }}: ["{{ series.get_image_url_array_not_json }}"],
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
}
|
// Question time limit countdown + auto-submit
|
||||||
|
$(function () {
|
||||||
|
try {
|
||||||
|
var timeLimit = {{ collection.question_time_limit|default:'null' }};
|
||||||
|
var questionCompleted = {{ question_completed|yesno:"true,false" }};
|
||||||
|
console.debug('Timer init:', {timeLimit: timeLimit, questionCompleted: questionCompleted});
|
||||||
|
|
||||||
$(document).ready(function () {
|
if (!timeLimit || questionCompleted) {
|
||||||
setTimeout(() => {
|
return;
|
||||||
window.loadDicomViewer(window.images[0])
|
}
|
||||||
}, 500);
|
|
||||||
})
|
|
||||||
|
|
||||||
{% comment %} $('document').ready(function() {
|
var $timer = $('#question-timer');
|
||||||
|
var $form = $('form.post-form');
|
||||||
|
|
||||||
// Get value from either a json string or url pointing to a json file
|
if ($timer.length === 0 || $form.length === 0) {
|
||||||
function process(value) {
|
console.debug('Timer: required elements not found, aborting timer init');
|
||||||
var isjson=true;
|
return;
|
||||||
var result;
|
}
|
||||||
|
|
||||||
try {
|
var remaining = parseInt(timeLimit, 10);
|
||||||
result = JSON.parse(value);
|
|
||||||
} catch(e) {
|
|
||||||
isjson=false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isjson) {
|
function formatTime(s) {
|
||||||
return result;
|
var mins = Math.floor(s / 60);
|
||||||
} else {
|
var secs = s % 60;
|
||||||
return $.getJSON(value)
|
return mins + ':' + (secs < 10 ? '0' + secs : secs);
|
||||||
.then(function (response) {
|
}
|
||||||
return response;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$('.editor_holder').each(function() {
|
$timer.text(formatTime(remaining));
|
||||||
// Get the DOM Element
|
|
||||||
var element = $(this).get(0);
|
|
||||||
console.log("el", element)
|
|
||||||
|
|
||||||
var options_text = $(this).attr('options')
|
var intervalId = setInterval(function () {
|
||||||
var schema_text = $(this).attr('schema')
|
remaining -= 1;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
$timer.text('0:00');
|
||||||
|
var $next = $form.find('button[name="next"]');
|
||||||
|
var $finish = $form.find('button[name="finish"]');
|
||||||
|
|
||||||
var schema = process(schema_text);
|
if ($next.length) {
|
||||||
var options = process(options_text);
|
$next.prop('disabled', false);
|
||||||
|
$next.click();
|
||||||
|
} else if ($finish.length) {
|
||||||
|
$finish.prop('disabled', false);
|
||||||
|
$finish.click();
|
||||||
|
} else {
|
||||||
|
$form.submit();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$timer.text(formatTime(remaining));
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
var name = $(this).attr('name');
|
$form.on('submit', function () {
|
||||||
var hidden_identifier = 'input[name=' + name + ']';
|
clearInterval(intervalId);
|
||||||
var initial = $(hidden_identifier).val();
|
});
|
||||||
|
} catch (e) {
|
||||||
// Check if editor is within form
|
console.error('Error initializing timer:', e);
|
||||||
var form = $(this).closest('form')
|
|
||||||
console.log("form", form)
|
|
||||||
|
|
||||||
//Wait for any ajax requests to complete
|
|
||||||
$.when(schema, options).done(function(schemaresult, optionsresult) {
|
|
||||||
optionsresult.form_name_root = name;
|
|
||||||
|
|
||||||
// Pass initial value though to editor
|
|
||||||
if (initial) {
|
|
||||||
optionsresult.startval = JSON.parse(initial);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
optionsresult.schema = schemaresult;
|
|
||||||
// console.log(options);
|
|
||||||
var editor = new JSONEditor(element, optionsresult);
|
|
||||||
|
|
||||||
console.log("editor", editor)
|
|
||||||
if (form) {
|
|
||||||
$(form).submit(function(e) {
|
|
||||||
console.log("submitting")
|
|
||||||
// Set the hidden field value to the editors value
|
|
||||||
$(hidden_identifier).val(JSON.stringify(editor.getValue()));
|
|
||||||
// Disable the editor so it's values wont be submitted
|
|
||||||
//editor.disable();
|
|
||||||
// Validate the editor's current value against the schema
|
|
||||||
const errors = editor.validate();
|
|
||||||
|
|
||||||
if (errors.length) {
|
|
||||||
// errors is an array of objects, each with a `path`, `property`, and `message` parameter
|
|
||||||
// `property` is the schema keyword that triggered the validation error (e.g. "minLength")
|
|
||||||
// `path` is a dot separated path into the JSON object (e.g. "root.path.to.field")
|
|
||||||
console.log(errors);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.log("valid");
|
|
||||||
}
|
|
||||||
console.log(editor.getValue());
|
|
||||||
//e.preventDefault();
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.log("No form found")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
})
|
|
||||||
{% endcomment %}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock js %}
|
{% endblock js %}
|
||||||
|
|||||||
Reference in New Issue
Block a user