import logging import pandas as pd from typing import Optional, List, Dict from datetime import datetime from PyQt6.QtWidgets import ( QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QFrame, QSizePolicy ) from PyQt6.QtCore import Qt, pyqtSignal logger = logging.getLogger(__name__) class DetailTableBase(QTableWidget): """ Base riusabile per le tabelle di dettaglio. Le sottoclassi devono definire intestazioni e mapping colonna->campo DataFrame. """ row_selected = pyqtSignal(str) def __init__(self): super().__init__() self.theme = self._get_default_theme() self.standard_widths = {} self.sort_column = -1 self.sort_order = Qt.SortOrder.AscendingOrder self.original_data: Optional[pd.DataFrame] = None self._setup_base_table() self._setup_connections() # ---------- API da specializzare nelle sottoclassi ---------- def get_headers(self) -> List[str]: # pragma: no cover return [] def get_mapping(self) -> Dict[str, str]: # pragma: no cover return {} @property def column_mapping(self) -> Dict[str, str]: """ Property per esporre il mapping colonne all'export Excel. Ritorna il mapping header_text -> df_column_name. """ return self.get_mapping() def get_center_aligned_headers(self) -> List[str]: return [] # ---------- Helpers di formattazione ---------- def format_time_value(self, value) -> str: try: s_val = str(int(value)).zfill(6) return f"{s_val[:2]}.{s_val[2:4]}" except Exception: return str(value) def format_date_value(self, value) -> str: try: # Supporta stringhe e oggetti datetime-like if isinstance(value, str): for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d'): try: return datetime.strptime(value, fmt).strftime('%d/%m/%Y') except ValueError: pass elif hasattr(value, 'strftime'): return value.strftime('%d/%m/%Y') ts = pd.to_datetime(value, errors='coerce') return ts.strftime('%d/%m/%Y') if pd.notna(ts) else str(value) except Exception: return str(value) def safe_format_value(self, value, value_type: str = "text") -> str: if pd.isna(value) or value is None: return "" try: if value_type == "int_no_sep": return str(int(value)) if value_type == "int_dot_sep": return f"{int(value):,}".replace(",", ".") if value_type == "share": return f"{float(value):.1f}".replace('.', ',') if value_type == "duration_in_min": minutes = int(round(float(value) / 60.0)) return str(minutes) if value_type == "prima_tv": s = str(value).strip().upper() return "S" if s in {"S","SI","SÌ","Y","1","TRUE"} else "" return str(value) except Exception: return str(value) def _get_default_theme(self): return { 'fonts': { 'system': 'Segoe UI', 'primary_size': 12, 'small_size': 11 }, 'table': { 'header_bg': '#0078D4', 'header_text': '#FFFFFF', 'row_bg_primary': '#FFFFFF', 'row_bg_alternate': '#F5F8FF', 'row_text': '#323130', 'row_hover': '#EAF4FF', 'border': '#EDEBE9', 'selected_bg': '#DEECF9', 'selected_text': '#323130' }, 'components': { 'table_row_height': 24, 'border_radius': 2 } } # ---------- Setup e popolamento ---------- def set_theme(self, theme): self.theme = theme or self._get_default_theme() self._apply_microsoft_styling() def _setup_base_table(self): headers = self.get_headers() self.setColumnCount(len(headers)) self.setHorizontalHeaderLabels(headers) self.horizontalHeader().setSectionsMovable(True) self.horizontalHeader().setHighlightSections(True) self.setSortingEnabled(False) try: self.horizontalHeader().sectionClicked.disconnect() except TypeError: pass self.horizontalHeader().sectionClicked.connect(self._on_header_clicked) header = self.horizontalHeader() for i, header_text in enumerate(headers): header.setSectionResizeMode(i, QHeaderView.ResizeMode.Interactive) if header_text in self.standard_widths: self.setColumnWidth(i, self.standard_widths[header_text]) else: self.resizeColumnToContents(i) self.verticalHeader().setVisible(False) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.setFrameShape(QFrame.Shape.NoFrame) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self._apply_microsoft_styling() def apply_standard_widths(self): """Re-applica le larghezze standard alle colonne correnti. Da chiamare dopo aver impostato/aggiornato self.standard_widths nelle sottoclassi, per assicurare che le larghezze custom siano applicate dopo il setup iniziale della tabella. """ try: headers = self.get_headers() for i, header_text in enumerate(headers): if header_text in self.standard_widths: self.setColumnWidth(i, self.standard_widths[header_text]) except Exception: pass def _apply_microsoft_styling(self): table_colors = self.theme.get('table', {}) fonts = self.theme.get('fonts', {}) stylesheet = f""" QTableWidget {{ background-color: {table_colors.get('row_bg_primary', '#FFFFFF')}; alternate-background-color: {table_colors.get('row_bg_alternate', '#F5F8FF')}; border: 1px solid {table_colors.get('border', '#EDEBE9')}; font-family: {fonts.get('system', 'Segoe UI, Arial, sans-serif')}; font-size: {fonts.get('primary_size', 12)}px; gridline-color: transparent; }} QHeaderView::section {{ background-color: {table_colors.get('header_bg', '#0078D4')}; color: {table_colors.get('header_text', '#FFFFFF')}; padding: 8px; border: none; }} QTableWidget::item {{ padding: 6px 8px; border-bottom: 1px solid {table_colors.get('border', '#EDEBE9')}; }} QTableWidget::item:selected {{ background-color: {table_colors.get('selected_bg', '#DEECF9')}; color: {table_colors.get('selected_text', '#323130')}; }} """ self.setStyleSheet(stylesheet) self.setAlternatingRowColors(True) def _setup_connections(self): self.itemClicked.connect(self._on_item_clicked) def populate(self, data: pd.DataFrame): try: self.clear_data() if data is None or data.empty: return self.original_data = data.copy() self.setRowCount(len(self.original_data)) self._populate_rows_direct(self.original_data) except Exception as e: logger.error(f"Errore popolamento DetailTable: {e}", exc_info=True) def _populate_rows_direct(self, data: pd.DataFrame): headers = self.get_headers() mapping = self.get_mapping() for row_idx, (_, row_data) in enumerate(data.iterrows()): for col_idx, header_text in enumerate(headers): df_col_name = mapping.get(header_text) if df_col_name and df_col_name in row_data: value = row_data[df_col_name] item_type = self._resolve_item_type(header_text) display_text = self._format_value_for_header(header_text, value, item_type) item = QTableWidgetItem(display_text) # Alignment if header_text in self.get_center_aligned_headers(): item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) elif header_text == "Rif.": item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) else: if item_type in ("int_no_sep", "share", "duration_in_min"): item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) else: item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) self.setItem(row_idx, col_idx, item) def _resolve_item_type(self, header_text: str) -> str: if header_text == "Audience": return "int_no_sep" if header_text in ("Anno", "Rif."): return "int_no_sep" if header_text == "Share": return "share" if header_text == "Data": return "date" if header_text.strip().startswith("1") and "Tv" in header_text: return "prima_tv" if header_text.startswith("Dur."): return "duration_in_min" return "text" def _format_value_for_header(self, header_text: str, value, item_type: str) -> str: if header_text in ("Ora Inizio", "Ora Fine"): return self.format_time_value(value) if header_text == "Data": return self.format_date_value(value) return self.safe_format_value(value, item_type) # ---------- Sorting ---------- def _on_header_clicked(self, logical_index): headers = self.get_headers() sortable_cols = list(range(len(headers))) if logical_index not in sortable_cols: self.horizontalHeader().setSortIndicator(-1, Qt.SortOrder.AscendingOrder) return if self.sort_column == logical_index: self.sort_order = Qt.SortOrder.DescendingOrder if self.sort_order == Qt.SortOrder.AscendingOrder else Qt.SortOrder.AscendingOrder else: self.sort_column = logical_index self.sort_order = Qt.SortOrder.AscendingOrder self.horizontalHeader().setSortIndicator(self.sort_column, self.sort_order) self._sort_by_column(self.sort_column, self.sort_order) def _sort_by_column(self, column_index, order): if self.original_data is None or self.original_data.empty: return headers = self.get_headers() mapping = self.get_mapping() header_text = headers[column_index] data_column = mapping.get(header_text) if not data_column or data_column not in self.original_data.columns: return ascending = (order == Qt.SortOrder.AscendingOrder) sorted_data = self.original_data.sort_values(by=data_column, ascending=ascending, na_position='last') self._repopulate_with_sorted_data(sorted_data) def _repopulate_with_sorted_data(self, sorted_data: pd.DataFrame): selected_value = None if self.selectedItems(): selected_value = self.item(self.currentRow(), 0).text() self.setRowCount(0) self.setRowCount(len(sorted_data)) self._populate_rows_direct(sorted_data) if selected_value: self.select_row_by_value(selected_value) def _on_item_clicked(self, item): if not item: return row_value = self.item(item.row(), 0).text() self.row_selected.emit(row_value) def select_row_by_value(self, value: str): for row in range(self.rowCount()): if self.item(row, 0) and self.item(row, 0).text() == value: self.selectRow(row) break def clear_data(self): self.setRowCount(0) self.original_data = None self.sort_column = -1 if self.horizontalHeader(): self.horizontalHeader().setSortIndicator(-1, Qt.SortOrder.AscendingOrder)