Skip to content

Commit af742ad

Browse files
authored
Merge pull request #1380 from NHSDigital/DTOSS-12709-model-changes
Link DICOM Study records to Appointment
2 parents 0f5bf55 + efbf5cc commit af742ad

20 files changed

Lines changed: 187 additions & 111 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ __pycache__
1616
.venv/
1717

1818
# Python coverage
19-
.coverage
19+
.coverage*
2020

2121
# Django
2222
.env

manage_breast_screening/dicom/dicom_recorder.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
logger = logging.getLogger(__name__)
1616

1717

18+
def lookup_appointment(source_message_id):
19+
try:
20+
return GatewayAction.objects.get(id=source_message_id).appointment
21+
except GatewayAction.DoesNotExist:
22+
return None
23+
24+
1825
class DicomProcessingError(Exception):
1926
"""Custom exception for DICOM processing errors."""
2027

@@ -26,7 +33,8 @@ class DicomRecorder:
2633
def get_or_create_records(
2734
source_message_id: str, dicom_file: File
2835
) -> tuple[Study, Series, Image]:
29-
if not __class__.appointment_in_progress(source_message_id):
36+
appointment = lookup_appointment(source_message_id)
37+
if not appointment or not appointment.is_in_progress():
3038
raise DicomProcessingError(
3139
f"Cannot process DICOM file for source_message_id={source_message_id} "
3240
"because the associated appointment is not in progress."
@@ -46,6 +54,7 @@ def get_or_create_records(
4654
)
4755

4856
study, _ = Study.objects.get_or_create(
57+
appointment=appointment,
4958
study_instance_uid=study_uid,
5059
source_message_id=source_message_id,
5160
defaults={
@@ -135,10 +144,3 @@ def dataset_to_jpeg(sop_uid: str, ds: pydicom.Dataset) -> InMemoryUploadedFile:
135144
size=in_memory_file.getbuffer().nbytes,
136145
charset=None,
137146
)
138-
139-
@staticmethod
140-
def appointment_in_progress(source_message_id: str) -> bool:
141-
gateway_action = GatewayAction.objects.filter(id=source_message_id).first()
142-
if not gateway_action or not gateway_action.appointment:
143-
return False
144-
return gateway_action.appointment.current_status.is_in_progress()
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Generated by Django 6.0.4 on 2026-04-27 14:38
2+
3+
import django.db.models.deletion
4+
import django.utils.timezone
5+
from django.conf import settings
6+
from django.db import migrations, models
7+
8+
9+
def set_appointment(apps, schema_editor):
10+
Study = apps.get_model('dicom', 'Study')
11+
Appointment = apps.get_model('participants', 'Appointment')
12+
GatewayAction = apps.get_model('gateway', 'GatewayAction')
13+
for study in Study.objects.all():
14+
if study.source_message_id:
15+
action = GatewayAction.objects.filter(id=study.source_message_id).first()
16+
if action is None and settings.DJANGO_ENV != 'production':
17+
# workaround inconsistent demo data by linking orphan studies to arbitrary appointments
18+
study.appointment = Appointment.objects.filter(dicom_study=None).first()
19+
else:
20+
study.appointment = action.appointment
21+
study.save()
22+
23+
def unset_appointment(apps, schema_editor):
24+
Study = apps.get_model('dicom', 'Study')
25+
Study.objects.all().update(appointment=None)
26+
27+
class Migration(migrations.Migration):
28+
29+
dependencies = [
30+
('dicom', '0008_readingsession_reading_recallforassessmentdetails_and_more'),
31+
('participants', '0001_squashed_0067_participantreportedmammogram_created_by_and_more'),
32+
('gateway', '0005_relay_provider_to_setting'),
33+
]
34+
35+
operations = [
36+
migrations.RenameField(
37+
model_name='readingsessionitem',
38+
old_name='order',
39+
new_name='reading_order',
40+
),
41+
migrations.AlterUniqueTogether(
42+
name='readingsessionitem',
43+
unique_together={('session', 'reading_order')},
44+
),
45+
migrations.AddField(
46+
model_name='study',
47+
name='created_at',
48+
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
49+
preserve_default=False,
50+
),
51+
migrations.AddField(
52+
model_name='study',
53+
name='updated_at',
54+
field=models.DateTimeField(auto_now=True),
55+
),
56+
migrations.AddField(
57+
model_name='study',
58+
name='appointment',
59+
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='dicom_study', to='participants.appointment'),
60+
),
61+
migrations.RunPython(code=set_appointment, reverse_code=unset_appointment),
62+
migrations.AlterField(
63+
model_name='study',
64+
name='appointment',
65+
field=models.OneToOneField(
66+
to='participants.Appointment', on_delete=models.PROTECT, related_name="dicom_study"
67+
)
68+
)
69+
]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0008_readingsession_reading_recallforassessmentdetails_and_more
1+
0009_rename_order_readingsessionitem_reading_order_and_more

manage_breast_screening/dicom/models.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@
1212
RepeatType,
1313
StudyCompleteness,
1414
)
15+
from manage_breast_screening.participants.models.appointment import Appointment
1516

1617

1718
def dicom_storage():
1819
return storages["dicom"]
1920

2021

21-
class Study(models.Model):
22+
class Study(BaseModel):
2223
class Meta:
2324
indexes = [
2425
models.Index(fields=["study_instance_uid"]),
@@ -27,6 +28,9 @@ class Meta:
2728
]
2829

2930
id = models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True)
31+
appointment = models.OneToOneField(
32+
Appointment, on_delete=models.PROTECT, related_name="dicom_study"
33+
)
3034
study_instance_uid = models.CharField(max_length=128, unique=True)
3135
source_message_id = models.CharField(max_length=128)
3236
patient_id = models.CharField(max_length=10, blank=True)
@@ -53,14 +57,6 @@ def images(self) -> models.QuerySet["Image"]:
5357
"series__series_number", "instance_number"
5458
)
5559

56-
@classmethod
57-
def for_appointment(cls, appointment):
58-
action = appointment.gateway_actions.first()
59-
if not action:
60-
return None
61-
62-
return cls.objects.filter(source_message_id=action.id).first()
63-
6460
def series_with_multiple_images(self):
6561
return self.series.annotate(image_count=models.Count("images")).filter(
6662
image_count__gt=1
@@ -245,7 +241,7 @@ class ReadingSessionItem(BaseModel):
245241
study = models.ForeignKey(
246242
Study, on_delete=models.PROTECT, related_name="reading_session_items"
247243
)
248-
order = models.IntegerField()
244+
reading_order = models.IntegerField()
249245
reading = models.OneToOneField(
250246
Reading,
251247
on_delete=models.PROTECT,
@@ -254,4 +250,4 @@ class ReadingSessionItem(BaseModel):
254250
)
255251

256252
class Meta:
257-
unique_together = [("session", "order")]
253+
unique_together = [("session", "reading_order")]

manage_breast_screening/dicom/study_service.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@ def save(
2222
Save additional details to the Study associated with the appointment's GatewayAction.
2323
Returns the updated Study, or None if no Study is found.
2424
"""
25-
study = Study.for_appointment(self.appointment)
26-
25+
study = getattr(self.appointment, "dicom_study", None)
2726
if not study:
2827
return None
2928

manage_breast_screening/dicom/tests/factories.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
from factory.django import DjangoModelFactory, FileField
55
from factory.helpers import post_generation
66

7+
from manage_breast_screening.participants.models.appointment import (
8+
AppointmentStatusNames,
9+
)
10+
from manage_breast_screening.participants.tests.factories import AppointmentFactory
711
from manage_breast_screening.users.tests.factories import UserFactory
812

913
from .. import models
@@ -18,6 +22,9 @@ class Meta:
1822
patient_id = Sequence(lambda n: f"999{n:07d}")
1923
date_and_time = None
2024
description = "Test Study"
25+
appointment = SubFactory(
26+
AppointmentFactory, current_status=AppointmentStatusNames.SCREENED
27+
)
2128

2229

2330
class StudyWithImagesFactory(StudyFactory):
@@ -104,7 +111,7 @@ class Meta:
104111
model = models.ReadingSessionItem
105112

106113
study = SubFactory(StudyFactory)
107-
order = Sequence(lambda i: i)
114+
reading_order = Sequence(lambda i: i)
108115

109116

110117
class ReadingSessionFactory(DjangoModelFactory):

manage_breast_screening/dicom/tests/test_api.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
from ninja.testing import TestClient
99

1010
from manage_breast_screening.core.api import api
11-
from manage_breast_screening.dicom.dicom_recorder import DicomRecorder
1211
from manage_breast_screening.dicom.models import Study
1312
from manage_breast_screening.gateway.models import GatewayActionStatus
1413
from manage_breast_screening.gateway.tests.factories import GatewayActionFactory
14+
from manage_breast_screening.participants.models.appointment import (
15+
AppointmentStatusNames,
16+
)
17+
from manage_breast_screening.participants.tests.factories import AppointmentFactory
1518

1619
os.environ["NINJA_SKIP_REGISTRY"] = "yes"
1720

@@ -28,14 +31,26 @@ def dicom_file(dataset) -> bytes:
2831
)
2932

3033

34+
@pytest.fixture
35+
def appointment_stub():
36+
return AppointmentFactory.stub(
37+
is_in_progress=MagicMock(return_value=True),
38+
)
39+
40+
3141
@pytest.mark.django_db
3242
def test_upload_success(dataset, dicom_file, monkeypatch):
3343
monkeypatch.setenv("API_ENABLED", "true")
3444
monkeypatch.setenv("API_AUTH_TOKEN", "testtoken")
3545

36-
with patch.object(DicomRecorder, "appointment_in_progress", return_value=True):
46+
appointment = AppointmentFactory(current_status=AppointmentStatusNames.IN_PROGRESS)
47+
48+
with patch(
49+
"manage_breast_screening.dicom.dicom_recorder.lookup_appointment",
50+
return_value=appointment,
51+
):
3752
response = client.put(
38-
"/dicom/abc123",
53+
f"/dicom/{appointment.pk}",
3954
FILES={"file": dicom_file},
4055
headers={"Authorization": "Bearer " + os.getenv("API_AUTH_TOKEN", "")},
4156
)
@@ -47,7 +62,7 @@ def test_upload_success(dataset, dicom_file, monkeypatch):
4762
assert json["series_instance_uid"] == dataset.SeriesInstanceUID
4863
assert json["sop_instance_uid"] == dataset.SOPInstanceUID
4964
assert json["instance_id"] == str(study.images().first().id)
50-
assert study.source_message_id == "abc123"
65+
assert study.source_message_id == str(appointment.pk)
5166

5267

5368
def test_upload_no_file(monkeypatch):
@@ -63,15 +78,18 @@ def test_upload_no_file(monkeypatch):
6378
assert response.status_code == 422
6479

6580

66-
def test_upload_invalid_file(monkeypatch):
81+
def test_upload_invalid_file(monkeypatch, appointment_stub):
6782
monkeypatch.setenv("API_ENABLED", "true")
6883
monkeypatch.setenv("API_AUTH_TOKEN", "testtoken")
6984

7085
invalid_file = SimpleUploadedFile(
7186
"invalid.dcm", b"not a dicom file", content_type="application/dicom"
7287
)
7388

74-
with patch.object(DicomRecorder, "appointment_in_progress", return_value=True):
89+
with patch(
90+
"manage_breast_screening.dicom.dicom_recorder.lookup_appointment",
91+
return_value=appointment_stub,
92+
):
7593
response = client.put(
7694
"/dicom/abc123",
7795
FILES={"file": invalid_file},
@@ -102,7 +120,7 @@ def test_upload_file_thats_too_large(monkeypatch):
102120
assert response.json()["detail"] == "The file cannot be larger than 100MB"
103121

104122

105-
def test_upload_missing_uids(dataset, monkeypatch):
123+
def test_upload_missing_uids(dataset, monkeypatch, appointment_stub):
106124
monkeypatch.setenv("API_ENABLED", "true")
107125
monkeypatch.setenv("API_AUTH_TOKEN", "testtoken")
108126

@@ -117,7 +135,10 @@ def test_upload_missing_uids(dataset, monkeypatch):
117135
"temp.dcm", buffer.read(), content_type="application/dicom"
118136
)
119137

120-
with patch.object(DicomRecorder, "appointment_in_progress", return_value=True):
138+
with patch(
139+
"manage_breast_screening.dicom.dicom_recorder.lookup_appointment",
140+
return_value=appointment_stub,
141+
):
121142
response = client.put(
122143
"/dicom/abc123",
123144
FILES={"file": dicom_file},
@@ -133,11 +154,16 @@ def test_upload_missing_uids(dataset, monkeypatch):
133154
)
134155

135156

136-
def test_upload_appointment_not_in_progress(dicom_file, monkeypatch):
157+
def test_upload_appointment_not_in_progress(dicom_file, monkeypatch, appointment_stub):
137158
monkeypatch.setenv("API_ENABLED", "true")
138159
monkeypatch.setenv("API_AUTH_TOKEN", "testtoken")
139160

140-
with patch.object(DicomRecorder, "appointment_in_progress", return_value=False):
161+
appointment_stub.is_in_progress.return_value = False
162+
163+
with patch(
164+
"manage_breast_screening.dicom.dicom_recorder.lookup_appointment",
165+
return_value=appointment_stub,
166+
):
141167
response = client.put(
142168
"/dicom/abc123",
143169
FILES={"file": dicom_file},

manage_breast_screening/dicom/tests/test_models.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import pytest
22

3-
from manage_breast_screening.dicom.models import Study
43
from manage_breast_screening.dicom.tests.factories import (
54
ImageFactory,
65
SeriesFactory,
76
StudyFactory,
87
)
9-
from manage_breast_screening.gateway.tests.factories import GatewayActionFactory
108

119

1210
@pytest.mark.django_db
@@ -34,12 +32,6 @@ def test_study_does_not_have_series_with_multiple_images(self):
3432

3533
assert study.has_series_with_multiple_images() is False
3634

37-
def test_study_for_appointment(self):
38-
study = StudyFactory.create()
39-
action = GatewayActionFactory.create(id=study.source_message_id)
40-
41-
assert Study.for_appointment(action.appointment) == study
42-
4335

4436
@pytest.mark.django_db
4537
class TestSeries:

0 commit comments

Comments
 (0)