Coverage for main/ui/patient_selection_page.py: 99%
204 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 shutil
2import sys
3import os
5from PyQt6 import QtGui
6from PyQt6.QtWidgets import (
7 QWidget, QVBoxLayout, QLabel, QPushButton, QScrollArea, QFrame,
8 QGridLayout, QHBoxLayout, QMessageBox, QSizePolicy
9)
10from PyQt6.QtCore import Qt, QCoreApplication
12from ui.tool_selection_page import ToolSelectionPage
13from utils import resource_path
14from page import Page
15from logger import get_logger
17log = get_logger()
20class PatientSelectionPage(Page):
21 """
22 Page for selecting patients to analyze.
24 Displays all patient directories found in the workspace and allows
25 selecting/deselecting them individually or in bulk. Supports dynamic layout
26 resizing, deletion of unselected patients, and navigation to the next step.
28 Attributes
29 ----------
30 context : dict
31 Application-wide shared state (paths, callbacks, etc.).
32 previous_page : Page | None
33 The page to return to when navigating backward.
34 next_page : Page | None
35 The next page to navigate to.
36 workspace_path : str
37 Base folder containing patient data.
38 patient_buttons : dict[str, QPushButton]
39 Mapping of patient IDs to their toggle buttons.
40 selected_patients : set[str]
41 IDs of currently selected patients.
42 column_count : int
43 Number of columns in the patient grid.
44 """
46 def __init__(self, context=None, previous_page=None):
47 """
48 Initialize the patient selection page UI and logic.
50 Parameters
51 ----------
52 context : dict, optional
53 Shared context containing app settings and UI references.
54 previous_page : Page, optional
55 Reference to the previous page for navigation.
56 """
57 super().__init__()
59 self.context = context
60 self.previous_page = previous_page
61 self.next_page = None
62 self.workspace_path = context["workspace_path"]
64 self.patient_buttons = {}
65 self.selected_patients = set()
67 # ----- Layout setup -----
68 self.layout = QVBoxLayout(self)
69 self.title = QLabel("Select Patients to Analyze")
70 self.title.setStyleSheet("font-size: 18px; font-weight: bold;")
71 self.title.setAlignment(Qt.AlignmentFlag.AlignHCenter)
72 self.layout.addWidget(self.title)
74 # ----- Top button controls -----
75 top_buttons_layout = QHBoxLayout()
76 self.select_all_btn = QPushButton("Select All")
77 self.deselect_all_btn = QPushButton("Deselect All")
79 # Button styling
80 btn_style = """
81 QPushButton {
82 background-color: #e0e0e0;
83 padding: 10px 20px;
84 border-radius: 10px;
85 border: 1px solid #bdc3c7;
86 font-weight: bold;
87 }
88 QPushButton:hover {
89 background-color: #d0d0d0;
90 }
91 """
92 self.select_all_btn.setStyleSheet(btn_style)
93 self.deselect_all_btn.setStyleSheet(btn_style)
95 # Button actions
96 self.select_all_btn.clicked.connect(self._select_all_patients)
97 self.deselect_all_btn.clicked.connect(self._deselect_all_patients)
99 top_buttons_layout.addStretch()
100 top_buttons_layout.addWidget(self.select_all_btn)
101 top_buttons_layout.addWidget(self.deselect_all_btn)
102 self.layout.addLayout(top_buttons_layout)
104 # ----- Scroll area for patient cards -----
105 self.scroll_area = QScrollArea()
106 self.scroll_area.setWidgetResizable(True)
107 self.scroll_area.setStyleSheet("""
108 QScrollArea {
109 font-size: 13px;
110 border: 1px solid #CCCCCC;
111 border-radius: 10px;
112 padding: 5px;
113 }
114 """)
116 self.scroll_content = QWidget()
117 self.grid_layout = QGridLayout(self.scroll_content)
118 self.scroll_area.setWidget(self.scroll_content)
119 self.layout.addWidget(self.scroll_area)
121 self.column_count = 2 # Default column layout
122 self._load_patients()
124 # ----- Translation / Localization -----
125 self._translate_ui()
126 if context and "language_changed" in context:
127 context["language_changed"].connect(self._translate_ui)
129 # -------------------------------------------------------
130 # Dynamic Layout & Resizing
131 # -------------------------------------------------------
133 def _update_column_count(self):
134 """Adjust grid column count dynamically based on window width."""
135 available_width = self.scroll_area.viewport().width() - 40
136 min_card_width = 250
137 new_column_count = max(1, available_width // min_card_width)
139 if new_column_count != self.column_count:
140 self.column_count = new_column_count
141 self._reload_patient_grid()
143 def resizeEvent(self, event):
144 """Update layout columns when the window is resized."""
145 super().resizeEvent(event)
146 self._update_column_count()
148 def _reload_patient_grid(self):
149 """Reload patient cards keeping previous selection state."""
150 selected = self.selected_patients.copy()
151 while self.grid_layout.count():
152 item = self.grid_layout.takeAt(0)
153 widget = item.widget()
154 if widget:
155 widget.setParent(None)
156 widget.deleteLater()
157 self._load_patients()
158 self.selected_patients = selected
160 # -------------------------------------------------------
161 # Navigation
162 # -------------------------------------------------------
164 def on_enter(self):
165 """Called when the page becomes active."""
166 while self.grid_layout.count():
167 item = self.grid_layout.takeAt(0)
168 widget = item.widget()
169 if widget is not None:
170 widget.setParent(None)
171 widget.deleteLater()
172 self._load_patients()
174 def is_ready_to_advance(self):
175 """Return True if at least one patient is selected."""
176 return bool(self.selected_patients)
178 def is_ready_to_go_back(self):
179 """This page can always go back to the previous one."""
180 return True
182 def next(self, context):
183 """
184 Proceed to the next page, deleting unselected patient directories.
186 Returns
187 -------
188 Page | None
189 The next page to show, or None if the user cancels.
190 """
191 to_delete = [
192 p for p in self._find_patient_dirs()
193 if os.path.basename(p) not in self.selected_patients and os.path.basename(p) != "derivatives"
194 ]
195 unselected_ids = [os.path.basename(p) for p in to_delete]
197 # Confirm deletion of unselected patients
198 if to_delete:
199 reply = QMessageBox.question(
200 self,
201 QCoreApplication.translate("PatientSelectionPage", "Confirm Cleanup"),
202 QCoreApplication.translate(
203 "PatientSelectionPage",
204 "{0} unselected patient(s) will be removed from the workspace. Continue?"
205 ).format(len(to_delete)),
206 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
207 )
209 if reply == QMessageBox.StandardButton.No:
210 return None
212 # Delete directories
213 for patient_path in to_delete:
214 try:
215 shutil.rmtree(patient_path)
216 patient_id = os.path.basename(patient_path)
217 self.selected_patients.discard(patient_id)
218 log.info(f"Deleted patient directory: {patient_path}")
219 except Exception as e:
220 log.error(f"Failed to delete {patient_path}: {e}")
222 # Remove from 'derivatives'
223 derivatives_root = os.path.join(self.workspace_path, "derivatives")
224 if os.path.exists(derivatives_root):
225 for root, dirs, _ in os.walk(derivatives_root, topdown=False):
226 for dir_name in dirs:
227 if dir_name in unselected_ids:
228 full_path = os.path.join(root, dir_name)
229 try:
230 shutil.rmtree(full_path)
231 log.info(f"Deleted from derivatives: {full_path}")
232 except Exception as e:
233 log.error(f"Failed to delete from derivatives: {full_path}: {e}")
235 # Create next page if needed
236 if not self.next_page:
237 self.next_page = ToolSelectionPage(context, self)
238 self.context["history"].append(self.next_page)
240 self.next_page.on_enter()
241 return self.next_page
243 def back(self):
244 """Return to the previous page, if any."""
245 if self.previous_page:
246 self.previous_page.on_enter()
247 return self.previous_page
248 return None
250 # -------------------------------------------------------
251 # Patient Loading & Selection
252 # -------------------------------------------------------
254 def _load_patients(self):
255 """Load patient directories into the grid view."""
256 patient_dirs = self._find_patient_dirs()
257 patient_dirs.sort()
258 self.patient_buttons.clear()
260 for i, patient_path in enumerate(patient_dirs):
261 patient_id = os.path.basename(patient_path)
263 # Card container
264 patient_frame = QFrame()
265 patient_frame.setObjectName("patientCard")
266 patient_frame.setStyleSheet("""
267 QFrame#patientCard {
268 border: 1px solid #CCCCCC;
269 border-radius: 10px;
270 background-color: #FFFFFF;
271 padding: 6px;
272 }
273 """)
274 patient_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
275 patient_frame.setMaximumHeight(140)
277 # Layout for each patient card
278 patient_layout = QHBoxLayout(patient_frame)
279 patient_layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
281 profile = QFrame()
282 profile_layout = QVBoxLayout(profile)
284 # Patient icon
285 image = QLabel()
286 pixmap = QtGui.QPixmap(resource_path("resources/user.png")).scaled(
287 30, 30,
288 Qt.AspectRatioMode.KeepAspectRatio,
289 Qt.TransformationMode.SmoothTransformation
290 )
291 image.setPixmap(pixmap)
292 image.setAlignment(Qt.AlignmentFlag.AlignCenter)
294 # Label and selection button
295 label = QLabel(f"{patient_id}")
296 label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
298 button = QPushButton(QCoreApplication.translate("PatientSelectionFrame", "Select"))
299 button.setCheckable(True)
300 button.setStyleSheet("""
301 QPushButton {
302 border-radius: 12px;
303 padding: 8px 16px;
304 background-color: #DADADA;
305 }
306 QPushButton:checked {
307 background-color: #4CAF50;
308 color: white;
309 }
310 """)
312 # Restore selection state
313 is_selected = patient_id in self.selected_patients
314 button.setChecked(is_selected)
315 button.setText(
316 QCoreApplication.translate("PatientSelectionFrame", "Selected")
317 if is_selected else
318 QCoreApplication.translate("PatientSelectionFrame", "Select")
319 )
320 button.clicked.connect(lambda checked, pid=patient_id, btn=button:
321 self._toggle_patient(pid, checked, btn))
323 self.patient_buttons[patient_id] = button
325 # Assemble card
326 profile_layout.addWidget(image)
327 profile_layout.addWidget(label)
328 patient_layout.addWidget(profile)
329 patient_layout.addStretch()
330 patient_layout.addWidget(button)
332 self.grid_layout.addWidget(patient_frame, i // self.column_count, i % self.column_count)
334 def _select_all_patients(self):
335 """Mark all patients as selected."""
336 for patient_id, button in self.patient_buttons.items():
337 if not button.isChecked():
338 button.setChecked(True)
339 button.setText(QCoreApplication.translate("PatientSelectionFrame", "Selected"))
340 self.selected_patients.add(patient_id)
341 if self.context and "update_main_buttons" in self.context:
342 self.context["update_main_buttons"]()
344 def _deselect_all_patients(self):
345 """Unselect all patients."""
346 for patient_id, button in self.patient_buttons.items():
347 if button.isChecked():
348 button.setChecked(False)
349 button.setText(QCoreApplication.translate("PatientSelectionFrame", "Select"))
350 self.selected_patients.discard(patient_id)
351 if self.context and "update_main_buttons" in self.context:
352 self.context["update_main_buttons"]()
354 def _find_patient_dirs(self):
355 """Recursively find all patient directories (sub-*) in the workspace."""
356 patient_dirs = []
357 for root, dirs, _ in os.walk(self.workspace_path):
358 # Skip derivatives and pipeline folders
359 for skip in ("derivatives", "pipeline"):
360 if skip in dirs:
361 dirs.remove(skip)
363 # Collect patient directories
364 for dir_name in dirs:
365 if dir_name.startswith("sub-"):
366 patient_dirs.append(os.path.join(root, dir_name))
368 # Prevent recursion into patient subdirectories
369 dirs[:] = [d for d in dirs if not d.startswith("sub-")]
371 return patient_dirs
373 def _toggle_patient(self, patient_id, is_selected, button):
374 """Toggle selection for a single patient."""
375 if is_selected:
376 self.selected_patients.add(patient_id)
377 button.setText(QCoreApplication.translate("PatientSelectionFrame", "Selected"))
378 else:
379 self.selected_patients.discard(patient_id)
380 button.setText(QCoreApplication.translate("PatientSelectionFrame", "Select"))
382 if self.context and "update_main_buttons" in self.context:
383 self.context["update_main_buttons"]()
385 def get_selected_patients(self):
386 """Return a list of selected patient IDs."""
387 return list(self.selected_patients)
389 def reset_page(self):
390 """Reset the UI, clearing selections and reloading patients."""
391 while self.grid_layout.count():
392 item = self.grid_layout.takeAt(0)
393 widget = item.widget()
394 if widget is not None:
395 widget.setParent(None)
396 widget.deleteLater()
398 self.selected_patients.clear()
399 self.patient_buttons.clear()
400 self._load_patients()
402 # -------------------------------------------------------
403 # Translation
404 # -------------------------------------------------------
406 def _translate_ui(self):
407 """Apply localized text to all UI elements."""
408 self.title.setText(QCoreApplication.translate("PatientSelectionFrame", "Select Patients to Analyze"))
409 self.select_all_btn.setText(QCoreApplication.translate("PatientSelectionFrame", "Select All"))
410 self.deselect_all_btn.setText(QCoreApplication.translate("PatientSelectionFrame", "Deselect All"))
411 self._load_patients()