ui.import_page
1import gc 2import os 3 4from PyQt6.QtCore import Qt, QCoreApplication 5from PyQt6.QtGui import QFont 6from PyQt6.QtWidgets import ( 7 QHBoxLayout, QLabel, QFileDialog, QListView, QTreeView, QMessageBox, 8 QProgressDialog, QApplication 9) 10 11from threads.import_thread import ImportThread 12from ui.patient_selection_page import PatientSelectionPage 13from page import Page 14from logger import get_logger 15 16log = get_logger() 17 18 19class ImportPage(Page): 20 """ 21 GUI page that allows importing patient data directories. 22 23 This class provides both drag-and-drop and dialog-based import options. 24 It starts a background thread for each import operation and displays 25 progress with cancellable dialogs. 26 27 Attributes 28 ---------- 29 context : dict 30 Shared context across application pages (contains settings, paths, signals, etc.). 31 next_page : Page | None 32 Reference to the next page in the workflow. 33 workspace_path : str 34 Root path where imported data is stored. 35 threads : list[ImportThread] 36 Active import threads currently running. 37 progress_dialogs : list[QProgressDialog] 38 Dialogs tracking progress of ongoing imports. 39 """ 40 41 def __init__(self, context=None): 42 """ 43 Initialize the ImportPage with the given context. 44 45 Parameters 46 ---------- 47 context : dict, optional 48 Application context including 'workspace_path', 'main_window', and signals. 49 """ 50 super().__init__() 51 52 self.context = context 53 self.next_page = None 54 self.workspace_path = context["workspace_path"] 55 self.threads = [] 56 self.progress_dialogs = [] 57 58 # Enable drag & drop and apply a dashed border 59 self.setAcceptDrops(True) 60 self.setEnabled(True) 61 self.setStyleSheet("border: 2px dashed gray;") 62 63 # Central label 64 frame_layout = QHBoxLayout(self) 65 self.drop_label = QLabel("Import or select patients' data") 66 self.drop_label.setStyleSheet("font-size: 18px; font-weight: bold;") 67 font = QApplication.font() 68 font.setPointSize(14) 69 self.drop_label.setFont(font) 70 self.drop_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 71 frame_layout.addWidget(self.drop_label) 72 73 # Handle localization updates 74 self._translate_ui() 75 if context and "language_changed" in context: 76 context["language_changed"].connect(self._translate_ui) 77 78 # ---------------------------- 79 # Navigation 80 # ---------------------------- 81 82 def is_ready_to_advance(self): 83 """ 84 Check if the page can advance to the next step. 85 86 Returns 87 ------- 88 bool 89 True if the workspace contains at least one visible directory or link. 90 """ 91 has_content = any( 92 os.path.isdir(os.path.join(self.workspace_path, name)) or 93 os.path.islink(os.path.join(self.workspace_path, name)) 94 for name in os.listdir(self.workspace_path) 95 if not name.startswith(".") 96 ) 97 return has_content 98 99 def is_ready_to_go_back(self): 100 """ 101 Check if the user can navigate back to the previous page. 102 103 Returns 104 ------- 105 bool 106 Always returns False (first step in workflow). 107 """ 108 return False 109 110 def next(self, context): 111 """ 112 Transition to the next page (PatientSelectionPage). 113 114 Returns 115 ------- 116 Page 117 The newly created or existing PatientSelectionPage. 118 """ 119 if self.next_page: 120 self.next_page.on_enter() 121 return self.next_page 122 else: 123 self.next_page = PatientSelectionPage(context, self) 124 self.context["history"].append(self.next_page) 125 return self.next_page 126 127 def back(self): 128 """This page has no previous step.""" 129 return False 130 131 # ---------------------------- 132 # Drag & Drop Import 133 # ---------------------------- 134 135 def dragEnterEvent(self, event): 136 """Accept drag if the event contains file URLs.""" 137 if event.mimeData().hasUrls(): 138 event.acceptProposedAction() 139 140 def dropEvent(self, event): 141 """Handle dropped folders.""" 142 urls = event.mimeData().urls() 143 if urls: 144 file_path = [url.toLocalFile() for url in urls] 145 self._handle_import(file_path) 146 147 # ---------------------------- 148 # Folder Selection Dialog 149 # ---------------------------- 150 151 def mousePressEvent(self, event): 152 """Open a folder dialog on left-click.""" 153 if event.button() == Qt.MouseButton.LeftButton: 154 self.open_folder_dialog() 155 156 def open_folder_dialog(self): 157 """Open dialog to select one or more folders for import.""" 158 dialog = QFileDialog( 159 self.context["main_window"], 160 QCoreApplication.translate("ImportPage", "Select Folder") 161 ) 162 dialog.setFileMode(QFileDialog.FileMode.Directory) 163 dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True) 164 dialog.setOption(QFileDialog.Option.ReadOnly, True) 165 dialog.setDirectory(os.path.expanduser("~")) 166 167 # Allow multi-selection in both list and tree views 168 for view in dialog.findChildren((QListView, QTreeView)): 169 view.setSelectionMode(view.SelectionMode.ExtendedSelection) 170 171 if dialog.exec(): 172 folders = [os.path.abspath(path) for path in dialog.selectedFiles() if os.path.isdir(path)] 173 # Avoid nested duplicates (e.g., selecting parent + child folder) 174 unique_folders = [ 175 f for f in folders 176 if not any(f != other and other.startswith(f + os.sep) for other in folders) 177 ] 178 self._handle_import(unique_folders) 179 180 # ---------------------------- 181 # Import Handling 182 # ---------------------------- 183 184 def _handle_import(self, folders_path): 185 self.progress_dialogs.append(QProgressDialog(QCoreApplication.translate("ImportFrame", "Importing files..."), QCoreApplication.translate("ImportFrame", "Cancel"), 186 0, 100, self.context["main_window"])) 187 self.progress_dialogs[-1].setWindowModality(Qt.WindowModality.NonModal) 188 self.progress_dialogs[-1].setMinimumDuration(0) 189 190 # Start importing thread 191 self.threads.append(ImportThread(self.context, folders_path, self.workspace_path)) 192 self.threads[-1].finished.connect(self.on_import_finished) 193 self.threads[-1].error.connect(self.on_import_error) 194 self.threads[-1].progress.connect(self.progress_dialogs[-1].setValue) 195 self.threads[-1].start() 196 197 self.progress_dialogs[-1].canceled.connect(self.on_import_canceled) 198 199 # ---------------------------- 200 # Thread Callbacks 201 # ---------------------------- 202 203 def on_import_error(self, error): 204 """Handle file import errors from background threads.""" 205 thread = self.sender() 206 if thread not in self.threads: 207 log.warning(f"Ignored error from already removed thread: {error}") 208 return 209 210 index = self.threads.index(thread) 211 self.progress_dialogs[index].close() 212 QMessageBox.critical( 213 self.context["main_window"], 214 QCoreApplication.translate("ImportPage", "Error Importing Files"), 215 QCoreApplication.translate("ImportPage", "Failed to import files:\n{0}").format(error), 216 ) 217 log.error(f"Error Importing Files: {error}") 218 219 def on_import_finished(self): 220 """Cleanup after an import finishes successfully.""" 221 thread = self.sender() 222 if thread not in self.threads: 223 log.warning("Ignored 'finished' signal from an already removed thread.") 224 return 225 226 index = self.threads.index(thread) 227 self.threads.remove(thread) 228 self.progress_dialogs[index].close() 229 230 if self.context and "update_main_buttons" in self.context: 231 self.context["update_main_buttons"]() 232 log.info("Import completed.") 233 234 def on_import_canceled(self): 235 """Handle user-canceled import operations.""" 236 dialog = self.sender() 237 index = self.progress_dialogs.index(dialog) 238 239 if len(self.threads) > index: 240 thread = self.threads[index] 241 thread.cancel() 242 thread.terminate() 243 thread.wait() 244 self.threads.remove(thread) 245 246 def closeEvent(self, event): 247 """Clean up threads and dialogs on application exit.""" 248 for dialog in self.progress_dialogs: 249 dialog.destroy() 250 self.progress_dialogs.remove(dialog) 251 for thread in self.threads: 252 thread.cancel() 253 self.threads.remove(thread) 254 gc.collect() 255 event.accept() 256 257 # ---------------------------- 258 # Translation 259 # ---------------------------- 260 261 def _translate_ui(self): 262 """Apply translated text to UI elements.""" 263 self.drop_label.setText(QCoreApplication.translate("ImportPage", "Import or select patients' data"))
20class ImportPage(Page): 21 """ 22 GUI page that allows importing patient data directories. 23 24 This class provides both drag-and-drop and dialog-based import options. 25 It starts a background thread for each import operation and displays 26 progress with cancellable dialogs. 27 28 Attributes 29 ---------- 30 context : dict 31 Shared context across application pages (contains settings, paths, signals, etc.). 32 next_page : Page | None 33 Reference to the next page in the workflow. 34 workspace_path : str 35 Root path where imported data is stored. 36 threads : list[ImportThread] 37 Active import threads currently running. 38 progress_dialogs : list[QProgressDialog] 39 Dialogs tracking progress of ongoing imports. 40 """ 41 42 def __init__(self, context=None): 43 """ 44 Initialize the ImportPage with the given context. 45 46 Parameters 47 ---------- 48 context : dict, optional 49 Application context including 'workspace_path', 'main_window', and signals. 50 """ 51 super().__init__() 52 53 self.context = context 54 self.next_page = None 55 self.workspace_path = context["workspace_path"] 56 self.threads = [] 57 self.progress_dialogs = [] 58 59 # Enable drag & drop and apply a dashed border 60 self.setAcceptDrops(True) 61 self.setEnabled(True) 62 self.setStyleSheet("border: 2px dashed gray;") 63 64 # Central label 65 frame_layout = QHBoxLayout(self) 66 self.drop_label = QLabel("Import or select patients' data") 67 self.drop_label.setStyleSheet("font-size: 18px; font-weight: bold;") 68 font = QApplication.font() 69 font.setPointSize(14) 70 self.drop_label.setFont(font) 71 self.drop_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 72 frame_layout.addWidget(self.drop_label) 73 74 # Handle localization updates 75 self._translate_ui() 76 if context and "language_changed" in context: 77 context["language_changed"].connect(self._translate_ui) 78 79 # ---------------------------- 80 # Navigation 81 # ---------------------------- 82 83 def is_ready_to_advance(self): 84 """ 85 Check if the page can advance to the next step. 86 87 Returns 88 ------- 89 bool 90 True if the workspace contains at least one visible directory or link. 91 """ 92 has_content = any( 93 os.path.isdir(os.path.join(self.workspace_path, name)) or 94 os.path.islink(os.path.join(self.workspace_path, name)) 95 for name in os.listdir(self.workspace_path) 96 if not name.startswith(".") 97 ) 98 return has_content 99 100 def is_ready_to_go_back(self): 101 """ 102 Check if the user can navigate back to the previous page. 103 104 Returns 105 ------- 106 bool 107 Always returns False (first step in workflow). 108 """ 109 return False 110 111 def next(self, context): 112 """ 113 Transition to the next page (PatientSelectionPage). 114 115 Returns 116 ------- 117 Page 118 The newly created or existing PatientSelectionPage. 119 """ 120 if self.next_page: 121 self.next_page.on_enter() 122 return self.next_page 123 else: 124 self.next_page = PatientSelectionPage(context, self) 125 self.context["history"].append(self.next_page) 126 return self.next_page 127 128 def back(self): 129 """This page has no previous step.""" 130 return False 131 132 # ---------------------------- 133 # Drag & Drop Import 134 # ---------------------------- 135 136 def dragEnterEvent(self, event): 137 """Accept drag if the event contains file URLs.""" 138 if event.mimeData().hasUrls(): 139 event.acceptProposedAction() 140 141 def dropEvent(self, event): 142 """Handle dropped folders.""" 143 urls = event.mimeData().urls() 144 if urls: 145 file_path = [url.toLocalFile() for url in urls] 146 self._handle_import(file_path) 147 148 # ---------------------------- 149 # Folder Selection Dialog 150 # ---------------------------- 151 152 def mousePressEvent(self, event): 153 """Open a folder dialog on left-click.""" 154 if event.button() == Qt.MouseButton.LeftButton: 155 self.open_folder_dialog() 156 157 def open_folder_dialog(self): 158 """Open dialog to select one or more folders for import.""" 159 dialog = QFileDialog( 160 self.context["main_window"], 161 QCoreApplication.translate("ImportPage", "Select Folder") 162 ) 163 dialog.setFileMode(QFileDialog.FileMode.Directory) 164 dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True) 165 dialog.setOption(QFileDialog.Option.ReadOnly, True) 166 dialog.setDirectory(os.path.expanduser("~")) 167 168 # Allow multi-selection in both list and tree views 169 for view in dialog.findChildren((QListView, QTreeView)): 170 view.setSelectionMode(view.SelectionMode.ExtendedSelection) 171 172 if dialog.exec(): 173 folders = [os.path.abspath(path) for path in dialog.selectedFiles() if os.path.isdir(path)] 174 # Avoid nested duplicates (e.g., selecting parent + child folder) 175 unique_folders = [ 176 f for f in folders 177 if not any(f != other and other.startswith(f + os.sep) for other in folders) 178 ] 179 self._handle_import(unique_folders) 180 181 # ---------------------------- 182 # Import Handling 183 # ---------------------------- 184 185 def _handle_import(self, folders_path): 186 self.progress_dialogs.append(QProgressDialog(QCoreApplication.translate("ImportFrame", "Importing files..."), QCoreApplication.translate("ImportFrame", "Cancel"), 187 0, 100, self.context["main_window"])) 188 self.progress_dialogs[-1].setWindowModality(Qt.WindowModality.NonModal) 189 self.progress_dialogs[-1].setMinimumDuration(0) 190 191 # Start importing thread 192 self.threads.append(ImportThread(self.context, folders_path, self.workspace_path)) 193 self.threads[-1].finished.connect(self.on_import_finished) 194 self.threads[-1].error.connect(self.on_import_error) 195 self.threads[-1].progress.connect(self.progress_dialogs[-1].setValue) 196 self.threads[-1].start() 197 198 self.progress_dialogs[-1].canceled.connect(self.on_import_canceled) 199 200 # ---------------------------- 201 # Thread Callbacks 202 # ---------------------------- 203 204 def on_import_error(self, error): 205 """Handle file import errors from background threads.""" 206 thread = self.sender() 207 if thread not in self.threads: 208 log.warning(f"Ignored error from already removed thread: {error}") 209 return 210 211 index = self.threads.index(thread) 212 self.progress_dialogs[index].close() 213 QMessageBox.critical( 214 self.context["main_window"], 215 QCoreApplication.translate("ImportPage", "Error Importing Files"), 216 QCoreApplication.translate("ImportPage", "Failed to import files:\n{0}").format(error), 217 ) 218 log.error(f"Error Importing Files: {error}") 219 220 def on_import_finished(self): 221 """Cleanup after an import finishes successfully.""" 222 thread = self.sender() 223 if thread not in self.threads: 224 log.warning("Ignored 'finished' signal from an already removed thread.") 225 return 226 227 index = self.threads.index(thread) 228 self.threads.remove(thread) 229 self.progress_dialogs[index].close() 230 231 if self.context and "update_main_buttons" in self.context: 232 self.context["update_main_buttons"]() 233 log.info("Import completed.") 234 235 def on_import_canceled(self): 236 """Handle user-canceled import operations.""" 237 dialog = self.sender() 238 index = self.progress_dialogs.index(dialog) 239 240 if len(self.threads) > index: 241 thread = self.threads[index] 242 thread.cancel() 243 thread.terminate() 244 thread.wait() 245 self.threads.remove(thread) 246 247 def closeEvent(self, event): 248 """Clean up threads and dialogs on application exit.""" 249 for dialog in self.progress_dialogs: 250 dialog.destroy() 251 self.progress_dialogs.remove(dialog) 252 for thread in self.threads: 253 thread.cancel() 254 self.threads.remove(thread) 255 gc.collect() 256 event.accept() 257 258 # ---------------------------- 259 # Translation 260 # ---------------------------- 261 262 def _translate_ui(self): 263 """Apply translated text to UI elements.""" 264 self.drop_label.setText(QCoreApplication.translate("ImportPage", "Import or select patients' data"))
GUI page that allows importing patient data directories.
This class provides both drag-and-drop and dialog-based import options. It starts a background thread for each import operation and displays progress with cancellable dialogs.
Attributes
context : dict Shared context across application pages (contains settings, paths, signals, etc.). next_page : Page | None Reference to the next page in the workflow. workspace_path : str Root path where imported data is stored. threads : list[ImportThread] Active import threads currently running. progress_dialogs : list[QProgressDialog] Dialogs tracking progress of ongoing imports.
42 def __init__(self, context=None): 43 """ 44 Initialize the ImportPage with the given context. 45 46 Parameters 47 ---------- 48 context : dict, optional 49 Application context including 'workspace_path', 'main_window', and signals. 50 """ 51 super().__init__() 52 53 self.context = context 54 self.next_page = None 55 self.workspace_path = context["workspace_path"] 56 self.threads = [] 57 self.progress_dialogs = [] 58 59 # Enable drag & drop and apply a dashed border 60 self.setAcceptDrops(True) 61 self.setEnabled(True) 62 self.setStyleSheet("border: 2px dashed gray;") 63 64 # Central label 65 frame_layout = QHBoxLayout(self) 66 self.drop_label = QLabel("Import or select patients' data") 67 self.drop_label.setStyleSheet("font-size: 18px; font-weight: bold;") 68 font = QApplication.font() 69 font.setPointSize(14) 70 self.drop_label.setFont(font) 71 self.drop_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 72 frame_layout.addWidget(self.drop_label) 73 74 # Handle localization updates 75 self._translate_ui() 76 if context and "language_changed" in context: 77 context["language_changed"].connect(self._translate_ui)
Initialize the ImportPage with the given context.
Parameters
context : dict, optional Application context including 'workspace_path', 'main_window', and signals.
83 def is_ready_to_advance(self): 84 """ 85 Check if the page can advance to the next step. 86 87 Returns 88 ------- 89 bool 90 True if the workspace contains at least one visible directory or link. 91 """ 92 has_content = any( 93 os.path.isdir(os.path.join(self.workspace_path, name)) or 94 os.path.islink(os.path.join(self.workspace_path, name)) 95 for name in os.listdir(self.workspace_path) 96 if not name.startswith(".") 97 ) 98 return has_content
Check if the page can advance to the next step.
Returns
bool True if the workspace contains at least one visible directory or link.
100 def is_ready_to_go_back(self): 101 """ 102 Check if the user can navigate back to the previous page. 103 104 Returns 105 ------- 106 bool 107 Always returns False (first step in workflow). 108 """ 109 return False
Check if the user can navigate back to the previous page.
Returns
bool Always returns False (first step in workflow).
111 def next(self, context): 112 """ 113 Transition to the next page (PatientSelectionPage). 114 115 Returns 116 ------- 117 Page 118 The newly created or existing PatientSelectionPage. 119 """ 120 if self.next_page: 121 self.next_page.on_enter() 122 return self.next_page 123 else: 124 self.next_page = PatientSelectionPage(context, self) 125 self.context["history"].append(self.next_page) 126 return self.next_page
Transition to the next page (PatientSelectionPage).
Returns
Page The newly created or existing PatientSelectionPage.
136 def dragEnterEvent(self, event): 137 """Accept drag if the event contains file URLs.""" 138 if event.mimeData().hasUrls(): 139 event.acceptProposedAction()
Accept drag if the event contains file URLs.
141 def dropEvent(self, event): 142 """Handle dropped folders.""" 143 urls = event.mimeData().urls() 144 if urls: 145 file_path = [url.toLocalFile() for url in urls] 146 self._handle_import(file_path)
Handle dropped folders.
152 def mousePressEvent(self, event): 153 """Open a folder dialog on left-click.""" 154 if event.button() == Qt.MouseButton.LeftButton: 155 self.open_folder_dialog()
Open a folder dialog on left-click.
157 def open_folder_dialog(self): 158 """Open dialog to select one or more folders for import.""" 159 dialog = QFileDialog( 160 self.context["main_window"], 161 QCoreApplication.translate("ImportPage", "Select Folder") 162 ) 163 dialog.setFileMode(QFileDialog.FileMode.Directory) 164 dialog.setOption(QFileDialog.Option.DontUseNativeDialog, True) 165 dialog.setOption(QFileDialog.Option.ReadOnly, True) 166 dialog.setDirectory(os.path.expanduser("~")) 167 168 # Allow multi-selection in both list and tree views 169 for view in dialog.findChildren((QListView, QTreeView)): 170 view.setSelectionMode(view.SelectionMode.ExtendedSelection) 171 172 if dialog.exec(): 173 folders = [os.path.abspath(path) for path in dialog.selectedFiles() if os.path.isdir(path)] 174 # Avoid nested duplicates (e.g., selecting parent + child folder) 175 unique_folders = [ 176 f for f in folders 177 if not any(f != other and other.startswith(f + os.sep) for other in folders) 178 ] 179 self._handle_import(unique_folders)
Open dialog to select one or more folders for import.
204 def on_import_error(self, error): 205 """Handle file import errors from background threads.""" 206 thread = self.sender() 207 if thread not in self.threads: 208 log.warning(f"Ignored error from already removed thread: {error}") 209 return 210 211 index = self.threads.index(thread) 212 self.progress_dialogs[index].close() 213 QMessageBox.critical( 214 self.context["main_window"], 215 QCoreApplication.translate("ImportPage", "Error Importing Files"), 216 QCoreApplication.translate("ImportPage", "Failed to import files:\n{0}").format(error), 217 ) 218 log.error(f"Error Importing Files: {error}")
Handle file import errors from background threads.
220 def on_import_finished(self): 221 """Cleanup after an import finishes successfully.""" 222 thread = self.sender() 223 if thread not in self.threads: 224 log.warning("Ignored 'finished' signal from an already removed thread.") 225 return 226 227 index = self.threads.index(thread) 228 self.threads.remove(thread) 229 self.progress_dialogs[index].close() 230 231 if self.context and "update_main_buttons" in self.context: 232 self.context["update_main_buttons"]() 233 log.info("Import completed.")
Cleanup after an import finishes successfully.
235 def on_import_canceled(self): 236 """Handle user-canceled import operations.""" 237 dialog = self.sender() 238 index = self.progress_dialogs.index(dialog) 239 240 if len(self.threads) > index: 241 thread = self.threads[index] 242 thread.cancel() 243 thread.terminate() 244 thread.wait() 245 self.threads.remove(thread)
Handle user-canceled import operations.
247 def closeEvent(self, event): 248 """Clean up threads and dialogs on application exit.""" 249 for dialog in self.progress_dialogs: 250 dialog.destroy() 251 self.progress_dialogs.remove(dialog) 252 for thread in self.threads: 253 thread.cancel() 254 self.threads.remove(thread) 255 gc.collect() 256 event.accept()
Clean up threads and dialogs on application exit.