improve the linked cases editor
This commit is contained in:
+51
-4
@@ -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">
|
||||
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user