""" Groups Widget for Python Scheduler Display and manage job groups """ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QPushButton, QHeaderView, QLabel, QMessageBox, QMenu ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QAction from loguru import logger from core.database import SchedulerDatabase from core.config import SchedulerConfig from gui.group_editor_dialog import GroupEditorDialog class GroupsWidget(QWidget): """Job groups list widget""" def __init__(self, db: SchedulerDatabase, config: SchedulerConfig): super().__init__() self.db = db self.config = config self._setup_ui() self.refresh() def _setup_ui(self): """Setup UI""" layout = QVBoxLayout(self) # Header header_layout = QHBoxLayout() header_label = QLabel("Job Groups (Chains)") header_label.setStyleSheet("font-size: 14px; font-weight: bold;") header_layout.addWidget(header_label) header_layout.addStretch() # Refresh button self.refresh_btn = QPushButton("Refresh") self.refresh_btn.clicked.connect(self.refresh) header_layout.addWidget(self.refresh_btn) layout.addLayout(header_layout) # Action buttons actions_layout = QHBoxLayout() self.new_group_btn = QPushButton("New Group") self.new_group_btn.setStyleSheet("background-color: #4CAF50; color: white; padding: 5px 15px;") self.new_group_btn.clicked.connect(self._new_group) actions_layout.addWidget(self.new_group_btn) self.edit_group_btn = QPushButton("Edit") self.edit_group_btn.setEnabled(False) self.edit_group_btn.clicked.connect(self._edit_group) actions_layout.addWidget(self.edit_group_btn) self.run_group_btn = QPushButton("Run Group") self.run_group_btn.setStyleSheet("background-color: #2196F3; color: white; padding: 5px 15px;") self.run_group_btn.setEnabled(False) self.run_group_btn.clicked.connect(self._run_group) actions_layout.addWidget(self.run_group_btn) self.delete_group_btn = QPushButton("Delete") self.delete_group_btn.setStyleSheet("background-color: #f44336; color: white; padding: 5px 15px;") self.delete_group_btn.setEnabled(False) self.delete_group_btn.clicked.connect(self._delete_group) actions_layout.addWidget(self.delete_group_btn) actions_layout.addStretch() layout.addLayout(actions_layout) # Groups table self.table = QTableWidget() self.table.setColumnCount(5) self.table.setHorizontalHeaderLabels([ "ID", "Name", "Jobs Count", "Error Handling", "Created" ]) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.table.setAlternatingRowColors(True) self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) # Enable context menu self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table.customContextMenuRequested.connect(self._show_context_menu) # Enable/disable buttons on selection change self.table.itemSelectionChanged.connect(self._on_selection_changed) layout.addWidget(self.table) def refresh(self): """Refresh groups list""" try: with self.db._get_connection() as conn: cursor = conn.execute("SELECT * FROM job_groups ORDER BY name") groups = [dict(row) for row in cursor.fetchall()] self.table.setRowCount(len(groups)) for row, group in enumerate(groups): # ID self.table.setItem(row, 0, QTableWidgetItem(str(group['id']))) # Name name_item = QTableWidgetItem(group['name']) self.table.setItem(row, 1, name_item) # Jobs count members = self.db.get_job_group_members(group['id']) count_item = QTableWidgetItem(str(len(members))) count_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.table.setItem(row, 2, count_item) # Error handling self.table.setItem(row, 3, QTableWidgetItem(group['error_handling'])) # Created created_str = group['created_at'][:19] if group['created_at'] else "N/A" self.table.setItem(row, 4, QTableWidgetItem(created_str)) logger.debug(f"Refreshed groups table: {len(groups)} groups") except Exception as e: logger.error(f"Error refreshing groups: {e}") def _on_selection_changed(self): """Handle selection change""" has_selection = len(self.table.selectedItems()) > 0 self.edit_group_btn.setEnabled(has_selection) self.run_group_btn.setEnabled(has_selection) self.delete_group_btn.setEnabled(has_selection) def _get_selected_group_id(self) -> int: """Get selected group ID""" selected_rows = self.table.selectionModel().selectedRows() if not selected_rows: return None row = selected_rows[0].row() group_id = int(self.table.item(row, 0).text()) return group_id def _new_group(self): """Create new group""" dialog = GroupEditorDialog(self, self.db, self.config) if dialog.exec(): self.refresh() def _edit_group(self): """Edit selected group""" group_id = self._get_selected_group_id() if not group_id: return dialog = GroupEditorDialog(self, self.db, self.config, group_id=group_id) if dialog.exec(): self.refresh() def _run_group(self): """Execute group (run all jobs in order)""" group_id = self._get_selected_group_id() if not group_id: return try: group = self.db.get_job_group(group_id) members = self.db.get_job_group_members(group_id) if not members: QMessageBox.warning(self, "Warning", f"Group '{group['name']}' has no jobs to execute") return job_names = [m['job_name'] for m in members] job_list = '\n'.join(f"{i+1}. {name}" for i, name in enumerate(job_names)) reply = QMessageBox.question( self, "Run Group", f"Execute group '{group['name']}' with {len(members)} jobs?\n\nExecution order:\n{job_list}", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: # Import executor here to avoid circular imports from daemon.job_executor import JobExecutor executor = JobExecutor(self.db, self.config) # Execute jobs in order error_handling = group['error_handling'] failed_jobs = [] for member in members: job_id = member['job_id'] job_name = member['job_name'] logger.info(f"Executing job {job_id} ({job_name}) in group {group_id}") try: execution_id = executor.execute_job( job_id, triggered_by='manual_group', group_id=group_id ) if not execution_id: failed_jobs.append(job_name) if error_handling == 'stop_on_error': QMessageBox.warning( self, "Group Execution Stopped", f"Job '{job_name}' failed to start.\n\nStopping group execution (error_handling: stop_on_error)" ) break except Exception as e: logger.error(f"Error executing job {job_id}: {e}") failed_jobs.append(job_name) if error_handling == 'stop_on_error': QMessageBox.critical( self, "Group Execution Stopped", f"Job '{job_name}' failed with error:\n{e}\n\nStopping group execution (error_handling: stop_on_error)" ) break # Show completion message if failed_jobs: QMessageBox.warning( self, "Group Execution Completed with Errors", f"Group '{group['name']}' completed with {len(failed_jobs)} failed job(s):\n" + '\n'.join(failed_jobs) ) else: QMessageBox.information( self, "Success", f"Group '{group['name']}' started successfully.\n\nAll {len(members)} jobs have been queued for execution." ) self.refresh() except Exception as e: logger.error(f"Error running group: {e}") QMessageBox.critical(self, "Error", f"Failed to run group: {e}") def _delete_group(self): """Delete selected group""" group_id = self._get_selected_group_id() if not group_id: return try: group = self.db.get_job_group(group_id) members = self.db.get_job_group_members(group_id) # Confirmation dialog reply = QMessageBox.question( self, "Confirm Delete", f"Delete group '{group['name']}'?\n\nThis will remove the group and its {len(members)} job member(s).\n(The jobs themselves will not be deleted)", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: self.db.delete_job_group(group_id) logger.info(f"Deleted group {group_id}") QMessageBox.information(self, "Success", f"Group '{group['name']}' deleted successfully") self.refresh() except Exception as e: logger.error(f"Error deleting group: {e}") QMessageBox.critical(self, "Error", f"Failed to delete group: {e}") def _show_context_menu(self, position): """Show context menu""" group_id = self._get_selected_group_id() if not group_id: return try: group = self.db.get_job_group(group_id) menu = QMenu(self) # Edit action edit_action = QAction("Edit...", self) edit_action.triggered.connect(self._edit_group) menu.addAction(edit_action) # Run action run_action = QAction("Run Group", self) run_action.triggered.connect(self._run_group) menu.addAction(run_action) # Delete action delete_action = QAction("Delete", self) delete_action.triggered.connect(self._delete_group) menu.addAction(delete_action) menu.exec(self.table.viewport().mapToGlobal(position)) except Exception as e: logger.error(f"Error showing context menu: {e}")