Coverage for main/ui/import_page.py: 95%
119 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
1import gc
2import os
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)
11from threads.import_thread import ImportThread
12from ui.patient_selection_page import PatientSelectionPage
13from page import Page
14from logger import get_logger
16log = get_logger()
19class ImportPage(Page):
20 """
21 GUI page that allows importing patient data directories.
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.
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 """
41 def __init__(self, context=None):
42 """
43 Initialize the ImportPage with the given context.
45 Parameters
46 ----------
47 context : dict, optional
48 Application context including 'workspace_path', 'main_window', and signals.
49 """
50 super().__init__()
52 self.context = context
53 self.next_page = None
54 self.workspace_path = context["workspace_path"]
55 self.threads = []
56 self.progress_dialogs = []
58 # Enable drag & drop and apply a dashed border
59 self.setAcceptDrops(True)
60 self.setEnabled(True)
61 self.setStyleSheet("border: 2px dashed gray;")
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)
73 # Handle localization updates
74 self._translate_ui()
75 if context and "language_changed" in context:
76 context["language_changed"].connect(self._translate_ui)
78 # ----------------------------
79 # Navigation
80 # ----------------------------
82 def is_ready_to_advance(self):
83 """
84 Check if the page can advance to the next step.
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
99 def is_ready_to_go_back(self):
100 """
101 Check if the user can navigate back to the previous page.
103 Returns
104 -------
105 bool
106 Always returns False (first step in workflow).
107 """
108 return False
110 def next(self, context):
111 """
112 Transition to the next page (PatientSelectionPage).
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
127 def back(self):
128 """This page has no previous step."""
129 return False
131 # ----------------------------
132 # Drag & Drop Import
133 # ----------------------------
135 def dragEnterEvent(self, event):
136 """Accept drag if the event contains file URLs."""
137 if event.mimeData().hasUrls():
138 event.acceptProposedAction()
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)
147 # ----------------------------
148 # Folder Selection Dialog
149 # ----------------------------
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()
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("~"))
167 # Allow multi-selection in both list and tree views
168 for view in dialog.findChildren((QListView, QTreeView)):
169 view.setSelectionMode(view.SelectionMode.ExtendedSelection)
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)
180 # ----------------------------
181 # Import Handling
182 # ----------------------------
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)
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()
197 self.progress_dialogs[-1].canceled.connect(self.on_import_canceled)
199 # ----------------------------
200 # Thread Callbacks
201 # ----------------------------
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
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}")
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
226 index = self.threads.index(thread)
227 self.threads.remove(thread)
228 self.progress_dialogs[index].close()
230 if self.context and "update_main_buttons" in self.context:
231 self.context["update_main_buttons"]()
232 log.info("Import completed.")
234 def on_import_canceled(self):
235 """Handle user-canceled import operations."""
236 dialog = self.sender()
237 index = self.progress_dialogs.index(dialog)
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)
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()
257 # ----------------------------
258 # Translation
259 # ----------------------------
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"))