diff --git a/pacs_connection/__init__.py b/pacs_connection/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pacs_connection/config.json b/pacs_connection/config.json new file mode 100644 index 000000000..b2be8c7a0 --- /dev/null +++ b/pacs_connection/config.json @@ -0,0 +1 @@ +{"configured_pacs": [{"IP ADDRESS": "DicomServer.co.uk", "PORT": "104", "AE TITLE": "MEDTECH", "Description": "Public Server", "Retrievel Protocol": "DICOM", "Preferred Transfer Syntax": "Implicit VR Little Endian"}, {"IP ADDRESS": "127.0.0.6", "PORT": "4242", "AE TITLE": "Orthanc6", "Description": "Testing Server", "Retrievel Protocol": "DICOM", "Preferred Transfer Syntax": "Implicit VR Little Endian"}, {"IP ADDRESS": "127.0.0.7", "PORT": "4242", "AE TITLE": "Orthanc7-Testing", "Description": "Testing Server", "Retrievel Protocol": "DICOM", "Preferred Transfer Syntax": "Implicit VR Little Endian"}, {"IP ADDRESS": "127.0.3.9", "PORT": "4247", "AE TITLE": "Orthanc23", "Description": "Testing Server", "Retrievel Protocol": "DICOM", "Preferred Transfer Syntax": "Implicit VR Little Endian"}, {"IP ADDRESS": "127.0.0.4", "PORT": "3287", "AE TITLE": "Best-Test", "Description": "Testing Purpose", "Retrievel Protocol": "DICOM", "Preferred Transfer Syntax": "Implicit VR Little Endian"}, {"IP ADDRESS": "127.1.0.7", "PORT": "4248", "AE TITLE": "Orthanc21-Testing", "Description": "Testing Server", "Retrievel Protocol": "DICOM", "Preferred Transfer Syntax": "Implicit VR Little Endian"}, {"IP ADDRESS": "127.0.5.6", "PORT": "7890", "AE TITLE": "Orthanc45", "Description": "Testing Server", "Retrievel Protocol": "DICOM", "Preferred Transfer Syntax": "Implicit VR Little Endian"}], "menu_options": {"advanced_settings": ["Query Timeout"], "Query Timeout": ["5 sec", "10 sec", "15 sec", "20 sec", "25 sec", "30 sec"]}, "default_settings": {"Query Timeout": "20 sec"}} \ No newline at end of file diff --git a/pacs_connection/constants.py b/pacs_connection/constants.py new file mode 100644 index 000000000..b5ac7961c --- /dev/null +++ b/pacs_connection/constants.py @@ -0,0 +1,12 @@ + +COLS = ["PatientName", "PatientID", "StudyInstanceUID", "SeriesInstanceUID", "StudyDate", "StudyTime", "AccessionNumber", "Modality", "PatientBirthDate", "PatientSex", "PatientAge", "IssuerOfPatientID", "Retrieve AE Title", "StudyDescription"] +INV_PORT = 5050 +INV_AET = 'INVESALIUS' +INV_HOST = 'localhost' +READ_MAPPER = { + 'Patient ID' : 'PatientID', + 'Patient Name' : 'PatientName', + 'StudyInstanceUID' : 'StudyInstanceUID', +} +CONFIG_FILE = 'pacs_connection\config.json' + diff --git a/pacs_connection/dicom_client/__init__.py b/pacs_connection/dicom_client/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pacs_connection/dicom_client/cecho.py b/pacs_connection/dicom_client/cecho.py new file mode 100644 index 000000000..f6932021f --- /dev/null +++ b/pacs_connection/dicom_client/cecho.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass, field +from pynetdicom import AE +from pynetdicom.sop_class import Verification +from pynetdicom.association import Association + +@dataclass +class CEcho: + ip_address: str + port: int = 4242 + ae: AE = field(default_factory=AE) + assoc: Association = field(init=False) + + def __post_init__(self): + print("Postinit") + self.ae.add_requested_context(Verification) + self.assoc = self.ae.associate(self.ip_address, self.port) + + def verify(self) -> bool: + if self.assoc.is_established: + status = self.assoc.send_c_echo() + + if status: + print('C-ECHO request status: 0x{0:04x}'.format(status.Status)) + self.assoc.release() + return True + else: + print('Connection timed out, was aborted or received invalid response') + self.assoc.release() + return False + else: + print('Association rejected, aborted or never connected') + return False \ No newline at end of file diff --git a/pacs_connection/dicom_client/cfind.py b/pacs_connection/dicom_client/cfind.py new file mode 100644 index 000000000..b8399166a --- /dev/null +++ b/pacs_connection/dicom_client/cfind.py @@ -0,0 +1,167 @@ +from dataclasses import dataclass, field +from typing import Any +from pydicom.dataset import Dataset + + +from pynetdicom import AE +from pynetdicom.sop_class import PatientRootQueryRetrieveInformationModelFind + +from pacs_connection.constants import COLS + + + +def date_formater(s): + year = s[0:4] + month = s[4:6] + day = s[6:8] + return f"{year}-{month}-{day}" + +def time_formater(s): + try: + hour = int(s[0:2]) + minute = int(s[2:4]) + second = int(s[4:6]) + return f"{hour}:{minute}:{second}" + except Exception as e: + print("ERROR: ", e) + return s + +def serializer(obj): + # obj: List[Dict] + for dict_item in obj: + for key, value in dict_item.items(): + if 'Date' in key: + dict_item[key] = date_formater(value) + elif 'Time' in key: + dict_item[key] = time_formater(value) + return obj + +@dataclass +class CFind: + """ + TODO: Search via patient name/id or accession nu and get all details like patient id, patient name, study date, dob etc + """ + host: str + port: int = 4242 + ae: AE = field(default_factory=AE) + mapper: dict[str, Any] = field(init=False) + + def __post_init__(self): + self.mapper = { + "PATIENT": self.create_patient_identifier, + "STUDY": self.create_study_identifier, + "SERIES": self.create_series_identifier, + } + + def make_request(self, **kwargs) -> list: + self.ae.add_requested_context(PatientRootQueryRetrieveInformationModelFind) + self.assoc = self.ae.associate(self.host, self.port) + self.ae.acse_timeout = 180 # setting timeout to 3 minutes + final_result = [] + if self.assoc.is_established: + final_result = self.execute_search(**kwargs) + + self.assoc.release() + + return final_result + + def create_patient_identifier(self, dataset : Dataset, **kwargs) -> Dataset: + dataset.PatientName = kwargs.get('PatientName', '*') + dataset.PatientID = kwargs.get('PatientID', '*') + dataset.PatientBirthDate = kwargs.get('PatientBirthDate', '19000101-99991231') + return dataset + + def create_study_identifier(self, dataset: Dataset, **kwargs) -> Dataset: + dataset = self.create_patient_identifier(dataset, **kwargs) + dataset.StudyInstanceUID = kwargs.get('StudyInstanceUID', '*') + dataset.StudyDate = kwargs.get('StudyDate', '19000101-99991231') + dataset.AccessionNumber = kwargs.get('AccessionNumber', '*') + return dataset + + def create_series_identifier(self, dataset: Dataset, **kwargs) -> Dataset: + dataset = self.create_study_identifier(dataset, **kwargs) + dataset.SeriesInstanceUID = kwargs.get('SeriesInstanceUID', '*') + dataset.Modality = kwargs.get('Modality', '*') + return dataset + + def create_identifier(self, dataset: Dataset= None, **kwargs) -> Dataset: + if not dataset: + dataset = Dataset() + qr_lvl = kwargs.get('QueryRetrieveLevel', 'PATIENT') + dataset.QueryRetrieveLevel = qr_lvl + return self.mapper[qr_lvl](dataset, **kwargs) + + + def get_user_input(self, **kwargs) -> dict: + inputs = {} + for k, v in kwargs.items(): + inputs[k] = v + return inputs + + + def decode_response(self, identifier: Dataset) -> dict: + import collections + tags = COLS + d = collections.defaultdict() + if not identifier: return {} + for tag in tags: + if tag in identifier: + try: + d[tag] = identifier.get(tag) + except Exception as e: + print(e) + continue + return d + + def execute_search(self, **kwargs) -> list: + dataset = Dataset() + kwargs['QueryRetrieveLevel'] = 'PATIENT' + patient_output = self.send_cfind(dataset, **kwargs) # List[Dict] + final_result = [] + for p_op in patient_output: + n_op = p_op.copy() + new_dataset = Dataset() + nkwargs = kwargs.copy() + nkwargs['QueryRetrieveLevel'] = 'STUDY' + if n_op.get('PatientID', False): + nkwargs['PatientID'] = n_op['PatientID'] + elif n_op.get('PatientName', False): + nkwargs['PatientName'] = n_op['PatientName'] + + study_output = self.send_cfind(new_dataset, **nkwargs) + for s_op in study_output: + if s_op != {} or (final_result and final_result[-1] != s_op): + f_op = n_op | s_op + final_result.append(f_op) + final_result = serializer(final_result) + return final_result + + + def send_cfind(self, dataset: Dataset = Dataset(), **kwargs) -> list: + identifier = self.create_identifier(dataset, **kwargs) + retries = 0 + while retries <5: + try: + responses = self.assoc.send_c_find(identifier, PatientRootQueryRetrieveInformationModelFind) + break + except RuntimeError: + retries +=1 + self.assoc = self.ae.associate(self.host, self.port) + + output = [] + count = 0 + for status, res_identifier in responses: + count +=1 + if status and res_identifier: + res = self.decode_response(res_identifier) + if len(res) >0: output.append(res) + else: + print('Connection timed out, was aborted or received invalid response') + return output + +if __name__ == '__main__': + host = ['DicomServer.co.uk', '184.73.255.26'] + port = [104, 11112] + x = 0 + cfind = CFind(host[x], port[x]) + cfind.make_request() diff --git a/pacs_connection/dicom_client/cstore.py b/pacs_connection/dicom_client/cstore.py new file mode 100644 index 000000000..3a446bfd7 --- /dev/null +++ b/pacs_connection/dicom_client/cstore.py @@ -0,0 +1,165 @@ +import time +import csv +import os +from dataclasses import dataclass, field +import concurrent.futures +from concurrent.futures import ThreadPoolExecutor +from pydicom.dataset import Dataset +from pydicom import dcmread +from pynetdicom import AE, StoragePresentationContexts + + +@dataclass +class CStore: + """ + Used to Upload DICOM Files to Remote PACS Server + TODO: Use Chunking Concept to Upload Large Files + TODO: Need to handle uploading large number of files + TODO: Add Custom Compressor Handler for JPEG Files/DICOM Files before Upload + TODO: Add FPS Custom Handler for MP4 Files + """ + + host: str + port: int = 4242 + ae: AE = field(default_factory=AE) + + def __post_init__(self) -> None: + self.ae.requested_contexts = StoragePresentationContexts + + def send_c_store(self, path: str) -> bool: + ds = dcmread(path) + patient_name = ds.PatientName + study_description = ds.StudyDescription + print( + f"Patient name is: {patient_name}, Study Description is: {study_description}, File Path is: {path}") + self.assoc = self.ae.associate(self.host, self.port) + success = False + if self.assoc.is_established: + status = self.assoc.send_c_store(ds) + if status: + status_str = '0x{0:04x}'.format(status.Status) + if status_str != '0x0000': + print(f"File {path} was not uploaded successfully") + error_cause = self.status_mapper(status_str) + print(f"Error Cause is: {error_cause}") + else: + success = True + else: + print('Connection timed out, was aborted or received invalid response') + self.assoc.release() + else: + print('Association rejected, aborted or never connected') + return success + + @staticmethod + def status_mapper(status_code: str) -> str: + status_messages = { + '0x0000': 'Success', + '0x0001': 'Unrecognized Operation', + '0x0106': 'Duplicate SOP Instance', + '0x0122': 'Missing Attribute Value', + } + return status_messages.get(status_code, 'Unknown error') + + def upload_full_study(self, folder_path: str) -> bool: + + dummy_name = folder_path.split("\\")[-1] + dummy_name = dummy_name.replace(" ", "_") + failed = False + with open(f'pacs_connection/upload_results/result_{dummy_name}.csv', mode='w', newline='') as csv_file: + writer = csv.writer(csv_file) + writer.writerow(['File Path', 'Status']) + for file_name in os.listdir(folder_path): + # create full path + full_path = os.path.join(folder_path, file_name) + # call send_c_store + success = self.send_c_store(full_path) + writer.writerow( + [full_path, 'Success' if success else 'Failed']) + if not success: + failed = True + print( + 'Connection timed out, was aborted or received invalid response') + return not failed + + def upload_full_study_thread(self, folder_path: str) -> bool: + dummy_name = folder_path.split("/")[-1] + dummy_name = dummy_name.replace(" ", "_") + failed = False + with open(f'pacs_connection/upload_results/result_{dummy_name}.csv', mode='w', newline='') as csv_file: + writer = csv.writer(csv_file) + writer.writerow(['File Path', 'Status']) + files = list(os.listdir(folder_path)) + full_files_path = [os.path.join( + folder_path, file_name) for file_name in files] + with ThreadPoolExecutor(max_workers=10) as executor: + future_to_obj = {executor.submit( + self.send_c_store, file_path): file_path for file_path in full_files_path} + for future in concurrent.futures.as_completed(future_to_obj): + obj = future_to_obj[future] + try: + success = future.result() + writer.writerow( + [obj, 'Success' if success else 'Failed']) + if not success: + failed = True + print( + 'Connection timed out, was aborted or received invalid response') + except Exception as exc: + print(f"{obj} generated an exception: {exc}") + return failed + + def handle_failed_request(self, report_path: str) -> bool: + print('failed_request report_path', report_path) + failed = False + updated_rows = [] + with open(report_path, 'r') as csv_file: + reader = csv.reader(csv_file) + for idx, row in enumerate(reader): + if not row: + continue + file_path = row[0] + if file_path == 'File Path': + continue + # skip the file if it's already success + if row[1] == 'Success': + updated_rows.append(row) + continue + print(f"Uploading file: {file_path}") + success = self.send_c_store(file_path) + + # If the upload was successful, update the status in the CSV file + if success: + row[1] = 'Success' + else: + row[1] = 'Failed' + failed = True + updated_rows.append(row) + + # Write the updated row to the CSV file + with open(report_path, 'w', newline='') as csv_file: + fieldnames = ['File Path', 'Status'] + writer = csv.DictWriter( + csv_file, delimiter=',', fieldnames=fieldnames) + writer.writeheader() + for row in updated_rows: + writer.writerow({'File Path': row[0], 'Status': row[1]}) + return not failed + + def upload(self, path: str, folder=True) -> bool: + if folder: + dummy_name = path.split("\\")[-1] + dummy_name = dummy_name.replace(" ", "_") + report_file = f"pacs_connection/upload_results/result_{dummy_name}.csv" + if not self.upload_full_study(path): + count = 0 + while count < 2: + if not self.handle_failed_request(report_file): + count += 1 + print(f"Retrying failed request {count} time(s)") + else: + return True + return False + + else: + return self.send_c_store(path) diff --git a/pacs_connection/helpers.py b/pacs_connection/helpers.py new file mode 100644 index 000000000..a596f6e75 --- /dev/null +++ b/pacs_connection/helpers.py @@ -0,0 +1,31 @@ +import json +import socket + + +def json_serial(filename, mode='r'): + try: + with open(filename, mode) as file: + return json.load(file) + except Exception as e: + print("Error: ", e) + return [] + + + +def is_valid_ip_address(ip): + try: + # Try to parse the input string as an IP address + ip_address = str(socket.gethostbyname(ip)).strip() + print("IP ADDRESS: ", ip_address) + socket.inet_pton(socket.AF_INET, ip_address) + return True + except socket.error: + # If parsing fails, return False + return False + +def is_valid_port(n): + try: + n = int(n) + return 1<= n<= 65535 + except Exception as e: + return False \ No newline at end of file diff --git a/pacs_connection/requirements.txt b/pacs_connection/requirements.txt new file mode 100644 index 000000000..69d62d297 --- /dev/null +++ b/pacs_connection/requirements.txt @@ -0,0 +1,2 @@ +pydicom==2.3.1 +pynetdicom==2.0.2 \ No newline at end of file diff --git a/pacs_connection/ui/__init__.py b/pacs_connection/ui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pacs_connection/ui/components.py b/pacs_connection/ui/components.py new file mode 100644 index 000000000..cf670c3ca --- /dev/null +++ b/pacs_connection/ui/components.py @@ -0,0 +1,93 @@ +import wx +import time +import wx.adv as wxadv +import wx.grid as gridlib +import wx.lib.agw.pybusyinfo as PBI + + +class CustomDialog(wx.Dialog): + def __init__(self, parent, message): + super().__init__(parent, title="Confirmation", size=(250, 150)) + panel = wx.Panel(self) + sizer = wx.BoxSizer(wx.VERTICAL) + message_label = wx.StaticText(panel, label=message) + sizer.Add(message_label, 0, wx.ALL | wx.CENTER, 5) + button_sizer = wx.BoxSizer(wx.HORIZONTAL) + yes_button = wx.Button(panel, label="Yes") + no_button = wx.Button(panel, label="No") + button_sizer.Add(yes_button, 0, wx.ALL | wx.CENTER, 5) + button_sizer.Add(no_button, 0, wx.ALL | wx.CENTER, 5) + sizer.Add(button_sizer, 0, wx.CENTER) + panel.SetSizer(sizer) + yes_button.Bind(wx.EVT_BUTTON, self.on_yes) + no_button.Bind(wx.EVT_BUTTON, self.on_no) + + def on_yes(self, event): + self.EndModal(wx.ID_YES) + + def on_no(self, event): + self.EndModal(wx.ID_NO) + +class BasicCompo: + + @staticmethod + def create_label_textbox(panel, label: str = '', text_box_value: str = '', enable: bool = True, textbox_needed: int = 1, horizontal: int = 0, **kwargs) -> list: + + if horizontal: + sizer = wx.BoxSizer(wx.HORIZONTAL) + else: + sizer = wx.BoxSizer(wx.VERTICAL) + + label = wx.StaticText(panel, label=label) + if kwargs.get('label_size'): + label.SetSize(kwargs.get('label_size')) + + if horizontal: + sizer.Add(label, 0, wx.ALIGN_CENTER_VERTICAL | + wx.LEFT | wx.TOP | wx.BOTTOM, border=5) + else: + sizer.Add(label, 0, wx.LEFT | wx.EXPAND | + wx.ALL | wx.TOP | wx.BOTTOM, border=1) + + textbox = wx.TextCtrl(panel, value=text_box_value, + style=wx.TE_LEFT | wx.TE_PROCESS_ENTER) + textbox.Enable(enable) + if kwargs.get('textbox_size'): + textbox.SetSize(kwargs.get('textbox_size')) + sizer.AddSpacer(10) # Add spacer between label and text control + sizer.Add(textbox, 1, wx.EXPAND | wx.RIGHT | + wx.TOP | wx.BOTTOM, border=1) + + # preparing response + res = [sizer] + if textbox_needed: + res.append(textbox) + return res + + @staticmethod + def create_button(panel, label='', enable=True): + button = wx.Button(panel, label=label) + button.Enable(enable) + return button + + @staticmethod + def showmsg(t: int, msgs: str = 'PACS Details are Deleted') -> PBI.PyBusyInfo: + app = wx.App(redirect=False) + msg = msgs + title = 'Message!' + d = PBI.PyBusyInfo(msg, title=title) + time.sleep(t) + return d + + @staticmethod + def create_slider(panel, label, value=100, min_value=0, max_value=100): + slider_comp = wx.Slider(panel, value=value, minValue=min_value, maxValue=max_value, style=wx.SL_HORIZONTAL | wx.SL_LABELS) + slider_comp.SetTickFreq(100) + slider_comp.SetPageSize(100) + slider_comp.SetLineSize(100) + slider_comp.SetTick(100) + slider_comp.SetLabel(label) + slider_comp.SetThumbLength(25) + slider_comp.SetValue(50) + return slider_comp + diff --git a/pacs_connection/ui/download_history.py b/pacs_connection/ui/download_history.py new file mode 100644 index 000000000..bc75fae99 --- /dev/null +++ b/pacs_connection/ui/download_history.py @@ -0,0 +1,85 @@ +import wx +import wx.grid + +class DownloadTable(wx.grid.Grid): + """A grid table to display download history.""" + COL_NAMES = ['Patient Name', 'Study Date', 'Size', 'Progress', 'Download Time', 'Bytes Transferred', 'PACS Location'] + + def __init__(self, parent): + super().__init__(parent) + self.CreateGrid(0, len(self.COL_NAMES)) + for i, col_name in enumerate(self.COL_NAMES): + self.SetColLabelValue(i, col_name) + self.EnableEditing(False) + self.HideRowLabels() + + def add_row(self, data:list): + row = self.GetNumberRows() + self.AppendRows(1) + for i, value in enumerate(data): + self.SetCellValue(row, i, str(value)) + + def set_column_widths(self, widths): + for i, width in enumerate(widths): + self.SetColSize(i, width) + + +class DownloadHistory(wx.Frame): + """A GUI to display download history.""" + + def __init__(self, title:str ="Download History", size:tuple=(-1, 35), data=None) -> None: + super().__init__(None, -1, title, style=wx.DEFAULT_FRAME_STYLE ^ wx.RESIZE_BORDER) + self.data = data or [] + self.create_ui() + + def create_ui(self): + self.panel = wx.Panel(self) + self.main_layout = wx.BoxSizer(wx.VERTICAL) + + # Create the download table + self.table = DownloadTable(self.panel) + self.main_layout.Add(self.table, 1, wx.EXPAND|wx.ALL, 10) + + # Add the data to the download table + for row_data in self.data: + self.table.add_row(row_data) + + # Set the column widths of the download table + self.table.set_column_widths([150, 100, 80, 80, 100, 120, 250]) + + # Add the cancel and close buttons to the footer + self.footer_layout = wx.BoxSizer(wx.HORIZONTAL) + self.cancel_button = wx.Button(self.panel, wx.ID_ANY, 'Cancel') + self.cancel_button.Disable() + self.close_button = wx.Button(self.panel, wx.ID_ANY, 'Close') + self.close_button.Bind(wx.EVT_BUTTON, self.on_close) + + self.footer_layout.Add(self.cancel_button, 0, wx.ALIGN_LEFT|wx.ALL, 5) + self.footer_layout.AddStretchSpacer(1) + self.footer_layout.Add(self.close_button, 0, wx.ALIGN_LEFT|wx.ALL, 5) + self.main_layout.Add(self.footer_layout, 0, wx.EXPAND|wx.ALL, 10) + + # Set the panel sizer + self.panel.SetSizer(self.main_layout) + + def enable_close_button(self, enabled=True): + self.close_button.Enable(enabled) + + def update_row(self, row, data): + for i, value in enumerate(data): + self.table.SetCellValue(row, i, str(value)) + + def on_close(self, event:wx.Event)->None: + self.Close() + + +if __name__ == '__main__': + app = wx.App() + data=[ + ['John Doe', '2022-01-01', '100 MB', '50%', '00:10:00', '50 MB', 'https://pacs.example.com/study1'], + ['Jane Doe', '2022-01-02', '200 MB', '75%', '00:20:00', '150 MB', 'https://pacs.example.com/study2'], + ['Bob Smith', '2022-01-03', '50 MB', '25%', '00:05:00', '10 MB', 'https://pacs.example.com/study3'], + ] + frame = DownloadHistory(data=data) + frame.Show() + app.MainLoop() \ No newline at end of file diff --git a/pacs_connection/ui/pacs_config.py b/pacs_connection/ui/pacs_config.py new file mode 100644 index 000000000..fe0203db4 --- /dev/null +++ b/pacs_connection/ui/pacs_config.py @@ -0,0 +1,494 @@ +import time +from typing import Any +import wx +import wx.grid as gridlib +import wx.lib.agw.pybusyinfo as PBI +import json +from pacs_connection.constants import INV_PORT, INV_AET, INV_HOST, CONFIG_FILE +from pacs_connection.helpers import is_valid_ip_address, is_valid_port, json_serial +from pacs_connection.dicom_client.cecho import CEcho +from components import CustomDialog, BasicCompo + + +PACS_CONFIG_DATA = json_serial(CONFIG_FILE) +CONFIGURED_PACS = PACS_CONFIG_DATA['configured_pacs'] + + + +class Configuration(wx.Frame): + def __init__(self, size: tuple = (600, 450)) -> None: + wx.Frame.__init__(self, None, -1, "PACS Configuration", + size=size, style=wx.DEFAULT_FRAME_STYLE ^ wx.RESIZE_BORDER) + self.panel = wx.Panel(self, wx.ID_ANY, size=size) + self.main_layout = wx.BoxSizer(wx.VERTICAL) + self.pacs_data = PACS_CONFIG_DATA + self.configured_pacs = self.pacs_data['configured_pacs'] + self.menu_options = self.pacs_data['menu_options'] + self.default_settings = self.pacs_data['default_settings'] + self.pacs_table = None + self.panel.SetSizer(self.main_layout) + self.create_ui() + + def create_line(self, horizontal: int = 1) -> None: + if horizontal: + self.sl = wx.StaticLine(self.panel, 1, style=wx.LI_HORIZONTAL) + else: + self.sl = wx.StaticLine(self.panel, 2, style=wx.LI_VERTICAL) + self.main_layout.Add(self.sl, 0, wx.EXPAND | wx.ALL, 1) + + def create_ui(self) -> None: + self.create_client_info_sizer = self.create_client_info() + self.main_layout.Add(self.create_client_info_sizer, + 0, wx.EXPAND | wx.ALL, 1) + self.create_line(horizontal=1) + self.create_pacs_info_sizeer = self.create_pacs_info() + self.main_layout.Add(self.create_pacs_info_sizeer, + 0, wx.EXPAND | wx.ALL, 1) + self.create_add_pacs_server_sizer = self.create_add_pacs_server() + self.main_layout.Add( + self.create_add_pacs_server_sizer, 0, wx.EXPAND | wx.ALL, 1) + self.create_footer_sizer = self.create_footer() + self.main_layout.Add(self.create_footer_sizer, 0, + wx.RIGHT | wx.EXPAND | wx.ALL, 1) + + + return + + + def create_menu(self, key: str) -> wx.Menu: + options = self.menu_options[key] + menu = wx.Menu() + des_pos = 0 + for option in options: + if self.menu_options.get(option, None): + des_pos = 0 + sub_menu = self.create_menu(option) + menu.AppendSubMenu(sub_menu, option) + if option in self.default_settings: + default_option = self.default_settings[option] + default_menu_item_id = sub_menu.FindItem(default_option) + default_menu_item = sub_menu.FindItemById( + default_menu_item_id) + else: + menu_item = menu.InsertRadioItem(des_pos,wx.ID_ANY, option) + self.Bind(wx.EVT_MENU, self.on_menu_select, menu_item) + if option == self.default_settings['Query Timeout']: + menu_item.Check(True) + + + des_pos += 1 + return menu + + def on_menu_select(self, event: wx.MenuEvent) -> None: + selected_item_id = event.GetId() + menu = event.GetEventObject() + selected_item = menu.FindItemById(selected_item_id) + selected_item_label = selected_item.GetItemLabel() + selected_item.Check(True) + self.default_settings['Query Timeout'] = selected_item_label + self.pacs_data['default_settings'] = self.default_settings + event.Skip() + + + def on_advance_setting_click(self, event: wx.CommandEvent) -> None: + pos = self.advanced_settings_button.GetPosition() + size = self.advanced_settings_button.GetSize() + self.panel.PopupMenu(self.create_menu( + 'advanced_settings'), pos + (0, size[1])) + + def create_client_info(self) -> None: + main_sizer = wx.BoxSizer(wx.HORIZONTAL) + + # port + self.port_label, self.port_label_text = BasicCompo.create_label_textbox(panel =self.panel, + label='Listener Port: ', text_box_value=str(INV_PORT), enable=False, textbox_needed=1, horizontal=1) + main_sizer.Add(self.port_label, 1, wx.EXPAND | wx.ALL, 3) + + # AE Title + self.ae_title_label, self.ae_title_label_text = BasicCompo.create_label_textbox(panel= self.panel, + label='AE Title: ', text_box_value=INV_AET, enable=False, textbox_needed=1, horizontal=1) + main_sizer.Add(self.ae_title_label, 1, wx.EXPAND | wx.ALL, 3) + + # Advanced Settings Button + self.advanced_settings_button = BasicCompo.create_button(panel= self.panel, + label='Advanced Settings', enable=True) + self.advanced_settings_button.Bind( + wx.EVT_BUTTON, self.on_advance_setting_click) + main_sizer.Add(self.advanced_settings_button, 0, wx.EXPAND | wx.ALL, 5) + return main_sizer + + def create_header_label(self) -> wx.BoxSizer: + main_header_sizer = wx.BoxSizer(wx.HORIZONTAL) + label_sizer = wx.BoxSizer(wx.VERTICAL) + header_sizer = wx.BoxSizer(wx.HORIZONTAL) + + self.pacs_label = wx.StaticText(self.panel, label='PACS Servers') + label_sizer.Add(self.pacs_label, 0, wx.LEFT | + wx.TOP | wx.BOTTOM, border=5) + main_header_sizer.Add(label_sizer, 0, wx.EXPAND | wx.ALL, 3) + + self.spacer = wx.StaticText(self.panel, label='', size=(150, 10)) + header_sizer.Add(self.spacer, 0, wx.ALIGN_CENTER_VERTICAL | + wx.LEFT | wx.TOP | wx.BOTTOM, border=5) + + self.verify_pacs_button = BasicCompo.create_button(panel= self.panel, + label='Verify', enable=False) + header_sizer.Add(self.verify_pacs_button, 0, wx.ALIGN_CENTER_VERTICAL | + wx.LEFT | wx.TOP | wx.BOTTOM, border=5) + + self.verify_pacs_button.Bind(wx.EVT_BUTTON, self.verify_pacs) + + self.delete_pacs_button = BasicCompo.create_button(panel= self.panel, + label='Delete', enable=False) + header_sizer.Add(self.delete_pacs_button, 0, wx.ALIGN_CENTER_VERTICAL | + wx.RIGHT | wx.TOP | wx.BOTTOM, border=5) + + self.delete_pacs_button.Bind(wx.EVT_BUTTON, self.delete_row) + + self.up_button = BasicCompo.create_button(panel= self.panel,label='Up', enable=False) + header_sizer.Add(self.up_button, 0, wx.ALIGN_CENTER_VERTICAL | + wx.RIGHT | wx.TOP | wx.BOTTOM, border=5) + self.up_button.Bind(wx.EVT_BUTTON, self.on_up) + + self.down_button = BasicCompo.create_button(panel= self.panel,label='Down', enable=False) + header_sizer.Add(self.down_button, 0, wx.ALIGN_CENTER_VERTICAL | + wx.RIGHT | wx.TOP | wx.BOTTOM, border=5) + self.down_button.Bind(wx.EVT_BUTTON, self.on_down) + + main_header_sizer.Add(header_sizer, 1, wx.LEFT, 5) + + return main_header_sizer + + def create_table(self, array: list, cols_list: list) -> gridlib.Grid: + tmp_table = gridlib.Grid(self.panel, wx.ID_ANY) + tmp_table.SetMinSize((500, 150)) + + + tmp_table.CreateGrid(len(array), len(cols_list)) + + for i, col in enumerate(cols_list): + tmp_table.SetColLabelValue(i, col) + tmp_table.SetDefaultColSize(150) + + for i, row in enumerate(array): + for j, col_tag in enumerate(cols_list): + val = row.get(col_tag, '') + tmp_table.SetCellAlignment(i, j, wx.ALIGN_CENTER, wx.ALIGN_CENTER) + tmp_table.SetCellValue(i, j, val) + tmp_table.SetCellOverflow(i, j, True) + tmp_table.HideRowLabels() + tmp_table.SetScrollbars(10, 10, 100, 100) + + + return tmp_table + + + def on_select(self, event: Any) -> None: + # Enable the button if a row is selected + initiator = self.pacs_table + reactor_list = [self.verify_pacs_button, self.delete_pacs_button, self.up_button, self.down_button, self.ip_edit_button] + row_id = event.GetRow() + + initiator.SelectRow(row_id) + if row_id >= 0: + for reactor in reactor_list: + reactor.Enable() + # fill the self.ip_add, self.port, self.ae_title of form with this data + self.change_box_value(row_id) + # enable all the buttons + self.ip_add_button.Enable() + self.ip_edit_button.Enable() + + def on_up(self, event: Any) -> None: + initiator = self.pacs_table + row_id = initiator.GetSelectedRows()[0] + reactor = event.GetEventObject() + if row_id > 0: + initiator.MoveCursorDown(True) + initiator.SelectRow(row_id-1) + self.change_box_value(row_id-1) + reactor.Enable() + + else: + reactor.Disable() + + def change_box_value(self,row_id) -> None: + initiator = self.pacs_table + self.ip_address_textbox.SetValue(initiator.GetCellValue(row_id, 0)) + self.port_textbox.SetValue(initiator.GetCellValue(row_id, 1)) + self.ae_title_textbox.SetValue(initiator.GetCellValue(row_id, 2)) + self.description_textbox.SetValue(initiator.GetCellValue(row_id, 3)) + return + + def on_down(self, event: Any) -> None: + reactor = event.GetEventObject() + initiator = self.pacs_table + row_id = initiator.GetSelectedRows()[0] + if row_id < initiator.GetNumberRows()-1: + initiator.MoveCursorUp(True) + initiator.SelectRow(row_id+1) + reactor.Enable() + self.change_box_value(row_id+1) + grid_cursor_row = initiator.GetGridCursorRow() + initiator.SetGridCursor(max(0, grid_cursor_row - 1), 0) + + def delete_row(self, event: Any) -> None: + try: + initiator = self.pacs_table + row_id = initiator.GetSelectedRows()[0] + dialog = CustomDialog( + self, 'Are you sure you want to delete this IP configuration?') + if dialog.ShowModal() == wx.ID_YES: + wx.MessageBox('PACS Configuration Deleted Successfully', "Success", wx.OK | wx.ICON_INFORMATION) + initiator.DeleteRows(row_id) + self.configured_pacs.pop(row_id) + with open(CONFIG_FILE, 'w') as file: + json.dump(self.pacs_data, file) + + self.ip_address_textbox.SetValue('') + self.port_textbox.SetValue('') + self.ae_title_textbox.SetValue('') + self.description_textbox.SetValue('') + + except Exception as e: + wx.MessageBox('Please Select a row to delete', "", wx.OK | wx.ICON_INFORMATION) + print(f"ERROR WHILE DELETING THE ROW: {e}") + + def verify_pacs(self, event: Any) -> None: + + initiator = self.pacs_table + row_id = initiator.GetSelectedRows()[0] + c_echo = CEcho(initiator.GetCellValue(row_id, 0), + int(initiator.GetCellValue(row_id, 1))) + status = c_echo.verify() + if status: + wx.MessageBox('PACS Server Verified Successfully', "Success", wx.OK | wx.ICON_INFORMATION) + + num_cols = initiator.GetNumberCols() + for col in range(num_cols): + initiator.SetCellBackgroundColour(row_id, col, wx.GREEN) + else: + wx.MessageBox('PACS Server is not responding', "Failed", wx.OK | wx.ICON_INFORMATION) + num_cols = initiator.GetNumberCols() + for col in range(num_cols): + initiator.SetCellBackgroundColour(row_id, col, wx.RED) + self.deselect_rows_pacs(initiator) + return status + + def deselect_rows(self, initiator: Any, reactor_list: list) -> None: + #TODO: Add Logic + return + + def deselect_rows_pacs(self, initiator: Any) -> None: + #TODO: Add Logic + return + + def create_pacs_info(self) -> wx.BoxSizer: + main_sizer = wx.BoxSizer(wx.VERTICAL) + + #Labels and Buttons + main_header_sizer = self.create_header_label() + main_sizer.Add(main_header_sizer, 0, wx.EXPAND | wx.ALL, 5) + + # grid + configured_pacs_columns = ['IP ADDRESS', 'PORT', 'AE TITLE', + 'Description', 'Retrievel Protocol', 'Preferred Transfer Syntax'] + self.pacs_table = self.create_table( + self.configured_pacs, configured_pacs_columns) + self.pacs_table.Bind(wx.grid.EVT_GRID_CELL_LEFT_CLICK, self.on_select) + main_sizer.Add(self.pacs_table, 1, wx.EXPAND | wx.ALL, 5) + + return main_sizer + + def create_add_pacs_server(self) -> wx.StaticBoxSizer: + box = wx.StaticBox(self.panel, label='Add PACS Server') + main_sizer = wx.StaticBoxSizer(box, wx.HORIZONTAL) + + ip_address, self.ip_address_textbox = BasicCompo.create_label_textbox(panel = self.panel, + label='IP Address', enable=True, label_size=(10, -1), textbox_size=(30, -1)) + self.ip_address_textbox.Bind(wx.EVT_TEXT, self.on_text_enter) + main_sizer.Add(ip_address, 1, wx.EXPAND | wx.ALL, 5) + + port, self.port_textbox = BasicCompo.create_label_textbox(panel = self.panel, + label='Port', enable=True) + self.port_textbox.Bind(wx.EVT_TEXT, self.on_text_enter) + main_sizer.Add(port, 1, wx.EXPAND | wx.ALL, 5) + + ae_title, self.ae_title_textbox = BasicCompo.create_label_textbox(panel=self.panel, + label='AE Title', enable=True) + self.ae_title_textbox.Bind(wx.EVT_TEXT, self.on_text_enter) + main_sizer.Add(ae_title, 1, wx.EXPAND | wx.ALL, 5) + + description, self.description_textbox = BasicCompo.create_label_textbox(panel = self.panel, + label='Description', enable=True) + # bind description with the on_text_enter fucntion + self.description_textbox.Bind(wx.EVT_TEXT, self.on_text_enter) # wx.EVT_TEXT_ENTER + main_sizer.Add(description, 1, wx.EXPAND | wx.ALL, 5) + + # add button + button_sizer = wx.BoxSizer(wx.VERTICAL) + self.ip_add_button = BasicCompo.create_button(panel= self.panel,label='Add', enable=False) + self.ip_add_button.Bind(wx.EVT_BUTTON, self.add_pacs_server) + + self.ip_edit_button = BasicCompo.create_button(panel= self.panel,label='Update', enable=False) + self.ip_edit_button.Bind(wx.EVT_BUTTON, self.add_pacs_server) + + button_sizer.Add(self.ip_add_button, 1, wx.EXPAND | wx.ALL, 5) + button_sizer.Add(self.ip_edit_button, 1, wx.EXPAND | wx.ALL, 5) + main_sizer.Add(button_sizer, 0, wx.EXPAND | wx.ALL, 5) + + return main_sizer + + def validators(self, ip_address_value: str, port_value: int, ae_title_value: str, edit: bool = False, **kwargs) -> bool: + if not is_valid_port(port_value): + raise ValueError('Invalid Port') + + if not is_valid_ip_address(ip_address_value): + raise ValueError('Invalid IP Address') + + # now same pair of ip_address and port_number should not be added + same_ip_address_port_pair = any(ip_address_value == obj.get( + 'IP ADDRESS') and port_value == obj.get('PORT') for obj in self.configured_pacs) + same_aet_title = any(ae_title_value == obj.get('AE TITLE') + for obj in self.configured_pacs) + + # for edit, we will check whether and pair is unique or not excluding the current row + same_ip_address_port_pair_edit = any(ip_address_value == obj.get( + 'IP ADDRESS') and port_value == obj.get('PORT') and kwargs.get('row', 0) != index for index, obj in enumerate(self.configured_pacs)) + same_aet_title_edit = any(ae_title_value == obj.get('AE TITLE') and kwargs.get('row', 0) != index + for index, obj in enumerate(self.configured_pacs)) + + if edit: + if same_ip_address_port_pair_edit or same_aet_title_edit: + raise ValueError('Same AE Title or IP Address and Port number already exists') + else: + return True + else: + if same_ip_address_port_pair or same_aet_title: + raise ValueError('Same AE Title or IP Address and Port number already exists') + else: + return True + + + def add_pacs_server(self, evt: Any) -> None: + #TODO: Refactor this function, it's becoming hard to maintain + ip_address_value = self.ip_address_textbox.GetValue() + port_value = self.port_textbox.GetValue() + ae_title_value = self.ae_title_textbox.GetValue() + description_value = self.description_textbox.GetValue() + evt_obj = evt.GetEventObject() + button_label = evt_obj.GetLabel() + edit = button_label == 'Update' + try: + self.row = 0 + if self.pacs_table.GetSelectedRows(): + self.row = self.pacs_table.GetSelectedRows()[0] + self.validators(ip_address_value, port_value, + ae_title_value, edit=edit, row=self.row) + except ValueError as ve: + errordlg = wx.MessageDialog( + self, f"Invalid: {ve}", f"Error: {ve}", wx.OK | wx.ICON_ERROR) + errordlg.ShowModal() + time.sleep(3) + errordlg.Destroy() + return + dlg = CustomDialog(self, f"Do you want to {button_label} this item?") + result = dlg.ShowModal() + valid = True # need to create_function to verify if the data are valid + if valid and result == wx.ID_YES: + try: + ip_address_value = self.ip_address_textbox.GetValue() + port_value = self.port_textbox.GetValue() + ae_title_value = self.ae_title_textbox.GetValue() + description_value = self.description_textbox.GetValue() + + configured_pacs_columns = ['IP ADDRESS', 'PORT', 'AE TITLE', + 'Description', 'Retrievel Protocol', 'Preferred Transfer Syntax'] + new_data = {'IP ADDRESS': ip_address_value, 'PORT': port_value, 'AE TITLE': ae_title_value, + 'Description': description_value, 'Retrievel Protocol': 'DICOM', 'Preferred Transfer Syntax': 'Implicit VR Little Endian'} + if edit: + row_number = self.pacs_table.GetSelectedRows()[0] + self.configured_pacs[row_number] = new_data + for col_nu in range(self.pacs_table.GetNumberCols()): + col_tag = configured_pacs_columns[col_nu] + self.pacs_table.SetCellValue( + row_number, col_nu, new_data.get(col_tag, '')) + else: + self.configured_pacs.append(new_data) + self.pacs_table.AppendRows(1) + for col_nu in range(self.pacs_table.GetNumberCols()): + col_tag = configured_pacs_columns[col_nu] + self.pacs_table.SetCellValue( + self.pacs_table.GetNumberRows()-1, col_nu, new_data.get(col_tag, '')) + self.pacs_table.SetCellAlignment(self.pacs_table.GetNumberRows()-1, col_nu, wx.ALIGN_CENTER, wx.ALIGN_CENTER) + + except Exception as e: + print(f"ERROR While Adding PACS Server details: {e}") + if not valid and result == wx.ID_YES: + self.show_error_message('Invalid Data', 'Please check your data') + + dlg.Destroy() + + self.ip_address_textbox.SetValue('') + self.port_textbox.SetValue('') + self.ae_title_textbox.SetValue('') + self.description_textbox.SetValue('') + return + + def create_footer(self) -> wx.BoxSizer: + main_sizer = wx.BoxSizer(wx.HORIZONTAL) + + # save button + save_button = BasicCompo.create_button(panel= self.panel,label='Save', enable=True) + save_button.Bind(wx.EVT_BUTTON, self.on_save) + + + # cancel button + cancel_button = BasicCompo.create_button(panel= self.panel,label='Cancel', enable=True) + cancel_button.Bind(wx.EVT_BUTTON, self.on_cancel) + + + button_sizer = wx.BoxSizer(wx.HORIZONTAL) + button_sizer.AddStretchSpacer() + button_sizer.Add(cancel_button, 0, wx.RIGHT, 5) + button_sizer.Add(save_button, 0, wx.RIGHT) + main_sizer.Add(button_sizer, 0, wx.RIGHT | wx.EXPAND | wx.ALL, 3) + + return main_sizer + + def on_save(self, event: Any) -> None: + dlg = CustomDialog(self, "Do you want to save this item?") + result = dlg.ShowModal() + if result == wx.ID_YES: + with open(CONFIG_FILE, 'w') as file: + print('Writing to json file') + json.dump(self.pacs_data, file) + self.Close() + + dlg.Destroy() + + def on_cancel(self, event: Any) -> None: + dlg = CustomDialog(self, "Do you want to cancel this item?") + result = dlg.ShowModal() + if result == wx.ID_YES: + self.Close() + dlg.Destroy() + + def show_error_message(self, message: str = 'Some Error Occured') -> None: + app = wx.App() + msg_dlg = wx.MessageDialog( + self, message, "Error", wx.OK | wx.ICON_ERROR) + msg_dlg.ShowModal() + + def on_text_enter(self, event: Any)->None: + if self.ip_address_textbox.GetValue() and self.port_textbox.GetValue() and self.ae_title_textbox.GetValue(): + self.ip_add_button.Enable(True) + else: + self.ip_add_button.Disable() + +if __name__ == "__main__": + + app = wx.App() + frame = Configuration() + frame.Show() + app.MainLoop() diff --git a/pacs_connection/ui/panel.py b/pacs_connection/ui/panel.py new file mode 100644 index 000000000..3914df44e --- /dev/null +++ b/pacs_connection/ui/panel.py @@ -0,0 +1,71 @@ +import wx +import sys +import webbrowser +from pacs_connection.ui.search_download import Browse +from pacs_connection.ui.upload import UploadFiles +from pacs_connection.ui.pacs_config import Configuration + +class NetPanel(wx.Frame): + + def __init__(self): + super().__init__(parent=None, size= (-1,300), title='Network Panel') + self.panel = wx.Panel(self) + self.main_sizer = wx.BoxSizer(wx.VERTICAL) + self.create_ui() + self.panel.SetSizer(self.main_sizer) + + def create_ui(self): + #self.dummy_menu() + self.create_menu() + + def create_menu(self): + sizer = wx.GridBagSizer(1, 1) + self.browse_button = wx.Button(self.panel, label='Browse') + self.upload_button = wx.Button(self.panel, label='Upload') + self.config_button = wx.Button(self.panel, label='Configuration') + self.help_button = wx.Button(self.panel, label='Help') + sizer.Add(self.browse_button, pos=(3,4), flag=wx.ALL, border=5) + sizer.Add(self.upload_button, pos=(3,8), flag=wx.ALL, border=5) + sizer.Add(self.config_button, pos=(5,4), flag=wx.ALL, border=5) + sizer.Add(self.help_button, pos=(5,8), flag=wx.ALL, border=5) + #add this sizer in the middle of the main sizer + self.main_sizer.Add(sizer, 0, wx.ALL|wx.EXPAND | wx.CentreX | wx.CentreY, 5) + + #mapping to action + self.browse_button.Bind(wx.EVT_BUTTON, self.on_browse) + self.upload_button.Bind(wx.EVT_BUTTON, self.on_upload) + self.config_button.Bind(wx.EVT_BUTTON, self.on_config) + self.help_button.Bind(wx.EVT_BUTTON, self.on_help) + + def on_browse(self, event): + pop = Browse() + #close current panel + self.Close() + pop.Show() + pop.Bind(wx.EVT_CLOSE, lambda event : pop.Destroy()) + + def on_upload(self, event): + pop = UploadFiles() + self.Close() + pop.Show() + pop.Bind(wx.EVT_CLOSE, lambda event : pop.Destroy()) + + def on_config(self, event): + pop = Configuration() + self.Close() + pop.Show() + pop.Bind(wx.EVT_CLOSE, lambda event : pop.Destroy()) + + def on_help(self, event): + # TODO: add help page + webbrowser.open('https://github.com/invesalius/invesalius3') + self.Close() + + + +if __name__ == '__main__': + app = wx.App() + frame = NetPanel() + frame.Show() + app.MainLoop() + diff --git a/pacs_connection/ui/search_download.py b/pacs_connection/ui/search_download.py new file mode 100644 index 000000000..38675dcc3 --- /dev/null +++ b/pacs_connection/ui/search_download.py @@ -0,0 +1,373 @@ +from typing import Any +import time +import wx +import wx.adv as wxadv +import wx.grid as gridlib +import concurrent.futures +from concurrent.futures import ThreadPoolExecutor +import sys +sys.path.append('D:\Opensource\Invesaliusproject\\fork\invesalius3') +from pacs_connection.dicom_client.cfind import CFind +from pacs_connection.ui.pacs_config import Configuration +from pacs_connection.ui.download_history import DownloadHistory +from pacs_connection.helpers import json_serial +from pacs_connection.constants import COLS, CONFIG_FILE +import datetime + + +def get_pacs_details(): + pacs_config_data = json_serial(CONFIG_FILE) + configured_pacs = pacs_config_data['configured_pacs'] + configured_pacs_mapper = {} + all_pacs = [] + for i, pacs_obj in enumerate(configured_pacs): + configured_pacs_mapper[pacs_obj['AE TITLE']] = pacs_obj + all_pacs.append(pacs_obj['AE TITLE']) + return configured_pacs_mapper, all_pacs, configured_pacs + + +class Browse(wx.Frame): + + CONFIGURED_PACS_MAPPER, ALL_PACS, CONFIGURED_PACS = get_pacs_details() + def __init__(self, title:str ="Browse and Download", size:tuple=(800, 550)) -> None: + wx.Frame.__init__(self, None, -1, title, size =size, style=wx.DEFAULT_FRAME_STYLE ^ wx.RESIZE_BORDER) + self.panel = wx.Panel(self, wx.ID_ANY, size=size) + self.main_layout = wx.BoxSizer(wx.VERTICAL) + self.configured_pacs_mapper = self.CONFIGURED_PACS_MAPPER + self.all_pacs = self.ALL_PACS + self.configured_pacs = self.CONFIGURED_PACS + self.create_ui() + self.panel.SetSizer(self.main_layout) + + def create_line(self, horizontal:int =1) -> None: + if horizontal: + self.sl = wx.StaticLine(self.panel, 1, style=wx.LI_HORIZONTAL) + else: + self.sl = wx.StaticLine(self.panel, 2, style=wx.LI_VERTICAL) + self.main_layout.Add(self.sl, 0, wx.EXPAND | wx.ALL, 1) + + def create_ui(self) -> None: + self.main_layout.Add(self.create_header_1(), 0, wx.EXPAND | wx.ALL, 1) + self.create_line() + self.main_layout.Add(self.create_header_2(), 0, wx.EXPAND | wx.ALL, 1) + self.main_layout.Add(self.create_show_search_result([]), 1, wx.EXPAND | wx.ALL, 1) + self.main_layout.Add(self.create_image_details(size = (-1,130)), 1, wx.EXPAND | wx.ALL, 1) + self.main_layout.Add(self.create_footer(),1, wx.RIGHT, 1) + + def create_select_box(self, locations:list, cur_selection:int=0, **kwargs) -> wx.ComboBox: + combo = wx.ComboBox(self.panel, choices=locations, style =wx.CB_DROPDOWN | wx.CB_SORT) + combo.SetSelection(cur_selection) + combo.Bind(wx.EVT_COMBOBOX_CLOSEUP, lambda event: self.on_selection_X(event, **kwargs)) + return combo + + def on_selection_X(self, event:wx.Event, **kwargs) ->None: + obj = event.GetEventObject() + selected_text = obj.GetStringSelection() + idx = obj.FindString(selected_text) + if kwargs.get('reactors'): + reactors = kwargs.get('reactors') + on_option = kwargs.get('on_option') + if selected_text == on_option or idx == on_option: + for reactor in reactors: + reactor.Enable() + else: + for reactor in reactors: + reactor.Disable() + + def popup(self, event:wx.Event)->None: + pop = Configuration() + pop.Show() + pop.Bind(wx.EVT_CLOSE, self.on_close) + + + def on_close(self, event:wx.Event)->None: + obj = json_serial(CONFIG_FILE) + self.configured_pacs_mapper, self.all_pacs, self.configured_pacs = get_pacs_details() + self.pacs_location.Clear() + for i, pacs in enumerate(self.all_pacs): + self.pacs_location.Append(pacs) + self.pacs_location.SetSelection(0) + event.Skip() + + def on_date_changed(self, event:wx.Event)->None: + + try: + print(event.GetEventObject()) + print(dir (event.GetEventObject()) ) + print(event.GetEventObject().LabelText) + print(f"cur_date is: {event.GetEventObject().GetValue()}") + except Exception as e: + print(f"ERROR IS: {e}") + + + def date_range_helper(self, option:str)->str: + if option == 'ALL': return "19000101-99991231" + elif option == 'TODAY': + # find today's date + today = datetime.date.today() + # format this in string of "YYYYMMDD" + today = today.strftime("%Y%m%d") + return f"{today}-{today}" + elif option == 'YESTERDAY': + # find yesterday's date + yesterday = datetime.date.today() - datetime.timedelta(days=1) + # format this in string of "YYYYMMDD" + yesterday = yesterday.strftime("%Y%m%d") + return f"{yesterday}-{yesterday}" + + elif option == 'LAST 7 DAYS': + # find today's date + today = datetime.date.today() + # find 7 days before today's date + last_7_days = today - datetime.timedelta(days=7) + # format this in string of "YYYYMMDD" + today = today.strftime("%Y%m%d") + last_7_days = last_7_days.strftime("%Y%m%d") + return f"{last_7_days}-{today}" + + elif option == 'LAST 30 DAYS': + today = datetime.date.today() + last_30_days = today - datetime.timedelta(days=30) + today = today.strftime("%Y%m%d") + last_30_days = last_30_days.strftime("%Y%m%d") + return f"{last_30_days}-{today}" + else: + start_date_range = self.start_date_range.GetValue() + start_date_range = start_date_range.FormatISODate().replace('-', '') + end_date_range = self.end_date_range.GetValue().FormatISODate().replace('-', '') + return f"{start_date_range}-{end_date_range}" + + + def search_result(self, event:wx.Event, **kwargs)->None: + """ + This function is called when the search button is clicked or Enter is pressed + It Make CFIND Request to the PACS and get the result + """ + + date_range = self.date_range_helper(self.all_dates.GetValue()) + + pacs_location = self.pacs_location.GetStringSelection() + + # get the value of the data type + data_type = self.search_type.GetStringSelection() + data_type = data_type.replace(' ', '') + search_value = kwargs.get('obj').GetValue() if kwargs.get('obj') else self.search_textbox.GetValue() + + search_filter = { + 'PatientID': '*', + 'PatientName': '*', + 'AccessionNumber': '*', + } + search_filter[data_type] = search_value + + #host, port = self.configured_pacs_mapper.get(pacs_location).get('IP ADDRESS'), int(self.configured_pacs_mapper.get(pacs_location).get('PORT')) + + + + def make_c_find_request(pacs_obj): + thost = pacs_obj.get('IP ADDRESS') + tport = pacs_obj.get('PORT', 104) + cfind_obj = CFind(host=thost, port=tport) + print(date_range, "daterange") + tmp_result = cfind_obj.make_request(aet='', aet_title='', StudyDate=date_range, pacs_location=pacs_location, PatientID=search_filter.get('PatientID', '*'), PatientName=search_filter.get('PatientName', '*'), AccessionNumber=search_filter.get('AccessionNumber', '*')) + + return tmp_result + + result = [] + self.searching_text.SetLabel("Searching.....") + PUBLIC_PACS_SERVER = [{'IP ADDRESS': 'DicomServer.co.uk', 'PORT': 104}] #configured_pacs + #PUBLIC_PACS_SERVER = self.configured_pacs + msg_dialog = wx.MessageDialog(self, "Searching, please wait...", "Search", wx.OK|wx.ICON_INFORMATION) + msg_dialog.ShowModal() + + start_time = time.time() + + with ThreadPoolExecutor(max_workers=len(PUBLIC_PACS_SERVER)) as executor: + future_to_obj = {executor.submit(make_c_find_request, pacs_obj): pacs_obj for pacs_obj in PUBLIC_PACS_SERVER} + for future in concurrent.futures.as_completed(future_to_obj): + obj = future_to_obj[future] + try: + tmp_result = future.result() + except Exception as exc: + print('%r generated an exception: %s' % (obj, exc)) + else: + result.extend(tmp_result) + + end_time = time.time() + msg_dialog.Destroy() + print(f"Time taken is: {end_time - start_time}") + search_img_table = self.result_table + if search_img_table.GetNumberRows() > 0: + search_img_table.DeleteRows(0, numRows=search_img_table.GetNumberRows()) + try: + tables_data = result + except Exception as e: + print(f"ERROR IS: {e}") + tables_data = [] + + for i in range(len(tables_data)): + values = tables_data[i] + search_img_table.AppendRows(numRows=1) + for col_nu in range(search_img_table.GetNumberCols()): + cols_name = search_img_table.GetColLabelValue(col_nu) + cell_value = str(values.get(cols_name, 'NA')) + + search_img_table.SetCellValue( max(0, search_img_table.GetNumberRows()-1), col_nu, cell_value ) + search_img_table.SetCellOverflow(max(0, search_img_table.GetNumberRows()-1), col_nu,True) + + + self.searching_text.SetLabel(f"Total Result Found: {len(tables_data)}") + + def on_clear(self, event:wx.Event, obj:Any): + obj.SetValue("") + + def on_selection(self, event:wx.Event): + obj = event.GetEventObject() + print(obj) + row_id = event.GetRow() + print(row_id) + obj.SelectRow(row_id) + d = {} + for col in range(obj.GetNumberCols()): + label = obj.GetColLabelValue(col) + value = obj.GetCellValue(row_id, col) + d[label] = value + print(label, value) + + image_details_panel = self.img_details_table + if image_details_panel.GetNumberRows() >0: + image_details_panel.DeleteRows(0) + image_details_panel.AppendRows(1) + for col_nu in range(image_details_panel.GetNumberCols()): + col_label = image_details_panel.GetColLabelValue(col_nu) + print(d.get(col_label, "TEST DATA")) + image_details_panel.SetCellValue(image_details_panel.GetNumberRows()-1, col_nu, d.get(col_label, "TEST DATA")) + self.download_image_btn.Enable() + + def create_header_1(self): + main_sizer = wx.BoxSizer(wx.HORIZONTAL) + + #pacs configuration button + pacs_config_button = wx.Button(self.panel, label="PACS Config") + pacs_config_button.Bind(wx.EVT_BUTTON, self.popup) + main_sizer.Add(pacs_config_button, 0, wx.ALL, 5) + + # pacs location selector + self.pacs_locations_list = self.all_pacs + self.pacs_location = self.create_select_box(self.pacs_locations_list, 0) + main_sizer.Add(self.pacs_location, 0, wx.ALL, 5) + + # modalitites + self.modalities_list = ["All Modalities", "CT", "MR", "US", "CR", "DX", "MG", "NM", "OT", "PT", "RF", "SC", "XA", "XC"] + self.modalities = self.create_select_box(self.modalities_list, 0) + main_sizer.Add(self.modalities, 0, wx.ALL, 5) + + + + #custom date range + self.start_date_range = wxadv.DatePickerCtrl(self.panel, style=wxadv.DP_DROPDOWN) + self.start_date_range.Bind(wxadv.EVT_DATE_CHANGED, self.on_date_changed) + self.start_date_range.Disable() + self.end_date_range = wxadv.DatePickerCtrl(self.panel, style=wxadv.DP_DROPDOWN) + self.end_date_range.Bind(wxadv.EVT_DATE_CHANGED, self.on_date_changed) + self.end_date_range.Disable() + + + # all dates + self.all_dates_list = ['ALL','Custom', 'Today', 'Yesterday', 'Last 7 Days'] + self.all_dates = self.create_select_box(self.all_dates_list, 0, reactors=[self.start_date_range, self.end_date_range], on_option='Custom') + + main_sizer.Add(self.all_dates, 0, wx.ALL, 5) + main_sizer.Add(self.start_date_range, 0, wx.ALL, 5) + main_sizer.Add(self.end_date_range, 0, wx.ALL, 5) + + return main_sizer + + def create_header_2(self): + + main_sizer = wx.BoxSizer(wx.HORIZONTAL) + + #search type + search_type_list = ["Patient ID", "Patient Name", "Accession Number"] + self.search_type = self.create_select_box(search_type_list, 2) + main_sizer.Add(self.search_type, 0, wx.ALL, 5) + + #textbox + self.search_textbox = wx.TextCtrl(self.panel, size=(250, -1) ,style=wx.TE_PROCESS_ENTER) + self.search_textbox.SetHint("Enter Search Text") + self.search_textbox.Bind(wx.EVT_TEXT_ENTER, self.search_result) + main_sizer.Add(self.search_textbox, 0, wx.ALL, 5) + + #search button + search_button = wx.Button(self.panel, label="Search") + search_button.Bind(wx.EVT_BUTTON, lambda event :self.search_result(event, obj= self.search_textbox)) + main_sizer.Add(search_button, 0, wx.ALL, 5) + + #clear button + clear_button = wx.Button(self.panel, label="Clear") + clear_button.Bind(wx.EVT_BUTTON, lambda event : self.on_clear(event, self.search_textbox)) + + main_sizer.Add(clear_button, 0, wx.ALL, 5) + + return main_sizer + + def create_table(self, array:list, cols_list:list, size:tuple= (-1,200)) -> gridlib.Grid: + cur_table = gridlib.Grid(self.panel, wx.ID_ANY, size=size) + cur_table.CreateGrid(len(array), len(cols_list)) + cur_table.SetDefaultColSize(150) + + for i,col in enumerate(cols_list): + cur_table.SetColLabelValue(i, col) + + for i, row in enumerate(array): + for j, val in enumerate(row): + cur_table.SetCellValue(i, j, str(val)) + cur_table.HideRowLabels() + + cur_table.SetScrollbars(100, 100, 10, 10) + return cur_table + + def create_show_search_result(self, arr:list)-> wx.StaticBoxSizer: + box = wx.StaticBox(self.panel, label='') + main_sizer = wx.StaticBoxSizer(box, wx.VERTICAL) + cols_list = COLS + self.result_table = self.create_table(arr, cols_list) + self.result_table.Bind(gridlib.EVT_GRID_CELL_LEFT_CLICK, self.on_selection) + main_sizer.Add(self.result_table, 1, wx.EXPAND | wx.ALL, 5) + return main_sizer + + def create_image_details(self, size:tuple= (-1,200))-> wx.StaticBoxSizer: + box = wx.StaticBox(self.panel, label='') + main_sizer = wx.StaticBoxSizer(box, wx.VERTICAL) + cols_list = COLS + self.img_details_table= self.create_table([], cols_list, size= size) + main_sizer.Add(self.img_details_table, 1, wx.EXPAND | wx.ALL, 5) + return main_sizer + + def create_footer(self)-> wx.BoxSizer: + main_sizer = wx.BoxSizer(wx.HORIZONTAL) + button_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.download_image_btn = wx.Button(self.panel, label = 'Download') + self.download_image_btn.Disable() + button_sizer.Add(self.download_image_btn, 0, wx.ALL, 5) + self.download_history = wx.Button(self.panel, label = 'Download History') + self.download_history.Bind(wx.EVT_BUTTON, self.show_download_history) + + main_sizer.Add(button_sizer, 1, wx.RIGHT, 1) + main_sizer.Add(self.download_history, 0, wx.ALL, 5) + self.searching_text = wx.StaticText(self.panel, label="") + main_sizer.Add(self.searching_text, 0, wx.ALL, 5) + return main_sizer + + def show_download_history(self, event:wx.Event)->None: + pop = DownloadHistory() + pop.Show() + pop.Bind(wx.EVT_CLOSE, self.on_close) + + +if __name__ == '__main__': + app = wx.App() + frame = Browse() + frame.Show() + app.MainLoop() + diff --git a/pacs_connection/ui/upload.py b/pacs_connection/ui/upload.py new file mode 100644 index 000000000..5978bf262 --- /dev/null +++ b/pacs_connection/ui/upload.py @@ -0,0 +1,234 @@ +import wx +from pacs_connection.dicom_client.cstore import CStore +from components import BasicCompo + + +class UploadFiles(wx.Frame): + + def __init__(self): + super().__init__(parent=None, size= (-1,700), title='Upload Files') + self.panel = wx.Panel(self) + self.main_layout = wx.BoxSizer(wx.VERTICAL) + self.panel.SetSizer(self.main_layout) + self.create_gui() + + def create_gui(self): + self.main_layout.Add(self.create_export_option(), 0, wx.EXPAND | wx.ALL, 5) + self.main_layout.Add(self.create_file_format_option(), 0, wx.EXPAND | wx.ALL, 5) + self.main_layout.Add(self.create_export_location(), 0, wx.EXPAND | wx.ALL, 5) + self.main_layout.Add(self.create_file_settings(), 0, wx.EXPAND | wx.ALL, 5) + self.main_layout.Add(self.create_footer(), 0, wx.EXPAND | wx.ALL, 5) + pass + + def create_export_option(self): + self.export_options = wx.RadioBox(self.panel, label='Export', choices=['Selected series', 'Selected Studies'], majorDimension=1, style=wx.RA_SPECIFY_ROWS) + + return self.export_options + + def create_file_format_option(self): + self.file_format_options = wx.RadioBox(self.panel,label='File Format', choices=['DICOM', 'NIFTI', 'JPEG', 'MP4', 'BMP'], majorDimension=1, style=wx.RA_SPECIFY_ROWS) + #bind with on_file_format_selection + self.file_format_options.Bind(wx.EVT_RADIOBOX, self.on_file_format_selection) + return self.file_format_options + + def create_export_location(self): + border = wx.StaticBox(self.panel, label='Export Location') + box = wx.StaticBoxSizer(border, wx.VERTICAL) + browse_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.folder_form, self.folder_form_textbox= BasicCompo.create_label_textbox(self.panel, 'Folder Name', '', True,horizontal=1 ) + self.folder_browse_button = BasicCompo.create_button(self.panel, 'Browse', True) + + browse_sizer.Add(self.folder_form, 0, wx.EXPAND | wx.ALL, 5) + browse_sizer.Add(self.folder_browse_button, 0, wx.EXPAND | wx.ALL, 5) + + box.Add(browse_sizer, 0, wx.EXPAND | wx.ALL, 5) + + #Bind actions + self.folder_browse_button.Bind(wx.EVT_BUTTON, self.on_browse_file) + return box + + def create_file_settings(self): + + border = wx.StaticBox(self.panel, label='File Settings') + sizer= wx.StaticBoxSizer(border, wx.VERTICAL) + self.image_size = wx.RadioBox(self.panel, label='image size', choices=['Original', 'Default(1:1)', 'Custom'], majorDimension=2, style=wx.RA_SPECIFY_ROWS) + self.annotations = wx.RadioBox(self.panel, label='Annotations', choices=['None', 'Default', 'Custom'], majorDimension=1, style=wx.RA_SPECIFY_ROWS) + + self.frame_rate = wx.RadioBox(self.panel, label='Frame Rate', choices=['Default', 'Custom'], majorDimension=2, style=wx.RA_SPECIFY_ROWS) + self.frame_rate.Disable() + self.frame_rate.Bind(wx.EVT_RADIOBOX, self.on_radio_box) + self.custom_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.custom_label = wx.StaticText(self.panel, label="FPS") + self.custom_sizer.Add(self.custom_label, 0, wx.EXPAND | wx.ALL, 5) + + self.custom_frame_rate = wx.TextCtrl(self.panel, style=wx.TE_PROCESS_ENTER, value='30') + self.custom_sizer.Add(self.custom_frame_rate, 0, wx.EXPAND | wx.ALL, 5) + self.custom_frame_rate.Enable(False) + self.custom_frame_rate.Bind(wx.EVT_TEXT_ENTER, self.on_custom_frame_rate, self.custom_frame_rate) + + self.jpeg_conf = wx.BoxSizer(wx.HORIZONTAL) + self.jpeg_label = wx.StaticText(self.panel, label='JPEG Quality') + self.jpeg_quality = BasicCompo.create_slider(self.panel, 'JPEG Quality',100, 0, 100) + self.jpeg_quality.Disable() + self.jpeg_conf.Add(self.jpeg_label, 0, wx.EXPAND | wx.ALL, 5) + self.jpeg_conf.Add(self.jpeg_quality, 0, wx.EXPAND | wx.ALL, 5) + sizer.Add(self.image_size, 0, wx.EXPAND | wx.ALL, 5) + sizer.Add(self.annotations, 0, wx.EXPAND | wx.ALL, 5) + sizer.Add(self.frame_rate, 0, wx.EXPAND | wx.ALL, 5) + sizer.Add(self.custom_sizer, 0, wx.EXPAND | wx.ALL, 5) + sizer.Add(self.jpeg_conf, 0, wx.EXPAND | wx.ALL, 5) + + return sizer + + def create_footer(self): + sizer = wx.BoxSizer(wx.HORIZONTAL) + self.cancel_button = BasicCompo.create_button(self.panel, 'Cancel', True) + self.cancel_button.SetPosition((self.GetSize().GetWidth() - self.cancel_button.GetSize().GetWidth(), 50)) + self.export_button = BasicCompo.create_button(self.panel, 'Export', True) + self.export_button.SetPosition((self.GetSize().GetWidth() - self.export_button.GetSize().GetWidth(), 0)) + + #Bind actions + self.cancel_button.Bind(wx.EVT_BUTTON, self.on_cancel) + self.export_button.Bind(wx.EVT_BUTTON, self.on_export) + + + sizer.Add(self.cancel_button, 0, wx.EXPAND | wx.ALL, 5) + sizer.Add(self.export_button, 0, wx.EXPAND | wx.ALL, 5) + + return sizer + + def on_cancel(self, event:wx.Event)->None: + self.Close() + + def on_custom_frame_rate(self, event:wx.Event)->None: + try: + frame_rate = int(self.custom_frame_rate.GetValue()) + print(f"Custom frame rate: {frame_rate}") + except ValueError: + wx.MessageBox("Please enter an integer value.", "Invalid Value", wx.OK|wx.ICON_ERROR) + + def on_radio_box(self, event: wx.Event)->None: + if event.GetInt() == 1: # Custom option selected + self.custom_frame_rate.Enable(True) + else: + self.custom_frame_rate.Enable(False) + + def on_export(self, even: wx.Event)->None: + # find which export option is selected + try: + export_option = self.export_options.GetStringSelection() + # find which file format is selected + file_format = self.file_format_options.GetStringSelection() + cstore = CStore("DicomServer.co.uk", 104) + path = self.folder_form_textbox.GetValue() + + # get other information like JPEG Quality, FPS Rate + jpeg_quality = self.jpeg_quality.GetValue() + print(self.jpeg_quality.IsEnabled()) + print(f"JPEG Quality: {jpeg_quality}") + frame_rate = self.custom_frame_rate.GetValue() + print(self.custom_frame_rate.IsEnabled()) + print(f"Frame Rate: {frame_rate}") + + if export_option == 'Selected series': + print('Selected series') + print('File format: ', file_format) + req = cstore.upload(path, False) + print(req) + elif export_option =='Selected Studies': + print('Selected Studies') + print('File format: ', file_format) + req = cstore.upload(path, True) + print(req) + # Notifications for success/failure + req_status = "Success" if req else "Failed" + msg = 'Successfully exported' if req else 'Failed to export' + wx.MessageBox(msg, req_status, wx.OK | wx.ICON_INFORMATION) + print(msg) + except Exception as e: + print(e) + wx.MessageBox("Please select a valid export option.", "Invalid Value", wx.OK|wx.ICON_ERROR) + + return + + def on_file_format_selection(self, event:wx.Event)->None: + event = event.GetEventObject() + file_format = event.GetStringSelection() + print(file_format) + + if file_format in ['JPEG', 'BITMAP']: + print('HERE 290') + self.jpeg_quality.Enable() + self.frame_rate.Disable() + + elif file_format in ['MP4']: + self.frame_rate.Enable() + self.jpeg_quality.Disable() + else: + self.frame_rate.Disable() + self.jpeg_quality.Enable() + + return + + def on_browse_file(self, event: wx.Event)->None: + evt_obj = event.GetEventObject() + # now we wanna see the selected Export Type + export_type = self.export_options.GetStringSelection() + file_format = self.file_format_options.GetStringSelection() + wildcard = self.wild_card_mapper(file_format) + if export_type == 'Selected series': + dialog = wx.FileDialog(None, "Choose a file", + wildcard=wildcard, + style=wx.FD_OPEN | wx.FD_MULTIPLE) + if dialog.ShowModal() == wx.ID_OK: + paths = dialog.GetPaths() + print("Selected files:") + self.folder_form_textbox.SetValue(paths[0]) + for path in paths: + print(path) + dialog.Destroy() + + else: + dialog = wx.DirDialog(None, "Choose a directory:", + style=wx.DD_DEFAULT_STYLE + ) + if dialog.ShowModal() == wx.ID_OK: + folder_path = dialog.GetPath() + import os + files = os.listdir(folder_path) + # print the names of any files in the folder + self.folder_form_textbox.SetValue(folder_path) + for file in files: + if os.path.isfile(os.path.join(folder_path, file)): + print(file) + dialog.Destroy() + + # TODO: WE NEED TO SELECT SET OF REQUIRED FILES, so let users select folders + return + + @staticmethod + def wild_card_mapper(file_format:str)->str: + if file_format == 'DICOM': + return "DICOM files (*.dcm)|*.dcm" + elif file_format == 'NIFTI': + return "NIFTI files (*.nii)|*.nii" + elif file_format == 'JPEG': + return "JPEG files (*.jpg)|*.jpg" + elif file_format == 'MP4': + return "MP4 files (*.mp4)|*.mp4" + elif file_format == 'BMP': + return "BMP files (*.bmp)|*.bmp" + else: + return "All files (*.*)|*.*" + + + + + + + +if __name__ == '__main__': + app = wx.App() + frame = UploadFiles() + frame.Show() + app.MainLoop() \ No newline at end of file