Skip to content

Commit 070aaaa

Browse files
Make MWLStorage#update_status smarter
Such that it only performs valid status transitions
1 parent d38fcef commit 070aaaa

7 files changed

Lines changed: 60 additions & 51 deletions

File tree

src/services/dicom/c_store.py

Lines changed: 2 additions & 1 deletion
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__)
@@ -102,7 +103,7 @@ def _mark_in_progress(self, accession_number: str) -> None:
102103
if not self.mwl_storage or not accession_number:
103104
return
104105
try:
105-
self.mwl_storage.mark_in_progress(accession_number)
106+
self.mwl_storage.update_status(accession_number, MWLStatus.IN_PROGRESS.value)
106107
except Exception as e:
107108
logger.error(f"Failed to mark worklist item in progress: {e}", exc_info=True)
108109

src/services/storage.py

Lines changed: 28 additions & 17 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,31 +498,21 @@ 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

494-
def mark_in_progress(self, accession_number: str) -> bool:
495-
"""
496-
Transition worklist item from SCHEDULED to IN PROGRESS.
497-
498-
Returns True if the status was updated, False if not found or already past SCHEDULED.
499-
"""
500-
item = self.get_worklist_item(accession_number)
501-
if item is None or item.status != "SCHEDULED":
502-
return False
503-
return self.update_status(accession_number, "IN PROGRESS") is not None
504-
505516
def update_study_instance_uid(self, accession_number: str, study_instance_uid: str) -> bool:
506517
"""
507518
Update the study instance UID for a worklist item.

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_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: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,26 +124,26 @@ 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_mark_in_progress_called_on_success(self, mock_storage, mock_event):
127+
def test_worklist_marked_in_progress_on_success(self, mock_storage, mock_event):
128128
mock_mwl = Mock(spec=MWLStorage)
129129
subject = CStore(mock_storage, mwl_storage=mock_mwl)
130130

131131
assert subject.call(mock_event) == SUCCESS
132132

133-
mock_mwl.mark_in_progress.assert_called_once_with("ABC123")
133+
mock_mwl.update_status.assert_called_once_with("ABC123", "IN PROGRESS")
134134

135-
def test_mark_in_progress_not_called_on_failure(self, mock_storage, mock_event):
135+
def test_worklist_not_updated_on_store_failure(self, mock_storage, mock_event):
136136
mock_storage.store_instance.side_effect = Exception("store failed")
137137
mock_mwl = Mock(spec=MWLStorage)
138138
subject = CStore(mock_storage, mwl_storage=mock_mwl)
139139

140140
assert subject.call(mock_event) == FAILURE
141141

142-
mock_mwl.mark_in_progress.assert_not_called()
142+
mock_mwl.update_status.assert_not_called()
143143

144-
def test_mark_in_progress_error_does_not_fail_store(self, mock_storage, mock_event):
144+
def test_worklist_update_error_does_not_fail_store(self, mock_storage, mock_event):
145145
mock_mwl = Mock(spec=MWLStorage)
146-
mock_mwl.mark_in_progress.side_effect = Exception("db error")
146+
mock_mwl.update_status.side_effect = Exception("db error")
147147
subject = CStore(mock_storage, mwl_storage=mock_mwl)
148148

149149
assert subject.call(mock_event) == SUCCESS

tests/services/test_storage.py

Lines changed: 24 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,20 +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
235-
236236
assert mwl_storage.get_worklist_item(item.accession_number).status == "COMPLETED"
237237

238-
def test_update_status_with_no_update(self, mwl_storage):
239-
result = mwl_storage.update_status("DOES_NOT_EXIST", "COMPLETED")
240-
241-
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
242240

243241
def test_update_status_with_mpps(self, mwl_storage, result):
244242
item = self._insert_item(mwl_storage, result)
243+
mwl_storage.update_status(item.accession_number, "IN PROGRESS")
245244

246245
returned = mwl_storage.update_status(
247246
item.accession_number,
@@ -250,14 +249,19 @@ def test_update_status_with_mpps(self, mwl_storage, result):
250249
)
251250

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

254-
with mwl_storage._get_connection() as conn:
255-
row = conn.execute(
256-
"SELECT mpps_instance_uid FROM worklist_items WHERE accession_number = ?",
257-
(item.accession_number,),
258-
).fetchone()
254+
def test_update_status_raises_on_invalid_target(self, mwl_storage, result):
255+
item = self._insert_item(mwl_storage, result)
259256

260-
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
261265

262266
def test_update_study_instance_uid(self, mwl_storage, result):
263267
item = self._insert_item(mwl_storage, result)
@@ -288,6 +292,7 @@ def test_delete_worklist_item_raises(self, mwl_storage):
288292
def test_mpps_instance_exists(self, mwl_storage, result):
289293
uid = generate_uid()
290294
item = self._insert_item(mwl_storage, result)
295+
mwl_storage.update_status(item.accession_number, "IN PROGRESS")
291296

292297
mwl_storage.update_status(item.accession_number, "COMPLETED", mpps_instance_uid=uid)
293298

@@ -299,6 +304,7 @@ def test_mpps_instance_not_exists(self, mwl_storage):
299304
def test_get_worklist_item_by_mpps_instance_uid(self, mwl_storage, result):
300305
uid = generate_uid()
301306
item = self._insert_item(mwl_storage, result)
307+
mwl_storage.update_status(item.accession_number, "IN PROGRESS")
302308

303309
mwl_storage.update_status(item.accession_number, "COMPLETED", mpps_instance_uid=uid)
304310

@@ -313,18 +319,17 @@ def test_get_worklist_item_by_mpps_instance_uid(self, mwl_storage, result):
313319
def test_get_worklist_item_by_mpps_instance_uid_returns_none(self, mwl_storage):
314320
assert mwl_storage.get_worklist_item_by_mpps_instance_uid("nope") is None
315321

316-
def test_mark_in_progress(self, mwl_storage, result):
317-
item = self._insert_item(mwl_storage, result) # inserted with status='SCHEDULED'
322+
def test_update_status_scheduled_to_in_progress(self, mwl_storage, result):
323+
item = self._insert_item(mwl_storage, result)
318324

319-
assert mwl_storage.mark_in_progress(item.accession_number) is True
325+
mwl_storage.update_status(item.accession_number, "IN PROGRESS")
320326

321327
assert mwl_storage.get_worklist_item(item.accession_number).status == "IN PROGRESS"
322328

323-
def test_mark_in_progress_is_idempotent(self, mwl_storage, result):
329+
def test_update_status_in_progress_to_discontinued(self, mwl_storage, result):
324330
item = self._insert_item(mwl_storage, result)
325331
mwl_storage.update_status(item.accession_number, "IN PROGRESS")
326332

327-
assert mwl_storage.mark_in_progress(item.accession_number) is False
333+
mwl_storage.update_status(item.accession_number, "DISCONTINUED")
328334

329-
def test_mark_in_progress_returns_false_when_not_found(self, mwl_storage):
330-
assert mwl_storage.mark_in_progress("DOES_NOT_EXIST") is False
335+
assert mwl_storage.get_worklist_item(item.accession_number).status == "DISCONTINUED"

0 commit comments

Comments
 (0)