""" DICOM sender service - Implements storescu functionality using pynetdicom. """ import os import glob import pydicom import shutil from pydicom.dataset import Dataset from pynetdicom import AE, StoragePresentationContexts, evt, build_role from config import settings from utils.logger import main_logger as logger from utils.error_handler import DicomStoreError, dicom_retry from utils.dicom_utils import create_directory_if_not_exists from pynetdicom.sop_class import ( StudyRootQueryRetrieveInformationModelGet, PatientRootQueryRetrieveInformationModelGet ) class DicomSender: """ Class to perform DICOM C-STORE operations to send DICOM studies to a destination PACS. """ def __init__(self, pacs_config=None): """ Initialize DicomSender with PACS settings. Args: pacs_config (dict, optional): Destination PACS configuration dict containing host, port, aet """ self.pacs_config = pacs_config or settings.DESTINATION_PACS self.ae = AE(ae_title=settings.SOURCE_AET) # Add the Query/Retrieve SOP classes self.ae.add_requested_context(StudyRootQueryRetrieveInformationModelGet) self.ae.add_requested_context(PatientRootQueryRetrieveInformationModelGet) # Add storage presentation contexts (needed for receiving the images) # for context in StoragePresentationContexts: # self.ae.add_requested_context(context.abstract_syntax) storage_uids = [ '1.2.840.10008.5.1.4.1.1.1', # CR Storage '1.2.840.10008.5.1.4.1.1.1.1', # Digital X-Ray Image Storage '1.2.840.10008.5.1.4.1.1.2', # CT Image Storage '1.2.840.10008.5.1.4.1.1.4', # MR Image Storage '1.2.840.10008.5.1.4.1.1.7', # Secondary Capture Image Storage '1.2.840.10008.5.1.4.1.1.6.1', # Ultrasound Image Storage '1.2.840.10008.5.1.4.1.1.128', # PET Image Storage '1.2.840.10008.5.1.4.1.1.20', # Nuclear Medicine Image Storage '1.2.840.10008.5.1.4.1.1.9.1.1', # 12-lead ECG Waveform Storage '1.2.840.10008.5.1.4.1.1.9.1.2', # General ECG Waveform Storage ] self.ext_neg = [] for uid in storage_uids: self.ae.add_requested_context(uid) role = build_role(uid, scp_role=True) self.ext_neg.append(role) logger.info(f"DicomSender initialized with destination PACS: {self.pacs_config['aet']}@{self.pacs_config['host']}:{self.pacs_config['port']}") def _remove_dicom_files(self, directory): """ Remove all DICOM files in the specified directory. Args: directory (str): Path to the directory to remove files from. """ try: if os.path.exists(directory): shutil.rmtree(directory) logger.info(f"Removed DICOM files from directory: {directory}") except Exception as e: logger.error(f"Failed to remove DICOM files from {directory}: {str(e)}") @dicom_retry(exception_types=(DicomStoreError, ConnectionError)) def send_study(self, study_path): """ Send all DICOM files in a study directory to the destination PACS. Args: study_path (str): Path to study directory containing series subdirectories with DICOM files Returns: dict: Summary of store operations with counts and status """ logger.info(f"Sending all DICOM files from study: {study_path}") # Check if study directory exists if not os.path.exists(study_path) or not os.path.isdir(study_path): error_msg = f"Study directory does not exist: {study_path}" logger.error(error_msg) raise DicomStoreError(error_msg) # Find all DICOM files in the study directory (recursively) dicom_files = [] for root, _, files in os.walk(study_path): for file in files: if file.endswith('.dcm'): dicom_files.append(os.path.join(root, file)) if not dicom_files: logger.warning(f"No DICOM files found in study directory: {study_path}") return { 'success': False, 'total_files': 0, 'successful_sends': 0, 'failed_sends': 0, 'error': 'No DICOM files found' } logger.info(f"Found {len(dicom_files)} DICOM files to send") # Create association assoc = self.ae.associate( self.pacs_config['host'], self.pacs_config['port'], ae_title=self.pacs_config['aet'] ) result = { 'success': False, 'total_files': len(dicom_files), 'successful_sends': 0, 'failed_sends': 0, 'failures': [], 'study_path': study_path } if assoc.is_established: try: logger.debug(f"Association established with {self.pacs_config['aet']}, sending DICOM files") for file_path in dicom_files: try: # Read the DICOM file dataset = pydicom.dcmread(file_path, force=True) # Get identifying information for logging sop_instance_uid = getattr(dataset, 'SOPInstanceUID', 'Unknown') series_instance_uid = getattr(dataset, 'SeriesInstanceUID', 'Unknown') # Send the dataset status = assoc.send_c_store(dataset) if status and status.Status == 0x0000: # Success logger.debug(f"Successfully sent: {os.path.basename(file_path)}") result['successful_sends'] += 1 else: logger.error(f"Failed to send {os.path.basename(file_path)}: {status}") result['failed_sends'] += 1 result['failures'].append({ 'file': file_path, 'sop_instance_uid': sop_instance_uid, 'series_instance_uid': series_instance_uid, 'status': str(status) if status else 'Unknown error' }) except Exception as e: logger.error(f"Error processing file {file_path}: {str(e)}") result['failed_sends'] += 1 result['failures'].append({ 'file': file_path, 'error': str(e) }) # Update success flag based on results if result['successful_sends'] == result['total_files']: result['success'] = True elif result['successful_sends'] > 0: # Partial success result['success'] = True logger.warning(f"Partial success: {result['successful_sends']}/{result['total_files']} files sent") logger.info(f"C-STORE completed: {result['successful_sends']}/{result['total_files']} files sent") # Remove DICOM files after sending self._remove_dicom_files(study_path) except Exception as e: error_msg = f"Error during C-STORE operations: {str(e)}" logger.error(error_msg) result['error'] = error_msg raise DicomStoreError(error_msg) finally: # Release the association assoc.release() logger.debug("Association released") else: error_msg = f"Association rejected, aborted or never connected to {self.pacs_config['aet']}" logger.error(error_msg) result['error'] = error_msg raise DicomStoreError(error_msg) return result @dicom_retry(exception_types=(DicomStoreError, ConnectionError)) def send_series(self, study_uid, series_uid, base_dir=None): """ Send all DICOM files in a specific series to the destination PACS. Args: study_uid (str): StudyInstanceUID series_uid (str): SeriesInstanceUID to send base_dir (str, optional): Base directory where DICOM files are stored. Defaults to settings.DICOM_STORE_DIR. Returns: dict: Summary of store operations with counts and status """ base_dir = base_dir or settings.DICOM_STORE_DIR series_path = os.path.join(base_dir, study_uid, series_uid) logger.info(f"Sending DICOM files from series: {series_uid}") # Check if series directory exists if not os.path.exists(series_path) or not os.path.isdir(series_path): error_msg = f"Series directory does not exist: {series_path}" logger.error(error_msg) raise DicomStoreError(error_msg) # Find all DICOM files in the series directory dicom_files = glob.glob(os.path.join(series_path, '*.dcm')) if not dicom_files: logger.warning(f"No DICOM files found in series directory: {series_path}") return { 'success': False, 'total_files': 0, 'successful_sends': 0, 'failed_sends': 0, 'error': 'No DICOM files found' } logger.info(f"Found {len(dicom_files)} DICOM files to send") # Create association assoc = self.ae.associate( self.pacs_config['host'], self.pacs_config['port'], ae_title=self.pacs_config['aet'] ) result = { 'success': False, 'total_files': len(dicom_files), 'successful_sends': 0, 'failed_sends': 0, 'failures': [], 'series_path': series_path } if assoc.is_established: try: logger.debug(f"Association established with {self.pacs_config['aet']}, sending DICOM files") for file_path in dicom_files: try: # Read the DICOM file dataset = pydicom.dcmread(file_path, force=True) # Send the dataset status = assoc.send_c_store(dataset) if status and status.Status == 0x0000: # Success logger.debug(f"Successfully sent: {os.path.basename(file_path)}") result['successful_sends'] += 1 else: logger.error(f"Failed to send {os.path.basename(file_path)}: {status}") result['failed_sends'] += 1 result['failures'].append({ 'file': file_path, 'status': str(status) if status else 'Unknown error' }) except Exception as e: logger.error(f"Error processing file {file_path}: {str(e)}") result['failed_sends'] += 1 result['failures'].append({ 'file': file_path, 'error': str(e) }) # Update success flag based on results if result['successful_sends'] == result['total_files']: result['success'] = True elif result['successful_sends'] > 0: # Partial success result['success'] = True logger.warning(f"Partial success: {result['successful_sends']}/{result['total_files']} files sent") logger.info(f"C-STORE completed: {result['successful_sends']}/{result['total_files']} files sent") # Remove DICOM files after sending self._remove_dicom_files(series_path) except Exception as e: error_msg = f"Error during C-STORE operations: {str(e)}" logger.error(error_msg) result['error'] = error_msg raise DicomStoreError(error_msg) finally: # Release the association assoc.release() logger.debug("Association released") else: error_msg = f"Association rejected, aborted or never connected to {self.pacs_config['aet']}" logger.error(error_msg) result['error'] = error_msg raise DicomStoreError(error_msg) return result @dicom_retry(exception_types=(DicomStoreError, ConnectionError)) def send_file(self, file_path): """ Send a single DICOM file to the destination PACS. Args: file_path (str): Path to DICOM file to send Returns: dict: Summary of store operation with status """ logger.info(f"Sending DICOM file: {file_path}") # Check if file exists if not os.path.exists(file_path) or not os.path.isfile(file_path): error_msg = f"DICOM file does not exist: {file_path}" logger.error(error_msg) raise DicomStoreError(error_msg) # Create association assoc = self.ae.associate( self.pacs_config['host'], self.pacs_config['port'], ae_title=self.pacs_config['aet'] ) result = { 'success': False, 'file_path': file_path, 'status': '', 'error': '' } if assoc.is_established: try: logger.debug(f"Association established with {self.pacs_config['aet']}, sending file") # Read the DICOM file dataset = pydicom.dcmread(file_path, force=True) # Get identifying information for logging sop_instance_uid = getattr(dataset, 'SOPInstanceUID', 'Unknown') series_instance_uid = getattr(dataset, 'SeriesInstanceUID', 'Unknown') study_instance_uid = getattr(dataset, 'StudyInstanceUID', 'Unknown') # Send the dataset status = assoc.send_c_store(dataset) if status and status.Status == 0x0000: # Success result['success'] = True result['status'] = 'Success' logger.info(f"Successfully sent file: {file_path}") logger.debug(f"File details: Study={study_instance_uid}, Series={series_instance_uid}, SOP={sop_instance_uid}") else: result['status'] = f"Failed - Status: {status}" if status else "Failed - Unknown error" logger.error(f"Failed to send file: {file_path}") logger.error(f"Status: {status}") except Exception as e: error_msg = f"Error sending file {file_path}: {str(e)}" logger.error(error_msg) result['error'] = error_msg raise DicomStoreError(error_msg) finally: # Release the association assoc.release() logger.debug("Association released") else: error_msg = f"Association rejected, aborted or never connected to {self.pacs_config['aet']}" logger.error(error_msg) result['error'] = error_msg raise DicomStoreError(error_msg) return result