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

1import shutil 

2import sys 

3import os 

4 

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 

11 

12from ui.tool_selection_page import ToolSelectionPage 

13from utils import resource_path 

14from page import Page 

15from logger import get_logger 

16 

17log = get_logger() 

18 

19 

20class PatientSelectionPage(Page): 

21 """ 

22 Page for selecting patients to analyze. 

23 

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. 

27 

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 """ 

45 

46 def __init__(self, context=None, previous_page=None): 

47 """ 

48 Initialize the patient selection page UI and logic. 

49 

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__() 

58 

59 self.context = context 

60 self.previous_page = previous_page 

61 self.next_page = None 

62 self.workspace_path = context["workspace_path"] 

63 

64 self.patient_buttons = {} 

65 self.selected_patients = set() 

66 

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) 

73 

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") 

78 

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) 

94 

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) 

98 

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) 

103 

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 """) 

115 

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) 

120 

121 self.column_count = 2 # Default column layout 

122 self._load_patients() 

123 

124 # ----- Translation / Localization ----- 

125 self._translate_ui() 

126 if context and "language_changed" in context: 

127 context["language_changed"].connect(self._translate_ui) 

128 

129 # ------------------------------------------------------- 

130 # Dynamic Layout & Resizing 

131 # ------------------------------------------------------- 

132 

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) 

138 

139 if new_column_count != self.column_count: 

140 self.column_count = new_column_count 

141 self._reload_patient_grid() 

142 

143 def resizeEvent(self, event): 

144 """Update layout columns when the window is resized.""" 

145 super().resizeEvent(event) 

146 self._update_column_count() 

147 

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 

159 

160 # ------------------------------------------------------- 

161 # Navigation 

162 # ------------------------------------------------------- 

163 

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() 

173 

174 def is_ready_to_advance(self): 

175 """Return True if at least one patient is selected.""" 

176 return bool(self.selected_patients) 

177 

178 def is_ready_to_go_back(self): 

179 """This page can always go back to the previous one.""" 

180 return True 

181 

182 def next(self, context): 

183 """ 

184 Proceed to the next page, deleting unselected patient directories. 

185 

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] 

196 

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 ) 

208 

209 if reply == QMessageBox.StandardButton.No: 

210 return None 

211 

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}") 

221 

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}") 

234 

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) 

239 

240 self.next_page.on_enter() 

241 return self.next_page 

242 

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 

249 

250 # ------------------------------------------------------- 

251 # Patient Loading & Selection 

252 # ------------------------------------------------------- 

253 

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() 

259 

260 for i, patient_path in enumerate(patient_dirs): 

261 patient_id = os.path.basename(patient_path) 

262 

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) 

276 

277 # Layout for each patient card 

278 patient_layout = QHBoxLayout(patient_frame) 

279 patient_layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) 

280 

281 profile = QFrame() 

282 profile_layout = QVBoxLayout(profile) 

283 

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) 

293 

294 # Label and selection button 

295 label = QLabel(f"{patient_id}") 

296 label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) 

297 

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 """) 

311 

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)) 

322 

323 self.patient_buttons[patient_id] = button 

324 

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) 

331 

332 self.grid_layout.addWidget(patient_frame, i // self.column_count, i % self.column_count) 

333 

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"]() 

343 

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"]() 

353 

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) 

362 

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)) 

367 

368 # Prevent recursion into patient subdirectories 

369 dirs[:] = [d for d in dirs if not d.startswith("sub-")] 

370 

371 return patient_dirs 

372 

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")) 

381 

382 if self.context and "update_main_buttons" in self.context: 

383 self.context["update_main_buttons"]() 

384 

385 def get_selected_patients(self): 

386 """Return a list of selected patient IDs.""" 

387 return list(self.selected_patients) 

388 

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() 

397 

398 self.selected_patients.clear() 

399 self.patient_buttons.clear() 

400 self._load_patients() 

401 

402 # ------------------------------------------------------- 

403 # Translation 

404 # ------------------------------------------------------- 

405 

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()