Skip to content

Commit caa6d4b

Browse files
authored
Merge pull request #19 from NHSDigital/feat/implement-n-create
Implement N-CREATE and N-SET
2 parents 00e7dd1 + eb2be7c commit caa6d4b

14 files changed

Lines changed: 631 additions & 10 deletions

compose.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ services:
7979
- pacs-storage:/var/lib/pacs/storage
8080
- pacs-db:/var/lib/pacs
8181
environment:
82-
- CLOUD_API_ENDPOINT=${CLOUD_API_ENDPOINT:-http://host.docker.internal:8000/api/dicom/upload/}
82+
- CLOUD_API_ENDPOINT=${CLOUD_API_ENDPOINT:-http://host.docker.internal:8000/api/v1/dicom}
83+
- CLOUD_API_TOKEN=${CLOUD_API_TOKEN:-testtoken}
8384
- PACS_DB_PATH=/var/lib/pacs/pacs.db
8485
- PACS_STORAGE_PATH=/var/lib/pacs/storage
8586
- MWL_DB_PATH=/var/lib/pacs/worklist.db

src/server.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,16 @@
88
import logging
99

1010
from pynetdicom import AE, StoragePresentationContexts, evt
11-
from pynetdicom.sop_class import ModalityWorklistInformationFind # type: ignore[attr-defined]
11+
from pynetdicom.sop_class import (
12+
ModalityPerformedProcedureStep, # type: ignore[attr-defined]
13+
ModalityWorklistInformationFind, # type: ignore[attr-defined]
14+
)
1215

1316
from services.dicom.c_echo import CEcho
1417
from services.dicom.c_store import CStore
1518
from services.mwl.c_find import CFindHandler
19+
from services.mwl.n_create import NCreate
20+
from services.mwl.n_set import NSet
1621
from services.storage import MWLStorage, PACSStorage
1722

1823
logger = logging.getLogger(__name__)
@@ -98,8 +103,13 @@ def start(self):
98103

99104
self.ae = AE(ae_title=self.ae_title)
100105
self.ae.add_supported_context(ModalityWorklistInformationFind)
106+
self.ae.add_supported_context(ModalityPerformedProcedureStep)
101107

102-
handlers = [(evt.EVT_C_FIND, CFindHandler(self.storage).call)]
108+
handlers = [
109+
(evt.EVT_C_FIND, CFindHandler(self.storage).call),
110+
(evt.EVT_N_CREATE, NCreate(self.storage).call),
111+
(evt.EVT_N_SET, NSet(self.storage).call),
112+
]
103113

104114
logger.info(f"MWL server listening on 0.0.0.0:{self.port}")
105115
logger.info(f"Database: {self.storage.db_path}")

src/services/dicom/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@
44
SUCCESS = 0x0000
55
FAILURE = 0xC000
66
PENDING = 0xFF00
7+
INVALID_ATTRIBUTE = 0x0106
8+
DUPLICATE_SOP_INSTANCE = 0x0111
9+
UNKNOWN_SOP_INSTANCE = 0x0112
10+
MISSING_ATTRIBUTE = 0x0120
11+
PROCESSING_FAILURE = 0x0110

src/services/init_worklist_db.sql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS worklist_items (
2626
procedure_code TEXT,
2727

2828
-- Status tracking
29-
status TEXT DEFAULT 'SCHEDULED' CHECK(status IN ('SCHEDULED', 'IN_PROGRESS', 'COMPLETED', 'DISCONTINUED')),
29+
status TEXT DEFAULT 'SCHEDULED' CHECK(status IN ('SCHEDULED', 'IN PROGRESS', 'COMPLETED', 'DISCONTINUED')),
3030

3131
-- DICOM identifiers
3232
study_instance_uid TEXT,

src/services/mwl/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
"""Modality Worklist (MWL) services for DICOM worklist management."""
22

3-
from services.mwl.c_find import CFindHandler
3+
from enum import Enum
44

5-
__all__ = ["CFindHandler"]
5+
6+
class MWLStatus(Enum):
7+
SCHEDULED = "SCHEDULED"
8+
IN_PROGRESS = "IN PROGRESS"
9+
COMPLETED = "COMPLETED"
10+
DISCONTINUED = "DISCONTINUED"

src/services/mwl/n_create.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import logging
2+
3+
from pydicom.dataset import Dataset
4+
from pynetdicom.events import Event
5+
from pynetdicom.sop_class import ModalityPerformedProcedureStep # pyright: ignore[reportAttributeAccessIssue]
6+
7+
from services.dicom import DUPLICATE_SOP_INSTANCE, INVALID_ATTRIBUTE, MISSING_ATTRIBUTE, PROCESSING_FAILURE, SUCCESS
8+
from services.mwl import MWLStatus
9+
from services.storage import MWLStorage
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class NCreate:
15+
def __init__(self, storage: MWLStorage):
16+
self.storage = storage
17+
18+
def call(self, event: Event) -> tuple[int, Dataset | None]:
19+
"""Handle N-CREATE request for MPPS (start of procedure)."""
20+
ds = Dataset()
21+
try:
22+
req = event.request
23+
24+
affected_sop_instance_uid = getattr(req, "AffectedSOPInstanceUID", None)
25+
26+
if affected_sop_instance_uid is None:
27+
return INVALID_ATTRIBUTE, None
28+
29+
if self.storage.mpps_instance_exists(affected_sop_instance_uid):
30+
return DUPLICATE_SOP_INSTANCE, None
31+
32+
attr_list = event.attribute_list
33+
status = getattr(attr_list, "PerformedProcedureStepStatus", None)
34+
35+
if not status:
36+
logger.warning("MPPS N-CREATE: Missing PerformedProcedureStepStatus in request")
37+
return MISSING_ATTRIBUTE, None
38+
39+
if status.upper() != MWLStatus.IN_PROGRESS.value:
40+
logger.warning("MPPS N-CREATE: Invalid PerformedProcedureStepStatus value: %s", status)
41+
return INVALID_ATTRIBUTE, None
42+
43+
ds.SOPClassUID = ModalityPerformedProcedureStep
44+
ds.SOPInstanceUID = affected_sop_instance_uid
45+
ds.update(attr_list)
46+
47+
scheduled_step_sequence = getattr(attr_list, "ScheduledStepAttributesSequence", [])
48+
if len(scheduled_step_sequence) == 0:
49+
logger.warning("MPPS N-CREATE: Missing ScheduledStepAttributesSequence in request")
50+
return MISSING_ATTRIBUTE, None
51+
52+
sps = attr_list.ScheduledStepAttributesSequence[0]
53+
accession_number = sps.get("AccessionNumber")
54+
55+
logger.info("MPPS N-CREATE: Started procedure for Accession Number: %s", accession_number)
56+
57+
if not accession_number:
58+
logger.warning("MPPS N-CREATE: Missing Accession Number in ScheduledStepAttributesSequence")
59+
return MISSING_ATTRIBUTE, None
60+
61+
source_message_id = self.storage.update_status(
62+
accession_number, MWLStatus.IN_PROGRESS.value, ds.SOPInstanceUID
63+
)
64+
if source_message_id:
65+
logger.info("Worklist item updated: %s -> %s", accession_number, MWLStatus.IN_PROGRESS.value)
66+
else:
67+
logger.warning("Could not find accession %s in database", accession_number)
68+
69+
except Exception as e:
70+
logger.error("Error in handle_create: %s", str(e), exc_info=True)
71+
return PROCESSING_FAILURE, None
72+
73+
# Success - return the created dataset
74+
logger.info("MPPS N-CREATE successful, returning dataset")
75+
return SUCCESS, ds

src/services/mwl/n_set.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import logging
2+
3+
from pydicom import Dataset
4+
from pynetdicom.events import Event
5+
from pynetdicom.sop_class import ModalityPerformedProcedureStep # pyright: ignore[reportAttributeAccessIssue]
6+
7+
from services.dicom import INVALID_ATTRIBUTE, MISSING_ATTRIBUTE, PROCESSING_FAILURE, SUCCESS, UNKNOWN_SOP_INSTANCE
8+
from services.mwl import MWLStatus
9+
from services.storage import MWLStorage
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class NSet:
15+
def __init__(self, storage: MWLStorage):
16+
self.storage = storage
17+
18+
def call(self, event: Event) -> tuple[int, Dataset | None]:
19+
try:
20+
req = event.request
21+
requested_sop_instance_uid = getattr(req, "RequestedSOPInstanceUID", None)
22+
logger.info("MPPS N-SET: Received request for SOP Instance UID: %s", requested_sop_instance_uid)
23+
24+
mod_list = event.attribute_list
25+
status = mod_list.get("PerformedProcedureStepStatus")
26+
if not status:
27+
logger.warning("MPPS N-SET: Missing PerformedProcedureStepStatus in request")
28+
return MISSING_ATTRIBUTE, None
29+
30+
if status not in [MWLStatus.COMPLETED.value, MWLStatus.DISCONTINUED.value]:
31+
logger.warning("MPPS N-SET: Invalid PerformedProcedureStepStatus: %s", status)
32+
return INVALID_ATTRIBUTE, None
33+
34+
worklist_item = self.storage.get_worklist_item_by_mpps_instance_uid(requested_sop_instance_uid)
35+
if not worklist_item:
36+
logger.warning(
37+
"MPPS N-SET: No worklist item found for SOP Instance UID: %s", requested_sop_instance_uid
38+
)
39+
return UNKNOWN_SOP_INSTANCE, None
40+
41+
accession_number = worklist_item.accession_number
42+
43+
source_message_id = self.storage.update_status(accession_number, status)
44+
if source_message_id:
45+
logger.info("Database updated: %s -> %s", accession_number, status)
46+
47+
ds = Dataset()
48+
ds.SOPClassUID = ModalityPerformedProcedureStep
49+
ds.SOPInstanceUID = requested_sop_instance_uid
50+
ds.update(mod_list)
51+
52+
logger.info("MPPS N-SET successful")
53+
return SUCCESS, ds
54+
else:
55+
logger.warning("MPPS N-SET: Failed to update database with new status")
56+
return PROCESSING_FAILURE, None
57+
except Exception as e:
58+
logger.error("Error in handle_set: %s", str(e), exc_info=True)
59+
return PROCESSING_FAILURE, None

src/services/storage.py

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

10+
from services.mwl import MWLStatus
11+
1012
logger = logging.getLogger(__name__)
1113

1214

@@ -288,7 +290,7 @@ class WorklistItem:
288290
patient_name: str = field(doc="Name of the patient. Lastname^Firstname.")
289291
scheduled_date: str = field(doc="Date the procedure is scheduled for.")
290292
scheduled_time: str = field(doc="Time the procedure is scheduled for.")
291-
status: str = field(doc="Status of the worklist item", default="SCHEDULED")
293+
status: str = field(doc="Status of the worklist item", default=MWLStatus.SCHEDULED.value)
292294
source_message_id: Optional[str] = field(
293295
default=None, doc="Message ID from system which created this worklist item", hash=True
294296
)
@@ -429,7 +431,7 @@ def update_status(
429431
430432
Args:
431433
accession_number: The accession number to update
432-
status: New status (SCHEDULED, IN_PROGRESS, COMPLETED, DISCONTINUED)
434+
status: New status (SCHEDULED, IN PROGRESS, COMPLETED, DISCONTINUED)
433435
mpps_instance_uid: Optional MPPS instance UID
434436
435437
Returns:
@@ -515,3 +517,27 @@ def get_source_message_id(self, accession_number: str) -> Optional[str]:
515517
)
516518
row = cursor.fetchone()
517519
return row["source_message_id"] if row and row["source_message_id"] else None
520+
521+
def mpps_instance_exists(self, mpps_instance_uid: str) -> bool:
522+
"""Check if an MPPS instance UID already exists in any worklist item."""
523+
with self._get_connection() as conn:
524+
cursor = conn.execute("SELECT 1 FROM worklist_items WHERE mpps_instance_uid = ?", (mpps_instance_uid,))
525+
return cursor.fetchone() is not None
526+
527+
def get_worklist_item_by_mpps_instance_uid(self, mpps_instance_uid: str | None) -> Optional[WorklistItem]:
528+
"""Get a worklist item by its associated MPPS instance UID."""
529+
if mpps_instance_uid is None:
530+
return None
531+
532+
with self._get_connection() as conn:
533+
cursor = conn.execute(
534+
(
535+
"SELECT accession_number, modality, patient_birth_date, patient_id, "
536+
"patient_name, patient_sex, procedure_code, scheduled_date, scheduled_time, "
537+
"source_message_id, study_description, study_instance_uid, status, mpps_instance_uid "
538+
"FROM worklist_items WHERE mpps_instance_uid = ?"
539+
),
540+
(mpps_instance_uid,),
541+
)
542+
row = cursor.fetchone()
543+
return WorklistItem(**row) if row else None
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import datetime
2+
3+
import pytest
4+
from pydicom.dataset import Dataset
5+
from pydicom.uid import generate_uid
6+
from pynetdicom import AE
7+
from pynetdicom.sop_class import ModalityPerformedProcedureStep # pyright: ignore[reportAttributeAccessIssue]
8+
9+
from server import MWLServer
10+
from services.dicom import SUCCESS
11+
from services.storage import MWLStorage, WorklistItem
12+
13+
14+
class TestNCreateUpdatesWorklistStatus:
15+
@pytest.fixture(autouse=True)
16+
def with_mwl_server(self, tmp_dir):
17+
server = MWLServer("SCREENING_MWL", 4243, f"{tmp_dir}/test.db", block=False)
18+
server.start()
19+
20+
yield
21+
22+
server.stop()
23+
24+
@pytest.fixture
25+
def worklist_item(self):
26+
return WorklistItem(
27+
accession_number="ACC123",
28+
patient_id="999123456",
29+
patient_name="SMITH^JANE",
30+
patient_birth_date="19800101",
31+
patient_sex="F",
32+
scheduled_date="20240101",
33+
scheduled_time="090000",
34+
modality="MG",
35+
procedure_code="12345-6",
36+
study_description="MAMMOGRAPHY SCREENING",
37+
study_instance_uid=generate_uid(),
38+
source_message_id="MSGID123456",
39+
)
40+
41+
def test_n_create_updates_worklist_status(self, tmp_dir, worklist_item):
42+
storage = MWLStorage(f"{tmp_dir}/test.db")
43+
study_instance_uid = generate_uid()
44+
accession_number = storage.store_worklist_item(worklist_item)
45+
46+
ae = AE(ae_title="MODALITY_SCU")
47+
ae.add_requested_context(ModalityPerformedProcedureStep)
48+
49+
assoc = ae.associate("localhost", 4243, ae_title="SCREENING_MWL")
50+
51+
mpps_ds = Dataset()
52+
mpps_instance_uid = generate_uid()
53+
mpps_ds.SOPClassUID = ModalityPerformedProcedureStep
54+
mpps_ds.SOPInstanceUID = mpps_instance_uid
55+
mpps_ds.PerformedProcedureStepStatus = "IN PROGRESS"
56+
now = datetime.datetime.now()
57+
mpps_ds.PerformedProcedureStepStartDate = now.strftime("%Y%m%d")
58+
mpps_ds.PerformedProcedureStepStartTime = now.strftime("%H%M%S")
59+
mpps_ds.Modality = "MG"
60+
61+
scheduled_step_seq = Dataset()
62+
scheduled_step_seq.StudyInstanceUID = study_instance_uid
63+
scheduled_step_seq.AccessionNumber = accession_number
64+
65+
mpps_ds.ScheduledStepAttributesSequence = [scheduled_step_seq]
66+
mpps_ds.PerformedSeriesSequence = []
67+
68+
response = assoc.send_n_create(mpps_ds, ModalityPerformedProcedureStep, mpps_instance_uid)
69+
70+
assert response[0].Status == SUCCESS
71+
72+
updated_item = storage.get_worklist_item(accession_number)
73+
74+
assert updated_item is not None
75+
assert updated_item.mpps_instance_uid == mpps_instance_uid
76+
assert updated_item.status == "IN PROGRESS"

0 commit comments

Comments
 (0)