Coverage for nexusLIMS/tui/apps/instruments/screens.py: 100%

189 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2026-03-24 05:23 +0000

1""" 

2Screens for the instrument management TUI application. 

3 

4Provides List, Add, Edit, and Delete screens for instrument CRUD operations. 

5""" 

6 

7from pathlib import Path 

8from typing import ClassVar 

9 

10import pytz 

11from sqlmodel import select 

12from textual.app import ComposeResult 

13from textual.containers import Vertical 

14from textual.screen import ModalScreen 

15from textual.widgets import Button, Input, Label, Select, Static 

16 

17from nexusLIMS.db.models import Instrument 

18from nexusLIMS.tui.apps.instruments.validators import ( 

19 get_example_values, 

20 validate_api_url_unique, 

21 validate_instrument_pid, 

22) 

23from nexusLIMS.tui.common.base_screens import ( 

24 BaseFormScreen, 

25 BaseListScreen, 

26 ConfirmDialog, 

27) 

28from nexusLIMS.tui.common.db_utils import get_session_log_count 

29from nexusLIMS.tui.common.validators import ( 

30 validate_timezone, 

31) 

32from nexusLIMS.tui.common.widgets import AutocompleteInput, FormField, NumpadInput 

33 

34 

35class WelcomeDialog(ModalScreen): 

36 """Welcome dialog shown when instruments table is empty.""" 

37 

38 CSS_PATH: ClassVar = [ 

39 Path(__file__).parent.parent.parent 

40 / "styles" 

41 / "instruments" 

42 / "welcome_dialog.tcss" 

43 ] 

44 

45 def compose(self) -> ComposeResult: 

46 """Compose the welcome dialog.""" 

47 with Vertical(id="welcome-dialog"): 

48 yield Label( 

49 "Welcome to the NexusLIMS Instrument Manager!", id="welcome-title" 

50 ) 

51 yield Static( 

52 "No instruments were found in the database! If this is your first time " 

53 "running NexusLIMS, welcome!\n\n" 

54 "To get started, add your first instrument by pressing 'a' or clicking " 

55 "the button below.\n\n" 

56 "You can also:\n" 

57 "• Press '?' for help and keybindings\n" 

58 "• Press Ctrl+T to toggle dark/light theme\n" 

59 "• Press Ctrl+Q or 'q' to quit", 

60 id="welcome-message", 

61 ) 

62 with Vertical(id="welcome-buttons"): 

63 yield Button( 

64 "Add First Instrument (a)", id="add-btn", variant="primary" 

65 ) 

66 yield Button("Close", id="close-btn", variant="default") 

67 

68 def on_button_pressed(self, event: Button.Pressed) -> None: 

69 """Handle button presses.""" 

70 if event.button.id == "add-btn": 

71 self.dismiss(True) # Signal to open add screen 

72 else: 

73 self.dismiss(False) # Just close 

74 

75 

76class InstrumentListScreen(BaseListScreen): 

77 """Screen displaying all instruments in a table.""" 

78 

79 def on_mount(self) -> None: 

80 """Set up the screen and check if welcome dialog should be shown.""" 

81 super().on_mount() 

82 

83 # Check if instruments table is empty 

84 instruments = self.app.db_session.exec(select(Instrument)).all() 

85 

86 if not instruments: 

87 # Show welcome dialog 

88 self.app.push_screen(WelcomeDialog(), self.on_welcome_complete) 

89 

90 def on_welcome_complete(self, should_add: bool) -> None: 

91 """Handle welcome dialog completion.""" 

92 if should_add: 

93 # User wants to add first instrument 

94 self.action_add() 

95 

96 def get_columns(self) -> list[str]: 

97 """Get column headers.""" 

98 return [ 

99 "Display Name", 

100 "PID", 

101 "API URL", 

102 "Filestore Path", 

103 "Location", 

104 "Property Tag", 

105 "Timezone", 

106 ] 

107 

108 def get_data(self) -> list[dict]: 

109 """Get instrument data from database.""" 

110 instruments = self.app.db_session.exec(select(Instrument)).all() 

111 

112 data = [] 

113 for instr in instruments: 

114 data.append( 

115 { 

116 "Display Name": instr.display_name, 

117 "PID": instr.instrument_pid, 

118 "API URL": instr.api_url, 

119 "Filestore Path": instr.filestore_path, 

120 "Location": instr.location, 

121 "Property Tag": instr.property_tag, 

122 "Timezone": instr.timezone_str, 

123 } 

124 ) 

125 

126 return data 

127 

128 def on_row_selected(self, _row_key, row_data: dict) -> None: 

129 """Handle row selection - show edit screen.""" 

130 # Load full instrument from database 

131 instrument_pid = row_data["PID"] 

132 instrument = self.app.db_session.get(Instrument, instrument_pid) 

133 

134 if instrument: 

135 # Push edit screen 

136 edit_screen = InstrumentEditScreen(instrument) 

137 self.app.push_screen(edit_screen, self.on_edit_complete) 

138 

139 def on_edit_complete(self, result) -> None: 

140 """Handle edit screen completion.""" 

141 if result: 

142 self.refresh_data() 

143 

144 def action_add(self) -> None: 

145 """Show add instrument screen.""" 

146 add_screen = InstrumentAddScreen() 

147 self.app.push_screen(add_screen, self.on_add_complete) 

148 

149 def on_add_complete(self, result) -> None: 

150 """Handle add screen completion.""" 

151 if result: 

152 self.refresh_data() 

153 

154 def action_delete(self) -> None: 

155 """Delete selected instrument.""" 

156 table = self.query_one("DataTable") 

157 if table.cursor_row is not None and table.row_count > 0: 

158 # Get instrument PID from current row 

159 columns = self.get_columns() 

160 cursor_row = table.cursor_row 

161 row_data = {} 

162 

163 for i, column in enumerate(columns): 

164 row_data[column] = table.get_row_at(cursor_row)[i] 

165 

166 instrument_pid = row_data["PID"] 

167 

168 # Load instrument 

169 instrument = self.app.db_session.get(Instrument, instrument_pid) 

170 if instrument: 

171 # Check for session logs 

172 session_count = get_session_log_count( 

173 self.app.db_session, instrument_pid 

174 ) 

175 

176 # Build confirmation message 

177 message = f"Delete instrument '{instrument_pid}'?" 

178 if session_count > 0: 

179 message += ( 

180 f"\n\nWarning: This instrument has {session_count} " 

181 "session log entries.\nThese entries will NOT be deleted " 

182 "but will reference a non-existent instrument." 

183 ) 

184 

185 # Show confirmation dialog 

186 def on_confirm(confirmed: bool): 

187 if confirmed: 

188 self.delete_instrument(instrument_pid) 

189 

190 self.app.push_screen( 

191 ConfirmDialog(message, title="Confirm Delete"), 

192 on_confirm, 

193 ) 

194 

195 def delete_instrument(self, instrument_pid: str) -> None: 

196 """Delete an instrument from the database.""" 

197 try: 

198 instrument = self.app.db_session.get(Instrument, instrument_pid) 

199 if instrument: 

200 self.app.db_session.delete(instrument) 

201 self.app.db_session.commit() 

202 self.app.show_success(f"Deleted instrument: {instrument_pid}") 

203 self.refresh_data() 

204 except Exception as e: 

205 self.app.db_session.rollback() 

206 self.app.show_error(f"Failed to delete instrument: {e}") 

207 

208 

209class InstrumentAddScreen(BaseFormScreen): 

210 """Screen for adding a new instrument.""" 

211 

212 # Disable auto-focus to prevent scrolling to first input 

213 AUTO_FOCUS = "" 

214 

215 CSS_PATH: ClassVar = [ 

216 Path(__file__).parent.parent.parent / "styles" / "instruments" / "screens.tcss" 

217 ] 

218 

219 def __init__(self, **kwargs): 

220 """Initialize add screen.""" 

221 super().__init__(title="Add New Instrument", **kwargs) 

222 self.examples = get_example_values() 

223 

224 def on_mount(self) -> None: 

225 """Focus first input without scrolling.""" 

226 # Focus the first input without scrolling the viewport 

227 first_input = self.query_one("#instrument_pid", Input) 

228 first_input.focus(scroll_visible=False) 

229 

230 def get_form_fields(self) -> ComposeResult: 

231 """Generate form fields for instrument creation in two columns.""" 

232 with Vertical(classes="form-column"): 

233 yield FormField( 

234 "Instrument PID", 

235 NumpadInput( 

236 placeholder=self.examples["instrument_pid"], 

237 id="instrument_pid", 

238 ), 

239 required=True, 

240 help_text=( 

241 f"Unique identifier (e.g., {self.examples['instrument_pid']})" 

242 ), 

243 ) 

244 yield FormField( 

245 "API URL", 

246 Input( 

247 placeholder=self.examples["api_url"], 

248 id="api_url", 

249 ), 

250 required=True, 

251 help_text=( 

252 f"Calendar API endpoint URL (e.g., {self.examples['api_url']})" 

253 ), 

254 ) 

255 yield FormField( 

256 "Calendar URL", 

257 Input( 

258 placeholder=self.examples["calendar_url"], 

259 id="calendar_url", 

260 ), 

261 required=True, 

262 help_text=( 

263 "Web-accessible calendar URL" 

264 f" (e.g., {self.examples['calendar_url']})" 

265 ), 

266 ) 

267 yield FormField( 

268 "Location", 

269 Input( 

270 placeholder=self.examples["location"], 

271 id="location", 

272 ), 

273 required=True, 

274 help_text=f"Physical location (e.g., {self.examples['location']})", 

275 ) 

276 yield FormField( 

277 "Display Name", 

278 Input( 

279 placeholder=self.examples["display_name"], 

280 id="display_name", 

281 ), 

282 required=True, 

283 help_text=( 

284 f"Human-readable instrument name for NexusLIMS records " 

285 f"(e.g., {self.examples['display_name']})" 

286 ), 

287 ) 

288 

289 with Vertical(classes="form-column"): 

290 yield FormField( 

291 "Property Tag", 

292 Input( 

293 placeholder=self.examples["property_tag"], 

294 id="property_tag", 

295 ), 

296 required=True, 

297 help_text=( 

298 f"Unique numeric identifier (e.g., {self.examples['property_tag']})" 

299 ), 

300 ) 

301 yield FormField( 

302 "Filestore Path", 

303 Input( 

304 placeholder=self.examples["filestore_path"], 

305 id="filestore_path", 

306 ), 

307 required=True, 

308 help_text=( 

309 f"Relative path under NX_INSTRUMENT_DATA_PATH " 

310 f"(e.g., {self.examples['filestore_path']})" 

311 ), 

312 ) 

313 yield FormField( 

314 "Harvester", 

315 Select( 

316 [("nemo", "nemo")], 

317 value="nemo", 

318 id="harvester", 

319 ), 

320 required=True, 

321 help_text='Harvester module ("nemo" is the only option, currently)', 

322 ) 

323 yield FormField( 

324 "Timezone", 

325 AutocompleteInput( 

326 suggestions=pytz.common_timezones, 

327 placeholder=self.examples["timezone_str"], 

328 value="America/New_York", 

329 id="timezone_str", 

330 ), 

331 required=True, 

332 help_text="IANA timezone (e.g., America/New_York)", 

333 ) 

334 

335 def collect_form_data(self) -> dict: 

336 """Collect data from form fields.""" 

337 # Get form fields 

338 return { 

339 "instrument_pid": self.query_one("#instrument_pid", Input).value, 

340 "api_url": self.query_one("#api_url", Input).value, 

341 "calendar_url": self.query_one("#calendar_url", Input).value, 

342 "location": self.query_one("#location", Input).value, 

343 "display_name": self.query_one("#display_name", Input).value, 

344 "property_tag": self.query_one("#property_tag", Input).value, 

345 "filestore_path": self.query_one("#filestore_path", Input).value, 

346 "harvester": self.query_one("#harvester", Select).value, 

347 "timezone_str": self.query_one("#timezone_str", Input).value, 

348 } 

349 

350 def validate_form(self) -> dict[str, str]: 

351 """Validate form data.""" 

352 errors = {} 

353 data = self.collect_form_data() 

354 

355 # Validate instrument_pid 

356 is_valid, error = validate_instrument_pid(data["instrument_pid"]) 

357 if not is_valid: 

358 errors["instrument_pid"] = error 

359 

360 # Validate api_url (with uniqueness check) 

361 is_valid, error = validate_api_url_unique(self.app.db_session, data["api_url"]) 

362 if not is_valid: 

363 errors["api_url"] = error 

364 

365 # Validate timezone 

366 is_valid, error = validate_timezone(data["timezone_str"]) 

367 if not is_valid: 

368 errors["timezone_str"] = error 

369 

370 # Show a notification if there are validation errors (without logging as error) 

371 if errors: 

372 error_count = len(errors) 

373 field_names = ", ".join(errors.keys()) 

374 msg = ( 

375 f"Validation failed: {error_count} error(s) in {field_names}. " 

376 "See details at bottom of form." 

377 ) 

378 self.app.notify(msg, severity="warning", timeout=5) 

379 

380 return errors 

381 

382 def on_save(self, data: dict) -> None: 

383 """Save new instrument to database.""" 

384 try: 

385 # Create instrument 

386 instrument = Instrument(**data) 

387 

388 # Add to database 

389 self.app.db_session.add(instrument) 

390 self.app.db_session.commit() 

391 

392 self.app.show_success(f"Created instrument: {data['instrument_pid']}") 

393 self.dismiss(True) 

394 

395 except Exception as e: 

396 self.app.db_session.rollback() 

397 self.app.show_error(f"Failed to create instrument: {e}") 

398 

399 

400class InstrumentEditScreen(InstrumentAddScreen): 

401 """Screen for editing an existing instrument.""" 

402 

403 def __init__(self, instrument: Instrument, **kwargs): 

404 """ 

405 Initialize edit screen. 

406 

407 Parameters 

408 ---------- 

409 instrument : Instrument 

410 Instrument to edit 

411 **kwargs 

412 Additional arguments passed to BaseFormScreen 

413 """ 

414 self.instrument = instrument 

415 super().__init__(**kwargs) 

416 self.screen_title = f"Edit Instrument: {instrument.instrument_pid}" 

417 

418 def on_mount(self) -> None: 

419 """Populate form with existing instrument data.""" 

420 super().on_mount() 

421 

422 # Populate fields 

423 self.query_one("#instrument_pid", Input).value = self.instrument.instrument_pid 

424 self.query_one("#instrument_pid", Input).disabled = True # Can't change PID 

425 

426 self.query_one("#api_url", Input).value = self.instrument.api_url 

427 self.query_one("#calendar_url", Input).value = self.instrument.calendar_url 

428 self.query_one("#location", Input).value = self.instrument.location 

429 self.query_one("#display_name", Input).value = self.instrument.display_name 

430 self.query_one("#property_tag", Input).value = self.instrument.property_tag 

431 self.query_one("#filestore_path", Input).value = self.instrument.filestore_path 

432 self.query_one("#harvester", Select).value = self.instrument.harvester 

433 self.query_one("#timezone_str", Input).value = self.instrument.timezone_str 

434 

435 def validate_form(self) -> dict[str, str]: 

436 """Validate form data (excluding PID from uniqueness checks).""" 

437 errors = {} 

438 data = self.collect_form_data() 

439 

440 # Validate api_url (with uniqueness check, excluding current instrument) 

441 is_valid, error = validate_api_url_unique( 

442 self.app.db_session, 

443 data["api_url"], 

444 exclude_pid=self.instrument.instrument_pid, 

445 ) 

446 if not is_valid: 

447 errors["api_url"] = error 

448 

449 # Validate timezone 

450 is_valid, error = validate_timezone(data["timezone_str"]) 

451 if not is_valid: 

452 errors["timezone_str"] = error 

453 

454 # Show a notification if there are validation errors (without logging as error) 

455 if errors: 

456 error_count = len(errors) 

457 field_names = ", ".join(errors.keys()) 

458 msg = ( 

459 f"Validation failed: {error_count} error(s) in {field_names}. " 

460 "See details at bottom of form." 

461 ) 

462 self.app.notify(msg, severity="warning", timeout=5) 

463 

464 return errors 

465 

466 def on_save(self, data: dict) -> None: 

467 """Update existing instrument in database.""" 

468 try: 

469 # Update instrument fields (except PID) 

470 self.instrument.api_url = data["api_url"] 

471 self.instrument.calendar_url = data["calendar_url"] 

472 self.instrument.location = data["location"] 

473 self.instrument.display_name = data["display_name"] 

474 self.instrument.property_tag = data["property_tag"] 

475 self.instrument.filestore_path = data["filestore_path"] 

476 self.instrument.harvester = data["harvester"] 

477 self.instrument.timezone_str = data["timezone_str"] 

478 

479 # Commit changes 

480 self.app.db_session.add(self.instrument) 

481 self.app.db_session.commit() 

482 

483 self.app.show_success(f"Updated instrument: {data['instrument_pid']}") 

484 self.dismiss(True) 

485 

486 except Exception as e: 

487 self.app.db_session.rollback() 

488 self.app.show_error(f"Failed to update instrument: {e}")