From 32d8430f94fd72fb9abced7ae5991204de2ac582 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 13 Oct 2025 11:28:07 +0100 Subject: [PATCH] Add CID user support in collection history and reset answers functionality --- atlas/admin.py | 4 ++ atlas/models.py | 7 ++ .../atlas/collection_case_view_take.html | 44 +++++++++---- atlas/templates/atlas/collection_history.html | 62 ++++++++++++------ .../atlas/collection_history_user.html | 64 +++++++++++-------- atlas/urls.py | 10 +++ atlas/views.py | 57 ++++++++++++++++- generic/models.py | 32 +++++++++- 8 files changed, 222 insertions(+), 58 deletions(-) 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}}
    + +
  • + {% endfor %} +
+ {% endif %} + + + {% if cidexams %} +

CID Users

+ +
    + {% for cidexam in cidexams %} +
  • + {{cidexam.cid_user}}
    + Completed: {{cidexam.completed}}
    + Started: {{cidexam.start_time}}, Ended: {{cidexam.end_time}}
    + + +
  • + {% 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",