add future relations to cases in collection
This commit is contained in:
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
+29
-2
@@ -1054,6 +1054,21 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
|||||||
|
|
||||||
return prior_cases
|
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):
|
def get_series(self):
|
||||||
return self.series.all().prefetch_related("images", "examination", "plane")
|
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:
|
if prior.prior_visibility == CasePrior.PriorVisibility.REVIEW and not review_mode:
|
||||||
continue
|
continue
|
||||||
prior_case = prior.prior_case
|
prior_case = prior.prior_case
|
||||||
|
label = "Prior" if prior.relation_type == CasePrior.RelationType.PRIOR else "Future"
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"caseId": f"CASE-{prior_case.pk}",
|
"caseId": f"CASE-{prior_case.pk}",
|
||||||
"studyId": f"Prior: {prior.relation_text}",
|
"studyId": f"{label}: {prior.relation_text}",
|
||||||
"stacks": build_stacks_for(prior_case, prefix="Prior"),
|
"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")
|
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):
|
class PriorVisibility(models.TextChoices):
|
||||||
NONE = "NO", _("None")
|
NONE = "NO", _("None")
|
||||||
ALWAYS = "AL", _("Always")
|
ALWAYS = "AL", _("Always")
|
||||||
|
|||||||
@@ -15,14 +15,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="mb-3">Available priors</h3>
|
<h3 class="mb-3">Available Prior Exams</h3>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3 mb-4">
|
||||||
{% for case, added, relation, visibility in available_priors %}
|
{% for case, added, relation, visibility, relation_type in available_priors %}
|
||||||
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility collection=collection %}
|
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility relation_type=relation_type collection=collection %}
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="alert alert-info">No available prior cases found for the case.</div>
|
<div class="alert alert-info">No available prior exams found for the case.</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mb-3">Available Future Exams</h3>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
{% for case, added, relation, visibility, relation_type in available_futures %}
|
||||||
|
{% include 'atlas/partials/_prior_card.html' with case=case added=added relation=relation visibility=visibility relation_type=relation_type collection=collection %}
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="alert alert-info">No available future exams found for the case.</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<h5 class="card-title mb-0">{{case.title}}</h5>
|
<h5 class="card-title mb-0">{{case.title}}</h5>
|
||||||
<div>
|
<div class="d-flex gap-1">
|
||||||
|
<span class="badge {% if relation_type == 'PR' %}bg-info{% else %}bg-warning text-dark{% endif %}">{% if relation_type == 'PR' %}Prior{% else %}Future{% endif %}</span>
|
||||||
{% if added %}
|
{% if added %}
|
||||||
<span class="badge bg-success">Added</span>
|
<span class="badge bg-success">Added</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -15,7 +16,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if relation %}
|
||||||
<p class="card-text small text-muted mb-2">Relation: <span class="fw-medium">{{ relation }}</span></p>
|
<p class="card-text small text-muted mb-2">Relation: <span class="fw-medium">{{ relation }}</span></p>
|
||||||
|
{% endif %}
|
||||||
<p class="card-text small text-muted mb-2">
|
<p class="card-text small text-muted mb-2">
|
||||||
Study date:
|
Study date:
|
||||||
{% if case.study_date %}
|
{% if case.study_date %}
|
||||||
|
|||||||
@@ -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()
|
||||||
+42
-11
@@ -7003,7 +7003,7 @@ def collection_case_priors(request, exam_id, case_number):
|
|||||||
except Exception:
|
except Exception:
|
||||||
raise Http404("Case not found in collection")
|
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(
|
html = render_to_string(
|
||||||
"atlas/partials/_prior_card.html",
|
"atlas/partials/_prior_card.html",
|
||||||
{
|
{
|
||||||
@@ -7011,6 +7011,7 @@ def collection_case_priors(request, exam_id, case_number):
|
|||||||
"added": added,
|
"added": added,
|
||||||
"relation": relation,
|
"relation": relation,
|
||||||
"visibility": visibility,
|
"visibility": visibility,
|
||||||
|
"relation_type": relation_type,
|
||||||
"casedetail": casedetail,
|
"casedetail": casedetail,
|
||||||
"collection": collection,
|
"collection": collection,
|
||||||
"case_number": case_number,
|
"case_number": case_number,
|
||||||
@@ -7026,6 +7027,10 @@ def collection_case_priors(request, exam_id, case_number):
|
|||||||
|
|
||||||
if "remove" in request.POST:
|
if "remove" in request.POST:
|
||||||
prior_pk = request.POST["remove"]
|
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:
|
try:
|
||||||
p = CasePrior.objects.get(casedetail=casedetail, prior_case=prior_pk)
|
p = CasePrior.objects.get(casedetail=casedetail, prior_case=prior_pk)
|
||||||
p.delete()
|
p.delete()
|
||||||
@@ -7033,24 +7038,36 @@ def collection_case_priors(request, exam_id, case_number):
|
|||||||
# Already removed; continue and render the not-added card
|
# Already removed; continue and render the not-added card
|
||||||
pass
|
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
|
added = False
|
||||||
relation = ""
|
relation = ""
|
||||||
visibility = "AL"
|
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(
|
return _render_prior_card_response(
|
||||||
prior_case,
|
prior_case,
|
||||||
added=added,
|
added=added,
|
||||||
relation=relation,
|
relation=relation,
|
||||||
visibility=visibility,
|
visibility=visibility,
|
||||||
|
relation_type=rel_type,
|
||||||
)
|
)
|
||||||
elif "prior_case_id" in request.POST:
|
elif "prior_case_id" in request.POST:
|
||||||
prior_case = Case.objects.filter(pk=request.POST.get("prior_case_id")).first()
|
prior_case = Case.objects.filter(pk=request.POST.get("prior_case_id")).first()
|
||||||
if prior_case is None:
|
if prior_case is None:
|
||||||
return HttpResponse("Prior case not found", status=404)
|
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")
|
visibility = request.POST.get("prior_visibility", "AL")
|
||||||
if visibility not in {
|
if visibility not in {
|
||||||
CasePrior.PriorVisibility.ALWAYS,
|
CasePrior.PriorVisibility.ALWAYS,
|
||||||
@@ -7074,6 +7091,7 @@ def collection_case_priors(request, exam_id, case_number):
|
|||||||
added=False,
|
added=False,
|
||||||
relation=relation,
|
relation=relation,
|
||||||
visibility=visibility,
|
visibility=visibility,
|
||||||
|
relation_type=rel_type,
|
||||||
error="Study-date relation requires both this case and the prior case to have study dates.",
|
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,
|
added=False,
|
||||||
relation=relation,
|
relation=relation,
|
||||||
visibility=visibility,
|
visibility=visibility,
|
||||||
|
relation_type=rel_type,
|
||||||
error="Enter relation text, or use study-date relation.",
|
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.relation_text = relation
|
||||||
p.prior_visibility = visibility
|
p.prior_visibility = visibility
|
||||||
|
p.relation_type = rel_type
|
||||||
p.save()
|
p.save()
|
||||||
|
|
||||||
added = True
|
added = True
|
||||||
@@ -7105,6 +7125,7 @@ def collection_case_priors(request, exam_id, case_number):
|
|||||||
added=added,
|
added=added,
|
||||||
relation=relation,
|
relation=relation,
|
||||||
visibility=visibility,
|
visibility=visibility,
|
||||||
|
relation_type=rel_type,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return HttpResponse("False", status=400)
|
return HttpResponse("False", status=400)
|
||||||
@@ -7115,18 +7136,27 @@ def collection_case_priors(request, exam_id, case_number):
|
|||||||
|
|
||||||
added_relation_map = {}
|
added_relation_map = {}
|
||||||
for prior in added_priors:
|
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
|
case = casedetail.case
|
||||||
|
|
||||||
available_cases = case.get_all_prior_cases()
|
available_prior_cases = case.get_all_prior_cases()
|
||||||
|
|
||||||
available_priors = []
|
available_priors = []
|
||||||
for case in available_cases:
|
for c in available_prior_cases:
|
||||||
if case in added_relation_map:
|
if c in added_relation_map:
|
||||||
available_priors.append((case, True, added_relation_map[case][0], added_relation_map[case][1]))
|
available_priors.append((c, True, added_relation_map[c][0], added_relation_map[c][1], added_relation_map[c][2]))
|
||||||
else:
|
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()
|
form = PriorCaseForm()
|
||||||
|
|
||||||
@@ -7156,6 +7186,7 @@ def collection_case_priors(request, exam_id, case_number):
|
|||||||
"case_number": case_number,
|
"case_number": case_number,
|
||||||
"added_priors": added_priors,
|
"added_priors": added_priors,
|
||||||
"available_priors": available_priors,
|
"available_priors": available_priors,
|
||||||
|
"available_futures": available_futures,
|
||||||
"can_edit": can_edit,
|
"can_edit": can_edit,
|
||||||
# Provide a default `error` value so `{% if error %}` checks in included
|
# Provide a default `error` value so `{% if error %}` checks in included
|
||||||
# partials won't raise during variable resolution when the key is absent.
|
# 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()
|
series.get_series_dicom_json()
|
||||||
for series in prior.prior_case.get_ordered_series()
|
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}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user