start working on case_study bulk editiing and add relative dates to the model

This commit is contained in:
Ross
2026-06-01 10:23:57 +01:00
parent 6aada9f991
commit 40cea43578
8 changed files with 584 additions and 195 deletions
+4 -2
View File
@@ -746,7 +746,8 @@ class CaseForm(ModelForm):
"open_access", "open_access",
"previous_case", "previous_case",
"diagnostic_certainty", "diagnostic_certainty",
"cimar_uuid", #cimar_uuid",
"study_date",
), ),
) )
@@ -779,7 +780,8 @@ class CaseForm(ModelForm):
"open_access", "open_access",
"previous_case", "previous_case",
"diagnostic_certainty", "diagnostic_certainty",
"cimar_uuid", #cimar_uuid",
"study_date",
] ]
# fields = ['question', 'findings', 'subspecialty', 'references'] # fields = ['question', 'findings', 'subspecialty', 'references']
widgets = { widgets = {
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-06-01 09:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0104_series_source_series_instance_uid'),
]
operations = [
migrations.AddField(
model_name='case',
name='study_date',
field=models.DateField(blank=True, help_text='Optional manually overridden study date for linked case series.', null=True),
),
]
+41
View File
@@ -840,6 +840,12 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
cimar_uuid = models.CharField(max_length=255, null=True, blank=True) cimar_uuid = models.CharField(max_length=255, null=True, blank=True)
study_date = models.DateField(
null=True,
blank=True,
help_text="Optional manually overridden study date for linked case series.",
)
def get_ordered_series_details(self): def get_ordered_series_details(self):
cached = getattr(self, "_ordered_series_details_cache", None) cached = getattr(self, "_ordered_series_details_cache", None)
if cached is not None: if cached is not None:
@@ -873,6 +879,41 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
self._ordered_series_cache = ordered self._ordered_series_cache = ordered
return ordered return ordered
@staticmethod
def _parse_dicom_date(value):
if not value:
return None
try:
return datetime.datetime.strptime(str(value), "%Y%m%d").date()
except (TypeError, ValueError):
return None
def get_effective_study_date(self):
if self.study_date:
return self.study_date
for series in self.get_ordered_series():
image = series.images.filter(removed=False).first()
if not image:
continue
try:
ds = image.get_dicom_data()
except Exception:
ds = None
if not ds:
continue
raw_date = (
getattr(ds, "StudyDate", None)
or getattr(ds, "AcquisitionDate", None)
or getattr(ds, "SeriesDate", None)
)
parsed = self._parse_dicom_date(raw_date)
if parsed:
return parsed
return None
def get_app_name(self): def get_app_name(self):
return "atlas" return "atlas"
@@ -18,6 +18,84 @@
</div> </div>
</div> </div>
{% if can_edit_chain %}
<div class="card border-secondary-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">Study Dates</h2>
<p class="text-muted mb-0">Edit each case date directly, or use the copy action to apply one case date to the rest of the linked chain.</p>
</div>
{% if anchor_date %}
<div class="small text-muted">Relative offsets are measured from {{ anchor_date|date:"Y-m-d" }}.</div>
{% endif %}
</div>
<form method="post">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-dark table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col">Case</th>
<th scope="col" class="text-nowrap">Study date</th>
<th scope="col" class="text-nowrap">Relative</th>
<th scope="col" class="text-nowrap">Actions</th>
</tr>
</thead>
<tbody>
{% for row in linked_case_rows %}
<tr class="{% if row.is_current %}table-primary{% endif %}">
<td>
<div class="fw-semibold">{{ row.case.title|default:"Untitled case" }}</div>
<div class="small text-muted">Case {{ row.case.pk }}{% if row.is_current %} · current{% endif %}</div>
</td>
<td style="min-width: 14rem;">
<input
type="date"
class="form-control form-control-sm"
name="study_date_{{ row.case.pk }}"
value="{% if row.study_date %}{{ row.study_date|date:'Y-m-d' }}{% endif %}"
{% if not row.can_edit %}disabled{% endif %}
>
</td>
<td class="text-nowrap">
{% if row.relative_days is not None %}
{% if row.relative_days == 0 %}
Day 0
{% elif row.relative_days > 0 %}
+{{ row.relative_days }} day{{ row.relative_days|pluralize }}
{% else %}
{{ row.relative_days }} day{{ row.relative_days|pluralize }}
{% endif %}
{% else %}
<span class="text-muted">No anchor</span>
{% endif %}
</td>
<td class="text-nowrap">
<button
type="submit"
class="btn btn-sm btn-outline-info"
name="apply_to_all_from"
value="{{ row.case.pk }}"
{% if not row.can_edit %}disabled{% endif %}
>
Copy to others
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2 mt-3">
<button type="submit" class="btn btn-primary">Save study dates</button>
</div>
</form>
</div>
</div>
{% endif %}
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card border-secondary-subtle shadow-sm h-100"> <div class="card border-secondary-subtle shadow-sm h-100">
@@ -29,7 +29,9 @@
{% endif %} {% endif %}
</div> </div>
<div class="text-sm-end small text-muted"> <div class="text-sm-end small text-muted">
{% if linked_case.published_date %} {% if linked_case.get_effective_study_date %}
<div>Study date {{ linked_case.get_effective_study_date|date:"Y-m-d" }}</div>
{% elif linked_case.published_date %}
<div>Published {{ linked_case.published_date|date:"Y-m-d" }}</div> <div>Published {{ linked_case.published_date|date:"Y-m-d" }}</div>
{% else %} {% else %}
<div>Created {{ linked_case.created_date|date:"Y-m-d" }}</div> <div>Created {{ linked_case.created_date|date:"Y-m-d" }}</div>
+172 -6
View File
@@ -1,15 +1,181 @@
import json import json
import pytest
# from django.contrib.auth.models import User from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
import pytest
from atlas.models import CaseCollection, Finding, Condition
#from atlas.views import GenericExamViews
from pytest_django.asserts import assertRedirects, assertContains, assertQuerySetEqual
from django.test import TestCase
from rich.pretty import pprint
from bs4 import BeautifulSoup #@pytest.mark.django_db
def test_create_condition(db):
abnormality = Condition.objects.create(name="Abnorm")
assert abnormality.name == "Abnorm"
from django.contrib.auth.models import Group
def test_condition_tree_structure(db):
parent = Condition.objects.create(name="parent")
child1 = Condition.objects.create(name="child1")
child2 = Condition.objects.create(name="child2")
parent.child.set([child1, child2])
child1_1 = Condition.objects.create(name="child1_1")
child1_2 = Condition.objects.create(name="child1_2")
child1.child.set([child1_1, child1_2])
assert len(parent.get_children()) == 2
assert len(child1.get_children()) == 2
assert len(child1.get_parents()) == 1
assert parent in child1.get_parents()
assert len(child2.get_parents()) == 1
assert parent in child2.get_parents()
#@pytest.fixture
#def create_basic_user(db, django_user_model):
# return django_user_model.objects.create_superuser("basicuser", "ross@xkjq.uk", "password")
#
#@pytest.fixture
#def create_exam(db):
# return Exam.objects.create(name="test exam", exam_mode=True)
#
#def test_exam_creation(create_exam):
# exams = Exam.objects.filter(name="test exam")
# assert exams.exists()
#
# exam = exams.first()
#
# assert exam.time_limit == 35 * 60
#
#def test_exam_list(rf, admin_user, create_exam):
# request = rf.get('/rapids/exam/')
# # Remember that when using RequestFactory, the request does not pass
# # through middleware. If your view expects fields such as request.user to be
# # set, you need to set them explicitly.
# # The following line sets request.user to an admin user.
# request.user = admin_user
# response = GenericExamViews.exam_list(request)
# #print(response)
# assert response.status_code == 200
#
#def test_exam_active_admin(rf, admin_user, create_exam, client):
# request = rf.get(reverse('rapids:active_exams'))
# request.user = admin_user
# response = GenericExamViews.active_exams(request)
# print(response.content)
# assert response.status_code == 200
#
#def test_exam_active_basic_user(create_basic_user, create_exam, client):
# client.login(username="basicuser", password="password")
# response = client.get(reverse('rapids:active_exams'))
# print(response.content)
# assert response.status_code == 200
def test_create_case(db, create_case):
case = create_case
assert case.title == "Sample Case"
def test_create_series(db, create_series):
series = create_series
assert series
def test_collection(db, create_case_collection, create_case, make_case, client, create_user):
collection: CaseCollection = create_case_collection
# create some cases
case1 = create_case
case2 = make_case()
case3 = make_case(title="Case 3")
case4 = make_case(title="Case 4")
collection.cases.set([case1, case2, case3, case4])
case5 = make_case(title="Case 5")
assert collection.cases.all().count() == 4
collection.add_case(case5)
assert collection.cases.all().count() == 5
assert collection.get_case_by_index(0) == case1
assert collection.get_case_by_index(1) == case2
assert collection.get_case_by_index(2) == case3
assert collection.get_case_by_index(3) == case4
assert collection.get_case_by_index(4) == case5
assert collection.get_absolute_url() == reverse("atlas:collection_detail", kwargs={"pk": collection.pk})
assert collection.get_take_url() == reverse("atlas:collection_take_start", kwargs={"pk": collection.pk})
start_order = collection.get_cases()
new_order = [
case5.pk,
case1.pk,
case2.pk,
case3.pk,
case4.pk
]
response: HttpResponse = client.post(reverse("atlas:exam_json_edit", kwargs={"pk":collection.pk} ), json=new_order, follow=True)
# Check that sorting the exam works
# First attempt is not logged in
assertRedirects(response, f"{reverse('login')}?next=%2Fatlas%2Fcollection%2F1%2Fjson_edit")
client.force_login(create_user)
# Random user cannot modify order
response: HttpResponse = client.post(reverse("atlas:exam_json_edit", kwargs={"pk":collection.pk} ), json=new_order, follow=True)
assert response.status_code == 403
assertQuerySetEqual(start_order , collection.get_cases())
# Once the user has been made an author, they can modify the order
collection.add_author(create_user)
response: HttpResponse = client.post(reverse("atlas:exam_json_edit", kwargs={"pk":collection.pk} ), data={"set_exam_order" : json.dumps(new_order)}, follow=True)
assert response.status_code == 200
assert collection.get_cases().count() == 5
assert collection.get_case_by_index(0) == case5
assert collection.get_case_by_index(1) == case1
@pytest.mark.django_db
def test_linked_cases_overview_can_copy_study_date_to_chain(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 = client.post(
reverse("atlas:linked_cases_overview", kwargs={"case_id": case1.pk}),
data={
f"study_date_{case1.pk}": "2026-01-10",
f"study_date_{case2.pk}": "2026-01-12",
"apply_to_all_from": str(case1.pk),
},
follow=True,
)
assert response.status_code == 200
case1.refresh_from_db()
case2.refresh_from_db()
assert str(case1.study_date) == "2026-01-10"
assert str(case2.study_date) == "2026-01-10"
+89 -7
View File
@@ -4,6 +4,7 @@ import csv
import random import random
import copy import copy
import io import io
import datetime
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
from dal import autocomplete from dal import autocomplete
@@ -168,6 +169,31 @@ from .filters import (
NormalCaseFilter, NormalCaseFilter,
) )
def _parse_study_date(value):
if not value:
return None
try:
parsed_value = str(value)
for date_format in ("%Y%m%d", "%Y-%m-%d"):
try:
return datetime.datetime.strptime(parsed_value, date_format).date()
except ValueError:
continue
return None
except (TypeError, ValueError):
return None
def _extract_study_date_from_dicoms(dicoms):
for dicom in dicoms.iterator():
tags = dicom.basic_dicom_tags or {}
for key in ("StudyDate", "AcquisitionDate", "SeriesDate"):
study_date = _parse_study_date(tags.get(key))
if study_date:
return study_date
return None
from .tasks import push_case_to_cimar_task, series_downsample_task, series_reconstruct_task from .tasks import push_case_to_cimar_task, series_downsample_task, series_reconstruct_task
try: try:
from django_tasks import TaskResultStatus from django_tasks import TaskResultStatus
@@ -3637,14 +3663,15 @@ def uploads_import_case_series_htmx(request):
for field_name in case_field_names: for field_name in case_field_names:
setattr(case, field_name, getattr(base_case, field_name)) setattr(case, field_name, getattr(base_case, field_name))
case.previous_case = previous_case case.previous_case = previous_case
study_series_uids = study_groups[study_uid]
study_dicoms = dicoms.filter(series_instance_uid__in=study_series_uids)
case.study_date = _extract_study_date_from_dicoms(study_dicoms)
case.save() case.save()
case.author.add(request.user) case.author.add(request.user)
for field_name in m2m_field_names: for field_name in m2m_field_names:
getattr(case, field_name).set(form.cleaned_data.get(field_name, [])) getattr(case, field_name).set(form.cleaned_data.get(field_name, []))
study_series_uids = study_groups[study_uid]
study_dicoms = dicoms.filter(series_instance_uid__in=study_series_uids)
imported_rows = import_dicoms_helper(request, case_id=case.pk, dicoms=study_dicoms) imported_rows = import_dicoms_helper(request, case_id=case.pk, dicoms=study_dicoms)
created_cases.append(case) created_cases.append(case)
import_results.append((case, study_uid, imported_rows)) import_results.append((case, study_uid, imported_rows))
@@ -9520,20 +9547,17 @@ def linked_cases_overview(request, case_id):
""" """
Display the linked-case chain around a case, including prior and follow-up cases. Display the linked-case chain around a case, including prior and follow-up cases.
""" """
# Get the current case
case = get_object_or_404(Case, pk=case_id) case = get_object_or_404(Case, pk=case_id)
# Traverse backward to get all previous cases
previous_cases = [] previous_cases = []
try: try:
current_case = case.previous_case current_case = case.previous_case
while current_case: while current_case:
previous_cases.insert(0, current_case) # Insert at the beginning to maintain order previous_cases.insert(0, current_case)
current_case = current_case.previous_case current_case = current_case.previous_case
except ObjectDoesNotExist: except ObjectDoesNotExist:
pass pass
# Traverse forward to get all next cases
next_cases = [] next_cases = []
try: try:
current_case = case.next_case current_case = case.next_case
@@ -9547,7 +9571,62 @@ def linked_cases_overview(request, case_id):
direct_previous = previous_cases[-1] if previous_cases else None direct_previous = previous_cases[-1] if previous_cases else None
direct_next = next_cases[0] if next_cases else None direct_next = next_cases[0] if next_cases else None
# Render the template with the linked cases 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.")
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
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).")
return redirect("atlas:linked_cases_overview", case_id=case.pk)
linked_case_rows = []
anchor_date = next(
(linked_case.get_effective_study_date() for linked_case in chain_cases if linked_case.get_effective_study_date()),
None,
)
for linked_case in chain_cases:
study_date = linked_case.get_effective_study_date()
relative_days = None
if anchor_date and study_date:
relative_days = (study_date - anchor_date).days
linked_case_rows.append(
{
"case": linked_case,
"study_date": study_date,
"relative_days": relative_days,
"can_edit": linked_case.check_user_can_edit(request.user),
"is_current": linked_case.pk == case.pk,
}
)
return render( return render(
request, request,
"atlas/linked_cases_overview.html", "atlas/linked_cases_overview.html",
@@ -9556,6 +9635,9 @@ def linked_cases_overview(request, case_id):
"previous_cases": previous_cases, "previous_cases": previous_cases,
"next_cases": next_cases, "next_cases": next_cases,
"chain_cases": chain_cases, "chain_cases": chain_cases,
"linked_case_rows": linked_case_rows,
"anchor_date": anchor_date,
"can_edit_chain": any(row["can_edit"] for row in linked_case_rows),
"chain_total": len(chain_cases), "chain_total": len(chain_cases),
"current_index": len(previous_cases) + 1, "current_index": len(previous_cases) + 1,
"direct_previous": direct_previous, "direct_previous": direct_previous,
+179 -179
View File
File diff suppressed because one or more lines are too long