Skip to content

Commit b5a1663

Browse files
On successful C-STORE, transition worklist item to IN_PROGRESS.
On successful C-STORE, transition worklist item to IN_PROGRESS. In the absence (for now) of MPPS N-CREATE and N-SET messages, manage appointment state with worklist item status. Status is set to "IN PROGRESS" on a successful C-STORE command for the corresponding accession_number.
1 parent e7bd065 commit b5a1663

8 files changed

Lines changed: 120 additions & 34 deletions

File tree

src/services/dicom/c_store.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from services.dicom.image_compressor import ImageCompressor
99
from services.dicom.validation_failure_notifier import ValidationFailureNotifier
1010
from services.dicom.validator import DicomValidationError, DicomValidator
11+
from services.mwl import MWLStatus
1112
from services.storage import InstanceExistsError, MWLStorage, PACSStorage
1213

1314
logger = logging.getLogger(__name__)
@@ -79,6 +80,7 @@ def call(self, event: Event) -> int:
7980
},
8081
event.assoc.requestor.ae_title,
8182
)
83+
self._mark_in_progress(accession_number)
8284
return SUCCESS
8385

8486
except InstanceExistsError:
@@ -97,6 +99,14 @@ def dataset_to_bytes(self, ds: Dataset) -> bytes:
9799
buffer.seek(0)
98100
return buffer.read()
99101

102+
def _mark_in_progress(self, accession_number: str) -> None:
103+
if not self.mwl_storage or not accession_number:
104+
return
105+
try:
106+
self.mwl_storage.update_status(accession_number, MWLStatus.IN_PROGRESS.value)
107+
except Exception as e:
108+
logger.error(f"Failed to mark worklist item in progress: {e}", exc_info=True)
109+
100110
def _notify_failure(self, accession_number: str, error: str) -> None:
101111
if not self.mwl_storage or not self.notifier:
102112
return

src/services/storage.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Dict, List, Optional
88

99
from models import WorklistItem
10+
from services.mwl import MWLStatus
1011

1112
logger = logging.getLogger(__name__)
1213

@@ -284,13 +285,25 @@ class WorklistItemNotFoundError(Exception):
284285
pass
285286

286287

288+
class InvalidStatusTransitionError(Exception):
289+
"""Raised when a requested status transition is not permitted."""
290+
291+
pass
292+
293+
287294
class DuplicateWorklistItemError(Exception):
288295
"""Raised when a worklist item with the same accession number already exists."""
289296

290297
pass
291298

292299

293300
class MWLStorage(Storage):
301+
_STATUS_TRANSITIONS: dict[MWLStatus, MWLStatus] = {
302+
MWLStatus.IN_PROGRESS: MWLStatus.SCHEDULED,
303+
MWLStatus.COMPLETED: MWLStatus.IN_PROGRESS,
304+
MWLStatus.DISCONTINUED: MWLStatus.IN_PROGRESS,
305+
}
306+
294307
def __init__(self, db_path: str = "/var/lib/pacs/worklist.db"):
295308
"""
296309
Initialize Worklist storage.
@@ -459,16 +472,24 @@ def update_status(
459472
self, accession_number: str, status: str, mpps_instance_uid: Optional[str] = None
460473
) -> Optional[str]:
461474
"""
462-
Update the status of a worklist item.
475+
Transition a worklist item to a new status, enforcing valid state transitions.
463476
464477
Args:
465478
accession_number: The accession number to update
466-
status: New status (SCHEDULED, IN PROGRESS, COMPLETED, DISCONTINUED)
479+
status: Target status
467480
mpps_instance_uid: Optional MPPS instance UID
468481
469482
Returns:
470483
source_message_id if item was updated, None if not found
484+
485+
Raises:
486+
InvalidStatusTransitionError: If the transition is not permitted
471487
"""
488+
target = MWLStatus(status)
489+
if target not in self._STATUS_TRANSITIONS:
490+
raise InvalidStatusTransitionError(f"Cannot transition to '{status}'")
491+
from_status = self._STATUS_TRANSITIONS[target]
492+
472493
with self._get_connection() as conn:
473494
cursor = conn.execute(
474495
"""
@@ -477,18 +498,19 @@ def update_status(
477498
mpps_instance_uid = COALESCE(?, mpps_instance_uid),
478499
updated_at = CURRENT_TIMESTAMP
479500
WHERE accession_number = ?
480-
""",
481-
(status, mpps_instance_uid, accession_number),
501+
AND status = ?
502+
""",
503+
(status, mpps_instance_uid, accession_number, from_status.value),
482504
)
483505
conn.commit()
484506

485507
if cursor.rowcount == 0:
486508
return None
487509

488510
result = conn.execute(
489-
"SELECT source_message_id FROM worklist_items WHERE accession_number = ?", (accession_number,)
511+
"SELECT source_message_id FROM worklist_items WHERE accession_number = ?",
512+
(accession_number,),
490513
).fetchone()
491-
492514
return result["source_message_id"] if result is not None else None
493515

494516
def update_study_instance_uid(self, accession_number: str, study_instance_uid: str) -> bool:

tests/integration/test_c_find_returns_worklist_items.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,6 @@ def test_cfind_filters_by_modality(self, event, storage):
265265
source_message_id="MSGID123456",
266266
)
267267
)
268-
storage.update_status("ACC234567", "SCHEDULED")
269-
270268
event.identifier.ScheduledProcedureStepSequence[0].Modality = "MG"
271269

272270
results = list(CFind(storage).call(event))

tests/integration/test_c_store_saves_metadata.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
DigitalMammographyXRayImageStorageForProcessing,
1010
)
1111

12+
from models import WorklistItem
1213
from services.dicom.c_store import SUCCESS, CStore
13-
from services.storage import PACSStorage
14+
from services.storage import MWLStorage, PACSStorage
1415

1516

1617
@pytest.mark.integration
@@ -36,6 +37,10 @@ def mock_event(self):
3637
def storage(self, tmp_dir):
3738
return PACSStorage(f"{tmp_dir}/test.db", tmp_dir)
3839

40+
@pytest.fixture
41+
def mwl_storage(self, tmp_dir):
42+
return MWLStorage(f"{tmp_dir}/worklist.db")
43+
3944
def test_existing_sop_instance_uid(self, storage, mock_event):
4045
sop_instance_uid = "1.2.3.4.5.6" # gitleaks:allow
4146
subject = CStore(storage)
@@ -85,6 +90,24 @@ def test_valid_event_is_stored(self, storage, mock_event):
8590
assert storage_path == "ff/af/ffaff041ab509297.dcm"
8691
assert Path(f"{storage.storage_root}/{storage_path}").is_file()
8792

93+
def test_c_store_marks_worklist_in_progress(self, storage, mwl_storage, mock_event):
94+
item = WorklistItem(
95+
accession_number="ABC123",
96+
modality="MG",
97+
patient_birth_date="19800101",
98+
patient_id="9990001112",
99+
patient_name="JANE^SMITH",
100+
scheduled_date="20240101",
101+
scheduled_time="090000",
102+
)
103+
mwl_storage.store_worklist_item(item)
104+
105+
subject = CStore(storage, mwl_storage=mwl_storage)
106+
assert subject.call(mock_event) == SUCCESS
107+
108+
fetched = mwl_storage.get_worklist_item("ABC123")
109+
assert fetched.status == "IN PROGRESS"
110+
88111
def test_compressed_image_stored_on_filesystem(self, storage, dataset_with_pixels):
89112
"""Verify compressed images are stored with JPEG 2000 transfer syntax."""
90113
# Customize the shared dataset for this test

tests/integration/test_end_to_end_relay_to_upload.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,6 @@ async def test_full_flow_relay_to_upload(
134134
assert worklist_items[0].accession_number == TEST_ACCESSION_NUMBER
135135
assert worklist_items[0].patient_id == TEST_PATIENT_ID
136136

137-
# Update status to SCHEDULED so it appears in C-FIND results
138-
mwl_storage.update_status(TEST_ACCESSION_NUMBER, "SCHEDULED")
139-
140137
# ===== STEP 2: Query worklist via C-FIND =====
141138
mwl_server.start()
142139
try:

tests/integration/test_request_cfind_on_worklist.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ def with_pacs_server(self, tmp_dir):
3131
source_message_id="MSGID123456",
3232
)
3333
)
34-
storage.update_status("ACC123456", "SCHEDULED")
3534
storage.store_worklist_item(
3635
WorklistItem(
3736
accession_number="ACC234567",
@@ -48,8 +47,6 @@ def with_pacs_server(self, tmp_dir):
4847
source_message_id="MSGID234567",
4948
)
5049
)
51-
storage.update_status("ACC234567", "SCHEDULED")
52-
5350
server.start()
5451

5552
yield

tests/services/dicom/test_c_store.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,30 @@ def test_validation_failure_notifies_manage(self, mock_storage, mock_event):
124124
mock_notifier.notify.assert_called_once_with("action-uuid-123", "DICOM validation failed: Missing required tag")
125125
mock_mwl.get_source_message_id.assert_called_once_with("ABC123")
126126

127+
def test_worklist_marked_in_progress_on_success(self, mock_storage, mock_event):
128+
mock_mwl = Mock(spec=MWLStorage)
129+
subject = CStore(mock_storage, mwl_storage=mock_mwl)
130+
131+
assert subject.call(mock_event) == SUCCESS
132+
133+
mock_mwl.update_status.assert_called_once_with("ABC123", "IN PROGRESS")
134+
135+
def test_worklist_not_updated_on_store_failure(self, mock_storage, mock_event):
136+
mock_storage.store_instance.side_effect = Exception("store failed")
137+
mock_mwl = Mock(spec=MWLStorage)
138+
subject = CStore(mock_storage, mwl_storage=mock_mwl)
139+
140+
assert subject.call(mock_event) == FAILURE
141+
142+
mock_mwl.update_status.assert_not_called()
143+
144+
def test_worklist_update_error_does_not_fail_store(self, mock_storage, mock_event):
145+
mock_mwl = Mock(spec=MWLStorage)
146+
mock_mwl.update_status.side_effect = Exception("db error")
147+
subject = CStore(mock_storage, mwl_storage=mock_mwl)
148+
149+
assert subject.call(mock_event) == SUCCESS
150+
127151
def test_validation_failure_accession_not_in_mwl(self, mock_storage, mock_event):
128152
"""When accession is not in MWL, validation failure returns FAILURE without calling notify."""
129153
mock_validator = Mock(spec=DicomValidator)

tests/services/test_storage.py

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pydicom.uid import generate_uid
77

88
from models import WorklistItem
9-
from services.storage import MWLStorage, PACSStorage, WorklistItemNotFoundError
9+
from services.storage import InvalidStatusTransitionError, MWLStorage, PACSStorage, WorklistItemNotFoundError
1010

1111

1212
@pytest.fixture
@@ -228,26 +228,19 @@ def test_get_worklist_item_returns_none(self, mwl_storage):
228228

229229
def test_update_status(self, mwl_storage, result):
230230
item = self._insert_item(mwl_storage, result)
231+
mwl_storage.update_status(item.accession_number, "IN PROGRESS")
231232

232233
returned = mwl_storage.update_status(item.accession_number, "COMPLETED")
233234

234235
assert returned == item.source_message_id
236+
assert mwl_storage.get_worklist_item(item.accession_number).status == "COMPLETED"
235237

236-
with mwl_storage._get_connection() as conn:
237-
row = conn.execute(
238-
"SELECT status FROM worklist_items WHERE accession_number = ?",
239-
(item.accession_number,),
240-
).fetchone()
241-
242-
assert row["status"] == "COMPLETED"
243-
244-
def test_update_status_with_no_update(self, mwl_storage):
245-
result = mwl_storage.update_status("DOES_NOT_EXIST", "COMPLETED")
246-
247-
assert result is None
238+
def test_update_status_returns_none_when_not_found(self, mwl_storage):
239+
assert mwl_storage.update_status("DOES_NOT_EXIST", "IN PROGRESS") is None
248240

249241
def test_update_status_with_mpps(self, mwl_storage, result):
250242
item = self._insert_item(mwl_storage, result)
243+
mwl_storage.update_status(item.accession_number, "IN PROGRESS")
251244

252245
returned = mwl_storage.update_status(
253246
item.accession_number,
@@ -256,14 +249,19 @@ def test_update_status_with_mpps(self, mwl_storage, result):
256249
)
257250

258251
assert returned == item.source_message_id
252+
assert mwl_storage.get_worklist_item(item.accession_number).mpps_instance_uid == "some-uid"
259253

260-
with mwl_storage._get_connection() as conn:
261-
row = conn.execute(
262-
"SELECT mpps_instance_uid FROM worklist_items WHERE accession_number = ?",
263-
(item.accession_number,),
264-
).fetchone()
254+
def test_update_status_raises_on_invalid_target(self, mwl_storage, result):
255+
item = self._insert_item(mwl_storage, result)
265256

266-
assert row["mpps_instance_uid"] == "some-uid"
257+
with pytest.raises(InvalidStatusTransitionError):
258+
mwl_storage.update_status(item.accession_number, "SCHEDULED") # SCHEDULED is never a valid target
259+
260+
def test_update_status_returns_none_on_wrong_state(self, mwl_storage, result):
261+
item = self._insert_item(mwl_storage, result)
262+
mwl_storage.update_status(item.accession_number, "IN PROGRESS")
263+
264+
assert mwl_storage.update_status(item.accession_number, "IN PROGRESS") is None
267265

268266
def test_update_study_instance_uid(self, mwl_storage, result):
269267
item = self._insert_item(mwl_storage, result)
@@ -294,6 +292,7 @@ def test_delete_worklist_item_raises(self, mwl_storage):
294292
def test_mpps_instance_exists(self, mwl_storage, result):
295293
uid = generate_uid()
296294
item = self._insert_item(mwl_storage, result)
295+
mwl_storage.update_status(item.accession_number, "IN PROGRESS")
297296

298297
mwl_storage.update_status(item.accession_number, "COMPLETED", mpps_instance_uid=uid)
299298

@@ -305,6 +304,7 @@ def test_mpps_instance_not_exists(self, mwl_storage):
305304
def test_get_worklist_item_by_mpps_instance_uid(self, mwl_storage, result):
306305
uid = generate_uid()
307306
item = self._insert_item(mwl_storage, result)
307+
mwl_storage.update_status(item.accession_number, "IN PROGRESS")
308308

309309
mwl_storage.update_status(item.accession_number, "COMPLETED", mpps_instance_uid=uid)
310310

@@ -318,3 +318,18 @@ def test_get_worklist_item_by_mpps_instance_uid(self, mwl_storage, result):
318318

319319
def test_get_worklist_item_by_mpps_instance_uid_returns_none(self, mwl_storage):
320320
assert mwl_storage.get_worklist_item_by_mpps_instance_uid("nope") is None
321+
322+
def test_update_status_scheduled_to_in_progress(self, mwl_storage, result):
323+
item = self._insert_item(mwl_storage, result)
324+
325+
mwl_storage.update_status(item.accession_number, "IN PROGRESS")
326+
327+
assert mwl_storage.get_worklist_item(item.accession_number).status == "IN PROGRESS"
328+
329+
def test_update_status_in_progress_to_discontinued(self, mwl_storage, result):
330+
item = self._insert_item(mwl_storage, result)
331+
mwl_storage.update_status(item.accession_number, "IN PROGRESS")
332+
333+
mwl_storage.update_status(item.accession_number, "DISCONTINUED")
334+
335+
assert mwl_storage.get_worklist_item(item.accession_number).status == "DISCONTINUED"

0 commit comments

Comments
 (0)