From 1d8566612701c32ddedb6c265cd0cf792ce20028 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 1 Jun 2026 10:43:19 +0100 Subject: [PATCH] improve the linked cases editor --- atlas/models.py | 55 +++++- .../atlas/linked_cases_overview.html | 79 ++++++++ atlas/tests/test_cases.py | 56 +++++- atlas/views.py | 183 +++++++++++++++--- 4 files changed, 341 insertions(+), 32 deletions(-) diff --git a/atlas/models.py b/atlas/models.py index 1c331f82..19f6290d 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -888,14 +888,22 @@ class Case(models.Model, AuthorMixin, QuestionMixin): except (TypeError, ValueError): return None - def get_effective_study_date(self): - if self.study_date: - return self.study_date - + def _get_series_study_date(self): for series in self.get_ordered_series(): image = series.images.filter(removed=False).first() if not image: continue + + tags = getattr(image, "basic_dicom_tags", None) or {} + raw_date = ( + tags.get("StudyDate") + or tags.get("AcquisitionDate") + or tags.get("SeriesDate") + ) + parsed = self._parse_dicom_date(raw_date) + if parsed: + return parsed + try: ds = image.get_dicom_data() except Exception: @@ -914,6 +922,20 @@ class Case(models.Model, AuthorMixin, QuestionMixin): return None + def get_effective_study_date(self): + if self.study_date: + return self.study_date + + return self._get_series_study_date() + + def save(self, *args, **kwargs): + if self.pk and not self.study_date: + derived_study_date = self._get_series_study_date() + if derived_study_date: + self.study_date = derived_study_date + + super().save(*args, **kwargs) + def get_app_name(self): return "atlas" @@ -1352,6 +1374,20 @@ class SeriesImage(SeriesImageBase): # }} +@receiver(post_save, sender=SeriesImage) +def _populate_case_study_date_on_series_image_save(sender, instance, **kwargs): + if not instance.series_id: + return + + tags = instance.basic_dicom_tags or {} + raw_date = tags.get("StudyDate") or tags.get("AcquisitionDate") or tags.get("SeriesDate") + parsed = Case._parse_dicom_date(raw_date) + if not parsed: + return + + Case.objects.filter(series=instance.series, study_date__isnull=True).update(study_date=parsed) + + @receiver(pre_save, sender=SeriesImage) def _capture_previous_series_for_series_image(sender, instance, **kwargs): if not getattr(instance, "pk", None): @@ -2084,6 +2120,17 @@ class SeriesDetail(models.Model): class Meta: ordering = ("sort_order",) + +@receiver(post_save, sender=SeriesDetail) +def _populate_case_study_date_on_series_link(sender, instance, created, **kwargs): + case = instance.case + if case.study_date: + return + + derived_study_date = case._get_series_study_date() + if derived_study_date: + Case.objects.filter(pk=case.pk, study_date__isnull=True).update(study_date=derived_study_date) + class CaseDisplaySet(models.Model, AuthorMixin): """ This is analogous to a SeriesFinding but for a Case (it has diff --git a/atlas/templates/atlas/linked_cases_overview.html b/atlas/templates/atlas/linked_cases_overview.html index 314fa656..dbaef944 100644 --- a/atlas/templates/atlas/linked_cases_overview.html +++ b/atlas/templates/atlas/linked_cases_overview.html @@ -1,4 +1,5 @@ {% extends "atlas/base.html" %} +{% load crispy_forms_tags %} {% block content %}
@@ -15,6 +16,12 @@ {% if direct_next %} Next in chain {% endif %} + {% if can_edit_chain and not show_case_editor %} + Open case series editor + {% endif %} + {% if show_case_editor %} + Hide case series editor + {% endif %}
@@ -33,6 +40,7 @@
{% csrf_token %} +
@@ -96,6 +104,77 @@ {% endif %} + {% if show_case_editor and case_editor_formset %} +
+
+
+
+

Case Series Editor

+

Edit full case details across this linked chain in one pass, then optionally copy a single field from one case to every other case.

+
+
+ Editable: {{ editable_case_count }} + {% if locked_case_count %} · Read-only: {{ locked_case_count }}{% endif %} +
+
+ + + {% csrf_token %} + + {{ case_editor_formset.management_form }} + +
+
+ + +
+
+ + +
+
+
Apply runs after saving any edits you make below.
+
+
+ +
+ {% for row in case_editor_rows %} +
+

+ +

+
+
+ {{ row.form|crispy }} +
+
+
+ {% endfor %} +
+ +
+ +
+ +
+
+ {% endif %} +
diff --git a/atlas/tests/test_cases.py b/atlas/tests/test_cases.py index 0a246712..0bfc8bb6 100644 --- a/atlas/tests/test_cases.py +++ b/atlas/tests/test_cases.py @@ -1,9 +1,10 @@ import json +import datetime from django.http import HttpResponse from django.urls import reverse import pytest -from atlas.models import CaseCollection, Finding, Condition +from atlas.models import Case, CaseCollection, Finding, Condition #from atlas.views import GenericExamViews from pytest_django.asserts import assertRedirects, assertContains, assertQuerySetEqual from django.test import TestCase @@ -179,3 +180,56 @@ def test_linked_cases_overview_can_copy_study_date_to_chain(client, create_user, assert str(case1.study_date) == "2026-01-10" assert str(case2.study_date) == "2026-01-10" + + +@pytest.mark.django_db +def test_case_save_auto_populates_study_date_when_missing(monkeypatch, make_case): + expected_date = datetime.date(2026, 2, 14) + monkeypatch.setattr(Case, "_get_series_study_date", lambda self: expected_date) + + case = make_case(title="Autodate Case") + case.study_date = None + case.save(update_fields=["study_date"]) + case.refresh_from_db() + + assert case.study_date == expected_date + + +@pytest.mark.django_db +def test_case_save_preserves_manual_study_date(monkeypatch, make_case): + manual_date = datetime.date(2026, 3, 1) + monkeypatch.setattr(Case, "_get_series_study_date", lambda self: datetime.date(2026, 4, 1)) + + case = make_case(title="Manual Date Case") + case.study_date = manual_date + case.save(update_fields=["study_date"]) + case.refresh_from_db() + + assert case.study_date == manual_date + + +@pytest.mark.django_db +def test_linked_cases_editor_is_hidden_until_requested(client, create_user, make_case): + user = create_user + case1 = make_case(title="Case 1") + case2 = make_case(title="Case 2") + case2.previous_case = case1 + case2.save(update_fields=["previous_case"]) + + case1.author.add(user) + case2.author.add(user) + + client.force_login(user) + + response_default = client.get( + reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk}) + ) + assert response_default.status_code == 200 + assertContains(response_default, "Open case series editor") + assert "Case Series Editor" not in response_default.content.decode("utf-8") + + response_with_editor = client.get( + reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk}) + "?show_editor=1" + ) + assert response_with_editor.status_code == 200 + assertContains(response_with_editor, "Case Series Editor") diff --git a/atlas/views.py b/atlas/views.py index 63dc002c..c6168690 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -59,6 +59,7 @@ from django.http import Http404, JsonResponse from django.http import HttpResponseRedirect, HttpResponse from django.contrib.contenttypes.models import ContentType from django.forms.widgets import HiddenInput +from django.forms import modelformset_factory import zipfile import os from django.core.files.base import ContentFile @@ -9545,7 +9546,12 @@ def reorder_series(request, case_pk): def linked_cases_overview(request, case_id): """ - Display the linked-case chain around a case, including prior and follow-up cases. + Display and manage a linked-case chain. + + Scope: + - Read-only overview of previous/current/next linked cases. + - Study-date editing for relative timeline management. + - On-demand bulk case-series editor for updating full case fields across the chain. """ case = get_object_or_404(Case, pk=case_id) @@ -9568,44 +9574,158 @@ def linked_cases_overview(request, case_id): pass chain_cases = previous_cases + [case] + next_cases + chain_case_ids = [linked_case.pk for linked_case in chain_cases] + editable_chain_case_ids = [ + linked_case.pk + for linked_case in chain_cases + if linked_case.check_user_can_edit(request.user) + ] direct_previous = previous_cases[-1] if previous_cases else None direct_next = next_cases[0] if next_cases else None - if request.method == "POST": - if not case.check_user_can_edit(request.user): - raise PermissionDenied("You do not have permission to edit this case chain.") + case_editor_prefix = "case_series_editor" + show_case_editor = request.GET.get("show_editor") == "1" + case_editor_selected_field = "" + case_editor_selected_source = "" - apply_to_all_from = request.POST.get("apply_to_all_from") - updated_cases = 0 + case_editor_apply_fields = [ + field_name + for field_name in CaseForm.Meta.fields + if field_name != "previous_case" + ] + case_editor_apply_field_choices = [ + ( + field_name, + str(Case._meta.get_field(field_name).verbose_name).replace("_", " ").title(), + ) + for field_name in case_editor_apply_fields + ] - if apply_to_all_from: - source_case = next( - (linked_case for linked_case in chain_cases if str(linked_case.pk) == apply_to_all_from), - None, + case_editor_formset_factory = modelformset_factory(Case, form=CaseForm, extra=0) + + def build_case_editor_formset(data=None): + queryset = ( + Case.objects.filter(pk__in=editable_chain_case_ids) + .prefetch_related( + "subspecialty", + "condition", + "presentation", + "pathological_process", + "procedures", ) - if source_case and source_case.check_user_can_edit(request.user): - source_date = _parse_study_date(request.POST.get(f"study_date_{source_case.pk}")) + ) + formset = case_editor_formset_factory( + data=data, + queryset=queryset, + prefix=case_editor_prefix, + form_kwargs={"user": request.user}, + ) + forms_by_pk = {form.instance.pk: form for form in formset.forms} + ordered_rows = [] + for linked_case in chain_cases: + form = forms_by_pk.get(linked_case.pk) + if form is None: + continue + ordered_rows.append( + { + "case": linked_case, + "form": form, + "is_current": linked_case.pk == case.pk, + } + ) + return formset, ordered_rows + + case_editor_formset = None + case_editor_rows = [] + + if request.method == "POST": + form_action = request.POST.get("form_action", "study_dates") + + if form_action == "case_series_editor": + if not editable_chain_case_ids: + raise PermissionDenied("You do not have permission to edit this case chain.") + + show_case_editor = True + case_editor_selected_field = request.POST.get("apply_field", "") + case_editor_selected_source = request.POST.get("apply_from_case", "") + case_editor_formset, case_editor_rows = build_case_editor_formset(request.POST) + + if case_editor_formset.is_valid(): + case_editor_formset.save() + + applied_count = 0 + if case_editor_selected_field and case_editor_selected_source: + if case_editor_selected_field not in case_editor_apply_fields: + messages.error(request, "Select a valid field to apply across the linked series.") + else: + source_case = Case.objects.filter( + pk=case_editor_selected_source, + pk__in=editable_chain_case_ids, + ).first() + if source_case is None: + messages.error(request, "Select a valid source case for field propagation.") + else: + target_cases = Case.objects.filter(pk__in=editable_chain_case_ids).exclude(pk=source_case.pk) + case_field = Case._meta.get_field(case_editor_selected_field) + if case_field.many_to_many: + source_values = list(getattr(source_case, case_editor_selected_field).all()) + for target_case in target_cases: + getattr(target_case, case_editor_selected_field).set(source_values) + applied_count += 1 + else: + source_value = getattr(source_case, case_editor_selected_field) + for target_case in target_cases: + if getattr(target_case, case_editor_selected_field) != source_value: + setattr(target_case, case_editor_selected_field, source_value) + target_case.save(update_fields=[case_editor_selected_field]) + applied_count += 1 + + if applied_count: + messages.success(request, f"Applied {case_editor_selected_field} to {applied_count} linked case(s).") + + messages.success(request, "Saved linked case editor changes.") + return redirect(f"{reverse('atlas:linked_cases_overview', kwargs={'case_id': case.pk})}?show_editor=1") + + messages.error(request, "Please correct the errors in the linked case editor form.") + + else: + if not case.check_user_can_edit(request.user): + raise PermissionDenied("You do not have permission to edit this case chain.") + + apply_to_all_from = request.POST.get("apply_to_all_from") + updated_cases = 0 + + if apply_to_all_from: + source_case = next( + (linked_case for linked_case in chain_cases if str(linked_case.pk) == apply_to_all_from), + None, + ) + if source_case and source_case.check_user_can_edit(request.user): + source_date = _parse_study_date(request.POST.get(f"study_date_{source_case.pk}")) + for linked_case in chain_cases: + if not linked_case.check_user_can_edit(request.user): + continue + if linked_case.study_date != source_date: + linked_case.study_date = source_date + linked_case.save(update_fields=["study_date"]) + updated_cases += 1 + messages.success(request, f"Applied the study date to {updated_cases} case(s).") + else: for linked_case in chain_cases: if not linked_case.check_user_can_edit(request.user): continue - if linked_case.study_date != source_date: - linked_case.study_date = source_date + raw_value = request.POST.get(f"study_date_{linked_case.pk}", "").strip() + new_date = _parse_study_date(raw_value) + if linked_case.study_date != new_date: + linked_case.study_date = new_date linked_case.save(update_fields=["study_date"]) updated_cases += 1 - messages.success(request, f"Applied the study date to {updated_cases} case(s).") - else: - for linked_case in chain_cases: - if not linked_case.check_user_can_edit(request.user): - continue - raw_value = request.POST.get(f"study_date_{linked_case.pk}", "").strip() - new_date = _parse_study_date(raw_value) - if linked_case.study_date != new_date: - linked_case.study_date = new_date - linked_case.save(update_fields=["study_date"]) - updated_cases += 1 - messages.success(request, f"Updated {updated_cases} linked case date(s).") + messages.success(request, f"Updated {updated_cases} linked case date(s).") - return redirect("atlas:linked_cases_overview", case_id=case.pk) + return redirect("atlas:linked_cases_overview", case_id=case.pk) + + if show_case_editor and editable_chain_case_ids: + case_editor_formset, case_editor_rows = build_case_editor_formset() linked_case_rows = [] anchor_date = next( @@ -9642,6 +9762,15 @@ def linked_cases_overview(request, case_id): "current_index": len(previous_cases) + 1, "direct_previous": direct_previous, "direct_next": direct_next, + "show_case_editor": show_case_editor, + "case_editor_formset": case_editor_formset, + "case_editor_rows": case_editor_rows, + "case_editor_apply_field_choices": case_editor_apply_field_choices, + "case_editor_selected_field": case_editor_selected_field, + "case_editor_selected_source": case_editor_selected_source, + "case_editor_prefix": case_editor_prefix, + "editable_case_count": len(editable_chain_case_ids), + "locked_case_count": len(chain_case_ids) - len(editable_chain_case_ids), }, )