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),
},
)