Coverage for denofo/questionnaire/questionnaire_gui.py: 80%

1 import sys

2 from typing import Any

3 from enum import Enum

4 from pathlib import Path

5 from pydantic import BaseModel, ValidationError

6 from denofo.utils.constants import SECTIONS, GoQBack

7 from denofo.converter.convert import convert_to_json

8 from denofo.models import ModelValidError

9 from denofo.utils.ncbiTaxDBcheck import check_NCBI_taxDB

10 from denofo.questionnaire.questions import DeNovoQuestionnaire

11 from PyQt6.QtCore import Qt, QObject

12 from PyQt6.QtWidgets import (

13 QApplication,

14 QMainWindow,

15 QWidget,

16 QVBoxLayout,

17 QHBoxLayout,

18 QPushButton,

19 QLabel,

20 QListWidget,

21 QLineEdit,

22 QMessageBox,

23 QListWidgetItem,

24 QRadioButton,

25 QButtonGroup,

26 QDialog,

27 QFileDialog,

28)

29

30

31 def clearLayout(layout):

32 """

33 Function to clear the layout of a QWidget.

34 """

35 while layout.count():

36 child = layout.takeAt(0)

37 if child.widget():

38 child.widget().deleteLater()

39

40

41 class QSingleton(type(QObject)):

42 """Metaclass for Qt classes that are singletons."""

43

44 def __init__(self, *args, **kwargs):

45 super().__init__(*args, **kwargs)

46 self.instance = None

47

48 def __call__(self, *args, **kwargs):

49 if self.instance is None:

50 self.instance = super().__call__(*args, **kwargs)

51 return self.instance

52

53

54 class MainWindow(QMainWindow, metaclass=QSingleton):

55 """

56 Main window class for the GUI application.

57 """

58

59 _geometry = None

60 _centered = False

61

62 def __init__(self, *args, **kwargs):

63 super().__init__(*args, **kwargs)

64 self.setWindowTitle("DeNoFo Questionnaire")

65 self.setStyleSheet("background-color: #454746; color: white;")

66 self.resize(500, 250) # Set default size

67

68 if MainWindow._geometry is not None:

69 self.setGeometry(*MainWindow._geometry)

70 else:

71 self.center_on_screen()

72

73 def center_on_screen(self):

74 screen = QApplication.primaryScreen()

75 if screen is not None:

76 screen_geometry = screen.availableGeometry()

77 window_geometry = self.frameGeometry()

78 center_point = screen_geometry.center()

79 window_geometry.moveCenter(center_point)

80 self.move(window_geometry.topLeft())

81 MainWindow._geometry = self.geometry().getRect()

82 MainWindow._centered = True

83 else:

84 # Fallback position if screen cannot be detected

85 MainWindow._geometry = (100, 100, 500, 250)

86 self.setGeometry(*MainWindow._geometry)

87 MainWindow._centered = True

88

89 def closeEvent(self, event):

90 MainWindow._geometry = self.geometry().getRect()

91 if event.spontaneous():

92 if (

93 QMessageBox.question(

94 self,

95 "Quit",

96 "Are you sure you want to quit?",

97 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,

98 )

99 == QMessageBox.StandardButton.Yes

100 ):

101 event.accept()

102 QApplication.closeAllWindows()

103 sys.exit(0)

104 else:

105 event.ignore()

106 else:

107 event.accept()

108

109

110 class ErrorDialog(QDialog):

111 """

112 Error dialog class for the GUI application.

113 """

114

115 def __init__(self, err_warn_type: str = "Error", error_message: str = ""):

116 super().__init__()

117

118 self.err_warn_type = err_warn_type

119 self.error_message = error_message

120

121 self.initUI()

122

123 def initUI(self):

124 self.setWindowTitle(self.err_warn_type)

125

126 layout = QVBoxLayout()

127 error_label = QLabel(self.error_message)

128 error_label.setStyleSheet("font-weight: bold; color: white;")

129 error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

130 layout.addWidget(error_label)

131 ok_button = QPushButton("Ok")

132 layout.addWidget(ok_button)

133

134 if self.err_warn_type == "Error":

135 self.setStyleSheet("background-color: #8D3832;")

136 else:

137 self.setStyleSheet("background-color: #4F6B90;")

138

139 self.setLayout(layout)

140 ok_button.clicked.connect(self.close)

141

142

143 def show_error_message(warn_type: str = "Error", message: str = ""):

144 """

145 Function to show an error message to the user.

146 """

147 # Create main window

148 main_window = MainWindow()

149 main_layout = QVBoxLayout()

150

151 # Create a central widget

152 central_widget = QWidget(parent=main_window)

153

154 # Add ErrorDialog widget

155 error_dialog = ErrorDialog(warn_type, message)

156 main_layout.addWidget(error_dialog, stretch=1)

157

158 # Set the layout

159 central_widget.setLayout(main_layout)

160 main_window.setCentralWidget(central_widget)

161

162 error_dialog.exec()

163

164 return

165

166

167 class ProgressBar(QWidget):

168 """

169 Progress bar class for the GUI application.

170 """

171

172 def __init__(self, section_idx):

173 super().__init__()

174 self.section_idx = section_idx

175 self.initUI()

176

177 def initUI(self):

178 layout = QHBoxLayout()

179 for idx, section in enumerate(SECTIONS):

180 section_button = QPushButton(section)

181 section_button.setStyleSheet(

182 "background-color: #545A61; font-weight: bold;" # lowlight

183 )

184 if idx == self.section_idx:

185 section_button.setStyleSheet(

186 "background-color: #4F6B90; font-weight: bold;" # highlight

187 )

188 layout.addWidget(section_button)

189 if idx < len(SECTIONS) - 1:

190 dot_label = QLabel(" • ")

191 layout.addWidget(dot_label)

192 self.setLayout(layout)

193

194

195 class Back_button(QPushButton):

196 """

197 Back button class for the GUI application.

198 """

199

200 def __init__(self):

201 super().__init__()

202 self.choice = None

203 self.initUI()

204

205 def initUI(self):

206 self.setText("← Go Back")

207 self.setStyleSheet("background-color: #8D3832; padding: 5px;")

208 self.clicked.connect(self.on_click)

209

210 def on_click(self):

211 self.choice = GoQBack()

212 # clearLayout removes all widgets from the layout of the center_widget (such as ProgressBar, Enum_choice_selection and other open Dialogs)

213 # without this the Back_button just disappears, but the center_widget stays open

214 clearLayout(self.parent().layout())

215

216

217 class Enum_choice_selection(QDialog):

218 """

219 Enum choice selection class for the GUI application.

220 """

221

222 def __init__(self, enum_choices, question, multi_choice=False, prev_answer=None):

223 super().__init__()

224

225 self.enum_choices = enum_choices

226 self.question = question

227 self.multi_choice = multi_choice

228 self.prev_answer = prev_answer

229

230 self.choice = None

231

232 self.initUI()

233

234 def initUI(self):

235 self.setWindowTitle("Select an option")

236

237 layout = QVBoxLayout()

238 if self.question:

239 question_label = QLabel(self.question)

240 layout.addWidget(question_label)

241

242 if self.multi_choice:

243 self.choices = QListWidget()

244 self.choices.setSelectionMode(QListWidget.SelectionMode.MultiSelection)

245 self.choices.setStyleSheet(

246 "QListWidget::item:selected { background-color: #7E8A97; }"

247 )

248 for choice in self.enum_choices:

249 item = QListWidgetItem(choice.value)

250 self.choices.addItem(item)

251 if self.prev_answer and choice.value in self.prev_answer:

252 self.choices.setCurrentItem(item)

253 layout.addWidget(self.choices)

254 self.choices.itemSelectionChanged.connect(self.update_submit_button)

255 else:

256 self.choices = QButtonGroup()

257 for idx, choice in enumerate(self.enum_choices):

258 radio_button = QRadioButton(choice.value)

259 if self.prev_answer and choice.value == self.prev_answer.value:

260 radio_button.setChecked(True)

261 self.choices.addButton(radio_button, idx)

262 layout.addWidget(radio_button)

263 radio_button.toggled.connect(self.update_submit_button)

264

265 submit_button = QPushButton("Continue →")

266 submit_button.setStyleSheet(

267 """

268 QPushButton {

269 background-color: #454746;

270 color: grey;

271 padding: 5px;

272 }

273 QPushButton:enabled {

274 background-color: #545A61;

275 color: white;

276 }

277 QPushButton:disabled {

278 background-color: #454746;

279 color: gray;

280 }

281 """

282 )

283 submit_button.setEnabled(bool(self.prev_answer))

284 layout.addWidget(submit_button)

285 self.setLayout(layout)

286

287 submit_button.clicked.connect(self.on_submit)

288 self.submit_button = submit_button

289

290 def update_submit_button(self):

291 if self.multi_choice:

292 has_selection = len(self.choices.selectedItems()) > 0

293 else:

294 has_selection = self.choices.checkedId() != -1

295 self.submit_button.setEnabled(has_selection)

296

297 def on_submit(self):

298 if self.multi_choice:

299 self.choice = [item.text() for item in self.choices.selectedItems()]

300 else:

301 self.choice = list(self.enum_choices)[self.choices.checkedId()]

302 self.close()

303

304

305 class Custom_entry(QDialog):

306 """

307 Custom entry class for the GUI application.

308 """

309

310 def __init__(

311 self, question, multi_choice: bool, prev_answer: str | list[str] | None = None

312 ):

313 super().__init__()

314

315 self.question = question

316 self.multi_choice = multi_choice

317 self.prev_answer = prev_answer

318

319 self.choice = None

320

321 self.initUI()

322

323 def initUI(self):

324 self.setWindowTitle("Enter a value")

325

326 layout = QVBoxLayout()

327 if self.question:

328 question_label = QLabel(self.question)

329 layout.addWidget(question_label)

330

331 if self.multi_choice:

332 self.entries = []

333 self.add_entry_button = QPushButton("+")

334 self.remove_entry_button = QPushButton("-")

335 self.entry_layout = QVBoxLayout()

336 entry = QLineEdit()

337 self.entries.append(entry)

338 self.entry_layout.addWidget(entry)

339 layout.addLayout(self.entry_layout)

340 button_layout = QHBoxLayout()

341 button_layout.addWidget(self.add_entry_button)

342 button_layout.addWidget(self.remove_entry_button)

343 layout.addLayout(button_layout)

344 self.add_entry_button.clicked.connect(self.add_entry)

345 self.remove_entry_button.clicked.connect(self.remove_entry)

346 if self.prev_answer:

347 self.remove_entry(ignore_warning=True)

348 for entry_text in self.prev_answer:

349 self.add_entry(ignore_warning=True)

350 self.entries[-1].setText(entry_text)

351 else:

352 self.entry = QLineEdit()

353 if self.prev_answer:

354 self.entry.setText(self.prev_answer)

355 layout.addWidget(self.entry)

356

357 submit_button = QPushButton("Continue →")

358 submit_button.setStyleSheet(

359 """

360 QPushButton {

361 background-color: #454746;

362 color: grey;

363 padding: 5px;

364 }

365 QPushButton:enabled {

366 background-color: #545A61;

367 color: white;

368 }

369 QPushButton:disabled {

370 background-color: #454746;

371 color: gray;

372 }

373 """

374 )

375 layout.addWidget(submit_button)

376 self.setLayout(layout)

377 submit_button.clicked.connect(self.on_submit)

378 self.submit_button = submit_button

379 self.update_submit_button()

380 self.connect_signals()

381

382 def connect_signals(self):

383 if self.multi_choice:

384 for entry in self.entries:

385 entry.textChanged.connect(self.update_submit_button)

386 self.add_entry_button.clicked.connect(self.update_submit_button)

387 self.remove_entry_button.clicked.connect(self.update_submit_button)

388 else:

389 self.entry.textChanged.connect(self.update_submit_button)

390

391 def update_submit_button(self):

392 if self.multi_choice:

393 has_text = all(entry.text().strip() for entry in self.entries)

394 else:

395 has_text = bool(self.entry.text().strip())

396 self.submit_button.setEnabled(has_text)

397

398 def on_submit(self):

399 if self.multi_choice:

400 if any(not entry.text().strip() for entry in self.entries):

401 QMessageBox.warning(

402 self,

403 "Incomplete Entries",

404 "Please fill in all entries before submitting.",

405 )

406 return

407 self.choice = [entry.text() for entry in self.entries]

408 else:

409 if not self.entry.text().strip():

410 QMessageBox.warning(

411 self,

412 "Empty Field",

413 "Please fill in the text field before submitting.",

414 )

415 return

416 self.choice = self.entry.text()

417 self.close()

418

419 def add_entry(self, ignore_warning: bool = False):

420 if all(entry.text().strip() for entry in self.entries) or ignore_warning:

421 entry = QLineEdit()

422 self.entries.append(entry)

423 self.entry_layout.addWidget(entry)

424 if not ignore_warning:

425 entry.textChanged.connect(self.update_submit_button)

426 else:

427 QMessageBox.warning(

428 self,

429 "Incomplete Entries",

430 "Please fill in all existing entries before adding a new one.",

431 )

432

433 def remove_entry(self, ignore_warning: bool = False):

434 if (self.multi_choice and len(self.entries) > 1) or ignore_warning:

435 entry = self.entries.pop()

436 self.entry_layout.removeWidget(entry)

437 entry.deleteLater()

438 if not ignore_warning:

439 self.update_submit_button()

440 else:

441 QMessageBox.warning(

442 self,

443 "Cannot Remove",

444 "At least one entry is required.",

445 )

446

447

448 class Yes_no(QDialog):

449 """

450 Yes or No class for the GUI application.

451 """

452

453 def __init__(self, question, prev_answer=None):

454 super().__init__()

455

456 self.question = question

457 self.prev_answer = prev_answer

458

459 self.choice = None

460

461 self.initUI()

462

463 def initUI(self):

464 self.setWindowTitle("Yes or No")

465

466 layout = QVBoxLayout()

467 if self.question:

468 question_label = QLabel(self.question)

469 layout.addWidget(question_label)

470 self.yes_button = QPushButton("Yes")

471 self.no_button = QPushButton("No")

472 if self.prev_answer is not None:

473 if self.prev_answer is True:

474 self.yes_button.setStyleSheet("background-color: #2E5539")

475 elif self.prev_answer is False:

476 self.no_button.setStyleSheet("background-color: #73403F")

477 layout.addWidget(self.yes_button)

478 layout.addWidget(self.no_button)

479 self.setLayout(layout)

480

481 self.yes_button.clicked.connect(self.on_yes)

482 self.no_button.clicked.connect(self.on_no)

483

484 def on_yes(self):

485 self.choice = True

486 self.close()

487

488 def on_no(self):

489 self.choice = False

490 self.close()

491

492

493 def get_enum_choice_conversion(

494 my_enum: Enum,

495 question: str = "",

496 multi_choice: bool = False,

497 section_idx: int = 0,

498 prev_answer: Any = None,

499) -> Any:

500 """

501 Function to get the user's choice for an Enum.

502

503 :param my_enum: Enum class to get the choices from

504 :type my_enum: Enum

505 :param question: Question to ask the user

506 :type question: str

507 :param multi_choice: Whether the user can select multiple choices

508 :type multi_choice: bool

509 :param section_idx: Index of the section in the progress bar

510 :type section_idx: int

511 :param prev_answer: Previous answer given by the user

512 :type prev_answer: Any

513 :return: User's choice

514 :rtype: Any

515 """

516 choice = None

517

518 # Create main window

519 main_window = MainWindow()

520 main_layout = QVBoxLayout()

521

522 # Create a central widget and set the layout

523 central_widget = QWidget(parent=main_window)

524

525 # Add progress bar

526 progress_bar = ProgressBar(section_idx)

527 main_layout.addWidget(progress_bar, stretch=2)

528

529 # Add Enum_choice_selection widget

530 enum_selection_widget = Enum_choice_selection(

531 my_enum, question, multi_choice, prev_answer

532 )

533 main_layout.addWidget(enum_selection_widget, stretch=7)

534

535 # Add Back_button widget

536 back_button = Back_button()

537 main_layout.addWidget(back_button, stretch=1)

538

539 # Set the layout

540 central_widget.setLayout(main_layout)

541 main_window.setCentralWidget(central_widget)

542

543 # Execute the Yes_no dialog

544 enum_selection_widget.exec()

545

546 if isinstance(back_button.choice, GoQBack):

547 choice = GoQBack()

548 else:

549 choice = enum_selection_widget.choice

550

551 return choice

552

553

554 def get_custom_entry(

555 question: str = "",

556 multi_choice: bool = False,

557 section_idx: int = 0,

558 prev_answer: any = None,

559) -> Any:

560 """

561 Function to get the user's custom entry.

562

563 :param question: Question to ask the user

564 :type question: str

565 :param multi_choice: Whether the user can enter multiple values

566 :type multi_choice: bool

567 :param section_idx: Index of the section in the progress bar

568 :type section_idx: int

569 :param prev_answer: Previous answer given by the user

570 :type prev_answer: any

571 :return: User's choice

572 :rtype: Any

573 """

574 choice = None

575

576 # Create main window

577 main_window = MainWindow()

578 main_layout = QVBoxLayout()

579

580 # Create a central widget

581 central_widget = QWidget(parent=main_window)

582

583 # Add progress bar

584 progress_bar = ProgressBar(section_idx)

585 main_layout.addWidget(progress_bar, stretch=2)

586

587 # Add Custom_entry widget

588 custom_entry_widget = Custom_entry(question, multi_choice, prev_answer)

589 main_layout.addWidget(custom_entry_widget, stretch=7)

590

591 # Add Back_button widget

592 back_button = Back_button()

593 main_layout.addWidget(back_button, stretch=1)

594

595 # Set the layout

596 central_widget.setLayout(main_layout)

597 main_window.setCentralWidget(central_widget)

598

599 # Execute the Yes_no dialog

600 custom_entry_widget.exec()

601

602 if isinstance(back_button.choice, GoQBack):

603 choice = GoQBack()

604 else:

605 choice = custom_entry_widget.choice

606

607 return choice

608

609

610 def get_yes_no(

611 question: str = "", section_idx: int = 0, prev_answer: any = None

612) -> bool:

613 """

614 Function to get the user's choice for Yes or No.

615

616 :param question: Question to ask the user

617 :type question: str

618 :param section_idx: Index of the section in the progress bar

619 :type section_idx: int

620 :param prev_answer: Previous answer given by the user

621 :type prev_answer: any

622 :return: User's choice

623 :rtype: bool

624 """

625 choice = None

626

627 # Create main window

628 main_window = MainWindow()

629 main_layout = QVBoxLayout()

630

631 # Create a central widget

632 central_widget = QWidget(parent=main_window)

633

634 # Add progress bar

635 progress_bar = ProgressBar(section_idx)

636 main_layout.addWidget(progress_bar, stretch=2)

637

638 # Add Yes_no widget

639 yes_no_widget = Yes_no(question, prev_answer)

640 main_layout.addWidget(yes_no_widget, stretch=7)

641

642 # Add Back_button widget

643 back_button = Back_button()

644 main_layout.addWidget(back_button, stretch=1)

645

646 # Set the layout

647 central_widget.setLayout(main_layout)

648 main_window.setCentralWidget(central_widget)

649

650 # Execute the Yes_no dialog

651 yes_no_widget.exec()

652

653 if isinstance(back_button.choice, GoQBack):

654 choice = GoQBack()

655 else:

656 choice = yes_no_widget.choice

657

658 return choice

659

660

661 def valid_input_for_pydmodel(

662 pydmodel: BaseModel, field_name: str, inp_val: Any

663) -> bool:

664 """

665 Validate the input value with a certain pydantic model and model field

666 to ask the user for input again if the input is invalid.

667

668 :param pydmodel: Pydantic model to validate the input with

669 :type pydmodel: BaseModel

670 :param field_name: Field name of the model to validate the input with

671 :type field_name: str

672 :param inp_val: Input value to validate

673 :type inp_val: Any

674 :return: Whether the input is valid

675 :rtype: bool

676 """

677 try:

678 # pydmodel.validate({field_name: inp_val})

679 pydmodel.__pydantic_validator__.validate_assignment(

680 pydmodel.model_construct(), field_name, inp_val

681 )

682 return True

683 except UserWarning as w:

684 warning = str(w)

685 show_error_message("Warning", warning)

686 return True

687 except ValidationError as e:

688 errors = e.errors()

689 modelValErr = errors[0].get("ctx", dict()).get("error", None)

690 if isinstance(modelValErr, ModelValidError):

691 return True

692 else:

693 val_err = e

694 err_msg = ", ".join(val_err.errors()[0]["msg"].split(",")[1:])

695 show_error_message("Error", err_msg)

696 return False

697

698

699 def save_annotation(gene_annotation: BaseModel):

700 """

701 Function to save the gene annotation in the dngf format into a file.

702

703 :param gene_annotation: Gene annotation to save

704 :type gene_annotation: BaseModel

705 """

706 fileName = None

707 main_window = MainWindow()

708

709 while not fileName:

710 fileName, _ = QFileDialog.getSaveFileName(

711 main_window, "Save File", "my_model.dngf", "dngf (*.dngf)"

712 )

713 if not fileName:

714 reply = QMessageBox.question(

715 main_window,

716 "Quit without Saving?",

717 "Do you want to close the application without saving the annotation file?",

718 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,

719 )

720 if reply == QMessageBox.StandardButton.Yes:

721 # quit without saving file

722 return None

723

724 convert_to_json(gene_annotation, Path(fileName))

725

726

727 def main_app():

728 """

729 The main function for the GUI application. Calls questionnaire functions

730 and saves the gene annotation, while showing Widgets in the privously initiated

731 MainWindow.

732 """

733 GUI_INTERFACE_FUNCTS = {

734 "get_enum_choice_conversion": get_enum_choice_conversion,

735 "get_custom_entry": get_custom_entry,

736 "get_yes_no": get_yes_no,

737 "valid_input_for_pydmodel": valid_input_for_pydmodel,

738 }

739

740 try:

741 # Start of the questionnaire

742 de_novo_questionnaire = DeNovoQuestionnaire(GUI_INTERFACE_FUNCTS)

743 gene_annotation = de_novo_questionnaire.deNovoGeneAnnotation

744

745 # save model in the dngf (de novo gene format) format (JSON)

746 save_annotation(gene_annotation)

747

748 # close all windows and quit the application

749 QApplication.closeAllWindows()

750 sys.exit(0)

751

752 except Exception as e:

753 raise NotImplementedError(f"Error: {e}")

754

755

756 def main():

757 """

758 The main function of the program. Entry point for the GUI executable.

759 """

760 # Check the NCBI Taxonomy Database

761 check_NCBI_taxDB()

762

763 app = QApplication([])

764 main_window = MainWindow()

765 main_window.show()

766 main_app()

767 sys.exit(app.exec())

768

769

770 if __name__ == "__main__":

771 main()