Skip to content

Commit 709448b

Browse files
committed
Introduce Case model for image reading cases
It's useful to have an entity representing a thing to be read, that exists prior to us assigning it to a reader. The models we have now don't quite fit: - A `Reading` is not created until the opinion is given - A `ReadingSessionItem` is not created until it is assigned to a reading session. - A `Study` logically has 2 cases, the first read and the second read We have to ensure that each set of images gets assigned and read exactly 2 times (prior to arbitration, which we haven't implemented yet) I think the simplest way to handle this constraint is to have a separate `Case` queue, push 2 cases into it per study, and then grab the oldest case whenever we need to assign one to a reading session. We also refer to cases in the UI. Later we might decide to give each case a type (1st read/2nd read) so we can do further filtering on the image reading dashboard. When assigning to a reading session, we will link the `ReadingSessionItem` to the `Case`. Note: there is a second constraint that a reader can only read the same study once, and only cases belonging to the current provider will be visible, so it will actually be a filtered subset of this table that is available for assigning to a reading session.
1 parent 5cfcf51 commit 709448b

5 files changed

Lines changed: 125 additions & 15 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Generated by Django 6.0.4 on 2026-04-30 12:59
2+
3+
import uuid
4+
5+
import django.db.models.deletion
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('dicom', '0009_rename_order_readingsessionitem_reading_order_and_more'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='Case',
18+
fields=[
19+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
20+
('created_at', models.DateTimeField(auto_now_add=True)),
21+
('updated_at', models.DateTimeField(auto_now=True)),
22+
('study', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cases', to='dicom.study')),
23+
],
24+
options={
25+
'abstract': False,
26+
},
27+
),
28+
migrations.RunSQL(
29+
sql='TRUNCATE TABLE dicom_readingsession CASCADE',
30+
reverse_sql='TRUNCATE table dicom_readingsession CASCADE',
31+
),
32+
migrations.AlterField(
33+
model_name='reading',
34+
name='study',
35+
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='readings', to='dicom.study'),
36+
),
37+
migrations.RemoveField(
38+
model_name='readingsessionitem',
39+
name='reading',
40+
),
41+
migrations.RemoveField(
42+
model_name='readingsessionitem',
43+
name='study',
44+
),
45+
migrations.AddField(
46+
model_name='readingsessionitem',
47+
name='case',
48+
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='reading_session_item', to='dicom.case'),
49+
preserve_default=False,
50+
)
51+
]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0009_rename_order_readingsessionitem_reading_order_and_more
1+
0010_auto_20260430_1359

manage_breast_screening/dicom/models.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.contrib.postgres.fields import ArrayField
55
from django.core.files.storage import storages
66
from django.db import models
7+
from django.db.models import Exists, OuterRef
78

89
from manage_breast_screening.core.models import BaseModel
910
from manage_breast_screening.manual_images.models import (
@@ -162,10 +163,10 @@ class BreastOpinions(models.TextChoices):
162163

163164
class Reading(BaseModel):
164165
"""
165-
One reader's opinion of a study. All of the opinions feed into the consensus read.
166+
One reader's opinion of a study.
166167
"""
167168

168-
study = models.ForeignKey(Study, on_delete=models.PROTECT, related_name="opinions")
169+
study = models.ForeignKey(Study, on_delete=models.PROTECT, related_name="readings")
169170
reader = models.ForeignKey(
170171
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="readings"
171172
)
@@ -217,9 +218,37 @@ class RecallForAssessmentDetails(BaseModel):
217218
left_breast_comment = models.CharField(null=False, blank=True, default="")
218219

219220

221+
class CaseQueryset(models.QuerySet):
222+
def unassigned(self):
223+
return self.filter(
224+
~Exists(ReadingSessionItem.objects.filter(case=OuterRef("id")))
225+
)
226+
227+
def where_same_study_has_not_been_assigned_to_reader(self, reader):
228+
return self.filter(
229+
~Exists(
230+
ReadingSessionItem.objects.filter(
231+
case__study=OuterRef("study_id"),
232+
session__reader=reader,
233+
)
234+
)
235+
)
236+
237+
238+
class Case(BaseModel):
239+
"""
240+
A first or second read that needs doing for a study. These form the queue of cases
241+
that can be picked up by an image reader.
242+
"""
243+
244+
objects = CaseQueryset.as_manager()
245+
246+
study = models.ForeignKey(Study, on_delete=models.CASCADE, related_name="cases")
247+
248+
220249
class ReadingSession(BaseModel):
221250
"""
222-
A grouping of studies that are read by a reader in a single session
251+
A grouping of cases to be read by a reader in a single session
223252
"""
224253

225254
reader = models.ForeignKey(
@@ -232,22 +261,20 @@ class ReadingSession(BaseModel):
232261

233262
class ReadingSessionItem(BaseModel):
234263
"""
235-
Assigns a study to a particular reading session, with an ordering.
264+
An assignment of a pending reading to a reading session
236265
"""
237266

238267
session = models.ForeignKey(
239268
ReadingSession, on_delete=models.CASCADE, related_name="items"
240269
)
241-
study = models.ForeignKey(
242-
Study, on_delete=models.PROTECT, related_name="reading_session_items"
270+
case = models.OneToOneField(
271+
Case, on_delete=models.PROTECT, related_name="reading_session_item"
243272
)
244273
reading_order = models.IntegerField()
245-
reading = models.OneToOneField(
246-
Reading,
247-
on_delete=models.PROTECT,
248-
related_name="reading_session_item",
249-
null=True,
250-
)
251274

252275
class Meta:
253276
unique_together = [("session", "reading_order")]
277+
278+
@property
279+
def study(self):
280+
return self.case.study

manage_breast_screening/dicom/tests/factories.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
class StudyFactory(DjangoModelFactory):
1717
class Meta:
1818
model = models.Study
19+
skip_postgeneration_save = True
1920

2021
study_instance_uid = Sequence(lambda n: f"STUDY{n:04d}")
2122
source_message_id = uuid.uuid4()
@@ -26,6 +27,15 @@ class Meta:
2627
AppointmentFactory, current_status=AppointmentStatusNames.SCREENED
2728
)
2829

30+
case_1 = RelatedFactory(
31+
"manage_breast_screening.dicom.tests.factories.CaseFactory",
32+
factory_related_name="study",
33+
)
34+
case_2 = RelatedFactory(
35+
"manage_breast_screening.dicom.tests.factories.CaseFactory",
36+
factory_related_name="study",
37+
)
38+
2939

3040
class StudyWithImagesFactory(StudyFactory):
3141
class Meta:
@@ -106,11 +116,22 @@ class Params:
106116
)
107117

108118

119+
class CaseFactory(DjangoModelFactory):
120+
class Meta:
121+
model = models.Case
122+
123+
study = SubFactory(StudyFactory)
124+
125+
109126
class ReadingSessionItemFactory(DjangoModelFactory):
110127
class Meta:
111128
model = models.ReadingSessionItem
129+
skip_postgeneration_save = True
112130

113-
study = SubFactory(StudyFactory)
131+
case = SubFactory(CaseFactory)
132+
session = SubFactory(
133+
"manage_breast_screening.dicom.tests.factories.ReadingSessionFactory"
134+
)
114135
reading_order = Sequence(lambda i: i)
115136

116137

manage_breast_screening/nonprod/management/commands/seed_demo_data.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
SettingFactory,
1717
UserAssignmentFactory,
1818
)
19+
from manage_breast_screening.dicom.models import Case
1920
from manage_breast_screening.dicom.tests.factories import (
2021
ImageFactory as DicomImageFactory,
2122
)
@@ -403,7 +404,17 @@ def create_reading_session(self, session_key):
403404
else:
404405
reading = None
405406

406-
ReadingSessionItemFactory(session=session, reading=reading, **item)
407+
study_id = item.pop("study_id")
408+
case = Case.objects.unassigned().filter(study_id=study_id).first()
409+
410+
ReadingSessionItemFactory(
411+
session=session,
412+
case=case,
413+
**item,
414+
)
415+
416+
case.reading = reading
417+
case.save()
407418

408419
def create_reading(self, reader, reading_key):
409420
retake_requests = reading_key.pop("retake_requests", [])

0 commit comments

Comments
 (0)