improve the linked cases editor

This commit is contained in:
Ross
2026-06-01 10:43:19 +01:00
parent 40cea43578
commit 1d85666127
4 changed files with 341 additions and 32 deletions
+51 -4
View File
@@ -888,14 +888,22 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
def get_effective_study_date(self): def _get_series_study_date(self):
if self.study_date:
return self.study_date
for series in self.get_ordered_series(): for series in self.get_ordered_series():
image = series.images.filter(removed=False).first() image = series.images.filter(removed=False).first()
if not image: if not image:
continue 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: try:
ds = image.get_dicom_data() ds = image.get_dicom_data()
except Exception: except Exception:
@@ -914,6 +922,20 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
return None 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): def get_app_name(self):
return "atlas" 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) @receiver(pre_save, sender=SeriesImage)
def _capture_previous_series_for_series_image(sender, instance, **kwargs): def _capture_previous_series_for_series_image(sender, instance, **kwargs):
if not getattr(instance, "pk", None): if not getattr(instance, "pk", None):
@@ -2084,6 +2120,17 @@ class SeriesDetail(models.Model):
class Meta: class Meta:
ordering = ("sort_order",) 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): class CaseDisplaySet(models.Model, AuthorMixin):
""" """
This is analogous to a SeriesFinding but for a Case (it has This is analogous to a SeriesFinding but for a Case (it has
@@ -1,4 +1,5 @@
{% extends "atlas/base.html" %} {% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %} {% block content %}
<div class="container py-4"> <div class="container py-4">
@@ -15,6 +16,12 @@
{% if direct_next %} {% if direct_next %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' direct_next.pk %}">Next in chain</a> <a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' direct_next.pk %}">Next in chain</a>
{% endif %} {% endif %}
{% if can_edit_chain and not show_case_editor %}
<a class="btn btn-outline-info" href="{% url 'atlas:linked_cases_overview' current_case.pk %}?show_editor=1">Open case series editor</a>
{% endif %}
{% if show_case_editor %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' current_case.pk %}">Hide case series editor</a>
{% endif %}
</div> </div>
</div> </div>
@@ -33,6 +40,7 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="form_action" value="study_dates">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-dark table-hover align-middle mb-0"> <table class="table table-dark table-hover align-middle mb-0">
<thead> <thead>
@@ -96,6 +104,77 @@
</div> </div>
{% endif %} {% endif %}
{% if show_case_editor and case_editor_formset %}
<div class="card border-info-subtle shadow-sm mb-4">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<h2 class="h5 mb-1">Case Series Editor</h2>
<p class="text-muted mb-0">Edit full case details across this linked chain in one pass, then optionally copy a single field from one case to every other case.</p>
</div>
<div class="small text-muted">
Editable: {{ editable_case_count }}
{% if locked_case_count %} · Read-only: {{ locked_case_count }}{% endif %}
</div>
</div>
<form method="post">
{% csrf_token %}
<input type="hidden" name="form_action" value="case_series_editor">
{{ case_editor_formset.management_form }}
<div class="row g-2 align-items-end mb-3">
<div class="col-md-4">
<label class="form-label form-label-sm mb-1">Copy field from case</label>
<select class="form-select form-select-sm" name="apply_from_case">
<option value="">No field propagation</option>
{% for row in case_editor_rows %}
<option value="{{ row.case.pk }}" {% if case_editor_selected_source == row.case.pk|stringformat:'s' %}selected{% endif %}>
{{ row.case.pk }} · {{ row.case.title|default:"Untitled case" }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-5">
<label class="form-label form-label-sm mb-1">Field to copy to all linked cases</label>
<select class="form-select form-select-sm" name="apply_field">
<option value="">Choose a field</option>
{% for value, label in case_editor_apply_field_choices %}
<option value="{{ value }}" {% if case_editor_selected_field == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<div class="small text-muted">Apply runs after saving any edits you make below.</div>
</div>
</div>
<div class="accordion" id="linked-case-editor-accordion">
{% for row in case_editor_rows %}
<div class="accordion-item mb-2 border-secondary-subtle">
<h2 class="accordion-header" id="editor-heading-{{ row.case.pk }}">
<button class="accordion-button {% if not row.is_current %}collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#editor-body-{{ row.case.pk }}" aria-expanded="{% if row.is_current %}true{% else %}false{% endif %}" aria-controls="editor-body-{{ row.case.pk }}">
<span class="fw-semibold">Case {{ row.case.pk }} · {{ row.case.title|default:"Untitled case" }}</span>
{% if row.is_current %}<span class="badge text-bg-primary ms-2">Current</span>{% endif %}
</button>
</h2>
<div id="editor-body-{{ row.case.pk }}" class="accordion-collapse collapse {% if row.is_current %}show{% endif %}" aria-labelledby="editor-heading-{{ row.case.pk }}" data-bs-parent="#linked-case-editor-accordion">
<div class="accordion-body">
{{ row.form|crispy }}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="d-flex justify-content-end mt-3">
<button type="submit" class="btn btn-info">Save case series editor changes</button>
</div>
</form>
</div>
</div>
{% endif %}
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card border-secondary-subtle shadow-sm h-100"> <div class="card border-secondary-subtle shadow-sm h-100">
+55 -1
View File
@@ -1,9 +1,10 @@
import json import json
import datetime
from django.http import HttpResponse from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
import pytest import pytest
from atlas.models import CaseCollection, Finding, Condition from atlas.models import Case, CaseCollection, Finding, Condition
#from atlas.views import GenericExamViews #from atlas.views import GenericExamViews
from pytest_django.asserts import assertRedirects, assertContains, assertQuerySetEqual from pytest_django.asserts import assertRedirects, assertContains, assertQuerySetEqual
from django.test import TestCase 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(case1.study_date) == "2026-01-10"
assert str(case2.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")
+156 -27
View File
@@ -59,6 +59,7 @@ from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse from django.http import HttpResponseRedirect, HttpResponse
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.forms.widgets import HiddenInput from django.forms.widgets import HiddenInput
from django.forms import modelformset_factory
import zipfile import zipfile
import os import os
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
@@ -9545,7 +9546,12 @@ def reorder_series(request, case_pk):
def linked_cases_overview(request, case_id): 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) case = get_object_or_404(Case, pk=case_id)
@@ -9568,44 +9574,158 @@ def linked_cases_overview(request, case_id):
pass pass
chain_cases = previous_cases + [case] + next_cases 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_previous = previous_cases[-1] if previous_cases else None
direct_next = next_cases[0] if next_cases else None direct_next = next_cases[0] if next_cases else None
if request.method == "POST": case_editor_prefix = "case_series_editor"
if not case.check_user_can_edit(request.user): show_case_editor = request.GET.get("show_editor") == "1"
raise PermissionDenied("You do not have permission to edit this case chain.") case_editor_selected_field = ""
case_editor_selected_source = ""
apply_to_all_from = request.POST.get("apply_to_all_from") case_editor_apply_fields = [
updated_cases = 0 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: case_editor_formset_factory = modelformset_factory(Case, form=CaseForm, extra=0)
source_case = next(
(linked_case for linked_case in chain_cases if str(linked_case.pk) == apply_to_all_from), def build_case_editor_formset(data=None):
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: for linked_case in chain_cases:
if not linked_case.check_user_can_edit(request.user): if not linked_case.check_user_can_edit(request.user):
continue continue
if linked_case.study_date != source_date: raw_value = request.POST.get(f"study_date_{linked_case.pk}", "").strip()
linked_case.study_date = source_date 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"]) linked_case.save(update_fields=["study_date"])
updated_cases += 1 updated_cases += 1
messages.success(request, f"Applied the study date to {updated_cases} case(s).") messages.success(request, f"Updated {updated_cases} linked case date(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).")
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 = [] linked_case_rows = []
anchor_date = next( anchor_date = next(
@@ -9642,6 +9762,15 @@ def linked_cases_overview(request, case_id):
"current_index": len(previous_cases) + 1, "current_index": len(previous_cases) + 1,
"direct_previous": direct_previous, "direct_previous": direct_previous,
"direct_next": direct_next, "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),
}, },
) )