#!/usr/bin/env python3 """ Database Manager Widget - vzug-e-hinge ======================================= Comprehensive database management interface for inspecting and managing sessions. Features: - Session browser with sort/filter/search - Session details with editable notes - Rename, delete, and export sessions - Delete individual runs - Session statistics Author: Kynsight Version: 1.0.0 """ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QTableWidget, QTableWidgetItem, QHeaderView, QLabel, QPushButton, QLineEdit, QTextEdit, QGroupBox, QMessageBox, QInputDialog, QFileDialog, QCheckBox, QListWidget, QListWidgetItem ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont from datetime import datetime import sqlite3 class DatabaseManagerWidget(QWidget): """ Database Manager Widget. Provides comprehensive session management: - Browse all sessions - View session details and runs - Rename, delete, export sessions - Add notes to sessions - Delete individual runs """ def __init__(self, db_manager): """ Initialize Database Manager Widget. Args: db_manager: DatabaseManager instance """ super().__init__() self.db_manager = db_manager self.selected_session_id = None self._ensure_notes_column() self._init_ui() self._load_sessions() def _ensure_notes_column(self): """Ensure notes column exists in sessions table (migration).""" try: conn = self.db_manager.get_connection() # Check if notes column exists cursor = conn.execute("PRAGMA table_info(sessions)") columns = [row[1] for row in cursor.fetchall()] if 'notes' not in columns: # Add notes column conn.execute("ALTER TABLE sessions ADD COLUMN notes TEXT DEFAULT ''") conn.commit() print("[INFO] Added 'notes' column to sessions table") except Exception as e: print(f"[WARN] Failed to add notes column: {e}") def _init_ui(self): """Initialize user interface.""" layout = QVBoxLayout() self.setLayout(layout) # Main splitter (left: session browser, right: details) splitter = QSplitter(Qt.Orientation.Horizontal) layout.addWidget(splitter) # Left panel: Session Browser left_panel = self._create_session_browser() splitter.addWidget(left_panel) # Right panel: Session Details right_panel = self._create_session_details() splitter.addWidget(right_panel) # Set splitter ratio (40% left, 60% right) splitter.setStretchFactor(0, 4) splitter.setStretchFactor(1, 6) def _create_session_browser(self): """Create session browser panel (left side).""" panel = QWidget() layout = QVBoxLayout() panel.setLayout(layout) # Search and filter search_layout = QHBoxLayout() search_layout.addWidget(QLabel("Search:")) self.search_input = QLineEdit() self.search_input.setPlaceholderText("Filter sessions by name...") self.search_input.textChanged.connect(self._filter_sessions) search_layout.addWidget(self.search_input) layout.addLayout(search_layout) # Session table self.session_table = QTableWidget() self.session_table.setColumnCount(5) self.session_table.setHorizontalHeaderLabels([ "Select", "Session Name", "Date", "Runs", "Size (MB)" ]) # Configure table header = self.session_table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) self.session_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.session_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection) self.session_table.itemSelectionChanged.connect(self._on_session_selected) self.session_table.setSortingEnabled(True) layout.addWidget(self.session_table) # Batch action buttons btn_layout = QHBoxLayout() self.btn_select_all = QPushButton("Select All") self.btn_select_all.clicked.connect(self._select_all_sessions) btn_layout.addWidget(self.btn_select_all) self.btn_clear_selection = QPushButton("Clear") self.btn_clear_selection.clicked.connect(self._clear_selection) btn_layout.addWidget(self.btn_clear_selection) btn_layout.addStretch() self.btn_delete_selected = QPushButton("Delete Selected") self.btn_delete_selected.clicked.connect(self._delete_selected_sessions) btn_layout.addWidget(self.btn_delete_selected) self.btn_refresh = QPushButton("Refresh") self.btn_refresh.clicked.connect(self._load_sessions) btn_layout.addWidget(self.btn_refresh) layout.addLayout(btn_layout) return panel def _create_session_details(self): """Create session details panel (right side).""" panel = QWidget() layout = QVBoxLayout() panel.setLayout(layout) # Session info group info_group = QGroupBox("Session Information") info_layout = QVBoxLayout() info_group.setLayout(info_layout) # Session ID self.label_session_id = QLabel("No session selected") self.label_session_id.setFont(QFont("Arial", 10, QFont.Weight.Bold)) info_layout.addWidget(self.label_session_id) # Created date self.label_created = QLabel("") info_layout.addWidget(self.label_created) # Notes notes_layout = QHBoxLayout() notes_layout.addWidget(QLabel("Notes:")) self.text_notes = QTextEdit() self.text_notes.setMaximumHeight(80) self.text_notes.setPlaceholderText("Add notes or description for this session...") self.text_notes.textChanged.connect(self._on_notes_changed) notes_layout.addWidget(self.text_notes) info_layout.addLayout(notes_layout) # Session actions action_layout = QHBoxLayout() self.btn_rename = QPushButton("Rename Session") self.btn_rename.clicked.connect(self._rename_session) action_layout.addWidget(self.btn_rename) self.btn_delete = QPushButton("Delete Session") self.btn_delete.clicked.connect(self._delete_session) action_layout.addWidget(self.btn_delete) self.btn_export = QPushButton("Export CSV") self.btn_export.clicked.connect(self._export_session) action_layout.addWidget(self.btn_export) action_layout.addStretch() info_layout.addLayout(action_layout) layout.addWidget(info_group) # Runs group runs_group = QGroupBox("Runs in Session") runs_layout = QVBoxLayout() runs_group.setLayout(runs_layout) self.runs_list = QListWidget() runs_layout.addWidget(self.runs_list) # Run actions run_action_layout = QHBoxLayout() self.btn_delete_runs = QPushButton("Delete Selected Runs") self.btn_delete_runs.clicked.connect(self._delete_selected_runs) run_action_layout.addWidget(self.btn_delete_runs) run_action_layout.addStretch() runs_layout.addLayout(run_action_layout) layout.addWidget(runs_group) # Statistics group stats_group = QGroupBox("Session Statistics") stats_layout = QVBoxLayout() stats_group.setLayout(stats_layout) self.label_stats = QLabel("Select a session to view statistics") self.label_stats.setFont(QFont("Monospace", 9)) stats_layout.addWidget(self.label_stats) layout.addWidget(stats_group) # Disable all controls initially self._set_details_enabled(False) return panel def _set_details_enabled(self, enabled: bool): """Enable or disable detail panel controls.""" self.text_notes.setEnabled(enabled) self.btn_rename.setEnabled(enabled) self.btn_delete.setEnabled(enabled) self.btn_export.setEnabled(enabled) self.btn_delete_runs.setEnabled(enabled) def _load_sessions(self): """Load all sessions from database.""" try: conn = self.db_manager.get_connection() cursor = conn.execute(""" SELECT session_id, session_name, created_at, total_runs, notes, (SELECT COUNT(*) FROM telemetry_decoded WHERE telemetry_decoded.session_id = sessions.session_id) as data_points FROM sessions ORDER BY created_at DESC """) sessions = cursor.fetchall() # Store all sessions for filtering self.all_sessions = sessions # Display sessions self._display_sessions(sessions) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load sessions:\n{str(e)}") def _display_sessions(self, sessions): """Display sessions in table.""" self.session_table.setSortingEnabled(False) self.session_table.setRowCount(0) for row_idx, session in enumerate(sessions): session_id, session_name, created_at, total_runs, notes, data_points = session self.session_table.insertRow(row_idx) # Checkbox checkbox = QCheckBox() checkbox_widget = QWidget() checkbox_layout = QHBoxLayout(checkbox_widget) checkbox_layout.addWidget(checkbox) checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) checkbox_layout.setContentsMargins(0, 0, 0, 0) self.session_table.setCellWidget(row_idx, 0, checkbox_widget) # Session name name_item = QTableWidgetItem(session_name) name_item.setData(Qt.ItemDataRole.UserRole, session_id) self.session_table.setItem(row_idx, 1, name_item) # Date date_item = QTableWidgetItem(created_at) self.session_table.setItem(row_idx, 2, date_item) # Runs runs_item = QTableWidgetItem(str(total_runs)) runs_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.session_table.setItem(row_idx, 3, runs_item) # Size (estimate: ~100 bytes per data point) size_mb = (data_points * 100) / (1024 * 1024) size_item = QTableWidgetItem(f"{size_mb:.2f}") size_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) self.session_table.setItem(row_idx, 4, size_item) self.session_table.setSortingEnabled(True) def _filter_sessions(self): """Filter sessions based on search text.""" search_text = self.search_input.text().lower() if not search_text: # Show all sessions self._display_sessions(self.all_sessions) else: # Filter sessions filtered = [s for s in self.all_sessions if search_text in s[1].lower()] self._display_sessions(filtered) def _on_session_selected(self): """Handle session selection.""" selected_items = self.session_table.selectedItems() if not selected_items: self._set_details_enabled(False) self.selected_session_id = None self.label_session_id.setText("No session selected") self.label_created.setText("") self.text_notes.clear() self.runs_list.clear() self.label_stats.setText("Select a session to view statistics") return # Get session ID from selected row row = selected_items[0].row() session_id = self.session_table.item(row, 1).data(Qt.ItemDataRole.UserRole) self.selected_session_id = session_id self._load_session_details(session_id) self._set_details_enabled(True) def _load_session_details(self, session_id: str): """Load details for selected session.""" try: conn = self.db_manager.get_connection() # Get session info cursor = conn.execute(""" SELECT session_id, session_name, created_at, total_runs, notes FROM sessions WHERE session_id = ? """, (session_id,)) session = cursor.fetchone() if not session: return session_id, session_name, created_at, total_runs, notes = session # Update UI self.label_session_id.setText(f"Session: {session_name}") self.label_created.setText(f"Created: {created_at}") # Block signals while setting notes to avoid triggering save self.text_notes.blockSignals(True) self.text_notes.setPlainText(notes or "") self.text_notes.blockSignals(False) # Load runs self._load_runs(session_id) # Load statistics self._load_statistics(session_id) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load session details:\n{str(e)}") def _load_runs(self, session_id: str): """Load runs for selected session.""" try: conn = self.db_manager.get_connection() cursor = conn.execute(""" SELECT DISTINCT t.run_no, COALESCE(u.command_name, i.command_name, 'Unknown') as command_name, COUNT(*) as sample_count FROM telemetry_decoded t LEFT JOIN uart_commands u ON t.run_command_id = u.command_id LEFT JOIN i2c_commands i ON t.run_command_id = i.command_id WHERE t.session_id = ? GROUP BY t.run_no ORDER BY t.run_no """, (session_id,)) runs = cursor.fetchall() self.runs_list.clear() for run_no, command_name, sample_count in runs: item = QListWidgetItem(f"Run {run_no}: {command_name} ({sample_count:,} samples)") item.setData(Qt.ItemDataRole.UserRole, run_no) item.setCheckState(Qt.CheckState.Unchecked) self.runs_list.addItem(item) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to load runs:\n{str(e)}") def _load_statistics(self, session_id: str): """Load statistics for selected session.""" try: conn = self.db_manager.get_connection() # Get statistics cursor = conn.execute(""" SELECT COUNT(DISTINCT run_no) as total_runs, COUNT(*) as total_samples, MIN(t_ns) as start_time, MAX(t_ns) as end_time FROM telemetry_decoded WHERE session_id = ? """, (session_id,)) stats = cursor.fetchone() if stats and stats[0] > 0: total_runs, total_samples, start_time, end_time = stats # Calculate duration duration_ns = end_time - start_time duration_s = duration_ns / 1_000_000_000 # Format statistics stats_text = f"• Total Runs: {total_runs}\n" stats_text += f"• Total Samples: {total_samples:,}\n" stats_text += f"• Avg Samples/Run: {total_samples // total_runs:,}\n" stats_text += f"• Duration: {duration_s:.1f} seconds\n" self.label_stats.setText(stats_text) else: self.label_stats.setText("No data available for this session") except Exception as e: self.label_stats.setText(f"Error loading statistics: {str(e)}") def _on_notes_changed(self): """Handle notes text change - auto-save.""" if not self.selected_session_id: return notes = self.text_notes.toPlainText() try: conn = self.db_manager.get_connection() conn.execute(""" UPDATE sessions SET notes = ? WHERE session_id = ? """, (notes, self.selected_session_id)) conn.commit() except Exception as e: print(f"Failed to save notes: {e}") def _rename_session(self): """Rename selected session.""" if not self.selected_session_id: return # Get current name current_name = self.label_session_id.text().replace("Session: ", "") # Ask for new name new_name, ok = QInputDialog.getText( self, "Rename Session", "Enter new session name:", text=current_name ) if not ok or not new_name.strip(): return try: conn = self.db_manager.get_connection() # Update session name in sessions table conn.execute(""" UPDATE sessions SET session_name = ? WHERE session_id = ? """, (new_name.strip(), self.selected_session_id)) # Update session name in telemetry_raw table conn.execute(""" UPDATE telemetry_raw SET session_name = ? WHERE session_id = ? """, (new_name.strip(), self.selected_session_id)) # Update session name in telemetry_decoded table conn.execute(""" UPDATE telemetry_decoded SET session_name = ? WHERE session_id = ? """, (new_name.strip(), self.selected_session_id)) conn.commit() # Reload sessions self._load_sessions() # Reload details self._load_session_details(self.selected_session_id) QMessageBox.information(self, "Success", "Session renamed successfully!") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to rename session:\n{str(e)}") def _delete_session(self): """Delete selected session.""" if not self.selected_session_id: return # Confirm reply = QMessageBox.question( self, "Confirm Delete", f"Delete session '{self.selected_session_id}'?\n\n" "This will delete all runs and data for this session.\n" "This action cannot be undone!", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return try: conn = self.db_manager.get_connection() conn.execute("DELETE FROM telemetry_decoded WHERE session_id = ?", (self.selected_session_id,)) conn.execute("DELETE FROM telemetry_raw WHERE session_id = ?", (self.selected_session_id,)) conn.execute("DELETE FROM sessions WHERE session_id = ?", (self.selected_session_id,)) conn.commit() self.selected_session_id = None self._load_sessions() self._set_details_enabled(False) QMessageBox.information(self, "Success", "Session deleted successfully!") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to delete session:\n{str(e)}") def _export_session(self): """Export selected session to CSV.""" if not self.selected_session_id: return # Get save location filename = f"{self.selected_session_id}_export.csv" filepath, _ = QFileDialog.getSaveFileName( self, "Export Session", filename, "CSV Files (*.csv);;All Files (*)" ) if not filepath: return try: import csv conn = self.db_manager.get_connection() # Get all data for session cursor = conn.execute(""" SELECT session_id, session_name, run_no, t_ns, time_ms, motor_current, encoder_value, relative_encoder_value, v24_pec_diff, pwm, i2c_raw14, i2c_zero_raw14, i2c_delta_raw14, i2c_angle_deg, i2c_zero_angle_deg, angular_velocity, angular_acceleration FROM telemetry_decoded WHERE session_id = ? ORDER BY run_no, t_ns """, (self.selected_session_id,)) rows = cursor.fetchall() # Write to CSV with open(filepath, 'w', newline='') as f: writer = csv.writer(f) # Header writer.writerow([ 'session_id', 'session_name', 'run_no', 't_ns', 'time_ms', 'motor_current', 'encoder_value', 'relative_encoder_value', 'v24_pec_diff', 'pwm', 'i2c_raw14', 'i2c_zero_raw14', 'i2c_delta_raw14', 'i2c_angle_deg', 'i2c_zero_angle_deg', 'angular_velocity', 'angular_acceleration' ]) # Data writer.writerows(rows) QMessageBox.information( self, "Export Complete", f"Session exported successfully!\n\n" f"File: {filepath}\n" f"Rows: {len(rows):,}" ) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to export session:\n{str(e)}") def _delete_selected_runs(self): """Delete selected runs from session.""" if not self.selected_session_id: return # Get checked runs selected_runs = [] for i in range(self.runs_list.count()): item = self.runs_list.item(i) if item.checkState() == Qt.CheckState.Checked: run_no = item.data(Qt.ItemDataRole.UserRole) selected_runs.append(run_no) if not selected_runs: QMessageBox.warning(self, "No Selection", "Please select runs to delete.") return # Confirm reply = QMessageBox.question( self, "Confirm Delete", f"Delete {len(selected_runs)} run(s) from session?\n\n" "This will permanently delete all data for these runs.\n" "This action cannot be undone!", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return try: conn = self.db_manager.get_connection() for run_no in selected_runs: conn.execute(""" DELETE FROM telemetry_decoded WHERE session_id = ? AND run_no = ? """, (self.selected_session_id, run_no)) conn.execute(""" DELETE FROM telemetry_raw WHERE session_id = ? AND run_no = ? """, (self.selected_session_id, run_no)) # Update total_runs in sessions table cursor = conn.execute(""" SELECT COUNT(DISTINCT run_no) FROM telemetry_decoded WHERE session_id = ? """, (self.selected_session_id,)) new_run_count = cursor.fetchone()[0] conn.execute(""" UPDATE sessions SET total_runs = ? WHERE session_id = ? """, (new_run_count, self.selected_session_id)) conn.commit() # Reload details self._load_session_details(self.selected_session_id) self._load_sessions() QMessageBox.information(self, "Success", f"Deleted {len(selected_runs)} run(s) successfully!") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to delete runs:\n{str(e)}") def _select_all_sessions(self): """Select all checkboxes in session table.""" for row in range(self.session_table.rowCount()): checkbox_widget = self.session_table.cellWidget(row, 0) checkbox = checkbox_widget.findChild(QCheckBox) if checkbox: checkbox.setChecked(True) def _clear_selection(self): """Clear all checkboxes in session table.""" for row in range(self.session_table.rowCount()): checkbox_widget = self.session_table.cellWidget(row, 0) checkbox = checkbox_widget.findChild(QCheckBox) if checkbox: checkbox.setChecked(False) def _delete_selected_sessions(self): """Delete all checked sessions.""" # Get checked sessions selected_sessions = [] for row in range(self.session_table.rowCount()): checkbox_widget = self.session_table.cellWidget(row, 0) checkbox = checkbox_widget.findChild(QCheckBox) if checkbox and checkbox.isChecked(): session_id = self.session_table.item(row, 1).data(Qt.ItemDataRole.UserRole) session_name = self.session_table.item(row, 1).text() selected_sessions.append((session_id, session_name)) if not selected_sessions: QMessageBox.warning(self, "No Selection", "Please select sessions to delete.") return # Confirm session_list = "\n".join([f"• {name}" for _, name in selected_sessions]) reply = QMessageBox.question( self, "Confirm Delete", f"Delete {len(selected_sessions)} session(s)?\n\n{session_list}\n\n" "This will permanently delete all data for these sessions.\n" "This action cannot be undone!", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return try: conn = self.db_manager.get_connection() for session_id, _ in selected_sessions: conn.execute("DELETE FROM telemetry_decoded WHERE session_id = ?", (session_id,)) conn.execute("DELETE FROM telemetry_raw WHERE session_id = ?", (session_id,)) conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,)) conn.commit() # Vacuum database self.db_manager.vacuum() self.selected_session_id = None self._load_sessions() self._set_details_enabled(False) QMessageBox.information( self, "Success", f"Deleted {len(selected_sessions)} session(s) successfully!\n\n" "Database vacuumed." ) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to delete sessions:\n{str(e)}")