diff --git a/atlas/migrations/0107_caseprior_relation_type.py b/atlas/migrations/0107_caseprior_relation_type.py new file mode 100644 index 00000000..b5b11f8b --- /dev/null +++ b/atlas/migrations/0107_caseprior_relation_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-06-11 20:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('atlas', '0106_casedetail_learner_comment_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='caseprior', + name='relation_type', + field=models.CharField(choices=[('PR', 'Prior'), ('FU', 'Future')], default='PR', help_text='Defines whether the related case is a prior or a future exam', max_length=2), + ), + ] diff --git a/atlas/models.py b/atlas/models.py index d5cb1292..f64f7230 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -1054,6 +1054,21 @@ class Case(models.Model, AuthorMixin, QuestionMixin): return prior_cases + def get_all_future_cases(self): + from django.core.exceptions import ObjectDoesNotExist + future_cases = [] + current = self + while True: + try: + next_c = current.next_case + except ObjectDoesNotExist: + break + if next_c is None: + break + future_cases.append(next_c) + current = next_c + return future_cases + def get_series(self): return self.series.all().prefetch_related("images", "examination", "plane") @@ -2383,11 +2398,12 @@ class CaseDetail(models.Model): if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review_mode: continue prior_case = prior.prior_case + label = "Prior" if prior.relation_type == CasePrior.RelationType.PRIOR else "Future" results.append( { "caseId": f"CASE-{prior_case.pk}", - "studyId": f"Prior: {prior.relation_text}", - "stacks": build_stacks_for(prior_case, prefix="Prior"), + "studyId": f"{label}: {prior.relation_text}", + "stacks": build_stacks_for(prior_case, prefix=label), } ) @@ -2420,6 +2436,17 @@ class CasePrior(models.Model): relation_text = models.CharField(max_length=255, blank=True, help_text="Text to describe the relationship between the cases") + class RelationType(models.TextChoices): + PRIOR = "PR", _("Prior") + FUTURE = "FU", _("Future") + + relation_type = models.CharField( + max_length=2, + choices=RelationType.choices, + default=RelationType.PRIOR, + help_text="Defines whether the related case is a prior or a future exam", + ) + class PriorVisibility(models.TextChoices): NONE = "NO", _("None") ALWAYS = "AL", _("Always") diff --git a/atlas/templates/atlas/collection_case_priors.html b/atlas/templates/atlas/collection_case_priors.html index 4cd5db6b..d23b0c90 100644 --- a/atlas/templates/atlas/collection_case_priors.html +++ b/atlas/templates/atlas/collection_case_priors.html @@ -15,14 +15,26 @@ -
Relation: {{ relation }}
+ {% if relation %} +Relation: {{ relation }}
+ {% endif %}Study date: {% if case.study_date %} diff --git a/atlas/tests/test_priors_futures.py b/atlas/tests/test_priors_futures.py new file mode 100644 index 00000000..60cd4d0d --- /dev/null +++ b/atlas/tests/test_priors_futures.py @@ -0,0 +1,133 @@ +import pytest +import datetime +from django.urls import reverse +from django.contrib.auth import get_user_model +from django.test import Client +from atlas.models import CaseCollection, Case, CasePrior, CaseDetail + +@pytest.mark.django_db +def test_case_future_priors_chain(make_case): + # Create a chain of cases: case1 -> case2 -> case3 + case1 = make_case(title="Case 1") + case2 = make_case(title="Case 2") + case3 = make_case(title="Case 3") + + case2.previous_case = case1 + case2.save(update_fields=["previous_case"]) + + case3.previous_case = case2 + case3.save(update_fields=["previous_case"]) + + # Test get_all_prior_cases + assert case1.get_all_prior_cases() == [] + assert case2.get_all_prior_cases() == [case1] + assert case3.get_all_prior_cases() == [case2, case1] + + # Test get_all_future_cases + assert case1.get_all_future_cases() == [case2, case3] + assert case2.get_all_future_cases() == [case3] + assert case3.get_all_future_cases() == [] + + +@pytest.mark.django_db +def test_case_prior_and_future_relation_post(client, make_case): + # Setup user and collection + user = get_user_model().objects.create_user( + username="author_user", password="password", email="author@test.com" + ) + collection = CaseCollection.objects.create(name="Test Prior Future Collection") + collection.author.add(user) + + case1 = make_case(title="Case 1") + case2 = make_case(title="Case 2") + case3 = make_case(title="Case 3") + + case2.previous_case = case1 + case2.save(update_fields=["previous_case"]) + + case3.previous_case = case2 + case3.save(update_fields=["previous_case"]) + + # Add case2 (the index case) to the collection + casedetail = CaseDetail.objects.create(case=case2, collection=collection) + + client.force_login(user) + + # 1. Post to link case1 (should be auto-detected as a PRIOR relation) + url = reverse( + "atlas:collection_case_priors", + kwargs={"exam_id": collection.pk, "case_number": 0}, + ) + response = client.post( + url, + data={ + "prior_case_id": case1.pk, + "relation": "Baseline exam", + "prior_visibility": "AL", + }, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200 + assert "Baseline exam" in response.content.decode("utf-8") + assert "Prior" in response.content.decode("utf-8") + + # Verify in DB + p1 = CasePrior.objects.get(casedetail=casedetail, prior_case=case1) + assert p1.relation_type == CasePrior.RelationType.PRIOR + assert p1.relation_text == "Baseline exam" + + # 2. Post to link case3 (should be auto-detected as a FUTURE relation) + response = client.post( + url, + data={ + "prior_case_id": case3.pk, + "relation": "Follow-up exam", + "prior_visibility": "AL", + }, + HTTP_HX_REQUEST="true", + ) + assert response.status_code == 200 + assert "Follow-up exam" in response.content.decode("utf-8") + assert "Future" in response.content.decode("utf-8") + + # Verify in DB + p3 = CasePrior.objects.get(casedetail=casedetail, prior_case=case3) + assert p3.relation_type == CasePrior.RelationType.FUTURE + assert p3.relation_text == "Follow-up exam" + + # Verify GET returns both sections and shows the related cases + response_get = client.get(url) + assert response_get.status_code == 200 + content = response_get.content.decode("utf-8") + assert "Available Prior Exams" in content + assert "Available Future Exams" in content + assert "Baseline exam" in content + assert "Follow-up exam" in content + + # Test named stacks JSON payload + stacks_json = casedetail.get_case_named_stacks() + assert "Prior: Baseline exam" in stacks_json + assert "Future: Follow-up exam" in stacks_json + + # Test DICOM JSON endpoint + dicom_url = reverse( + "atlas:collection_case_dicom_json", + kwargs={"exam_id": collection.pk, "case_number": 0}, + ) + response_dicom = client.get(dicom_url) + assert response_dicom.status_code == 200 + dicom_data = response_dicom.json() + descriptions = [study.get("StudyDescription") for study in dicom_data["studies"] if "StudyDescription" in study] + assert "Prior: Baseline exam" in descriptions + assert "Future: Follow-up exam" in descriptions + + # 3. Post to remove case3 + response_remove = client.post( + url, + data={ + "remove": case3.pk, + }, + HTTP_HX_REQUEST="true", + ) + assert response_remove.status_code == 200 + assert not CasePrior.objects.filter(casedetail=casedetail, prior_case=case3).exists() diff --git a/atlas/views.py b/atlas/views.py index 25cdecd3..9a17fec7 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -7003,7 +7003,7 @@ def collection_case_priors(request, exam_id, case_number): except Exception: raise Http404("Case not found in collection") - def _render_prior_card_response(prior_case, added, relation, visibility, error=None): + def _render_prior_card_response(prior_case, added, relation, visibility, relation_type, error=None): html = render_to_string( "atlas/partials/_prior_card.html", { @@ -7011,6 +7011,7 @@ def collection_case_priors(request, exam_id, case_number): "added": added, "relation": relation, "visibility": visibility, + "relation_type": relation_type, "casedetail": casedetail, "collection": collection, "case_number": case_number, @@ -7026,6 +7027,10 @@ def collection_case_priors(request, exam_id, case_number): if "remove" in request.POST: prior_pk = request.POST["remove"] + prior_case = Case.objects.filter(pk=prior_pk).first() + if prior_case is None: + return HttpResponse("Prior case not found", status=404) + try: p = CasePrior.objects.get(casedetail=casedetail, prior_case=prior_pk) p.delete() @@ -7033,24 +7038,36 @@ def collection_case_priors(request, exam_id, case_number): # Already removed; continue and render the not-added card pass - prior_case = Case.objects.filter(pk=prior_pk).first() - if prior_case is None: - return HttpResponse("Prior case not found", status=404) added = False relation = "" visibility = "AL" + if prior_case in casedetail.case.get_all_prior_cases(): + rel_type = CasePrior.RelationType.PRIOR + elif prior_case in casedetail.case.get_all_future_cases(): + rel_type = CasePrior.RelationType.FUTURE + else: + rel_type = CasePrior.RelationType.PRIOR + return _render_prior_card_response( prior_case, added=added, relation=relation, visibility=visibility, + relation_type=rel_type, ) elif "prior_case_id" in request.POST: prior_case = Case.objects.filter(pk=request.POST.get("prior_case_id")).first() if prior_case is None: return HttpResponse("Prior case not found", status=404) + if prior_case in casedetail.case.get_all_prior_cases(): + rel_type = CasePrior.RelationType.PRIOR + elif prior_case in casedetail.case.get_all_future_cases(): + rel_type = CasePrior.RelationType.FUTURE + else: + rel_type = CasePrior.RelationType.PRIOR + visibility = request.POST.get("prior_visibility", "AL") if visibility not in { CasePrior.PriorVisibility.ALWAYS, @@ -7074,6 +7091,7 @@ def collection_case_priors(request, exam_id, case_number): added=False, relation=relation, visibility=visibility, + relation_type=rel_type, error="Study-date relation requires both this case and the prior case to have study dates.", ) @@ -7086,6 +7104,7 @@ def collection_case_priors(request, exam_id, case_number): added=False, relation=relation, visibility=visibility, + relation_type=rel_type, error="Enter relation text, or use study-date relation.", ) @@ -7094,6 +7113,7 @@ def collection_case_priors(request, exam_id, case_number): ) p.relation_text = relation p.prior_visibility = visibility + p.relation_type = rel_type p.save() added = True @@ -7105,6 +7125,7 @@ def collection_case_priors(request, exam_id, case_number): added=added, relation=relation, visibility=visibility, + relation_type=rel_type, ) else: return HttpResponse("False", status=400) @@ -7115,18 +7136,27 @@ def collection_case_priors(request, exam_id, case_number): added_relation_map = {} for prior in added_priors: - added_relation_map[prior.prior_case] = (prior.relation_text, prior.prior_visibility) + added_relation_map[prior.prior_case] = (prior.relation_text, prior.prior_visibility, prior.relation_type) case = casedetail.case - available_cases = case.get_all_prior_cases() + available_prior_cases = case.get_all_prior_cases() available_priors = [] - for case in available_cases: - if case in added_relation_map: - available_priors.append((case, True, added_relation_map[case][0], added_relation_map[case][1])) + for c in available_prior_cases: + if c in added_relation_map: + available_priors.append((c, True, added_relation_map[c][0], added_relation_map[c][1], added_relation_map[c][2])) else: - available_priors.append((case, False, "", "AL")) + available_priors.append((c, False, "", "AL", CasePrior.RelationType.PRIOR)) + + available_future_cases = case.get_all_future_cases() + + available_futures = [] + for c in available_future_cases: + if c in added_relation_map: + available_futures.append((c, True, added_relation_map[c][0], added_relation_map[c][1], added_relation_map[c][2])) + else: + available_futures.append((c, False, "", "AL", CasePrior.RelationType.FUTURE)) form = PriorCaseForm() @@ -7156,6 +7186,7 @@ def collection_case_priors(request, exam_id, case_number): "case_number": case_number, "added_priors": added_priors, "available_priors": available_priors, + "available_futures": available_futures, "can_edit": can_edit, # Provide a default `error` value so `{% if error %}` checks in included # partials won't raise during variable resolution when the key is absent. @@ -8593,7 +8624,7 @@ def collection_case_dicom_json(request, exam_id, case_number, review=False): series.get_series_dicom_json() for series in prior.prior_case.get_ordered_series() ], - "StudyDescription": f"Prior: {prior.relation_text}", + "StudyDescription": f"{'Prior' if prior.relation_type == CasePrior.RelationType.PRIOR else 'Future'}: {prior.relation_text}", } )