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):
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
@@ -1,4 +1,5 @@
{% extends "atlas/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container py-4">
@@ -15,6 +16,12 @@
{% if direct_next %}
<a class="btn btn-outline-secondary" href="{% url 'atlas:linked_cases_overview' direct_next.pk %}">Next in chain</a>
{% 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>
@@ -33,6 +40,7 @@
<form method="post">
{% csrf_token %}
<input type="hidden" name="form_action" value="study_dates">
<div class="table-responsive">
<table class="table table-dark table-hover align-middle mb-0">
<thead>
@@ -96,6 +104,77 @@
</div>
{% 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="col-lg-8">
<div class="card border-secondary-subtle shadow-sm h-100">
+55 -1
View File
@@ -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")
+156 -27
View File
@@ -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),
},
)