start working on case_study bulk editiing and add relative dates to the model
This commit is contained in:
+4
-2
@@ -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 = {
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user