Mercurial > hg > orthanc-tests
view NewTests/StorageCommitment/test_storage_commitment.py @ 992:69cf0d90c525 default tip
fix
| author | Alain Mazy <am@orthanc.team> |
|---|---|
| date | Thu, 22 Oct 2026 14:49:44 +0200 |
| parents | 07a74087e638 |
| children |
line wrap: on
line source
import unittest import time import os import sys import threading import pprint from helpers import OrthancTestCase, Helpers from orthanc_api_client import OrthancApiClient, ChangeType, HttpError from orthanc_api_client import helpers as OrthancHelpers FAKE_SERVER_AET = 'FAKE_MOD' FAKE_ALTERNATE_SERVER_AET = 'ALTERNATE' FAKE_SERVER_PORT = 4999 import logging # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger("fake DICOM SCP") try: from pydicom import Dataset from pydicom.uid import ( ExplicitVRLittleEndian, ImplicitVRLittleEndian, generate_uid, ) except ImportError as exc: # pragma: no cover sys.stderr.write(f"[!] pydicom is required: {exc}\n pip install pydicom\n") sys.exit(2) try: from pynetdicom import AE, build_role, debug_logger # SOP class import path moved between pynetdicom 1.x and 2.x. try: from pynetdicom.sop_class import StorageCommitmentPushModel except ImportError: # pynetdicom < 2.0 from pynetdicom.sop_class import ( StorageCommitmentPushModelSOPClass as StorageCommitmentPushModel, ) except ImportError as exc: # pragma: no cover sys.stderr.write(f"[!] pynetdicom is required: {exc}\n pip install pynetdicom\n") sys.exit(2) from pydicom.dataset import Dataset from pydicom.uid import ImplicitVRLittleEndian from pynetdicom import AE, evt, build_role from pynetdicom.sop_class import ( StorageCommitmentPushModel, ) from enum import Enum import pathlib import subprocess import glob here = pathlib.Path(__file__).parent.resolve() STORAGE_COMMITMENT_SOP_CLASS = "1.2.840.10008.1.20.1" STORAGE_COMMITMENT_SOP_INSTANCE = "1.2.840.10008.1.20.1.1" class PendingTransaction: def __init__(self, transaction_uid, sop_instances): self.transaction_uid = transaction_uid self.sop_instances = sop_instances class SimulatedFailure(Enum): NONE = 0 CHANGE_TXUID = 1 CHANGE_ONE_SOP_INSTANCE_UID = 2 CHANGE_ONE_SOP_CLASS_UID = 3 ADD_ONE_SOP_INSTANCE = 4 REMOVE_ONE_SOP_INSTANCE = 5 CHANGE_CALLED_AET = 6 class FakeStorageCommitmentServer: _should_stop = False _pending_transaction = None _simulated_failure = SimulatedFailure.CHANGE_TXUID _is_running = False _thread = None event_report_response_status = None def __init__(self, simulated_failure= SimulatedFailure): self._simulated_failure = simulated_failure def handle_n_action(self, event): """Receive Orthanc's Storage Commitment N-ACTION.""" transaction_uid = event.action_information.TransactionUID sop_instances = [] if hasattr(event.action_information, "ReferencedSOPSequence"): for item in event.action_information.ReferencedSOPSequence: sop_instances.append( ( item.ReferencedSOPClassUID, item.ReferencedSOPInstanceUID, ) ) print() print("Received Storage Commitment N-ACTION") print("------------------------------------") print(f"Transaction UID: {transaction_uid}") for sop_class, sop_instance in sop_instances: print(f" {sop_class} / {sop_instance}") if transaction_uid is not None: self._pending_transaction = PendingTransaction( transaction_uid, sop_instances, ) # N-ACTION success return 0x0000, None def send_event_report( self, host, port, calling_ae, called_ae, transaction_uid, sop_instances ): """ Open a new association to Orthanc and send an N-EVENT-REPORT. """ ae = AE(ae_title=calling_ae) ae.add_requested_context( StorageCommitmentPushModel, [ ImplicitVRLittleEndian, ], ) # We initiate the association, but act as SCP for # Storage Commitment. role = build_role( StorageCommitmentPushModel, scp_role=True, scu_role=False, ) assoc = ae.associate( host, port, ae_title=called_ae, ext_neg=[role], ) if not assoc.is_established: print("Could not establish association") return ds = Dataset() ds.TransactionUID = transaction_uid ds.ReferencedSOPSequence = [] for sop_class, sop_instance in sop_instances: item = Dataset() item.ReferencedSOPClassUID = sop_class item.ReferencedSOPInstanceUID = sop_instance ds.ReferencedSOPSequence.append(item) print() print("Sending N-EVENT-REPORT") print("----------------------") print(f"Transaction UID: {transaction_uid}") for sop_class, sop_instance in sop_instances: print(f" {sop_class} / {sop_instance}") status, response = assoc.send_n_event_report( ds, 1, # Event Type 1 = Request Successful STORAGE_COMMITMENT_SOP_CLASS, STORAGE_COMMITMENT_SOP_INSTANCE, ) if status is None: print("No N-EVENT-REPORT response") else: print( f"N-EVENT-REPORT response: " f"0x{status.Status:04X}" ) self.event_report_response_status = status.Status assoc.release() def start(self): self._thread = threading.Thread(target=self.execute) self._thread.start() while not self._is_running: time.sleep(0.1) def execute(self): ae = AE(ae_title=FAKE_SERVER_AET) # Receive N-ACTION from Orthanc. ae.add_supported_context( StorageCommitmentPushModel, [ ImplicitVRLittleEndian, ], scp_role=True, scu_role=False, ) handlers = [ ( evt.EVT_N_ACTION, self.handle_n_action, ), ] print( f"Listening on 0.0.0.0:{FAKE_SERVER_PORT} " f"as {FAKE_SERVER_AET}" ) # Start SCP in the background so that we can interact # with it from the command line. server = ae.start_server( ("0.0.0.0", FAKE_SERVER_PORT), block=False, evt_handlers=handlers, ) self._is_running = True try: while not self._should_stop: if self._pending_transaction is None: time.sleep(0.2) continue transaction = self._pending_transaction if self._simulated_failure == SimulatedFailure.NONE: self.send_event_report( host=Helpers.get_orthanc_ip(), port=Helpers.get_orthanc_dicom_port(), calling_ae=FAKE_SERVER_AET, called_ae='ORTHANC', transaction_uid=transaction.transaction_uid, sop_instances=transaction.sop_instances ) return elif self._simulated_failure == SimulatedFailure.CHANGE_TXUID: self.send_event_report( host=Helpers.get_orthanc_ip(), port=Helpers.get_orthanc_dicom_port(), calling_ae=FAKE_SERVER_AET, called_ae='ORTHANC', transaction_uid="1.2.3.4", sop_instances=transaction.sop_instances ) return elif self._simulated_failure == SimulatedFailure.CHANGE_ONE_SOP_INSTANCE_UID: modified_sop_instances = transaction.sop_instances.copy() modified_sop_instances[0] = (modified_sop_instances[0][0], "3.3.3") self.send_event_report( host=Helpers.get_orthanc_ip(), port=Helpers.get_orthanc_dicom_port(), calling_ae=FAKE_SERVER_AET, called_ae='ORTHANC', transaction_uid=transaction.transaction_uid, sop_instances=modified_sop_instances ) return elif self._simulated_failure == SimulatedFailure.CHANGE_ONE_SOP_CLASS_UID: modified_sop_instances = transaction.sop_instances.copy() modified_sop_instances[0] = ("3.3.3", modified_sop_instances[0][1]) self.send_event_report( host=Helpers.get_orthanc_ip(), port=Helpers.get_orthanc_dicom_port(), calling_ae=FAKE_SERVER_AET, called_ae='ORTHANC', transaction_uid=transaction.transaction_uid, sop_instances=modified_sop_instances ) return elif self._simulated_failure == SimulatedFailure.ADD_ONE_SOP_INSTANCE: modified_sop_instances = transaction.sop_instances.copy() modified_sop_instances.append(("4.4.0", "4.4.1")) self.send_event_report( host=Helpers.get_orthanc_ip(), port=Helpers.get_orthanc_dicom_port(), calling_ae=FAKE_SERVER_AET, called_ae='ORTHANC', transaction_uid=transaction.transaction_uid, sop_instances=modified_sop_instances ) return elif self._simulated_failure == SimulatedFailure.REMOVE_ONE_SOP_INSTANCE: modified_sop_instances = transaction.sop_instances.copy()[1:] self.send_event_report( host=Helpers.get_orthanc_ip(), port=Helpers.get_orthanc_dicom_port(), calling_ae=FAKE_SERVER_AET, called_ae='ORTHANC', transaction_uid=transaction.transaction_uid, sop_instances=modified_sop_instances ) return elif self._simulated_failure == SimulatedFailure.CHANGE_CALLED_AET: modified_sop_instances = transaction.sop_instances.copy()[1:] self.send_event_report( host=Helpers.get_orthanc_ip(), port=Helpers.get_orthanc_dicom_port(), calling_ae=FAKE_ALTERNATE_SERVER_AET, # the AET must be known from Orthanc in order to accept the incoming connection called_ae='ORTHANC', transaction_uid=transaction.transaction_uid, sop_instances=modified_sop_instances ) return finally: server.shutdown() def stop(self): self._should_stop = True self._thread.join() def __enter__(self): self.start() return self def __exit__(self, exc_type, exc, tb): self.stop() class TestStorageCommitment(OrthancTestCase): @classmethod def prepare(cls): print('-------------- preparing TestStorageCommitment tests') if not Helpers.tests_run_in_docker: cls.clear_storage(storage_name="TestStorageCommitment") config = { # this config is used only on desktop -> check the docker-compose-storage-commitment.yml for docker config "DicomModalities": { "fake-mod": { "AET": FAKE_SERVER_AET, "Port": FAKE_SERVER_PORT, "Host": "localhost" }, "alternate-mod": { "AET": FAKE_ALTERNATE_SERVER_AET, "Port": FAKE_SERVER_PORT, "Host": "localhost" } }, } config_path = cls.generate_configuration( config_name="storage_commitment", storage_name="StorageCommitment", config=config, plugins=Helpers.plugins ) if Helpers.break_after_preparation: print(f"++++ It is now time to start your Orthanc under tests with configuration file '{config_path}' +++++") input("Press Enter to continue") else: print('-------------- launching StorageCommitment tests') cls.launch_orthanc_under_tests( config_path=config_path, config_name="storage_commitment", storage_name="StorageCommitment", plugins=Helpers.plugins ) print('-------------- waiting for orthanc-under-tests to be available') # else: # cls.o = OrthancApiClient(f"{Helpers.orthanc_under_tests_hostname}:{Helpers.orthanc_under_tests_http_port}") cls.o.wait_started() def wait_transaction_complete(self, transaction_uid, timeout=10): retry = 0 while retry < timeout: r = self.o.get_json(f"/storage-commitment/{transaction_uid}") if r['Status'] != "Pending": return r['Status'] retry += 1 time.sleep(1) return r['Status'] def test_valid_transaction(self): with FakeStorageCommitmentServer(SimulatedFailure.NONE) as server: r = self.o.post("/modalities/fake-mod/storage-commitment", json={ "DicomInstances": [{ "SOPClassUID": "1.1.0", "SOPInstanceUID": "1.1.1" }]}).json() self.assertEqual('Success', self.wait_transaction_complete(r['ID'])) self.assertEqual(0x0000, server.event_report_response_status) def test_transaction_uuid_validation(self): if self.o.is_orthanc_version_at_least(1, 13, 1): with FakeStorageCommitmentServer(SimulatedFailure.CHANGE_TXUID) as server: r = self.o.post("/modalities/fake-mod/storage-commitment", json={ "DicomInstances": [{ "SOPClassUID": "1.1.0", "SOPInstanceUID": "1.1.1" }]}).json() self.assertEqual('Pending', self.wait_transaction_complete(r['ID'], timeout=2)) self.assertEqual(0x0211, server.event_report_response_status) def test_changed_sop_instance(self): if self.o.is_orthanc_version_at_least(1, 13, 1): with FakeStorageCommitmentServer(SimulatedFailure.CHANGE_ONE_SOP_INSTANCE_UID) as server: r = self.o.post("/modalities/fake-mod/storage-commitment", json={ "DicomInstances": [{ "SOPClassUID": "1.1.0", "SOPInstanceUID": "1.1.1" }, { "SOPClassUID": "1.2.0", "SOPInstanceUID": "1.2.1" }]}).json() self.assertEqual('Pending', self.wait_transaction_complete(r['ID'], timeout=2)) self.assertEqual(0x0117, server.event_report_response_status) def test_add_sop_instance(self): if self.o.is_orthanc_version_at_least(1, 13, 1): with FakeStorageCommitmentServer(SimulatedFailure.ADD_ONE_SOP_INSTANCE) as server: r = self.o.post("/modalities/fake-mod/storage-commitment", json={ "DicomInstances": [{ "SOPClassUID": "1.1.0", "SOPInstanceUID": "1.1.1" }]}).json() self.assertEqual('Pending', self.wait_transaction_complete(r['ID'], timeout=2)) self.assertEqual(0x0117, server.event_report_response_status) def test_remove_sop_instance(self): if self.o.is_orthanc_version_at_least(1, 13, 1): with FakeStorageCommitmentServer(SimulatedFailure.REMOVE_ONE_SOP_INSTANCE) as server: r = self.o.post("/modalities/fake-mod/storage-commitment", json={ "DicomInstances": [{ "SOPClassUID": "1.1.0", "SOPInstanceUID": "1.1.1" }, { "SOPClassUID": "1.2.0", "SOPInstanceUID": "1.2.1" }]}).json() self.assertEqual('Pending', self.wait_transaction_complete(r['ID'], timeout=2)) self.assertEqual(0x0110, server.event_report_response_status) def test_change_sop_class(self): if self.o.is_orthanc_version_at_least(1, 13, 1): with FakeStorageCommitmentServer(SimulatedFailure.CHANGE_ONE_SOP_CLASS_UID) as server: r = self.o.post("/modalities/fake-mod/storage-commitment", json={ "DicomInstances": [{ "SOPClassUID": "1.1.0", "SOPInstanceUID": "1.1.1" }, { "SOPClassUID": "1.2.0", "SOPInstanceUID": "1.2.1" }]}).json() self.assertEqual('Pending', self.wait_transaction_complete(r['ID'], timeout=2)) self.assertEqual(0x0119, server.event_report_response_status) def test_change_call_aet(self): if self.o.is_orthanc_version_at_least(1, 13, 1): with FakeStorageCommitmentServer(SimulatedFailure.CHANGE_CALLED_AET) as server: r = self.o.post("/modalities/fake-mod/storage-commitment", json={ "DicomInstances": [{ "SOPClassUID": "1.1.0", "SOPInstanceUID": "1.1.1" }, { "SOPClassUID": "1.2.0", "SOPInstanceUID": "1.2.1" }]}).json() self.assertEqual('Pending', self.wait_transaction_complete(r['ID'], timeout=2)) self.assertEqual(0x0110, server.event_report_response_status)
