From dc479cafd03ea4d41ac2384717853a640eb8f1ba Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 27 Apr 2026 12:55:27 +0100 Subject: [PATCH] feat: Add case review messaging system with feedback and query capabilities - Introduced CaseReviewMessage model to handle feedback and queries for cases. - Implemented views for displaying user and CID messages, including outstanding feedback. - Created templates for collection feedback overview and user messages. - Added functionality for acknowledging messages and sending feedback or queries. - Enhanced collection detail view to show outstanding messages and user message statistics. - Added tests for handling duplicate DICOM uploads without series. --- atlas/forms.py | 32 + atlas/migrations/0097_casereviewmessage.py | 33 + atlas/models.py | 65 ++ .../atlas/collection_case_view_take.html | 8 + atlas/templates/atlas/collection_detail.html | 44 ++ .../atlas/collection_feedback_overview.html | 95 +++ atlas/templates/atlas/collection_history.html | 10 + .../atlas/collection_history_user.html | 238 ++++--- .../atlas/collection_take_overview.html | 28 +- .../atlas/collection_user_messages.html | 99 +++ .../atlas/partials/_case_review_thread.html | 114 ++++ atlas/tests/test_api.py | 34 + atlas/urls.py | 25 + atlas/views.py | 639 +++++++++++++++++- rad.code-workspace | 3 +- 15 files changed, 1354 insertions(+), 113 deletions(-) create mode 100644 atlas/migrations/0097_casereviewmessage.py create mode 100644 atlas/templates/atlas/collection_feedback_overview.html create mode 100644 atlas/templates/atlas/collection_user_messages.html create mode 100644 atlas/templates/atlas/partials/_case_review_thread.html create mode 100644 atlas/tests/test_api.py diff --git a/atlas/forms.py b/atlas/forms.py index f3b567d2..16d6f5eb 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -46,6 +46,7 @@ from atlas.models import ( UncategorisedDicom, UserReportAnswer, CaseDisplaySet, + CaseReviewMessage, ) from .models import NormalCase @@ -1123,6 +1124,37 @@ class SelfReviewForm(ModelForm): ) +class CaseReviewMessageForm(ModelForm): + class Meta: + model = CaseReviewMessage + fields = ["message", "requires_acknowledgement"] + + def __init__(self, *args, allow_ack=False, message_placeholder=None, **kwargs): + super(CaseReviewMessageForm, self).__init__(*args, **kwargs) + + self.fields["message"].widget.attrs.update( + { + "rows": 3, + "placeholder": message_placeholder or "Write a message...", + "class": "form-control", + } + ) + + self.fields["requires_acknowledgement"].widget.attrs.update( + { + "class": "form-check-input", + } + ) + + if not allow_ack: + self.fields["requires_acknowledgement"].widget = HiddenInput() + self.fields["requires_acknowledgement"].required = False + + self.helper = FormHelper() + self.helper.form_method = "post" + self.helper.form_tag = False + + class UncategorisedDicomForm(ModelForm): class Meta: model = UncategorisedDicom diff --git a/atlas/migrations/0097_casereviewmessage.py b/atlas/migrations/0097_casereviewmessage.py new file mode 100644 index 00000000..ffa5782b --- /dev/null +++ b/atlas/migrations/0097_casereviewmessage.py @@ -0,0 +1,33 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("generic", "0031_flag"), + ("atlas", "0096_casecollection_show_differentials_post"), + ] + + operations = [ + migrations.CreateModel( + name="CaseReviewMessage", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("message_type", models.CharField(choices=[("feedback", "Feedback"), ("query", "Query"), ("reply", "Reply")], default="query", max_length=20)), + ("message", models.TextField()), + ("author_label", models.CharField(blank=True, max_length=255)), + ("requires_acknowledgement", models.BooleanField(default=False)), + ("acknowledged_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("author", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages_authored", to=settings.AUTH_USER_MODEL)), + ("case", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages", to="atlas.case")), + ("cid_user_exam", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="case_review_messages", to="generic.ciduserexam")), + ], + options={ + "ordering": ("created_at",), + }, + ), + ] diff --git a/atlas/models.py b/atlas/models.py index 71610616..9251e3d1 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -2024,6 +2024,71 @@ class SelfReview(models.Model): ) +class CaseReviewMessage(models.Model): + """Conversation message for one learner attempt on one case. + + Supervisors/collection authors use this for per-case feedback and replies. + Learners use it to raise questions/queries. Supervisor messages may require + acknowledgement so they continue to flag on the learner-facing pages until + explicitly acknowledged. + """ + + class MessageType(models.TextChoices): + FEEDBACK = "feedback", _("Feedback") + QUERY = "query", _("Query") + REPLY = "reply", _("Reply") + + cid_user_exam = models.ForeignKey( + CidUserExam, + on_delete=models.CASCADE, + related_name="case_review_messages", + ) + case = models.ForeignKey( + Case, + on_delete=models.CASCADE, + related_name="case_review_messages", + ) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="case_review_messages_authored", + null=True, + blank=True, + ) + author_label = models.CharField(max_length=255, blank=True) + message_type = models.CharField( + max_length=20, + choices=MessageType.choices, + default=MessageType.QUERY, + ) + message = models.TextField() + requires_acknowledgement = models.BooleanField(default=False) + acknowledged_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("created_at",) + + def __str__(self) -> str: + return f"{self.get_message_type_display()} for {self.case}" + + @property + def is_acknowledged(self) -> bool: + return self.acknowledged_at is not None + + def get_author_display(self) -> str: + if self.author is not None: + full_name = self.author.get_full_name().strip() + return full_name or self.author.username + return self.author_label or "Learner" + + def acknowledge(self): + if self.acknowledged_at is None: + self.acknowledged_at = timezone.now() + self.save(update_fields=["acknowledged_at"]) + + class UncategorisedDicom(models.Model): # We use image to maintain consitency across apps image = models.FileField(upload_to=uncategorised_dicom_directory_path) diff --git a/atlas/templates/atlas/collection_case_view_take.html b/atlas/templates/atlas/collection_case_view_take.html index 9c6f9478..2faad834 100644 --- a/atlas/templates/atlas/collection_case_view_take.html +++ b/atlas/templates/atlas/collection_case_view_take.html @@ -289,6 +289,14 @@ +
+
Loading case conversation...
+
+