Skip to content

Commit 53d7b06

Browse files
C-STORE validation failure notifies Manage API
Via ValidationFailureNotifier#notify
1 parent 12742c7 commit 53d7b06

6 files changed

Lines changed: 139 additions & 15 deletions

File tree

src/pacs_main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ def main():
2929
pacs_port = int(os.getenv("PACS_PORT", "4244"))
3030
pacs_storage_path = os.getenv("PACS_STORAGE_PATH", "/var/lib/pacs/storage")
3131
pacs_db_path = os.getenv("PACS_DB_PATH", "/var/lib/pacs/pacs.db")
32+
mwl_db_path = os.getenv("MWL_DB_PATH", "/var/lib/pacs/worklist.db")
3233

33-
pacs_server = PACSServer(pacs_aet, pacs_port, pacs_storage_path, pacs_db_path, block=True)
34+
pacs_server = PACSServer(pacs_aet, pacs_port, pacs_storage_path, pacs_db_path, block=True, mwl_db_path=mwl_db_path)
3435

3536
try:
3637
pacs_server.start()

src/server.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from services.dicom.c_echo import CEcho
1717
from services.dicom.c_store import CStore
18+
from services.dicom.validation_failure_notifier import ValidationFailureNotifier
1819
from services.mwl.c_find import CFind
1920
from services.mwl.n_create import NCreate
2021
from services.mwl.n_set import NSet
@@ -33,6 +34,7 @@ def __init__(
3334
storage_path: str = "/var/lib/pacs/storage",
3435
db_path: str = "/var/lib/pacs/pacs.db",
3536
block: bool = True,
37+
mwl_db_path: str = "/var/lib/pacs/worklist.db",
3638
):
3739
"""
3840
Initialize PACS server.
@@ -42,10 +44,13 @@ def __init__(
4244
port: Port to listen on
4345
storage_path: Directory for DICOM file storage
4446
db_path: Path to SQLite database
47+
mwl_db_path: Path to the MWL SQLite database (for failure notification lookups)
4548
"""
4649
self.ae_title = ae_title
4750
self.port = port
4851
self.storage = PACSStorage(db_path, storage_path)
52+
self.mwl_storage = MWLStorage(mwl_db_path)
53+
self.notifier = ValidationFailureNotifier()
4954
self.ae = None
5055
self.block = block
5156

@@ -56,7 +61,10 @@ def start(self):
5661
self.ae = AE(ae_title=self.ae_title)
5762
self.ae.supported_contexts = StoragePresentationContexts
5863

59-
handlers = [(evt.EVT_C_ECHO, CEcho().call), (evt.EVT_C_STORE, CStore(self.storage).call)]
64+
handlers = [
65+
(evt.EVT_C_ECHO, CEcho().call),
66+
(evt.EVT_C_STORE, CStore(self.storage, mwl_storage=self.mwl_storage, notifier=self.notifier).call),
67+
]
6068

6169
logger.info(f"PACS server listening on 0.0.0.0:{self.port}")
6270
logger.info(f"Storage: {self.storage.storage_root}")

src/services/dicom/c_store.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010

1111
from services.dicom import FAILURE, SUCCESS
1212
from services.dicom.image_compressor import ImageCompressor
13+
from services.dicom.validation_failure_notifier import ValidationFailureNotifier
1314
from services.dicom.validator import DicomValidationError, DicomValidator
14-
from services.storage import InstanceExistsError, PACSStorage
15+
from services.storage import InstanceExistsError, MWLStorage, PACSStorage
1516

1617
logger = logging.getLogger(__name__)
1718

@@ -27,10 +28,14 @@ def __init__(
2728
storage: PACSStorage,
2829
compressor: ImageCompressor | None = None,
2930
validator: DicomValidator | None = None,
31+
mwl_storage: MWLStorage | None = None,
32+
notifier: ValidationFailureNotifier | None = None,
3033
):
3134
self.storage = storage
3235
self.compressor = compressor or ImageCompressor()
3336
self.validator = validator or DicomValidator()
37+
self.mwl_storage = mwl_storage
38+
self.notifier = notifier
3439

3540
def call(self, event: Event) -> int:
3641
try:
@@ -42,24 +47,27 @@ def call(self, event: Event) -> int:
4247
return FAILURE
4348

4449
sop_instance_uid = ds.get("SOPInstanceUID", "")
50+
accession_number = ds.get("AccessionNumber", "")
51+
patient_id = ds.get("PatientID")
52+
patient_name = str(ds.get("PatientName", ""))
53+
4554
if not sop_instance_uid:
4655
logger.error("Missing SOPInstanceUID")
56+
self._notify_failure(accession_number, "Missing SOPInstanceUID")
4757
return FAILURE
4858

49-
patient_id = ds.get("PatientID")
5059
if not patient_id:
5160
logger.error("Missing PatientID")
61+
self._notify_failure(accession_number, "Missing PatientID")
5262
return FAILURE
5363

54-
accession_number = ds.get("AccessionNumber", "")
55-
patient_name = str(ds.get("PatientName", ""))
56-
5764
# Validate dataset before compression
5865
try:
5966
self.validator.validate_dataset(ds)
6067
self.validator.validate_pixel_data(ds)
6168
except DicomValidationError as e:
6269
logger.error(f"DICOM validation failed: {e}")
70+
self._notify_failure(accession_number, f"DICOM validation failed: {e}")
6371
return FAILURE
6472

6573
# Compress dataset before storing
@@ -71,6 +79,7 @@ def call(self, event: Event) -> int:
7179
self.validator.validate_bytes(dicom_bytes)
7280
except DicomValidationError as e:
7381
logger.error(f"Serialized DICOM invalid: {e}")
82+
self._notify_failure(accession_number, f"Serialized DICOM invalid: {e}")
7483
return FAILURE
7584

7685
self.storage.store_instance(
@@ -100,3 +109,16 @@ def dataset_to_bytes(self, ds: Dataset) -> bytes:
100109
dcmwrite(buffer, ds, enforce_file_format=True)
101110
buffer.seek(0)
102111
return buffer.read()
112+
113+
def _notify_failure(self, accession_number: str, error: str) -> None:
114+
if not self.mwl_storage or not self.notifier:
115+
return
116+
117+
source_message_id = self.mwl_storage.get_source_message_id(accession_number)
118+
if not source_message_id:
119+
logger.warning(
120+
f"Cannot report validation failure: no worklist item found for accession {accession_number!r}"
121+
)
122+
return
123+
124+
self.notifier.notify(source_message_id, error)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Notifier for DICOM C-STORE validation failures.
2+
3+
Reports validation failures to the Manage Breast Screening HTTP API.
4+
"""
5+
6+
import logging
7+
import os
8+
9+
import requests
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class ValidationFailureNotifier:
15+
def __init__(self, api_endpoint: str | None = None, timeout: int = 30, verify_ssl: bool = True):
16+
self.api_endpoint = api_endpoint or os.getenv("CLOUD_API_ENDPOINT", "http://localhost:8000/api/v1/dicom")
17+
self.timeout = timeout
18+
self.verify_ssl = verify_ssl
19+
20+
def headers(self) -> dict:
21+
return {
22+
"Authorization": f"Bearer {os.getenv('CLOUD_API_TOKEN', '')}",
23+
}
24+
25+
def notify(self, source_message_id: str, error: str) -> bool:
26+
try:
27+
logger.info(f"Reporting validation failure for action {source_message_id}")
28+
29+
response = requests.post(
30+
f"{self.api_endpoint}/{source_message_id}/failure",
31+
json={"error": error},
32+
timeout=self.timeout,
33+
verify=self.verify_ssl,
34+
headers=self.headers(),
35+
)
36+
37+
if response.status_code == 200:
38+
logger.info(f"Validation failure reported for action {source_message_id}")
39+
return True
40+
else:
41+
logger.error(
42+
f"Failed to report validation failure for {source_message_id}: "
43+
f"status {response.status_code}, body: {response.text}"
44+
)
45+
return False
46+
47+
except requests.exceptions.Timeout:
48+
logger.error(f"Timeout reporting validation failure for {source_message_id} after {self.timeout}s")
49+
return False
50+
except requests.exceptions.RequestException as e:
51+
logger.error(f"Error reporting validation failure for {source_message_id}: {e}", exc_info=True)
52+
return False

tests/services/dicom/test_c_store.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
from services.dicom import FAILURE, SUCCESS
99
from services.dicom.c_store import CStore
1010
from services.dicom.image_compressor import ImageCompressor
11+
from services.dicom.validation_failure_notifier import ValidationFailureNotifier
12+
from services.dicom.validator import DicomValidationError, DicomValidator
13+
from services.storage import MWLStorage
1114

1215

1316
class TestCStore:
@@ -104,3 +107,34 @@ def test_compression_applied_on_storage(self, mock_storage, mock_event):
104107
stored_bytes = mock_storage.store_instance.call_args[0][1]
105108
stored_ds = pydicom.dcmread(BytesIO(stored_bytes), force=True)
106109
assert stored_ds.file_meta.TransferSyntaxUID == JPEG2000
110+
111+
def test_validation_failure_notifies_manage(self, mock_storage, mock_event):
112+
"""When validation fails and accession is in MWL, notify manage."""
113+
mock_validator = Mock(spec=DicomValidator)
114+
mock_validator.validate_dataset.side_effect = DicomValidationError("Missing required tag")
115+
116+
mock_mwl = Mock(spec=MWLStorage)
117+
mock_mwl.get_source_message_id.return_value = "action-uuid-123"
118+
119+
mock_notifier = Mock(spec=ValidationFailureNotifier)
120+
121+
subject = CStore(mock_storage, validator=mock_validator, mwl_storage=mock_mwl, notifier=mock_notifier)
122+
assert subject.call(mock_event) == FAILURE
123+
124+
mock_notifier.notify.assert_called_once_with("action-uuid-123", "DICOM validation failed: Missing required tag")
125+
mock_mwl.get_source_message_id.assert_called_once_with("ABC123")
126+
127+
def test_validation_failure_accession_not_in_mwl(self, mock_storage, mock_event):
128+
"""When accession is not in MWL, validation failure returns FAILURE without calling notify."""
129+
mock_validator = Mock(spec=DicomValidator)
130+
mock_validator.validate_dataset.side_effect = DicomValidationError("Missing required tag")
131+
132+
mock_mwl = Mock(spec=MWLStorage)
133+
mock_mwl.get_source_message_id.return_value = None
134+
135+
mock_notifier = Mock(spec=ValidationFailureNotifier)
136+
137+
subject = CStore(mock_storage, validator=mock_validator, mwl_storage=mock_mwl, notifier=mock_notifier)
138+
assert subject.call(mock_event) == FAILURE
139+
140+
mock_notifier.notify.assert_not_called()

tests/test_server.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,34 +10,41 @@
1010
from server import MWLServer, PACSServer
1111

1212

13+
@patch(f"{PACSServer.__module__}.MWLStorage")
1314
@patch(f"{PACSServer.__module__}.PACSStorage")
1415
class TestPACSServer:
15-
def test_init(self, mock_storage, tmp_dir):
16-
subject = PACSServer("Custom AE Title", 2222, tmp_dir, f"{tmp_dir}/test.db", False)
16+
def test_init(self, mock_pacs_storage, mock_mwl_storage, tmp_dir):
17+
subject = PACSServer(
18+
"Custom AE Title", 2222, tmp_dir, f"{tmp_dir}/test.db", False, mwl_db_path=f"{tmp_dir}/worklist.db"
19+
)
1720

1821
assert subject.ae_title == "Custom AE Title"
1922
assert subject.port == 2222
20-
assert subject.storage == mock_storage.return_value
23+
assert subject.storage == mock_pacs_storage.return_value
24+
assert subject.mwl_storage == mock_mwl_storage.return_value
2125
assert subject.ae is None
2226
assert subject.block is False
2327

24-
mock_storage.assert_called_once_with(f"{tmp_dir}/test.db", tmp_dir)
28+
mock_pacs_storage.assert_called_once_with(f"{tmp_dir}/test.db", tmp_dir)
29+
mock_mwl_storage.assert_called_once_with(f"{tmp_dir}/worklist.db")
2530

26-
def test_init_defaults(self, mock_storage):
31+
def test_init_defaults(self, mock_pacs_storage, mock_mwl_storage):
2732
subject = PACSServer()
2833

2934
assert subject.ae_title == "SCREENING_PACS"
3035
assert subject.port == 4244
31-
assert subject.storage == mock_storage.return_value
36+
assert subject.storage == mock_pacs_storage.return_value
37+
assert subject.mwl_storage == mock_mwl_storage.return_value
3238
assert subject.ae is None
3339
assert subject.block is True
3440

35-
mock_storage.assert_called_once_with("/var/lib/pacs/pacs.db", "/var/lib/pacs/storage")
41+
mock_pacs_storage.assert_called_once_with("/var/lib/pacs/pacs.db", "/var/lib/pacs/storage")
42+
mock_mwl_storage.assert_called_once_with("/var/lib/pacs/worklist.db")
3643

3744
@patch(f"{PACSServer.__module__}.AE")
3845
@patch(f"{PACSServer.__module__}.CEcho")
3946
@patch(f"{PACSServer.__module__}.CStore")
40-
def test_start(self, mock_c_store, mock_c_echo, mock_ae, _):
47+
def test_start(self, mock_c_store, mock_c_echo, mock_ae, _mock_pacs_storage, _mock_mwl_storage):
4148
subject = PACSServer()
4249
subject.start()
4350

0 commit comments

Comments
 (0)