oqtopus 0.2.0__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,18 @@
1
1
  import os
2
+ from pathlib import Path
2
3
 
3
4
  import psycopg
4
- from pum.pum_config import PumConfig
5
- from pum.schema_migrations import SchemaMigrations
6
- from pum.upgrader import Upgrader
7
- from qgis.PyQt.QtCore import Qt
5
+ import yaml
6
+ from qgis.PyQt.QtCore import QTimer
8
7
  from qgis.PyQt.QtWidgets import QMessageBox, QWidget
9
8
 
10
9
  from ..core.module import Module
10
+ from ..core.module_operation_task import ModuleOperationTask
11
11
  from ..core.module_package import ModulePackage
12
+ from ..libs.pum.pum_config import PumConfig
13
+ from ..libs.pum.schema_migrations import SchemaMigrations
12
14
  from ..utils.plugin_utils import PluginUtils, logger
13
- from ..utils.qt_utils import CriticalMessageBox, OverrideCursor, QtUtils
15
+ from ..utils.qt_utils import CriticalMessageBox, QtUtils
14
16
 
15
17
  DIALOG_UI = PluginUtils.get_ui_class("module_widget.ui")
16
18
 
@@ -29,19 +31,136 @@ class ModuleWidget(QWidget, DIALOG_UI):
29
31
 
30
32
  self.moduleInfo_install_pushButton.clicked.connect(self.__installModuleClicked)
31
33
  self.moduleInfo_upgrade_pushButton.clicked.connect(self.__upgradeModuleClicked)
34
+ self.moduleInfo_roles_pushButton.clicked.connect(self.__rolesClicked)
35
+ self.uninstall_button.clicked.connect(self.__uninstallModuleClicked)
36
+ self.moduleInfo_cancel_button.clicked.connect(self.__cancelOperationClicked)
32
37
 
33
38
  self.__current_module_package = None
34
39
  self.__database_connection = None
40
+ self.__pum_config = None
41
+ self.__data_model_dir = None
42
+
43
+ # Background operation task
44
+ self.__operation_task = ModuleOperationTask(self)
45
+ self.__operation_task.signalProgress.connect(self.__onOperationProgress)
46
+ self.__operation_task.signalFinished.connect(self.__onOperationFinished)
47
+
48
+ # Timeout timer for detecting hung operations
49
+ self.__cancel_timeout_timer = QTimer(self)
50
+ self.__cancel_timeout_timer.setSingleShot(True)
51
+ self.__cancel_timeout_timer.timeout.connect(self.__onCancelTimeout)
52
+
53
+ # Hide cancel button and progress bar initially
54
+ self.moduleInfo_cancel_button.setVisible(False)
55
+ self.moduleInfo_progressbar.setVisible(False)
35
56
 
36
57
  def setModulePackage(self, module_package: Module):
58
+ # Clean up old hook imports before loading new version
59
+ if self.__pum_config is not None:
60
+ try:
61
+ self.__pum_config.cleanup_hook_imports()
62
+ except Exception:
63
+ # Ignore errors during cleanup
64
+ pass
65
+
37
66
  self.__current_module_package = module_package
38
67
  self.__packagePrepareGetPUMConfig()
39
68
  self.__updateModuleInfo()
40
69
 
70
+ def clearModulePackage(self):
71
+ """Clear module package state and disable the stacked widget."""
72
+ # Cancel any running operations before clearing
73
+ if self.__operation_task.isRunning():
74
+ logger.warning("Canceling running operation due to module package change")
75
+ self.__operation_task.cancel()
76
+ # Don't wait - just reset UI immediately to avoid freezing
77
+ # The finished signal will be emitted when the thread stops
78
+
79
+ # Reset UI state immediately
80
+ self.__resetOperationUI()
81
+
82
+ # Clean up any imported modules from hooks to prevent conflicts
83
+ if self.__pum_config is not None:
84
+ try:
85
+ self.__pum_config.cleanup_hook_imports()
86
+ except Exception:
87
+ # Ignore errors during cleanup
88
+ pass
89
+
90
+ self.__current_module_package = None
91
+ self.__pum_config = None
92
+ self.__data_model_dir = None
93
+ self.__updateModuleInfo()
94
+
95
+ def close(self):
96
+ """Clean up resources when the widget is closed."""
97
+ # Cancel any running operations
98
+ if self.__operation_task.isRunning():
99
+ logger.warning("Canceling running operation due to widget close")
100
+ self.__operation_task.cancel()
101
+
102
+ # Clean up hook imports to release sys.path and sys.modules entries
103
+ if self.__pum_config is not None:
104
+ try:
105
+ self.__pum_config.cleanup_hook_imports()
106
+ except Exception:
107
+ pass
108
+
109
+ def isOperationRunning(self) -> bool:
110
+ """Return True if an operation is currently running."""
111
+ return self.__operation_task.isRunning()
112
+
41
113
  def setDatabaseConnection(self, connection: psycopg.Connection):
114
+ # Cancel any running operations before changing database
115
+ if self.__operation_task.isRunning():
116
+ logger.warning("Canceling running operation due to database connection change")
117
+ self.__operation_task.cancel()
118
+ # Don't wait - just reset UI immediately to avoid freezing
119
+
120
+ # Reset UI state immediately
121
+ self.__resetOperationUI()
122
+
42
123
  self.__database_connection = connection
43
124
  self.__updateModuleInfo()
44
125
 
126
+ def __resetOperationUI(self):
127
+ """Reset UI elements related to operations."""
128
+ self.__setOperationInProgress(False)
129
+
130
+ def __setOperationInProgress(self, in_progress: bool):
131
+ """Enable or disable UI elements based on whether an operation is in progress.
132
+
133
+ Args:
134
+ in_progress: True to disable UI (operation starting), False to enable (operation finished)
135
+ """
136
+ # Main operation buttons - disable during operation
137
+ self.moduleInfo_install_pushButton.setEnabled(not in_progress)
138
+ self.db_parameters_CreateAndGrantRoles_upgrade_checkBox.setEnabled(not in_progress)
139
+ self.moduleInfo_upgrade_pushButton.setEnabled(not in_progress)
140
+ self.moduleInfo_roles_pushButton.setEnabled(not in_progress)
141
+ self.uninstall_button.setEnabled(not in_progress)
142
+
143
+ # Stacked widget contains all the form controls
144
+ self.moduleInfo_stackedWidget.setEnabled(not in_progress)
145
+
146
+ # Cancel button and progress bar - only visible during operation
147
+ self.moduleInfo_cancel_button.setVisible(in_progress)
148
+ self.moduleInfo_cancel_button.setEnabled(in_progress)
149
+ if not in_progress:
150
+ self.moduleInfo_cancel_button.setText(self.tr("Cancel"))
151
+
152
+ self.moduleInfo_progressbar.setVisible(in_progress)
153
+ if not in_progress:
154
+ self.moduleInfo_progressbar.setValue(0)
155
+
156
+ # Parent controls (module selection, database connection)
157
+ if self.parent() is not None:
158
+ parent_dialog = self.parent()
159
+ if hasattr(parent_dialog, "moduleSelection_groupBox"):
160
+ parent_dialog.moduleSelection_groupBox.setEnabled(not in_progress)
161
+ if hasattr(parent_dialog, "db_groupBox"):
162
+ parent_dialog.db_groupBox.setEnabled(not in_progress)
163
+
45
164
  def __packagePrepareGetPUMConfig(self):
46
165
  package_dir = self.__current_module_package.source_package_dir
47
166
 
@@ -70,7 +189,17 @@ class ModuleWidget(QWidget, DIALOG_UI):
70
189
  return
71
190
 
72
191
  try:
73
- self.__pum_config = PumConfig.from_yaml(pumConfigFilename, install_dependencies=True)
192
+ with open(pumConfigFilename) as file:
193
+ # since pum 1.3, the module id is mandatory in the pum config
194
+ config_data = yaml.safe_load(file)
195
+ if "pum" not in config_data:
196
+ config_data["pum"] = {}
197
+ if "module" not in config_data["pum"]:
198
+ config_data["pum"]["module"] = self.__current_module_package.module.id
199
+ base_path = Path(pumConfigFilename).parent
200
+ self.__pum_config = PumConfig(
201
+ base_path=base_path, install_dependencies=True, **config_data
202
+ )
74
203
  except Exception as exception:
75
204
  CriticalMessageBox(
76
205
  self.tr("Error"),
@@ -117,6 +246,20 @@ class ModuleWidget(QWidget, DIALOG_UI):
117
246
  ).exec()
118
247
  return
119
248
 
249
+ # Check that the module ID in the PUM config matches the selected module
250
+ pum_module_id = self.__pum_config.config.pum.module
251
+ selected_module_id = self.__current_module_package.module.id
252
+ if pum_module_id != selected_module_id:
253
+ CriticalMessageBox(
254
+ self.tr("Error"),
255
+ self.tr(
256
+ f"Module ID mismatch: The selected module is '{selected_module_id}' but the PUM configuration specifies '{pum_module_id}'."
257
+ ),
258
+ None,
259
+ self,
260
+ ).exec()
261
+ return
262
+
120
263
  try:
121
264
  parameters = self.parameters_groupbox.parameters_values()
122
265
 
@@ -124,34 +267,44 @@ class ModuleWidget(QWidget, DIALOG_UI):
124
267
  if (
125
268
  self.__current_module_package.type == ModulePackage.Type.PULL_REQUEST
126
269
  or self.__current_module_package.type == ModulePackage.Type.BRANCH
270
+ or self.__current_module_package.prerelease
127
271
  ):
128
272
  logger.warning(
129
- "Installing module from branch or pull request: set parameter beta_testing to True"
273
+ "Installing module from branch, pull request, or prerelease: set parameter beta_testing to True"
130
274
  )
131
275
  beta_testing = True
132
276
 
133
- upgrader = Upgrader(
134
- config=self.__pum_config,
135
- )
136
- with OverrideCursor(Qt.CursorShape.WaitCursor):
137
- upgrader.install(
138
- parameters=parameters,
139
- connection=self.__database_connection,
140
- roles=self.db_parameters_CreateAndGrantRoles_checkBox.isChecked(),
141
- grant=self.db_parameters_CreateAndGrantRoles_checkBox.isChecked(),
142
- beta_testing=beta_testing,
143
- commit=False,
277
+ # Warn user before installing in beta testing mode
278
+ reply = QMessageBox.warning(
279
+ self,
280
+ self.tr("Beta Testing Installation"),
281
+ self.tr(
282
+ "You are about to install this module in BETA TESTING mode.\n\n"
283
+ "This means the module will not be allowed to receive future updates through normal upgrade process.\n"
284
+ "We strongly discourage using this for production databases.\n\n"
285
+ "Are you sure you want to continue?"
286
+ ),
287
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
288
+ QMessageBox.StandardButton.No,
144
289
  )
290
+ if reply != QMessageBox.StandardButton.Yes:
291
+ return
292
+
293
+ # Start background install operation
294
+ options = {
295
+ "roles": self.db_parameters_CreateAndGrantRoles_install_checkBox.isChecked(),
296
+ "grant": self.db_parameters_CreateAndGrantRoles_install_checkBox.isChecked(),
297
+ "beta_testing": beta_testing,
298
+ "allow_multiple_modules": PluginUtils.get_allow_multiple_modules(),
299
+ "install_demo_data": self.db_demoData_checkBox.isChecked(),
300
+ "demo_data_name": (
301
+ self.db_demoData_comboBox.currentText()
302
+ if self.db_demoData_checkBox.isChecked()
303
+ else None
304
+ ),
305
+ }
145
306
 
146
- if self.db_demoData_checkBox.isChecked():
147
- demo_data_name = self.db_demoData_comboBox.currentText()
148
- upgrader.install_demo_data(
149
- connection=self.__database_connection,
150
- name=demo_data_name,
151
- parameters=parameters,
152
- )
153
-
154
- self.__database_connection.commit()
307
+ self.__startOperation("install", parameters, options)
155
308
 
156
309
  except Exception as exception:
157
310
  CriticalMessageBox(
@@ -159,66 +312,499 @@ class ModuleWidget(QWidget, DIALOG_UI):
159
312
  ).exec()
160
313
  return
161
314
 
162
- QMessageBox.information(
315
+ def __upgradeModuleClicked(self):
316
+ if self.__current_module_package is None:
317
+ CriticalMessageBox(
318
+ self.tr("Error"), self.tr("Please select a module package first."), None, self
319
+ ).exec()
320
+ return
321
+
322
+ if self.__database_connection is None:
323
+ CriticalMessageBox(
324
+ self.tr("Error"), self.tr("Please select a database service first."), None, self
325
+ ).exec()
326
+ return
327
+
328
+ if self.__pum_config is None:
329
+ CriticalMessageBox(
330
+ self.tr("Error"), self.tr("No valid module available."), None, self
331
+ ).exec()
332
+ return
333
+
334
+ # Check that the module ID in the PUM config matches the selected module
335
+ pum_module_id = self.__pum_config.config.pum.module
336
+ selected_module_id = self.__current_module_package.module.id
337
+ if pum_module_id != selected_module_id:
338
+ CriticalMessageBox(
339
+ self.tr("Error"),
340
+ self.tr(
341
+ f"Module ID mismatch: The selected module is '{selected_module_id}' but the PUM configuration specifies '{pum_module_id}'."
342
+ ),
343
+ None,
344
+ self,
345
+ ).exec()
346
+ return
347
+
348
+ # Check that the module ID matches the installed module in the database
349
+ sm = SchemaMigrations(self.__pum_config)
350
+ installed_beta_testing = False
351
+ with self.__database_connection.transaction():
352
+ if sm.exists(self.__database_connection):
353
+ migration_details = sm.migration_details(self.__database_connection)
354
+ installed_module_id = migration_details.get("module")
355
+ installed_beta_testing = migration_details.get("beta_testing", False)
356
+
357
+ if installed_module_id and installed_module_id != pum_module_id:
358
+ CriticalMessageBox(
359
+ self.tr("Error"),
360
+ self.tr(
361
+ f"Module ID mismatch: The database contains module '{installed_module_id}' but you are trying to upgrade with '{pum_module_id}'."
362
+ ),
363
+ None,
364
+ self,
365
+ ).exec()
366
+ return
367
+
368
+ # Confirm upgrade if installed module is in beta testing
369
+ if installed_beta_testing:
370
+ reply = QMessageBox.question(
371
+ self,
372
+ self.tr("Confirm Upgrade"),
373
+ self.tr(
374
+ "The installed module is in BETA TESTING mode.\n\n"
375
+ "Are you sure you want to upgrade? \n"
376
+ "This is not a recommended action: \n"
377
+ "if the installed version has missing or different changelogs, \n"
378
+ "the upgrade may fail or cause further issues."
379
+ ),
380
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
381
+ QMessageBox.StandardButton.No,
382
+ )
383
+ if reply != QMessageBox.StandardButton.Yes:
384
+ return
385
+
386
+ try:
387
+ parameters = self.parameters_groupbox.parameters_values()
388
+
389
+ beta_testing = False
390
+ if (
391
+ self.__current_module_package.type == ModulePackage.Type.PULL_REQUEST
392
+ or self.__current_module_package.type == ModulePackage.Type.BRANCH
393
+ or self.__current_module_package.prerelease
394
+ ):
395
+ logger.warning(
396
+ "Upgrading module from branch, pull request, or prerelease: set parameter beta_testing to True"
397
+ )
398
+ beta_testing = True
399
+
400
+ # Start background upgrade operation
401
+ options = {
402
+ "beta_testing": beta_testing,
403
+ "force": installed_beta_testing,
404
+ "roles": self.db_parameters_CreateAndGrantRoles_upgrade_checkBox.isChecked(),
405
+ "grant": self.db_parameters_CreateAndGrantRoles_upgrade_checkBox.isChecked(),
406
+ }
407
+
408
+ self.__startOperation("upgrade", parameters, options)
409
+
410
+ except Exception as exception:
411
+ CriticalMessageBox(
412
+ self.tr("Error"), self.tr("Can't upgrade the module:"), exception, self
413
+ ).exec()
414
+
415
+ def __uninstallModuleClicked(self):
416
+ if self.__current_module_package is None:
417
+ CriticalMessageBox(
418
+ self.tr("Error"), self.tr("Please select a module package first."), None, self
419
+ ).exec()
420
+ return
421
+
422
+ if self.__database_connection is None:
423
+ CriticalMessageBox(
424
+ self.tr("Error"), self.tr("Please select a database service first."), None, self
425
+ ).exec()
426
+ return
427
+
428
+ if self.__pum_config is None:
429
+ CriticalMessageBox(
430
+ self.tr("Error"), self.tr("No valid module available."), None, self
431
+ ).exec()
432
+ return
433
+
434
+ # Check if uninstall hooks are defined
435
+ if not self.__pum_config.config.uninstall:
436
+ CriticalMessageBox(
437
+ self.tr("Error"),
438
+ self.tr(
439
+ "No uninstall configuration found. The module does not provide uninstall functionality."
440
+ ),
441
+ None,
442
+ self,
443
+ ).exec()
444
+ return
445
+
446
+ # Check if the installed version matches the selected version
447
+ sm = SchemaMigrations(self.__pum_config)
448
+ version_warning = ""
449
+ with self.__database_connection.transaction():
450
+ if not sm.exists(self.__database_connection):
451
+ raise Exception("Module is not installed in the database. This should not happen.")
452
+ installed_version = sm.baseline(self.__database_connection)
453
+ selected_version = self.__pum_config.last_version()
454
+ if installed_version != selected_version:
455
+ version_warning = (
456
+ f"\n\n⚠️ WARNING: Version mismatch detected!\n"
457
+ f"Installed version: {installed_version}\n"
458
+ f"Selected version: {selected_version}\n\n"
459
+ f"This could be an issue as the uninstall instructions may not match the installed datamodel."
460
+ )
461
+
462
+ # Confirm uninstall with user
463
+ reply = QMessageBox.question(
163
464
  self,
164
- self.tr("Module installed"),
465
+ self.tr("Confirm Uninstall"),
165
466
  self.tr(
166
- f"Module '{self.__current_module_package.module.name}' version '{self.__current_module_package.name}' has been successfully installed."
467
+ f"Are you sure you want to uninstall module '{self.__current_module_package.module.name}'?\n\n"
468
+ f"This action will remove all module data from the database and cannot be undone."
469
+ f"{version_warning}"
167
470
  ),
471
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
472
+ QMessageBox.StandardButton.No,
168
473
  )
169
- logger.info(
170
- f"Module '{self.__current_module_package.module.name}' version '{self.__current_module_package.name}' has been successfully installed."
474
+
475
+ if reply != QMessageBox.StandardButton.Yes:
476
+ return
477
+
478
+ try:
479
+ parameters = self.parameters_groupbox.parameters_values()
480
+
481
+ # Start background uninstall operation
482
+ self.__startOperation("uninstall", parameters, {})
483
+
484
+ except Exception as exception:
485
+ CriticalMessageBox(
486
+ self.tr("Error"), self.tr("Can't uninstall the module:"), exception, self
487
+ ).exec()
488
+ return
489
+
490
+ def __rolesClicked(self):
491
+ """Create and grant roles for the current module."""
492
+ if self.__current_module_package is None:
493
+ CriticalMessageBox(
494
+ self.tr("Error"), self.tr("Please select a module package first."), None, self
495
+ ).exec()
496
+ return
497
+
498
+ if self.__database_connection is None:
499
+ CriticalMessageBox(
500
+ self.tr("Error"), self.tr("Please connect to a database first."), None, self
501
+ ).exec()
502
+ return
503
+
504
+ if self.__pum_config is None:
505
+ CriticalMessageBox(
506
+ self.tr("Error"), self.tr("Module configuration not loaded."), None, self
507
+ ).exec()
508
+ return
509
+
510
+ try:
511
+ parameters = self.parameters_groupbox.parameters_values()
512
+
513
+ # Start background roles operation
514
+ self.__startOperation("roles", parameters, {})
515
+
516
+ except Exception as exception:
517
+ CriticalMessageBox(
518
+ self.tr("Error"), self.tr("Can't create and grant roles:"), exception, self
519
+ ).exec()
520
+ return
521
+
522
+ def __show_error_state(self, message: str, on_label=None):
523
+ """Display an error state and hide the widget content."""
524
+ label = on_label or self.moduleInfo_selected_label
525
+ label.setText(self.tr(message))
526
+ QtUtils.setForegroundColor(label, PluginUtils.COLOR_WARNING)
527
+ # Hide the stacked widget entirely when in error state
528
+ self.moduleInfo_stackedWidget.setVisible(False)
529
+ # Also hide uninstall button since module info is not valid
530
+ self.uninstall_button.setVisible(False)
531
+
532
+ def __show_database_info_page(self):
533
+ """Show database information page when no module package is selected."""
534
+ with self.__database_connection.transaction():
535
+ installed_schemas = SchemaMigrations.schemas_with_migrations(
536
+ self.__database_connection
537
+ )
538
+
539
+ # Show selected module label with prompt
540
+ self.moduleInfo_selected_label.setText(
541
+ self.tr("No module package selected. Please select a module from the left panel.")
171
542
  )
543
+ QtUtils.setForegroundColor(self.moduleInfo_selected_label, PluginUtils.COLOR_WARNING)
172
544
 
173
- self.__updateModuleInfo()
545
+ # Show database info in installation label
546
+ if installed_schemas:
547
+ schema_list = ", ".join([f"<b>{schema}</b>" for schema in installed_schemas])
548
+ self.moduleInfo_installation_label.setText(
549
+ self.tr(f"Database has installed modules in schemas: {schema_list}")
550
+ )
551
+ else:
552
+ self.moduleInfo_installation_label.setText(
553
+ self.tr("No modules installed in this database")
554
+ )
555
+ QtUtils.resetForegroundColor(self.moduleInfo_installation_label)
174
556
 
175
- def __upgradeModuleClicked(self):
176
- QMessageBox.critical(
177
- self,
178
- self.tr("Not implemented"),
179
- self.tr("Upgrade module is not implemented yet."),
557
+ # Hide the stacked widget since no module is selected
558
+ self.moduleInfo_stackedWidget.setVisible(False)
559
+
560
+ # Hide uninstall button
561
+ self.uninstall_button.setVisible(False)
562
+
563
+ def __show_install_page(self, version: str):
564
+ """Switch to install page and configure it."""
565
+ # Check for modules in other schemas
566
+ with self.__database_connection.transaction():
567
+ installed_schemas = SchemaMigrations.schemas_with_migrations(
568
+ self.__database_connection
569
+ )
570
+
571
+ module_name = self.__current_module_package.module.name
572
+ module_id = self.__current_module_package.module.id
573
+ if installed_schemas:
574
+ schema_list = ", ".join([f"<b>{schema}</b>" for schema in installed_schemas])
575
+ self.moduleInfo_installation_label.setText(
576
+ self.tr(
577
+ f"No module <b>{module_name} ({module_id})</b> installed in this schema.<br>Module(s) in other schema(s): {schema_list}"
578
+ )
579
+ )
580
+ else:
581
+ self.moduleInfo_installation_label.setText(
582
+ self.tr(f"No module <b>{module_name} ({module_id})</b> installed")
583
+ )
584
+ QtUtils.resetForegroundColor(self.moduleInfo_installation_label)
585
+ self.moduleInfo_install_pushButton.setText(self.tr(f"Install {version}"))
586
+ self.moduleInfo_stackedWidget.setCurrentWidget(self.moduleInfo_stackedWidget_pageInstall)
587
+ # Ensure the stacked widget is visible when showing a valid page
588
+ self.moduleInfo_stackedWidget.setVisible(True)
589
+
590
+ def __show_upgrade_page(
591
+ self,
592
+ module_name: str,
593
+ baseline_version: str,
594
+ target_version: str,
595
+ beta_testing: bool = False,
596
+ ):
597
+ """Switch to upgrade page and configure it."""
598
+ beta_text = " (BETA TESTING)" if beta_testing else ""
599
+
600
+ # Check for modules in other schemas
601
+ sm = SchemaMigrations(self.__pum_config)
602
+ with self.__database_connection.transaction():
603
+ other_schemas = sm.exists_in_other_schemas(self.__database_connection)
604
+
605
+ # Build installation info text
606
+ install_text = f"Installed: module {module_name} at version {baseline_version}{beta_text}."
607
+ if other_schemas:
608
+ schema_list = ", ".join([f"<b>{schema}</b>" for schema in other_schemas])
609
+ install_text += f"<br>Module(s) in other schema(s): {schema_list}"
610
+
611
+ self.moduleInfo_installation_label.setText(install_text)
612
+ if beta_testing:
613
+ QtUtils.setForegroundColor(
614
+ self.moduleInfo_installation_label, PluginUtils.COLOR_WARNING
615
+ )
616
+ else:
617
+ QtUtils.resetForegroundColor(self.moduleInfo_installation_label)
618
+ self.moduleInfo_upgrade_pushButton.setText(self.tr(f"Upgrade to {target_version}"))
619
+ self.moduleInfo_stackedWidget.setCurrentWidget(self.moduleInfo_stackedWidget_pageUpgrade)
620
+ # Ensure the stacked widget is visible when showing a valid page
621
+ self.moduleInfo_stackedWidget.setVisible(True)
622
+
623
+ # Enable/disable upgrade button and show/hide roles button based on version comparison
624
+ if target_version <= baseline_version:
625
+ self.moduleInfo_upgrade_pushButton.setDisabled(True)
626
+ self.db_parameters_CreateAndGrantRoles_upgrade_checkBox.setDisabled(True)
627
+ # Show roles button when upgrade is not possible (same or higher version installed)
628
+ self.moduleInfo_roles_pushButton.setVisible(True)
629
+ logger.info(
630
+ f"Selected version {target_version} is equal to or lower than installed version {baseline_version}"
631
+ )
632
+ else:
633
+ self.moduleInfo_upgrade_pushButton.setEnabled(True)
634
+ self.db_parameters_CreateAndGrantRoles_upgrade_checkBox.setEnabled(True)
635
+ # Hide roles button when upgrade is possible
636
+ self.moduleInfo_roles_pushButton.setVisible(False)
637
+
638
+ def __configure_uninstall_button(self):
639
+ """Show/hide uninstall button based on configuration."""
640
+ has_uninstall = bool(
641
+ self.__pum_config
642
+ and self.__pum_config.config.uninstall
643
+ and len(self.__pum_config.config.uninstall) > 0
180
644
  )
181
- return
645
+ self.uninstall_button.setVisible(has_uninstall)
182
646
 
183
647
  def __updateModuleInfo(self):
184
648
  if self.__current_module_package is None:
185
- self.moduleInfo_label.setText(self.tr("No module package selected"))
186
- QtUtils.setForegroundColor(self.moduleInfo_label, PluginUtils.COLOR_WARNING)
649
+ # List installed modules if available
650
+ if self.__database_connection is not None:
651
+ self.__show_database_info_page()
652
+ else:
653
+ self.__show_error_state("No module package selected")
187
654
  return
188
655
 
189
656
  if self.__database_connection is None:
190
- self.moduleInfo_label.setText(self.tr("No database connection available"))
191
- QtUtils.setForegroundColor(self.moduleInfo_label, PluginUtils.COLOR_WARNING)
657
+ self.__show_error_state(
658
+ "No database connection available", on_label=self.moduleInfo_installation_label
659
+ )
192
660
  return
193
661
 
194
662
  if self.__pum_config is None:
195
- self.moduleInfo_label.setText(self.tr("No PUM config available"))
196
- QtUtils.setForegroundColor(self.moduleInfo_label, PluginUtils.COLOR_WARNING)
663
+ self.__show_error_state("No PUM config available")
197
664
  return
198
665
 
199
666
  migrationVersion = self.__pum_config.last_version()
200
667
  sm = SchemaMigrations(self.__pum_config)
201
668
 
202
- if sm.exists(self.__database_connection):
203
- # Case upgrade
204
- baseline_version = sm.baseline(self.__database_connection)
205
- self.moduleInfo_label.setText(self.tr(f"Version {baseline_version} found"))
206
- QtUtils.resetForegroundColor(self.moduleInfo_label)
207
- self.moduleInfo_upgrade_pushButton.setText(self.tr(f"Upgrade to {migrationVersion}"))
669
+ # Set the selected module info
670
+ self.moduleInfo_selected_label.setText(
671
+ self.tr(
672
+ f"Module selected:{self.__current_module_package.module.name} - {migrationVersion}"
673
+ )
674
+ )
675
+ QtUtils.resetForegroundColor(self.moduleInfo_selected_label)
676
+
677
+ self.moduleInfo_stackedWidget.setEnabled(True)
678
+ self.__configure_uninstall_button()
679
+
680
+ # Wrap read-only queries in transaction to prevent idle connections
681
+ with self.__database_connection.transaction():
682
+ if sm.exists(self.__database_connection):
683
+ # Module is installed - show upgrade page
684
+ baseline_version = sm.baseline(self.__database_connection)
685
+ migration_details = sm.migration_details(self.__database_connection)
686
+ installed_beta_testing = migration_details.get("beta_testing", False)
687
+ self.__show_upgrade_page(
688
+ self.__current_module_package.module.name,
689
+ baseline_version,
690
+ migrationVersion,
691
+ installed_beta_testing,
692
+ )
693
+
694
+ logger.info(f"Migration table details: {migration_details}")
695
+ else:
696
+ # Module not installed - show install page
697
+ self.__show_install_page(migrationVersion)
208
698
 
209
- self.moduleInfo_stackedWidget.setCurrentWidget(
210
- self.moduleInfo_stackedWidget_pageUpgrade
699
+ def __startOperation(self, operation: str, parameters: dict, options: dict):
700
+ """Start a background module operation."""
701
+ # Disable UI during operation
702
+ self.__setOperationInProgress(True)
703
+
704
+ # Start the background task
705
+ if operation == "install":
706
+ self.__operation_task.start_install(
707
+ self.__pum_config, self.__database_connection, parameters, **options
708
+ )
709
+ elif operation == "upgrade":
710
+ self.__operation_task.start_upgrade(
711
+ self.__pum_config, self.__database_connection, parameters, **options
712
+ )
713
+ elif operation == "uninstall":
714
+ self.__operation_task.start_uninstall(
715
+ self.__pum_config, self.__database_connection, parameters, **options
716
+ )
717
+ elif operation == "roles":
718
+ self.__operation_task.start_roles(
719
+ self.__pum_config, self.__database_connection, parameters, **options
211
720
  )
212
721
 
213
- logger.info(
214
- f"Migration table details: {sm.migration_details(self.__database_connection)}"
722
+ def __cancelOperationClicked(self):
723
+ """Cancel the current operation."""
724
+ self.moduleInfo_cancel_button.setEnabled(False)
725
+ self.moduleInfo_cancel_button.setText(self.tr("Canceling..."))
726
+ self.__operation_task.cancel()
727
+ logger.info("Operation cancel requested by user")
728
+ # Don't wait here - the __onOperationFinished signal will handle UI cleanup
729
+
730
+ # Start a timeout timer in case the operation hangs
731
+ self.__cancel_timeout_timer.start(5000) # 5 second timeout
732
+
733
+ def __onCancelTimeout(self):
734
+ """Handle timeout when cancel doesn't complete."""
735
+ if self.__operation_task.isRunning():
736
+ logger.error("Operation did not respond to cancel request, forcing termination")
737
+ self.__operation_task.terminate()
738
+ # Force UI reset
739
+ self.__resetOperationUI()
740
+ # Show warning
741
+ QMessageBox.warning(
742
+ self,
743
+ self.tr("Operation Terminated"),
744
+ self.tr(
745
+ "The operation did not respond to the cancel request and was forcefully terminated. "
746
+ "The database may be in an inconsistent state. Please verify manually."
747
+ ),
215
748
  )
216
749
 
750
+ def __onOperationProgress(self, message: str, current: int, total: int):
751
+ """Handle progress updates from background operation."""
752
+ # Update progress bar only, don't touch the installation label
753
+ if total > 0:
754
+ # Determinate progress
755
+ self.moduleInfo_progressbar.setFormat(message)
756
+ self.moduleInfo_progressbar.setTextVisible(True)
757
+ self.moduleInfo_progressbar.setMaximum(total)
758
+ self.moduleInfo_progressbar.setValue(current)
759
+ logger.debug(f"Progress update: {current}/{total} - {message}")
217
760
  else:
218
- # Case install
219
- self.moduleInfo_label.setText(self.tr("No module found"))
220
- QtUtils.resetForegroundColor(self.moduleInfo_label)
221
- self.moduleInfo_install_pushButton.setText(self.tr(f"Install {migrationVersion}"))
222
- self.moduleInfo_stackedWidget.setCurrentWidget(
223
- self.moduleInfo_stackedWidget_pageInstall
761
+ # Indeterminate progress
762
+ self.moduleInfo_progressbar.setFormat(message)
763
+ self.moduleInfo_progressbar.setTextVisible(True)
764
+ self.moduleInfo_progressbar.setMaximum(0)
765
+ self.moduleInfo_progressbar.setValue(0)
766
+
767
+ def __onOperationFinished(self, success: bool, error_message: str):
768
+ """Handle completion of background operation."""
769
+ # Stop the timeout timer if running
770
+ self.__cancel_timeout_timer.stop()
771
+
772
+ # Always reset UI state, even if already reset
773
+ self.__resetOperationUI()
774
+
775
+ if success:
776
+ # Show success message
777
+ operation = self.__operation_task._ModuleOperationTask__operation
778
+ if operation == "install":
779
+ operation_name = "installed"
780
+ elif operation == "upgrade":
781
+ operation_name = "upgraded"
782
+ elif operation == "uninstall":
783
+ operation_name = "uninstalled"
784
+ elif operation == "roles":
785
+ operation_name = "roles created and granted"
786
+ else:
787
+ operation_name = "completed"
788
+
789
+ QMessageBox.information(
790
+ self,
791
+ self.tr(f"Module {operation_name}"),
792
+ self.tr(
793
+ f"Module '{self.__current_module_package.module.name}': {operation_name} successfully."
794
+ ),
795
+ )
796
+ logger.info(
797
+ f"Module '{self.__current_module_package.module.name}': {operation_name} successfully."
224
798
  )
799
+
800
+ # Refresh module info
801
+ self.__updateModuleInfo()
802
+ else:
803
+ # Show error message only if there's an actual error (not just cancellation)
804
+ if error_message:
805
+ CriticalMessageBox(
806
+ self.tr("Error"),
807
+ self.tr(f"Operation failed: {error_message}"),
808
+ None,
809
+ self,
810
+ ).exec()