Skip to content

Commit 0fb0417

Browse files
authored
Merge pull request #162 from NHSDigital/DTOSS-13119-accept-worklist-create-test-item
Accept worklist.create_test_item actions and process with Modality Emulator
2 parents 093eaa5 + cfb9072 commit 0fb0417

4 files changed

Lines changed: 132 additions & 13 deletions

File tree

src/modality_emulator.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def __init__(self, mwl_storage: MWLStorage):
128128
self.mwl_storage = mwl_storage
129129
self.processed_items = set()
130130

131-
def process_worklist_items(self, ae: AE):
131+
def process_worklist_items(self, ae: AE, patient_name: str | None = None):
132132
"""
133133
Queries the MWL for items scheduled for today and sends generated DICOM files to the PACS server for each item.
134134
"""
@@ -140,7 +140,8 @@ def process_worklist_items(self, ae: AE):
140140
logger.info(f"Connected to PACS server {PACS_HOST}:{PACS_PORT} ({PACS_AET})")
141141

142142
logger.info("Querying MWL for scheduled items...")
143-
responses = mwl_assoc.send_c_find(self.c_find_dataset, query_model=ModalityWorklistInformationFind)
143+
c_find_dataset = self.c_find_dataset(patient_name=patient_name)
144+
responses = mwl_assoc.send_c_find(c_find_dataset, query_model=ModalityWorklistInformationFind)
144145
for status, ds in responses:
145146
status_code = getattr(status, "Status", SUCCESS)
146147

@@ -189,15 +190,18 @@ def process_worklist_items(self, ae: AE):
189190
mwl_assoc.release()
190191
pacs_assoc.release()
191192

192-
@property
193-
def c_find_dataset(self) -> Dataset:
193+
def c_find_dataset(self, patient_name: str | None = None) -> Dataset:
194194
date_today = datetime.date.today()
195195
ds = Dataset()
196196
sps_dataset = Dataset()
197197
sps_dataset.Modality = MODALITY
198198
sps_dataset.ScheduledProcedureStepStartDate = date_today.strftime("%Y%m%d")
199199
sps_dataset.ScheduledProcedureStepStartTime = "000000-"
200200
ds.ScheduledProcedureStepSequence = [sps_dataset]
201+
202+
if patient_name:
203+
ds.PatientName = patient_name
204+
201205
return ds
202206

203207

src/relay_listener.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,16 @@
1616

1717
from azure.identity import DefaultAzureCredential, ManagedIdentityCredential
1818
from dotenv import load_dotenv
19+
from pynetdicom import AE
20+
from pynetdicom.sop_class import (
21+
DigitalMammographyXRayImageStorageForPresentation, # type: ignore
22+
ModalityWorklistInformationFind, # type: ignore
23+
)
1924
from websockets.asyncio.client import connect
2025
from websockets.exceptions import ConnectionClosedError
2126

2227
from environment import Environment
28+
from modality_emulator import ModalityEmulator
2329
from services.mwl.create_worklist_item import CreateWorklistItem
2430
from services.mwl.update_worklist_item_status import UpdateWorklistItemStatus
2531
from services.storage import MWLStorage
@@ -94,10 +100,25 @@ def process_action(self, payload: dict):
94100
return {"status": "echo", "payload": payload}
95101
elif action_name == "worklist.create_item":
96102
return CreateWorklistItem(self.storage).call(payload)
103+
elif action_name == "worklist.create_test_item":
104+
result = CreateWorklistItem(self.storage).call(payload)
105+
patient_name = payload.get("parameters", {}).get("worklist_item", {}).get("participant", {}).get("name")
106+
107+
if not patient_name:
108+
logger.warning("No patient name provided for ModalityEmulator test item processing")
109+
return {
110+
"status": "error",
111+
"message": "No patient name provided for ModalityEmulator test item processing",
112+
}
113+
114+
self.process_with_modality_emulator(patient_name=patient_name)
115+
116+
return result
97117
elif action_name == "worklist.update_status":
98118
return UpdateWorklistItemStatus(self.storage).call(payload)
99119
else:
100-
raise ValueError(f"Unsupported action: {action_name}")
120+
logger.error("Unsupported action: %s", action_name)
121+
return {"status": "error", "message": f"Unsupported action: {action_name}"}
101122

102123
def _connect(self):
103124
"""Connect to Azure Relay."""
@@ -106,6 +127,26 @@ def _connect(self):
106127
compression=None,
107128
)
108129

130+
def process_with_modality_emulator(self, patient_name: str | None = None):
131+
"""Process worklist items with ModalityEmulator."""
132+
ae = AE(ae_title="ModalityEmulator")
133+
ae.add_requested_context(DigitalMammographyXRayImageStorageForPresentation)
134+
ae.add_requested_context(ModalityWorklistInformationFind)
135+
136+
def _run_emulator():
137+
try:
138+
ModalityEmulator(self.storage).process_worklist_items(ae, patient_name=patient_name)
139+
except Exception:
140+
logger.exception("Modality emulator processing failed")
141+
142+
try:
143+
loop = asyncio.get_running_loop()
144+
except RuntimeError:
145+
# Called outside an event loop (e.g. unit tests)
146+
_run_emulator()
147+
else:
148+
loop.create_task(asyncio.to_thread(_run_emulator))
149+
109150

110151
class RelayURI:
111152
def __init__(self):

tests/test_modality_emulator.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,36 @@ def test_process_worklist_items_returns_when_no_items(
187187
mwl_assoc.release.assert_called_once()
188188
pacs_assoc.release.assert_called_once()
189189

190+
@patch.object(ModalityEmulator, "c_find_dataset")
191+
def test_process_worklist_items_passes_patient_name_to_c_find_dataset(
192+
self,
193+
mock_c_find_dataset,
194+
success_status,
195+
):
196+
"""Process worklist items passes patient_name to c_find_dataset."""
197+
mwl_storage = MagicMock()
198+
emulator = ModalityEmulator(mwl_storage)
199+
200+
query_ds = Dataset()
201+
mock_c_find_dataset.return_value = query_ds
202+
203+
mwl_assoc = MagicMock()
204+
mwl_assoc.is_established = True
205+
mwl_assoc.send_c_find.return_value = [(success_status, None)]
206+
207+
pacs_assoc = MagicMock()
208+
pacs_assoc.is_established = True
209+
210+
ae = MagicMock()
211+
ae.associate.side_effect = [mwl_assoc, pacs_assoc]
212+
213+
emulator.process_worklist_items(ae, patient_name="Jane Doe")
214+
215+
mock_c_find_dataset.assert_called_once_with(patient_name="Jane Doe")
216+
mwl_storage.update_status.assert_not_called()
217+
mwl_assoc.release.assert_called_once()
218+
pacs_assoc.release.assert_called_once()
219+
190220
@patch("modality_emulator.time.sleep")
191221
def test_process_worklist_items_handles_failed_association(
192222
self,

tests/test_relay_listener.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,52 @@ def test_process_update_item_status_action(self, storage_instance, listener_payl
114114

115115
storage_instance.update_status.assert_called_once_with("ACC999999", "IN PROGRESS")
116116

117+
def test_process_create_test_item_action_triggers_modality_emulator(self, storage_instance, listener_payload):
118+
"""Process create test item action and trigger modality emulator."""
119+
subject = RelayListener(storage_instance)
120+
payload = dict(listener_payload)
121+
payload["action_type"] = "worklist.create_test_item"
122+
123+
with patch.object(subject, "process_with_modality_emulator") as mock_emulator:
124+
response = subject.process_action(payload)
125+
126+
assert response == {"action_id": "action-12345", "status": "created"}
127+
mock_emulator.assert_called_once_with(
128+
patient_name=payload["parameters"]["worklist_item"]["participant"]["name"]
129+
)
130+
131+
storage_instance.store_worklist_item.assert_called_once_with(
132+
WorklistItem(
133+
accession_number="ACC999999",
134+
patient_id="999123456",
135+
patient_name="SMITH^JANE",
136+
patient_birth_date="19900202",
137+
patient_sex="F",
138+
scheduled_date="20240615",
139+
scheduled_time="101500",
140+
modality="MG",
141+
study_description="MAMMOGRAPHY",
142+
source_message_id="action-12345",
143+
)
144+
)
145+
146+
def test_process_create_test_item_action_without_patient_name_returns_error(
147+
self, storage_instance, listener_payload
148+
):
149+
"""Process create test item action without a patient name returns an error."""
150+
subject = RelayListener(storage_instance)
151+
payload = dict(listener_payload)
152+
payload["action_type"] = "worklist.create_test_item"
153+
del payload["parameters"]["worklist_item"]["participant"]["name"]
154+
155+
with patch.object(subject, "process_with_modality_emulator"):
156+
response = subject.process_action(payload)
157+
158+
assert response == {
159+
"status": "error",
160+
"message": "No patient name provided for ModalityEmulator test item processing",
161+
}
162+
117163
def test_process_action_missing_keys(self, storage_instance, listener_payload):
118164
"""Process action missing keys."""
119165
subject = RelayListener(storage_instance)
@@ -132,15 +178,13 @@ def test_process_action_invalid_type(self, storage_instance, listener_payload):
132178

133179
listener_payload["action_type"] = "worklist.unknown_action"
134180

135-
with pytest.raises(ValueError):
136-
response = subject.process_action(listener_payload)
137-
assert response == {
138-
"status": "error",
139-
"action_id": "action-12345",
140-
"error": "Unknown action type: worklist.unknown_action",
141-
}
181+
response = subject.process_action(listener_payload)
182+
assert response == {
183+
"status": "error",
184+
"message": "Unsupported action: worklist.unknown_action",
185+
}
142186

143-
storage_instance.store_worklist_item.assert_not_called()
187+
storage_instance.store_worklist_item.assert_not_called()
144188

145189

146190
class TestRelayURIWithDefaultAzureCredential:

0 commit comments

Comments
 (0)