recent_results.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. """
  2. Recent Results Panel
  3. Displays a table of recent analysis results from the database.
  4. Supports sorting, scrolling, and all data display.
  5. """
  6. from datetime import datetime
  7. from typing import List, Dict, Optional, Callable
  8. import time
  9. from PyQt5.QtWidgets import (QGroupBox, QVBoxLayout,
  10. QTableView, QHeaderView, QPushButton)
  11. from PyQt5.QtCore import Qt, QAbstractTableModel, QVariant, QSortFilterProxyModel, pyqtSignal
  12. from PyQt5.QtGui import QFont, QColor, QBrush
  13. from resources.styles import (GROUP_BOX_STYLE, TABLE_STYLE,
  14. get_ripeness_color, get_grade_color)
  15. from utils.config import TABLE_ROW_HEIGHT
  16. def get_local_datetime_string() -> str:
  17. """
  18. Get current datetime string formatted in the computer's local timezone.
  19. Uses the system's local timezone for display.
  20. Returns:
  21. str: Formatted datetime string in format "YYYY-MM-DD HH:MM:SS"
  22. """
  23. return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  24. class AnalysisTableModel(QAbstractTableModel):
  25. """
  26. Custom table model for analysis results with sorting support.
  27. Supports automatic type detection for sorting:
  28. - Text: alphabetical sort
  29. - Numbers: numeric sort
  30. - Dates: chronological sort
  31. """
  32. def __init__(self, data: List[List[str]] = None):
  33. super().__init__()
  34. self.data = data if data else []
  35. self.headers = ["Report ID", "Grade", "Time (s)", "Created", "View"]
  36. def rowCount(self, parent=None) -> int:
  37. """Return number of rows."""
  38. return len(self.data)
  39. def columnCount(self, parent=None) -> int:
  40. """Return number of columns."""
  41. return len(self.headers)
  42. def data(self, index, role=Qt.DisplayRole):
  43. """Get data for a specific cell."""
  44. if not index.isValid():
  45. return QVariant()
  46. row = index.row()
  47. col = index.column()
  48. if row < 0 or row >= len(self.data):
  49. return QVariant()
  50. # View column (index 4) has no data in the model - it's rendered via setIndexWidget
  51. if col >= len(self.data[row]):
  52. return QVariant()
  53. cell_data = self.data[row][col]
  54. if role == Qt.DisplayRole:
  55. return str(cell_data)
  56. # Color coding for Grade column
  57. elif role == Qt.ForegroundRole:
  58. if col == 1: # Grade column
  59. color = get_grade_color(str(cell_data))
  60. return QBrush(QColor(color))
  61. # Font styling for Grade column
  62. elif role == Qt.FontRole:
  63. if col == 1: # Grade column
  64. font = QFont("Arial", 12, QFont.Bold)
  65. return font
  66. return QFont("Arial", 12)
  67. return QVariant()
  68. def headerData(self, section, orientation, role=Qt.DisplayRole):
  69. """Get header data."""
  70. if role == Qt.DisplayRole:
  71. if orientation == Qt.Horizontal:
  72. return self.headers[section]
  73. return QVariant()
  74. def set_data(self, data: List[List[str]]):
  75. """Update table data."""
  76. self.beginResetModel()
  77. self.data = data
  78. self.endResetModel()
  79. def sort(self, column: int, order: Qt.SortOrder = Qt.AscendingOrder):
  80. """Sort data by column with automatic type detection."""
  81. if not self.data:
  82. return
  83. # Detect column type
  84. col_type = self._detect_column_type(column)
  85. # Skip sorting for non-sortable columns
  86. if col_type == "none":
  87. return
  88. # Sort based on type
  89. reverse = (order == Qt.DescendingOrder)
  90. if col_type == "number":
  91. self.data.sort(key=lambda row: self._to_float(row[column]), reverse=reverse)
  92. elif col_type == "date":
  93. self.data.sort(key=lambda row: self._to_datetime(row[column]), reverse=reverse)
  94. else: # text
  95. self.data.sort(key=lambda row: str(row[column]).upper(), reverse=reverse)
  96. self.layoutChanged.emit()
  97. def _detect_column_type(self, column: int) -> str:
  98. """Detect column data type."""
  99. if not self.data:
  100. return "text"
  101. # View column (index 4) should not be sorted
  102. if column == 4:
  103. return "none"
  104. # Check column 2 (Time)
  105. if column == 2:
  106. return "number"
  107. # Check columns 3 (Timestamps)
  108. if column == 3:
  109. return "date"
  110. # Check column 1 (Grade) - single letter or N/A
  111. if column == 1:
  112. return "text"
  113. return "text"
  114. def _to_float(self, value) -> float:
  115. """Convert value to float for numeric sorting."""
  116. try:
  117. return float(str(value).replace("N/A", "0"))
  118. except:
  119. return 0.0
  120. def _to_datetime(self, value) -> datetime:
  121. """Convert value to datetime for date sorting."""
  122. try:
  123. # Handle various datetime formats
  124. value_str = str(value).strip()
  125. if not value_str or value_str == "N/A":
  126. return datetime.min
  127. # Try parsing as datetime
  128. for fmt in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"]:
  129. try:
  130. return datetime.strptime(value_str[:19], fmt.replace(".%f", ""))
  131. except:
  132. pass
  133. return datetime.min
  134. except:
  135. return datetime.min
  136. class RecentResultsPanel(QGroupBox):
  137. """
  138. Panel displaying recent analysis results in a table.
  139. Features:
  140. - Shows all results (not limited to 5)
  141. - Sortable columns (click headers)
  142. - Automatic type detection (text, number, date)
  143. - Scrollable for large datasets
  144. - Latest results at top by default (sorted by Created descending)
  145. - Color-coded grades (A=green, B=blue, C=red)
  146. - View buttons to load analyses in Reports tab
  147. Data is loaded from the SQLite database automatically.
  148. """
  149. # Signal emitted when user clicks View button for an analysis
  150. view_analysis_requested = pyqtSignal(str) # Emits report_id
  151. def __init__(self, data_manager: Optional[object] = None):
  152. """
  153. Initialize the recent results panel.
  154. Args:
  155. data_manager: DataManager instance for loading from database (optional)
  156. """
  157. super().__init__("Recent Analysis Results")
  158. self.setStyleSheet(GROUP_BOX_STYLE)
  159. self.results_data = [] # Store all results
  160. self.data_manager = data_manager
  161. self.model = None
  162. self.proxy_model = None
  163. self.table = None
  164. self.init_ui()
  165. self._load_initial_data()
  166. def init_ui(self):
  167. """Initialize the UI components."""
  168. layout = QVBoxLayout()
  169. # Create custom table model
  170. self.model = AnalysisTableModel(self.results_data)
  171. # Create proxy model for sorting
  172. self.proxy_model = QSortFilterProxyModel()
  173. self.proxy_model.setSourceModel(self.model)
  174. self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
  175. # Create table view
  176. self.table = QTableView()
  177. self.table.setModel(self.proxy_model)
  178. self.table.setFont(QFont("Arial", 12))
  179. self.table.setStyleSheet(TABLE_STYLE)
  180. # Table settings
  181. self.table.setAlternatingRowColors(True)
  182. self.table.horizontalHeader().setStretchLastSection(False)
  183. self.table.verticalHeader().setVisible(False)
  184. self.table.setSelectionBehavior(QTableView.SelectRows)
  185. self.table.verticalHeader().setDefaultSectionSize(TABLE_ROW_HEIGHT)
  186. # Enable sorting
  187. self.table.setSortingEnabled(True)
  188. self.table.horizontalHeader().setSectionsClickable(True)
  189. # Resize columns to content
  190. self.table.resizeColumnsToContents()
  191. # Configure column resize modes
  192. # Make first column (Report ID) wider and stretchable
  193. self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
  194. # Set Created column (index 3) to fixed width to show full datetime
  195. self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
  196. self.table.setColumnWidth(3, 190)
  197. # Set View column to fixed width
  198. self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
  199. self.table.setColumnWidth(4, 80)
  200. # Connect signal for button refresh after sorting
  201. self.proxy_model.layoutChanged.connect(self._add_view_buttons)
  202. layout.addWidget(self.table)
  203. self.setLayout(layout)
  204. def _load_initial_data(self):
  205. """Load initial data from database or use sample data as fallback."""
  206. if self.data_manager:
  207. self.refresh_from_database()
  208. else:
  209. # Fallback to sample data if data_manager not available
  210. self._load_sample_data()
  211. def _load_sample_data(self):
  212. """Load sample data for demonstration."""
  213. sample_data = [
  214. ["DUR-20260121-135138-2", "C", "3.50", "2026-01-21 13:51:38"],
  215. ["DUR-20260121-135138-1", "B", "2.75", "2026-01-21 13:51:35"],
  216. ["DUR-20260121-135138-0", "A", "1.95", "2026-01-21 13:51:32"],
  217. ["DUR-20260121-135053", "B", "4.23", "2026-01-21 13:50:53"],
  218. ["DUR-20260121-135052", "A", "5.50", "2026-01-21 13:50:52"]
  219. ]
  220. self.results_data = sample_data
  221. self.model.set_data(self.results_data)
  222. self._sort_by_created_descending()
  223. self._add_view_buttons()
  224. def add_result(self, report_id: str, grade: str, processing_time: float, created_at: str = None):
  225. """
  226. Add a new result to the table.
  227. Args:
  228. report_id: Report ID from analysis (e.g., "DUR-20260121-135153")
  229. grade: Grade (A, B, or C)
  230. processing_time: Total processing time in seconds
  231. created_at: Creation time (optional, defaults to now in local timezone)
  232. """
  233. # Use current local time if not provided
  234. if not created_at:
  235. created_at = get_local_datetime_string()
  236. # Format time string
  237. time_str = f"{processing_time:.2f}"
  238. row_data = [report_id, grade, time_str, created_at]
  239. self.results_data.append(row_data)
  240. # Update table display
  241. self.model.set_data(self.results_data)
  242. self._sort_by_created_descending()
  243. self._add_view_buttons()
  244. def refresh_from_database(self):
  245. """Refresh the table by loading recent analyses from the database."""
  246. if not self.data_manager:
  247. print("Data manager not initialized, cannot refresh from database")
  248. return
  249. try:
  250. # Get recent analyses from database
  251. recent_analyses = self.data_manager.list_recent_analyses(limit=50)
  252. # Clear existing data
  253. self.results_data.clear()
  254. # Add each analysis to results
  255. for analysis in recent_analyses:
  256. # Handle None processing_time
  257. processing_time = analysis.get('processing_time')
  258. if processing_time is None:
  259. time_str = 'N/A'
  260. else:
  261. time_str = f"{processing_time:.2f}"
  262. grade = analysis.get('overall_grade')
  263. if grade is None:
  264. grade = 'N/A'
  265. row_data = [
  266. analysis.get('report_id', 'N/A'),
  267. grade,
  268. time_str,
  269. analysis.get('created_at', '')
  270. ]
  271. self.results_data.append(row_data)
  272. print(f"Added row: {row_data}")
  273. # Update table display
  274. self.model.set_data(self.results_data)
  275. self._sort_by_created_descending()
  276. self._add_view_buttons()
  277. print(f"✓ Loaded {len(self.results_data)} analyses from database")
  278. except Exception as e:
  279. print(f"Error refreshing from database: {e}")
  280. import traceback
  281. traceback.print_exc()
  282. # Fallback to sample data
  283. self._load_sample_data()
  284. def _sort_by_created_descending(self):
  285. """Sort table by Created column (index 3) in descending order (latest first)."""
  286. # Column 3 is "Created"
  287. self.proxy_model.sort(3, Qt.DescendingOrder)
  288. def _add_view_buttons(self):
  289. """
  290. Add View buttons to the View column (index 4) for each row.
  291. Buttons are placed using setIndexWidget for each row.
  292. """
  293. if not self.table:
  294. return
  295. try:
  296. # Get the number of rows in the proxy model (after sorting/filtering)
  297. row_count = self.proxy_model.rowCount()
  298. # Clear existing buttons by removing widgets
  299. for row in range(row_count):
  300. self.table.setIndexWidget(self.proxy_model.index(row, 4), None)
  301. # Add new buttons
  302. for row in range(row_count):
  303. # Get the report_id from column 0 of the proxy model
  304. report_id_index = self.proxy_model.index(row, 0)
  305. report_id = self.proxy_model.data(report_id_index, Qt.DisplayRole)
  306. # Create the View button
  307. view_btn = QPushButton("View")
  308. view_btn.setStyleSheet("""
  309. QPushButton {
  310. background-color: #3498db;
  311. color: white;
  312. border: none;
  313. border-radius: 3px;
  314. padding: 5px 10px;
  315. font-size: 11px;
  316. font-weight: bold;
  317. }
  318. QPushButton:hover {
  319. background-color: #2980b9;
  320. }
  321. QPushButton:pressed {
  322. background-color: #1f618d;
  323. }
  324. """)
  325. # Use lambda with default argument to capture report_id correctly
  326. view_btn.clicked.connect(lambda checked=False, rid=report_id: self._on_view_clicked(rid))
  327. # Set the button in the View column
  328. self.table.setIndexWidget(self.proxy_model.index(row, 4), view_btn)
  329. except Exception as e:
  330. print(f"Error adding view buttons: {e}")
  331. import traceback
  332. traceback.print_exc()
  333. def _on_view_clicked(self, report_id: str):
  334. """
  335. Handle view button click.
  336. Args:
  337. report_id: The report ID to view
  338. """
  339. # Validate report_id
  340. if not report_id or report_id == 'N/A':
  341. print(f"Invalid report_id: {report_id}")
  342. return
  343. # Emit signal to notify main window
  344. self.view_analysis_requested.emit(report_id)
  345. def clear_results(self):
  346. """Clear all results from the table and storage."""
  347. self.results_data.clear()
  348. self.model.set_data([])
  349. def get_all_results(self) -> List[List[str]]:
  350. """
  351. Get all stored results.
  352. Returns:
  353. List of result rows
  354. """
  355. return self.results_data.copy()