Skip to content

Commit 834770d

Browse files
committed
Add ReadingSessionService for assigning cases to sessions
The first method is to begin a new session and assign a case to it. If there are no available cases, raise an exception, `NoImagesToRead`. This service should ensure the following: 1. A reader can only see cases linked to their current provider 2. The same case should not be assigned more than once At Case creation time, we will ensure that there are exactly 2 cases per study (not implemented yet). To avoid race conditions, I've used `SELECT... FOR UPDATE` to get the most recent item from the Case queue. This holds a lock on the selected table, so that concurrent executions cannot select the same case in between querying the most recent case and using it to create the session. `skip_locked` means that instead of blocking on concurrent access, it will skip over the locked row[1]. [1]. https://www.postgresql.org/docs/current/sql-select.html
1 parent 709448b commit 834770d

5 files changed

Lines changed: 140 additions & 0 deletions

File tree

manage_breast_screening/clinics/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from django.db import models
1010
from django.db.models import OuterRef, Subquery
1111

12+
from manage_breast_screening.dicom.models import Case
13+
1214
from ..auth.models import Role
1315
from ..core.models import BaseModel
1416
from ..participants.models import Appointment, Participant
@@ -34,6 +36,12 @@ def participants(self):
3436
screeningepisode__appointment__clinic_slot__clinic__setting__provider=self
3537
).distinct()
3638

39+
@property
40+
def image_reading_cases(self):
41+
return Case.objects.filter(
42+
study__appointment__clinic_slot__clinic__setting__provider=self
43+
)
44+
3745
def get_config(self) -> "ProviderConfig":
3846
config, _ = ProviderConfig.objects.get_or_create(provider=self)
3947
return config

manage_breast_screening/config/settings/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,5 @@ def list_env(key):
384384
}
385385

386386
BYPASS_API_TOKEN_AUTH = boolean_env("BYPASS_API_TOKEN_AUTH", default=False)
387+
388+
READING_SESSION_DEFAULT_SIZE = 50

manage_breast_screening/reading/services/__init__.py

Whitespace-only changes.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from django.conf import settings
2+
from django.db import transaction
3+
4+
from ...dicom.models import ReadingSession, ReadingSessionItem
5+
6+
7+
class NoImagesToRead(Exception):
8+
pass
9+
10+
11+
class ReadingSessionService:
12+
def __init__(self, reader, provider):
13+
self.reader = reader
14+
self.provider = provider
15+
16+
@transaction.atomic
17+
def assign_item_to_new_session(self) -> ReadingSessionItem:
18+
queue = (
19+
self.provider.image_reading_cases.unassigned()
20+
.where_same_study_has_not_been_assigned_to_reader(self.reader)
21+
.order_by("created_at", "id")
22+
.select_for_update(skip_locked=True)
23+
)
24+
25+
case = queue.first()
26+
if case is None:
27+
raise NoImagesToRead
28+
29+
session = ReadingSession.objects.create(
30+
reader=self.reader, session_size=settings.READING_SESSION_DEFAULT_SIZE
31+
)
32+
item = session.items.create(session=session, case=case, reading_order=1)
33+
34+
return item
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from datetime import datetime
2+
from datetime import timezone as tz
3+
4+
import pytest
5+
import time_machine
6+
7+
from manage_breast_screening.dicom.models import Study
8+
from manage_breast_screening.dicom.tests.factories import (
9+
ReadingSessionItemFactory,
10+
StudyFactory,
11+
)
12+
from manage_breast_screening.reading.services.reading_session_service import (
13+
NoImagesToRead,
14+
ReadingSessionService,
15+
)
16+
17+
18+
@pytest.mark.django_db
19+
class TestSessionService:
20+
@pytest.fixture
21+
def older_study(self, current_provider):
22+
with time_machine.travel(datetime(2026, 1, 1, 9, 0, 0, tzinfo=tz.utc)):
23+
return StudyFactory.create(
24+
appointment__clinic_slot__clinic__setting__provider=current_provider,
25+
)
26+
27+
@pytest.fixture
28+
def newer_study(self, current_provider):
29+
with time_machine.travel(datetime(2026, 1, 1, 10, 0, 0, tzinfo=tz.utc)):
30+
return StudyFactory.create(
31+
appointment__clinic_slot__clinic__setting__provider=current_provider,
32+
)
33+
34+
def test_create_with_one_study(self, user, current_provider):
35+
assert Study.objects.count() == 0
36+
study = StudyFactory.create(
37+
appointment__clinic_slot__clinic__setting__provider=current_provider,
38+
)
39+
40+
item = ReadingSessionService(
41+
user, current_provider
42+
).assign_item_to_new_session()
43+
44+
assert item.study == study
45+
assert item.session.items.count() == 1
46+
47+
def test_create_with_zero_studies(self, user, current_provider):
48+
with pytest.raises(NoImagesToRead):
49+
ReadingSessionService(user, current_provider).assign_item_to_new_session()
50+
51+
def test_create_with_many_studies(
52+
self, user, current_provider, older_study, newer_study
53+
):
54+
item = ReadingSessionService(
55+
user, current_provider
56+
).assign_item_to_new_session()
57+
assert item.study == older_study
58+
59+
def test_create_excludes_studies_already_assigned_to_me(
60+
self, user, current_provider, older_study, newer_study
61+
):
62+
"""
63+
A study could be assigned to another session, in which case we should ignore it when starting a new session.
64+
"""
65+
ReadingSessionItemFactory.create(
66+
case=older_study.cases.first(), session__reader=user
67+
)
68+
69+
item = ReadingSessionService(
70+
user, current_provider
71+
).assign_item_to_new_session()
72+
assert item.study == newer_study
73+
74+
def test_create_includes_studies_with_one_case_assigned(
75+
self, user, current_provider, older_study, newer_study
76+
):
77+
ReadingSessionItemFactory(case=older_study.cases.first())
78+
79+
item = ReadingSessionService(
80+
user, current_provider
81+
).assign_item_to_new_session()
82+
assert item.study == older_study
83+
84+
def test_create_excludes_studies_with_all_cases_assigned(
85+
self, user, current_provider, older_study, newer_study
86+
):
87+
ReadingSessionItemFactory(case=older_study.cases.first())
88+
ReadingSessionItemFactory(case=older_study.cases.last())
89+
90+
item = ReadingSessionService(
91+
user, current_provider
92+
).assign_item_to_new_session()
93+
assert item.study == newer_study
94+
95+
96+
#

0 commit comments

Comments
 (0)