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",
"previous_case",
"diagnostic_certainty",
"cimar_uuid",
#cimar_uuid",
"study_date",
),
)
@@ -779,7 +780,8 @@ class CaseForm(ModelForm):
"open_access",
"previous_case",
"diagnostic_certainty",
"cimar_uuid",
#cimar_uuid",
"study_date",
]
# fields = ['question', 'findings', 'subspecialty', 'references']
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)
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):
cached = getattr(self, "_ordered_series_details_cache", None)
if cached is not None:
@@ -873,6 +879,41 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
self._ordered_series_cache = 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):
return "atlas"
@@ -18,6 +18,84 @@
</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="col-lg-8">
<div class="card border-secondary-subtle shadow-sm h-100">
@@ -29,7 +29,9 @@
{% endif %}
</div>
<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>
{% else %}
<div>Created {{ linked_case.created_date|date:"Y-m-d" }}</div>
+172 -6
View File
@@ -1,15 +1,181 @@
import json
import pytest
# from django.contrib.auth.models import User
from django.http import HttpResponse
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 copy
import io
import datetime
from datetime import timedelta
from typing import Any
from dal import autocomplete
@@ -168,6 +169,31 @@ from .filters import (
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
try:
from django_tasks import TaskResultStatus
@@ -3637,14 +3663,15 @@ def uploads_import_case_series_htmx(request):
for field_name in case_field_names:
setattr(case, field_name, getattr(base_case, field_name))
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.author.add(request.user)
for field_name in m2m_field_names:
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)
created_cases.append(case)
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.
"""
# Get the current case
case = get_object_or_404(Case, pk=case_id)
# Traverse backward to get all previous cases
previous_cases = []
try:
current_case = case.previous_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
except ObjectDoesNotExist:
pass
# Traverse forward to get all next cases
next_cases = []
try:
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_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(
request,
"atlas/linked_cases_overview.html",
@@ -9556,6 +9635,9 @@ def linked_cases_overview(request, case_id):
"previous_cases": previous_cases,
"next_cases": next_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),
"current_index": len(previous_cases) + 1,
"direct_previous": direct_previous,
+179 -179
View File
File diff suppressed because one or more lines are too long