diff --git a/atlas/admin.py b/atlas/admin.py
index d7edf087..bc53fcd7 100755
--- a/atlas/admin.py
+++ b/atlas/admin.py
@@ -18,6 +18,8 @@ from .models import (
SeriesDetail,
Resource,
CaseDisplaySet,
+ UserReportAnswer,
+ CidReportAnswer,
)
from django.forms import ModelForm
@@ -48,6 +50,8 @@ admin.site.register(UncategorisedDicom)
admin.site.register(SeriesDetail)
admin.site.register(Resource)
admin.site.register(CaseDisplaySet)
+admin.site.register(UserReportAnswer)
+admin.site.register(CidReportAnswer)
class DifferentialInline(admin.TabularInline):
diff --git a/atlas/models.py b/atlas/models.py
index 22f505fa..64289d04 100644
--- a/atlas/models.py
+++ b/atlas/models.py
@@ -1254,6 +1254,13 @@ class CaseDetail(models.Model):
except UserReportAnswer.DoesNotExist:
return None
+ def get_cid_answers(self, cid):
+ """Returns the cid users answers as a json string"""
+ try:
+ return CidReportAnswer.objects.get(question=self, cid=cid)
+ except CidReportAnswer.DoesNotExist:
+ return None
+
def default_viewerstate_string(self):
return json.dumps(self.default_viewerstate) if self.default_viewerstate else "{}"
diff --git a/atlas/templates/atlas/collection_case_view_take.html b/atlas/templates/atlas/collection_case_view_take.html
index 5cf98fed..6785da09 100644
--- a/atlas/templates/atlas/collection_case_view_take.html
+++ b/atlas/templates/atlas/collection_case_view_take.html
@@ -26,6 +26,7 @@
+
{% endif %}
@@ -412,24 +413,45 @@
$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"]');
+ //var $next = $form.find('button[name="next"]');
+ //var $finish = $form.find('button[name="finish"]');
// Instead of navigating away, submit via fetch with timed_out flag
// Use HTMX to POST the timed_out flag and then lock the UI
+
var onAfter = function (evt) {
try {
- // lock the form UI in-place
- $form.find('input, textarea, select, button').prop('disabled', true);
- $form.addClass('timed-out-locked');
- $('#question-timer').text('Locked');
+ var resp = null;
+ try {
+ var xhr = evt && evt.detail && evt.detail.xhr;
+ if (xhr && xhr.responseText) {
+ resp = JSON.parse(xhr.responseText);
+ }
+ } catch (e) {
+ console.debug('No JSON response from timed_out request', e);
+ }
- // hide the progress bar
- $('#question-timer-progress, #question-timer-progress-outer, #question-timer-progress-inner').hide();
- // ensure inner shows empty state if something relies on it
- $('#question-timer-progress-inner').css({ width: '0%', background: '#dc3545' });
+ // Only disable save buttons to prevent further saves
+ var $saveBtns = $form.find('button[name="save"], #id_answer');
+ if ($saveBtns.length) {
+ $saveBtns.prop('disabled', true);
+ }
+
+ // Visual feedback: set timer to Locked and progress to red
+ $('#question-timer').text('Locked');
+ $progressInner.css('background', '#dc3545');
+ $progressInner.css('width', '0%');
+
+ var msg = 'Answer auto-submitted';
+ if (resp && resp.submitted_at) {
+ try {
+ var dt = new Date(resp.submitted_at);
+ msg += ' at ' + dt.toLocaleTimeString();
+ } catch (e) {}
+ }
+ toastr.info(msg);
} catch (e) {
- console.error('Error locking UI after htmx request', e);
+ console.error('Error handling timed_out htmx response', e);
} finally {
htmx.off('htmx:afterRequest', onAfter);
}
diff --git a/atlas/templates/atlas/collection_history.html b/atlas/templates/atlas/collection_history.html
index 820ed4cc..98db9347 100644
--- a/atlas/templates/atlas/collection_history.html
+++ b/atlas/templates/atlas/collection_history.html
@@ -3,26 +3,52 @@
{% block content %}
{{collection.name}}
- Users
-
- {% for userexam in userexams %}
-
- {{userexam.get_user_name}}
- Completed: {{userexam.completed}}
- Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}
+ {% if userexams %}
+ Users
-
- Delete
-
-
- {% endfor %}
-
+
+ {% for userexam in userexams %}
+
+ {{userexam.get_user_name}}
+ Completed: {{userexam.completed}}
+ Started: {{userexam.start_time}}, Ended: {{userexam.end_time}}
+
+ Delete
+
+
+ {% endfor %}
+
+ {% endif %}
+
+
+ {% if cidexams %}
+ CID Users
+
+
+ {% for cidexam in cidexams %}
+
+ {{cidexam.cid_user}}
+ Completed: {{cidexam.completed}}
+ Started: {{cidexam.start_time}}, Ended: {{cidexam.end_time}}
+
+
+ Delete
+
+
+ {% endfor %}
+
+ {% endif %}
{% endblock %}
diff --git a/atlas/templates/atlas/collection_history_user.html b/atlas/templates/atlas/collection_history_user.html
index df23dd55..c2c239c3 100644
--- a/atlas/templates/atlas/collection_history_user.html
+++ b/atlas/templates/atlas/collection_history_user.html
@@ -10,39 +10,53 @@
{{forloop.counter}} / Case: {{casedetail.case.title}}
+ {{user_answer.started_at}} - {{user_answer.submitted_at}}
+ {% if request.user.is_superuser and user_answer %}
+ (Edit in admin )
+ {% endif %}
+
{% if not user_answer %}
Case not answered.
{% else %}
+
+
+
- {% for value, user_answer, correct_answer, answer_is_correct, automark in user_answer.get_correct_json_answers %}
- {% if not user_answer %}
- Not answered
- {% else %}
-
+
{{value.title}}
+ {{value.description}}
+
+ Answer : {{user_answer}}
+
+ {% if not answer_is_correct %}
+
Correct answer: {{correct_answer}}
{% endif %}
- ">
-
{{value.title}}
- {{value.description}}
-
- Answer : {{user_answer}}
-
- {% if not answer_is_correct %}
- Correct answer: {{correct_answer}}
- {% endif %}
-
+
-
- {% endif %}
+ {% endif %}
- {% endfor %}
+ {% endfor %}
+ {% endif %}
{% endif %}
diff --git a/atlas/urls.py b/atlas/urls.py
index 0ed711e2..13e136cc 100755
--- a/atlas/urls.py
+++ b/atlas/urls.py
@@ -128,6 +128,11 @@ urlpatterns = [
views.collection_history_user,
name="collection_history_user",
),
+ path(
+ "collection//history//ciduser",
+ views.collection_history_ciduser,
+ name="collection_history_ciduser",
+ ),
path(
"collection//user_status",
@@ -201,6 +206,11 @@ urlpatterns = [
views.collection_reset_answers_user,
name="collection_reset_answers_user",
),
+ path(
+ "collection//cid//reset_answers",
+ views.collection_reset_answers_ciduser,
+ name="collection_reset_answers_ciduser",
+ ),
#path(
# "question_schemas_preset",
# views.question_schemas_preset,
diff --git a/atlas/views.py b/atlas/views.py
index 5c8af6b8..312f9aab 100755
--- a/atlas/views.py
+++ b/atlas/views.py
@@ -2102,9 +2102,11 @@ def collection_history(request, exam_id: int):
"sort_order"
)
- userexams = collection.get_cid_user_exams()
+ userexams = collection.get_user_exams()
+ cidexams = collection.get_cid_exams()
- print(userexams)
+ print("useruserexams:", userexams)
+ print("cidexams:", cidexams)
return render(
request,
@@ -2114,6 +2116,7 @@ def collection_history(request, exam_id: int):
"casesdetails": casedetails,
"can_edit": True,
"userexams": userexams,
+ "cidexams": cidexams,
},
)
@@ -2147,6 +2150,32 @@ def collection_history_user(request, exam_id: int, user_pk: int):
)
+@user_is_collection_author_or_atlas_editor
+def collection_history_ciduser(request, exam_id: int, cid: int):
+ collection = get_object_or_404(CaseCollection, pk=exam_id)
+
+ casedetails = CaseDetail.objects.filter(
+ collection=collection,
+ ).order_by("sort_order")
+
+ print(casedetails)
+
+ user_answers = [
+ (casedetail, casedetail.get_cid_answers(cid)) for casedetail in casedetails
+ ]
+
+ return render(
+ request,
+ "atlas/collection_history_user.html",
+ {
+ "collection": collection,
+ "user": cid,
+ "casedetails": casedetails,
+ "user_answers": user_answers,
+ },
+ )
+
+
def collection_take_start(request, pk, cid=None, passcode=None):
@@ -2173,7 +2202,7 @@ def collection_take_start(request, pk, cid=None, passcode=None):
cid_exam = None
if collection.collection_type == "REV" or valid_user:
- cid_exam = collection.get_cid_user_exams(user_user=request.user).first()
+ cid_exam = collection.get_cid_and_user_exams(user_user=request.user).first()
template_variables = {
"collection": collection,
@@ -3522,6 +3551,7 @@ def collection_reset_answers_user_list(request, exam_id: int):
raise Http404("Invalid request")
+@user_is_collection_author_or_atlas_editor
def collection_reset_answers_user(request, exam_id: int, user_id: int):
if request.htmx:
collection = get_object_or_404(CaseCollection, pk=exam_id)
@@ -3542,6 +3572,27 @@ def collection_reset_answers_user(request, exam_id: int, user_id: int):
else:
raise Http404("Invalid request")
+@user_is_collection_author_or_atlas_editor
+def collection_reset_answers_ciduser(request, exam_id: int, cid: int):
+ if request.htmx:
+ collection = get_object_or_404(CaseCollection, pk=exam_id)
+
+ # Select all case details (answers are linked to these)
+ case_details = collection.casedetail_set.all().prefetch_related()
+
+ # Delete all answers
+ for case in case_details:
+ cid_answers = case.cidreportanswer_set.filter(cid=cid)
+ cid_answers.delete()
+
+ collection.cid_users.filter(cid=cid).delete()
+ collection.exam_user_status.filter(cid_user_exam__cid__pk=cid).delete()
+
+ return HttpResponse(f"CID [{cid}] answers deleted")
+
+ else:
+ raise Http404("Invalid request")
+
def collection_reset_answers(request, exam_id: int):
if request.htmx:
diff --git a/generic/models.py b/generic/models.py
index 766c02d7..a453c036 100644
--- a/generic/models.py
+++ b/generic/models.py
@@ -905,9 +905,10 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
def get_question_user_user_answer(self, question_index, user):
raise NotImplementedError
- def get_cid_user_exams(
+ def get_cid_and_user_exams(
self, cid_user: Optional["CidUser"] = None, user_user: User | None = None
) -> "CidUserExam":
+ """Returns a queryset of CidUserExam for this exam/collection"""
content_type = ContentType.objects.get_for_model(self)
if cid_user is None and user_user is None:
return CidUserExam.objects.filter(
@@ -924,6 +925,35 @@ class ExamOrCollectionGenericBase(models.Model, AuthorMixin):
content_type=content_type, object_id=self.pk, user_user=user_user
)
+ def get_cid_exams(self, cid_user: Optional["CidUser"]= None) -> "CidUserExam":
+ """Returns a queryset of CidUserExam for this exam/collection"""
+ content_type = ContentType.objects.get_for_model(self)
+
+ if cid_user is None:
+ # Only return pure CID entries (cid_user set, user_user null)
+ return CidUserExam.objects.filter(
+ content_type=content_type,
+ object_id=self.pk,
+ cid_user__isnull=False,
+ user_user__isnull=True,
+ )
+ return CidUserExam.objects.filter(
+ content_type=content_type, object_id=self.pk, cid_user=cid_user
+ )
+
+ def get_user_exams(self, user_user: Optional[User] = None) -> "CidUserExam":
+ """Returns a queryset of CidUserExam for this exam/collection"""
+ content_type = ContentType.objects.get_for_model(self)
+ if user_user is None:
+ return CidUserExam.objects.filter(
+ content_type=content_type, object_id=self.pk, user_user__isnull=False, cid_user__isnull=True
+ )
+ return CidUserExam.objects.filter(
+ content_type=content_type, object_id=self.pk, user_user=user_user
+ )
+
+
+
def clone_model(self):
M2M_fields = (
"exam_questions",