""" LinkerSheetBase — foglio singolo: griglia, visualizzazione, filtri, sort, auto-fill RIFER. Classe base senza logica di aggancio/matching. LinkerSheetAggancio (linker_sheet_aggancio.py) estende questa classe con il matching engine. """ import logging from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QApplication, QMessageBox, QHeaderView, QMenu, QDialog, QLabel, QFrame, QPushButton, ) from PyQt6.QtCore import Qt, pyqtSignal, QThread, QUrl, QTimer from PyQt6.QtGui import QKeySequence, QShortcut, QDesktopServices from PyQt6.QtGui import QColor, QFont, QPainter from app.modules.linker.src.gui.delegates import RedTriangleDelegate, RiferLinkDelegate, CenteredDelegate, RightAlignedDelegate, ThousandsDelegate from app.modules.linker.src.gui.filter_bar import FilterBar from app.modules.linker.src.gui.grid_filler import ( GridFiller, FlatBgDelegate, paint_std_header_section, HEADER_HEIGHT, gf_to_comparable_date, SelectableVHeader, HEADER_SEL_COLOR, ) from app.modules.linker.src.gui.column_defs import HC, fl, fw from app.modules.linker.src.database.linker_renderer import LinkerRenderer logger = logging.getLogger(__name__) # ------------------------------------------------------------------ # # Colonne # # ------------------------------------------------------------------ # # Colonne valutazioni Gemma (nascoste di default, visibili dopo "Importa valutazioni") _VAL_COLS = [ fl('VAL_FORNITORE'), fl('VAL_RDA'), fl('VAL_C5'), fl('VAL_I1'), fl('VAL_R4'), fl('VAL_LA5'), fl('VAL_I2'), fl('VAL_IRIS'), fl('VAL_TOP'), fl('VAL_FOC'), fl('VAL_C20'), fl('VAL_CI34'), fl('VAL_C27'), fl('VAL_SUPPORTO'), fl('VAL_DATA'), fl('VAL_GEM_SIN'), ] _VAL_COUNT = len(_VAL_COLS) # 16 COLUMNS = ( _VAL_COLS + [ fl('RIFER'), fl('IMDB'), fl('TIPOL'), fl('TI'), fl('TO'), fl('ANNO'), fl('PAESE'), fl('GENERE'), fl('EPIS'), fl('DURATA'), fl('SUPERSERIE'), fl('VEG_FREE'), fl('REGISTA'), fl('ATTORI'), fl('SD'), 'DECOR.\nLISTINO', fl('DECOR'), fl('SCAD'), fl('DISTRIBUTORE'), fl('PERC'), fl('PASS_CONS'), fl('PASS_EFF'), fl('CAUS'), fl('DECOR_FUT'), fl('SCAD_FUT'), fl('RETE_SLOT'), 'OTT', fl('EM_RETE'), fl('EM_DATA'), fl('EM_INIZIO'), fl('EM_FASCIA'), fl('EM_AUD'), fl('EM_SHA'), fl('TEM_RETE'), fl('TEM_DATA'), fl('TEM_INIZIO'), fl('TEM_FASCIA'), fl('TEM_AUD'), fl('TEM_SHA'), fl('BO_DEB'), fl('BO_INCASSO'), fl('BO_SPETT'), fl('BO_DIST'), ] ) # --- Valutazioni (0–15) --- COL_VAL_FORNITORE = 0 COL_VAL_RDA = 1 COL_VAL_C5 = 2 COL_VAL_I1 = 3 COL_VAL_R4 = 4 COL_VAL_LA5 = 5 COL_VAL_I2 = 6 COL_VAL_IRIS = 7 COL_VAL_TOP = 8 COL_VAL_FOC = 9 COL_VAL_C20 = 10 COL_VAL_CI34 = 11 COL_VAL_C27 = 12 COL_VAL_SUPPORTO = 13 COL_VAL_DATA = 14 COL_VAL_GEM_SIN = 15 VAL_COLS_LIST = list(range(_VAL_COUNT)) # [0..15] # --- Colonne principali (offset +16) --- COL_RIFER = 16 COL_IMDB = 17 COL_TIPOL = 18 COL_TI = 19 COL_TO = 20 COL_ANNO = 21 COL_PAESE = 22 COL_GENERE = 23 COL_EPIS = 24 COL_DURATA = 25 COL_SUPERSERIE = 26 COL_VEGFREE = 27 COL_REGISTA = 28 COL_ATTORI = 29 COL_SD = 30 COL_DECORRENZA = 31 COL_DECOR = 32 COL_SCAD = 33 COL_DISTRIBUTORE = 34 COL_PERC = 35 COL_PASS_CONS = 36 COL_PASS_EFF = 37 COL_CAUS = 38 COL_DECOR_FUT = 39 COL_SCAD_FUT = 40 COL_RETE_SLOT = 41 COL_OTT = 42 COL_EM_RETE = 43 COL_EM_DATA = 44 COL_EM_INIZIO = 45 COL_EM_FASCIA = 46 COL_EM_AUD = 47 COL_EM_SHA = 48 COL_TEM_RETE = 49 COL_TEM_DATA = 50 COL_TEM_INIZIO = 51 COL_TEM_FASCIA = 52 COL_TEM_AUD = 53 COL_TEM_SHA = 54 COL_BO_DEBUTTO = 55 COL_BO_INCASSO = 56 COL_BO_SPETT = 57 COL_BO_DIST = 58 INPUT_COLS = [COL_TI, COL_TO, COL_ANNO, COL_REGISTA, COL_DECORRENZA] OUTPUT_COLS = ( VAL_COLS_LIST + [ COL_IMDB, COL_TIPOL, COL_PAESE, COL_GENERE, COL_EPIS, COL_DURATA, COL_SUPERSERIE, COL_VEGFREE, COL_ATTORI, COL_SD, COL_DECOR, COL_SCAD, COL_DISTRIBUTORE, COL_PERC, COL_PASS_CONS, COL_PASS_EFF, COL_CAUS, COL_DECOR_FUT, COL_SCAD_FUT, COL_RETE_SLOT, COL_OTT, COL_EM_RETE, COL_EM_DATA, COL_EM_INIZIO, COL_EM_FASCIA, COL_EM_AUD, COL_EM_SHA, COL_TEM_RETE, COL_TEM_DATA, COL_TEM_INIZIO, COL_TEM_FASCIA, COL_TEM_AUD, COL_TEM_SHA, COL_BO_DEBUTTO, COL_BO_INCASSO, COL_BO_SPETT, COL_BO_DIST, ] ) DIRITTI_COLS_SET = { COL_SD, COL_DECOR, COL_SCAD, COL_DISTRIBUTORE, COL_PERC, COL_PASS_CONS, COL_PASS_EFF, COL_CAUS, COL_DECOR_FUT, COL_SCAD_FUT, } EMESSO_COLS_SET = { COL_EM_RETE, COL_EM_DATA, COL_EM_INIZIO, COL_EM_FASCIA, COL_EM_AUD, COL_EM_SHA, } TEMATICHE_COLS_SET = { COL_TEM_RETE, COL_TEM_DATA, COL_TEM_INIZIO, COL_TEM_FASCIA, COL_TEM_AUD, COL_TEM_SHA, } BOXOFFICE_COLS_SET = { COL_BO_DEBUTTO, COL_BO_INCASSO, COL_BO_SPETT, COL_BO_DIST, } OTT_COLS_SET = {COL_OTT} COL_WIDTHS = { # Condivisi — da column_defs.FIELDS COL_VAL_FORNITORE: fw('VAL_FORNITORE'), COL_VAL_RDA: fw('VAL_RDA'), COL_VAL_C5: fw('VAL_C5'), COL_VAL_I1: fw('VAL_I1'), COL_VAL_R4: fw('VAL_R4'), COL_VAL_LA5: fw('VAL_LA5'), COL_VAL_I2: fw('VAL_I2'), COL_VAL_IRIS: fw('VAL_IRIS'), COL_VAL_TOP: fw('VAL_TOP'), COL_VAL_FOC: fw('VAL_FOC'), COL_VAL_C20: fw('VAL_C20'), COL_VAL_CI34: fw('VAL_CI34'), COL_VAL_C27: fw('VAL_C27'), COL_VAL_SUPPORTO: fw('VAL_SUPPORTO'), COL_VAL_DATA: fw('VAL_DATA'), COL_VAL_GEM_SIN: fw('VAL_GEM_SIN'), COL_RIFER: fw('RIFER'), COL_IMDB: fw('IMDB'), COL_TIPOL: fw('TIPOL'), COL_TI: fw('TI'), COL_TO: fw('TO'), COL_ANNO: fw('ANNO'), COL_PAESE: fw('PAESE'), COL_GENERE: fw('GENERE'), COL_EPIS: fw('EPIS'), COL_DURATA: fw('DURATA'), COL_SUPERSERIE: fw('SUPERSERIE'), COL_VEGFREE: fw('VEG_FREE'), COL_REGISTA: fw('REGISTA'), COL_ATTORI: fw('ATTORI'), COL_SD: fw('SD'), COL_DECOR: fw('DECOR'), COL_SCAD: fw('SCAD'), COL_DISTRIBUTORE: fw('DISTRIBUTORE'), COL_PERC: fw('PERC'), COL_PASS_CONS: fw('PASS_CONS'), COL_PASS_EFF: fw('PASS_EFF'), COL_CAUS: fw('CAUS'), COL_DECOR_FUT: fw('DECOR_FUT'), COL_SCAD_FUT: fw('SCAD_FUT'), COL_RETE_SLOT: fw('RETE_SLOT'), COL_EM_RETE: fw('EM_RETE'), COL_EM_DATA: fw('EM_DATA'), COL_EM_INIZIO: fw('EM_INIZIO'), COL_EM_FASCIA: fw('EM_FASCIA'), COL_EM_AUD: fw('EM_AUD'), COL_EM_SHA: fw('EM_SHA'), COL_TEM_RETE: fw('TEM_RETE'), COL_TEM_DATA: fw('TEM_DATA'), COL_TEM_INIZIO: fw('TEM_INIZIO'), COL_TEM_FASCIA: fw('TEM_FASCIA'), COL_TEM_AUD: fw('TEM_AUD'), COL_TEM_SHA: fw('TEM_SHA'), COL_BO_DEBUTTO: fw('BO_DEB'), COL_BO_INCASSO: fw('BO_INCASSO'), COL_BO_SPETT: fw('BO_SPETT'), COL_BO_DIST: fw('BO_DIST'), # Specifici di linker_sheet COL_OTT: 80, COL_DECORRENZA: 100, } NUM_ROWS = 1000 # ------------------------------------------------------------------ # # Dialog di avanzamento aggancio # # ------------------------------------------------------------------ # class ProgressDialog(QDialog): """Finestra non-modale che mostra l'avanzamento dell'aggancio.""" def __init__(self, total: int, parent=None): super().__init__(parent) self.setWindowTitle("Aggancio in corso") self.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.CustomizeWindowHint ) self.setFixedWidth(380) self.setModal(False) self.setStyleSheet('background: #F8F9FB;') self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) layout = QVBoxLayout(self) layout.setSpacing(14) layout.setContentsMargins(24, 22, 24, 22) title = QLabel("Aggancio in corso") title.setFont(QFont('Segoe UI', 12, QFont.Weight.Bold)) title.setStyleSheet('color: #1A1A2E;') layout.addWidget(title) self._lbl_info = QLabel(f"0 di {total} titoli elaborati") self._lbl_info.setFont(QFont('Segoe UI', 10)) self._lbl_info.setStyleSheet('color: #5C6470;') layout.addWidget(self._lbl_info) from PyQt6.QtWidgets import QProgressBar self._bar = QProgressBar() self._bar.setRange(0, total) self._bar.setValue(0) self._bar.setTextVisible(False) self._bar.setFixedHeight(6) self._bar.setStyleSheet( 'QProgressBar { border: none; border-radius: 3px; background: #E2E8F0; }' 'QProgressBar::chunk { background: #7E57C2; border-radius: 3px; }' ) layout.addWidget(self._bar) self._lbl_tempo = QLabel("Avvio…") self._lbl_tempo.setFont(QFont('Segoe UI', 9)) self._lbl_tempo.setStyleSheet('color: #9AA0AD;') layout.addWidget(self._lbl_tempo) self._total = total self.adjustSize() def update(self, current: int, elapsed: float): avg = elapsed / current if current > 0 else 0 pct = int(current / self._total * 100) if self._total else 0 self._lbl_info.setText(f"{current} di {self._total} titoli elaborati ({pct}%)") self._bar.setValue(current) self._lbl_tempo.setText( f"Tempo: {elapsed:.0f} sec · media: {avg:.2f} sec/titolo" ) QApplication.processEvents() # ------------------------------------------------------------------ # # Dialog prodotti di una superserie # # ------------------------------------------------------------------ # class EmissioniDialog(QDialog): """Mostra tutte le emissioni generaliste di un prodotto.""" def __init__(self, titolo: str, df, parent=None): super().__init__(parent) self.setWindowTitle(titolo) self.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowCloseButtonHint ) self.resize(580, 420) layout = QVBoxLayout(self) layout.setContentsMargins(8, 8, 8, 8) cols = ['RETE', 'DATA', 'HI', '1TV', 'FASCIA', 'AUD', 'SHA', 'R'] tbl = QTableWidget(len(df), len(cols), self) tbl.setFont(QFont("Calibri", 8)) tbl.setHorizontalHeaderLabels(cols) tbl.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) tbl.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectItems) tbl.setAlternatingRowColors(True) tbl.verticalHeader().setVisible(False) tbl.verticalHeader().setMinimumSectionSize(17) tbl.verticalHeader().setDefaultSectionSize(17) tbl.horizontalHeader().setStretchLastSection(True) tbl.setColumnWidth(0, 45) # RETE tbl.setColumnWidth(1, 85) # DATA tbl.setColumnWidth(2, 50) # HI tbl.setColumnWidth(3, 40) # 1TV tbl.setColumnWidth(4, 55) # FASCIA tbl.setColumnWidth(5, 60) # AUD tbl.setColumnWidth(6, 45) # SHA df = df.fillna('') keys = ('rete', 'data', 'hi', 'itv', 'fascia', 'aud', 'sha') for row_idx, row in df.iterrows(): for col_idx, key in enumerate(keys): val = row.get(key) item = QTableWidgetItem(str(val) if val is not None else '') item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) tbl.setItem(row_idx, col_idx, item) # Colonna R — n/d se il record è valorizzato r_item = QTableWidgetItem('n/d') r_item.setFlags(r_item.flags() & ~Qt.ItemFlag.ItemIsEditable) tbl.setItem(row_idx, len(keys), r_item) layout.addWidget(tbl) class EdizioniDialog(QDialog): """Mostra il dettaglio edizioni di un prodotto (ED, TIPOL, DUR, VM).""" def __init__(self, titolo: str, df, parent=None): super().__init__(parent) self.setWindowTitle(f"Dettaglio edizioni: {titolo}") self.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowCloseButtonHint ) self.resize(380, 280) layout = QVBoxLayout(self) layout.setContentsMargins(8, 8, 8, 8) tbl = QTableWidget(len(df), 4, self) tbl.setFont(QFont("Calibri", 8)) tbl.setHorizontalHeaderLabels(['ED', 'TIPOL', 'DUR', 'V.M.']) tbl.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) tbl.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectItems) tbl.setAlternatingRowColors(True) tbl.verticalHeader().setVisible(False) tbl.verticalHeader().setMinimumSectionSize(17) tbl.verticalHeader().setDefaultSectionSize(17) tbl.horizontalHeader().setStretchLastSection(True) tbl.setColumnWidth(0, 40) tbl.setColumnWidth(1, 90) tbl.setColumnWidth(2, 50) df = df.fillna('') for row_idx, row in df.iterrows(): for col_idx, key in enumerate(('ED', 'TIPOL', 'DUR', 'VM')): val = row.get(key) item = QTableWidgetItem(str(val) if val is not None else '') item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) tbl.setItem(row_idx, col_idx, item) layout.addWidget(tbl) class SuperserieDialog(QDialog): """Mostra tutti i prodotti appartenenti a una superserie.""" def __init__(self, superserie_name: str, df, parent=None): super().__init__(parent) self.setWindowTitle(f"Superserie: {superserie_name}") self.setWindowFlags( Qt.WindowType.Dialog | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowCloseButtonHint ) self.resize(520, 380) layout = QVBoxLayout(self) layout.setContentsMargins(8, 8, 8, 8) tbl = QTableWidget(len(df), 3, self) tbl.setFont(QFont("Calibri", 8)) tbl.setHorizontalHeaderLabels(['VEG', 'TIPOL', 'TI']) tbl.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) tbl.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectItems) tbl.setAlternatingRowColors(True) tbl.verticalHeader().setVisible(False) tbl.verticalHeader().setMinimumSectionSize(17) tbl.verticalHeader().setDefaultSectionSize(17) tbl.horizontalHeader().setStretchLastSection(True) tbl.setColumnWidth(0, 70) tbl.setColumnWidth(1, 90) df = df.fillna('') for row_idx, row in df.iterrows(): for col_idx, key in enumerate(('VEG', 'TIPOL', 'TI')): item = QTableWidgetItem(str(row.get(key) or '')) item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) tbl.setItem(row_idx, col_idx, item) layout.addWidget(tbl) # ------------------------------------------------------------------ # # Header personalizzato (giallo, IMDB in ciano) # # ------------------------------------------------------------------ # class LinkerHeaderView(QHeaderView): """Header con sfondo giallo e colonna IMDB in ciano.""" CYAN_COLS = {COL_IMDB} ONAIR_COLS = set(range(COL_RIFER, COL_ATTORI + 1)) - {COL_IMDB} VAL_COLS = set(range(_VAL_COUNT)) # 0..15 DIRITTI_COLS = DIRITTI_COLS_SET EMESSO_COLS = EMESSO_COLS_SET TEMATICHE_COLS = TEMATICHE_COLS_SET OTT_COLS = OTT_COLS_SET FEEDER_COLS = {COL_DECORRENZA} # nascosta in Linker standard, visibile solo in Aggancio def __init__(self, parent=None): super().__init__(Qt.Orientation.Horizontal, parent) self._table = parent self._sel_cols: set[int] = set() self.setSectionsClickable(True) self.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) self.setFixedHeight(HEADER_HEIGHT) self.setDefaultSectionSize(100) def mousePressEvent(self, event): col = self.logicalIndexAt(event.position().toPoint()) if col >= 0 and event.button() == Qt.MouseButton.LeftButton: if event.modifiers() & Qt.KeyboardModifier.ControlModifier: if col in self._sel_cols: self._sel_cols.discard(col) else: self._sel_cols.add(col) else: self._sel_cols = {col} self.viewport().update() super().mousePressEvent(event) def get_selected_cols(self) -> list[int]: return sorted(self._sel_cols) def paintSection(self, painter: QPainter, rect, logicalIndex: int): painter.save() if logicalIndex in self._sel_cols: bg = HEADER_SEL_COLOR elif logicalIndex in self.FEEDER_COLS: bg = HC.FUCHSIA elif logicalIndex in self.DIRITTI_COLS: bg = HC.DIRITTI elif logicalIndex in self.EMESSO_COLS: bg = HC.ORANGE elif logicalIndex in self.TEMATICHE_COLS: bg = HC.PURPLE elif logicalIndex in self.OTT_COLS: bg = HC.LIGHT_RED elif logicalIndex in self.CYAN_COLS: bg = HC.IMDB elif logicalIndex in self.ONAIR_COLS: bg = HC.ORANGE elif logicalIndex in self.VAL_COLS: bg = HC.VAL else: bg = HC.YELLOW text = self.model().headerData( logicalIndex, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole ) paint_std_header_section(painter, rect, str(text) if text else '', bg, HC.BORDER) if text and self.isSortIndicatorShown() \ and self.sortIndicatorSection() == logicalIndex: arrow = '▲' if self.sortIndicatorOrder() == Qt.SortOrder.AscendingOrder else '▼' painter.drawText( rect.adjusted(0, 0, -2, 0), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop, arrow, ) painter.restore() # ------------------------------------------------------------------ # # Tabella con Ctrl+V # # ------------------------------------------------------------------ # class LinkerTable(QTableWidget): """QTableWidget con paste personalizzato su colonne input e RIFER.""" rifers_pasted = pyqtSignal(list) # list of (row_idx, rifer_value) imdb_pasted = pyqtSignal(list) # list of (row_idx, imdb_code) def _handle_paste(self): clipboard_text = QApplication.clipboard().text() if not clipboard_text: return current_row = self.currentRow() current_col = self.currentColumn() if current_row < 0: current_row = 0 # --- Paste su colonna RIFER: imposta i codici e avvia auto-fill --- if current_col == COL_RIFER: rows_text = clipboard_text.strip('\r\n').split('\n') # Pre-calcola quante righe servono ed espandi in un colpo solo needed = current_row + len(rows_text) old_count = self.rowCount() if needed > old_count: self.verticalHeader().setDefaultSectionSize(17) self.setRowCount(needed) for r in range(old_count, needed): for col in range(self.columnCount()): new_item = QTableWidgetItem('') if col in OUTPUT_COLS and col != COL_IMDB: new_item.setFlags(new_item.flags() & ~Qt.ItemFlag.ItemIsEditable) self.setItem(r, col, new_item) rifer_rows = [] self.blockSignals(True) try: for i, row_text in enumerate(rows_text): val = row_text.rstrip('\r').strip() if not val: continue row_idx = current_row + i item = QTableWidgetItem(val) self.setItem(row_idx, COL_RIFER, item) rifer_rows.append((row_idx, val)) finally: self.blockSignals(False) if rifer_rows: self.rifers_pasted.emit(rifer_rows) return # --- Paste su colonna IMDB: cerca il RIFER corrispondente --- if current_col == COL_IMDB: rows_text = clipboard_text.strip('\r\n').split('\n') needed = current_row + len(rows_text) old_count = self.rowCount() if needed > old_count: self.verticalHeader().setDefaultSectionSize(17) self.setRowCount(needed) for r in range(old_count, needed): for col in range(self.columnCount()): new_item = QTableWidgetItem('') if col in OUTPUT_COLS and col != COL_IMDB: new_item.setFlags(new_item.flags() & ~Qt.ItemFlag.ItemIsEditable) self.setItem(r, col, new_item) imdb_rows = [] self.blockSignals(True) try: for i, row_text in enumerate(rows_text): val = row_text.rstrip('\r').strip() if not val: continue row_idx = current_row + i item = QTableWidgetItem(val) self.setItem(row_idx, COL_IMDB, item) imdb_rows.append((row_idx, val)) finally: self.blockSignals(False) if imdb_rows: self.imdb_pasted.emit(imdb_rows) return if current_col not in INPUT_COLS: return rows = clipboard_text.strip('\r\n').split('\n') # Espandi la tabella una volta sola (evita O(n²) resize) needed = current_row + len(rows) if needed > self.rowCount(): old_count = self.rowCount() self.verticalHeader().setDefaultSectionSize(17) self.setRowCount(needed) for row_idx in range(old_count, needed): for out_col in OUTPUT_COLS: item = QTableWidgetItem('') if out_col != COL_IMDB: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) self.setItem(row_idx, out_col, item) for i, row_text in enumerate(rows): row_idx = current_row + i cols_data = row_text.rstrip('\r').split('\t') for j, val in enumerate(cols_data): col_idx = current_col + j if col_idx in INPUT_COLS and col_idx < self.columnCount(): item = QTableWidgetItem(val.strip()) self.setItem(row_idx, col_idx, item) def mouseMoveEvent(self, event): index = self.indexAt(event.pos()) if index.isValid() and index.column() in (COL_RIFER, COL_IMDB, COL_SUPERSERIE, COL_EPIS, COL_EM_DATA, COL_TEM_DATA): item = self.item(index.row(), index.column()) if item and item.text().strip(): self.viewport().setCursor(Qt.CursorShape.PointingHandCursor) super().mouseMoveEvent(event) return self.viewport().setCursor(Qt.CursorShape.ArrowCursor) super().mouseMoveEvent(event) _ONAIR_URL = ( "https://onairinquiry.apps.mediaset.it/InquiryOnair/Login.do" "?ActionMethod=Link&codProd={rifer}&tipProd=F&tipRicerca=P&activeTab=1" ) _IMDB_URL = "https://www.imdb.com/it/title/{code}/" def mouseDoubleClickEvent(self, event): super().mouseDoubleClickEvent(event) @staticmethod def open_onair(rifer: str): url = ( "https://onairinquiry.apps.mediaset.it/InquiryOnair/Login.do" f"?ActionMethod=Link&codProd={rifer}&tipProd=F&tipRicerca=P&activeTab=1" ) QDesktopServices.openUrl(QUrl(url)) @staticmethod def open_imdb(code: str): QDesktopServices.openUrl(QUrl(f"https://www.imdb.com/it/title/{code}/")) # ------------------------------------------------------------------ # # Worker thread per emesso.parquet (file grande, non bloccare Qt) # # ------------------------------------------------------------------ # class _EmessoWorker(QThread): """Legge emesso.parquet in background con connessione DuckDB dedicata. Se agg_path è disponibile usa emesso_agg.parquet — molto più veloce.""" done = pyqtSignal(object) # emette un pandas DataFrame _GENERALISTE = ('N1', 'N2', 'N3', 'C5', 'I1', 'MC', 'R4') def __init__(self, em_path: str, rifers: list, agg_path: str = None, parent=None): super().__init__(parent) self._em_path = em_path.replace('\\', '/') self._agg_path = agg_path.replace('\\', '/') if agg_path else None self._rifers = rifers def run(self): import pandas as pd import duckdb import traceback try: if not self._rifers: self.done.emit(pd.DataFrame()) return con = duckdb.connect() con.execute("SET threads TO 1") placeholders = ', '.join( f"'{str(r).replace(chr(39), chr(39)*2)}'" for r in self._rifers ) if self._agg_path: df = con.execute(f""" SELECT RIFER, tipo_rete, rete_last AS rete, data_last AS data_emissione, ora_last AS ora_inizio, fascia_last AS fascia, aud_last AS audience, sha_last AS share FROM read_parquet('{self._agg_path}') WHERE RIFER IN ({placeholders}) """).df() else: nets = ', '.join(f"'{n}'" for n in self._GENERALISTE) df = con.execute(f""" WITH src AS ( SELECT CAST(prodotto AS VARCHAR) AS RIFER, rete, data_emissione, ora_inizio, fascia, audience, share, CASE WHEN rete IN ({nets}) THEN 'G' ELSE 'T' END AS tipo_rete FROM read_parquet('{self._em_path}') WHERE CAST(prodotto AS VARCHAR) IN ({placeholders}) ), last_date AS ( SELECT RIFER, tipo_rete, MAX(data_emissione) AS max_data FROM src GROUP BY RIFER, tipo_rete ), last_ora AS ( SELECT s.RIFER, s.tipo_rete, d.max_data, MAX(s.ora_inizio) AS max_ora FROM src s JOIN last_date d ON s.RIFER = d.RIFER AND s.tipo_rete = d.tipo_rete AND s.data_emissione = d.max_data GROUP BY s.RIFER, s.tipo_rete, d.max_data ) SELECT s.RIFER, s.rete, s.data_emissione, s.ora_inizio, s.fascia, s.audience, s.share, s.tipo_rete FROM src s JOIN last_ora lo ON s.RIFER = lo.RIFER AND s.tipo_rete = lo.tipo_rete AND s.data_emissione = lo.max_data AND s.ora_inizio = lo.max_ora GROUP BY s.RIFER, s.tipo_rete, s.rete, s.data_emissione, s.ora_inizio, s.fascia, s.audience, s.share """).df() con.close() self.done.emit(df) except Exception as e: logger.error(f"_EmessoWorker: {e}\n{traceback.format_exc()}", exc_info=True) self.done.emit(pd.DataFrame()) # ------------------------------------------------------------------ # # Worker thread per IMDB-only fill (nessun match RIFER OnAir) # # ------------------------------------------------------------------ # class _ImdbResolveWorker(QThread): """Risolve IMDB → RIFER in background con connessione DuckDB dedicata.""" done = pyqtSignal(object) # emette (imdb_rows, imdb_to_rifer dict) def __init__(self, parquet_path: str, imdb_rows: list, is_seriale: bool, parent=None): super().__init__(parent) self._parquet_path = parquet_path.replace('\\', '/') self._imdb_rows = imdb_rows self._is_seriale = is_seriale def run(self): import traceback import duckdb try: imdb_codes = [code for _, code in self._imdb_rows if code.strip()] if not imdb_codes: self.done.emit((self._imdb_rows, {})) return placeholders = ', '.join( f"'{str(c).replace(chr(39), chr(39)*2)}'" for c in imdb_codes ) imdb_f = f"{self._parquet_path}/imdb.parquet" con = duckdb.connect() con.execute("SET threads TO 2") df = con.execute(f""" SELECT riferimento_imdb AS IMDB_CODICE, CAST(codice AS VARCHAR) AS RIFER, COUNT(*) OVER (PARTITION BY riferimento_imdb) AS n_rifers FROM read_parquet('{imdb_f}') WHERE riferimento_imdb IN ({placeholders}) QUALIFY ROW_NUMBER() OVER ( PARTITION BY riferimento_imdb ORDER BY codice ) = 1 """).df() con.close() imdb_to_rifer = {} if not df.empty: for _, row in df.iterrows(): key = str(row.get('IMDB_CODICE', '') or '').strip() if not key: continue n = int(row.get('n_rifers', 1)) if self._is_seriale and n > 1: continue imdb_to_rifer[key] = str(row.get('RIFER', '') or '').strip() self.done.emit((self._imdb_rows, imdb_to_rifer)) except Exception as e: logger.error(f"_ImdbResolveWorker: {e}\n{traceback.format_exc()}") self.done.emit((self._imdb_rows, {})) class _ImdbOnlyWorker(QThread): """Risolve codici IMDB senza RIFER in background con connessione DuckDB dedicata.""" done = pyqtSignal(list) # [(row_idx, result_dict_or_None), ...] def __init__(self, parquet_path: str, imdb_rows: list, parent=None): super().__init__(parent) self._parquet_path = parquet_path.replace('\\', '/') self._imdb_rows = imdb_rows def run(self): import duckdb, traceback results = [] try: p = self._parquet_path prod_f = f"{p}/prodotti.parquet" cast_f = f"{p}/cast.parquet" imdb_f = f"{p}/imdb.parquet" imdb_full_f = f"{p}/imdb_full.parquet" con = duckdb.connect() con.execute("SET threads TO 1") con.execute(f""" CREATE VIEW v_custom_anagr AS WITH prod AS ( SELECT * FROM read_parquet('{prod_f}') QUALIFY ROW_NUMBER() OVER (PARTITION BY prodotto ORDER BY edizione) = 1 ), imdb AS ( SELECT codice, MIN(riferimento_imdb) AS riferimento_imdb FROM read_parquet('{imdb_f}') GROUP BY codice ), reg AS ( SELECT prodotto, cognome AS REGISTA_COGN, nome AS REGISTA_NOME FROM read_parquet('{cast_f}') WHERE ruolo = 'FRE' QUALIFY ROW_NUMBER() OVER (PARTITION BY prodotto ORDER BY progr_cast) = 1 ), a1 AS (SELECT prodotto, TRIM(COALESCE(nome,'') || ' ' || COALESCE(cognome,'')) AS ATTORE1 FROM read_parquet('{cast_f}') WHERE ruolo='C001' AND progr_cast=1), a2 AS (SELECT prodotto, TRIM(COALESCE(nome,'') || ' ' || COALESCE(cognome,'')) AS ATTORE2 FROM read_parquet('{cast_f}') WHERE ruolo='C001' AND progr_cast=2), a3 AS (SELECT prodotto, TRIM(COALESCE(nome,'') || ' ' || COALESCE(cognome,'')) AS ATTORE3 FROM read_parquet('{cast_f}') WHERE ruolo='C001' AND progr_cast=3) SELECT CAST(p.prodotto AS VARCHAR) AS RIFER, p.tipologia AS TIPOL, p.titolo_originale AS "TO", p.titolo_italiano AS TI, p.anno_produzione AS ANNO, p.paesi_produzione1 AS PAESE, p.genere1 AS GENERE1, p.num_episodi AS EPIS, p.durata AS DUR, p.superserie_descr AS SUPERSERIE, CASE p.veg WHEN 'EVER GREEN' THEN 'EV.GR' ELSE p.veg END AS VEG_FREE, i.riferimento_imdb AS IMDB_CODICE, r.REGISTA_COGN, r.REGISTA_NOME, a1.ATTORE1, a2.ATTORE2, a3.ATTORE3 FROM prod p LEFT JOIN imdb i ON i.codice = p.prodotto LEFT JOIN reg r ON r.prodotto = p.prodotto LEFT JOIN a1 ON a1.prodotto = p.prodotto LEFT JOIN a2 ON a2.prodotto = p.prodotto LEFT JOIN a3 ON a3.prodotto = p.prodotto """) has_imdb_full = False try: con.execute(f"CREATE VIEW v_imdb_full AS SELECT *, DUR AS DURATA FROM read_parquet('{imdb_full_f}')") has_imdb_full = True except Exception: pass codes = [c for _, c in self._imdb_rows if c.strip()] placeholders = ', '.join(f"'{c.replace(chr(39), chr(39)*2)}'" for c in codes) custom_lookup = {} if codes: df_c = con.execute(f"SELECT * FROM v_custom_anagr WHERE IMDB_CODICE IN ({placeholders})").df() for _, row in df_c.iterrows(): key = str(row.get('IMDB_CODICE', '') or '').strip() if key: custom_lookup[key] = row imdb_lookup = {} if has_imdb_full: not_in_custom = [c for c in codes if c not in custom_lookup] if not_in_custom: ph2 = ', '.join(f"'{c.replace(chr(39), chr(39)*2)}'" for c in not_in_custom) df_i = con.execute(f"SELECT * FROM v_imdb_full WHERE IMDB_CODICE IN ({ph2})").df() for _, row in df_i.iterrows(): key = str(row.get('IMDB_CODICE', '') or '').strip() if key: imdb_lookup[key] = row con.close() def _s(v): import pandas as pd if v is None or (isinstance(v, float) and pd.isna(v)): return '' return str(v).strip() def _anno(v): import pandas as pd if v is None or (isinstance(v, float) and pd.isna(v)): return '' try: return str(int(float(str(v)))) except (ValueError, TypeError): return str(v).strip() if v else '' for row_idx, imdb_code in self._imdb_rows: imdb_code = imdb_code.strip() if not imdb_code: results.append((row_idx, None)) continue result = { 'IMDB_CODICE': imdb_code, 'RIFER': '', 'TI': '', 'TO': '', 'ANNO': '', 'REGISTA': '', 'TIPOL': '', 'PAESE': '', 'GENERE': '', 'EPIS': '', 'DURATA': '', 'SUPERSERIE': '', 'VEG_FREE': '', 'ATTORI': '', 'match_source': 'IMDB', 'color': '#FFFF99', } crow = custom_lookup.get(imdb_code) if crow is not None: result['TI'] = _s(crow.get('TI')) result['TO'] = _s(crow.get('TO')) result['ANNO'] = _anno(crow.get('ANNO')) reg_c = _s(crow.get('REGISTA_COGN')) reg_n = _s(crow.get('REGISTA_NOME')) result['REGISTA'] = (reg_n + ' ' + reg_c).strip() result['TIPOL'] = _s(crow.get('TIPOL')) result['PAESE'] = _s(crow.get('PAESE')) result['GENERE'] = _s(crow.get('GENERE1')) result['EPIS'] = _s(crow.get('EPIS')) result['DURATA'] = _s(crow.get('DUR')) result['SUPERSERIE'] = _s(crow.get('SUPERSERIE')) result['VEG_FREE'] = _s(crow.get('VEG_FREE')) result['ATTORI'] = ', '.join(filter(None, [_s(crow.get('ATTORE1')), _s(crow.get('ATTORE2')), _s(crow.get('ATTORE3'))])) elif imdb_code in imdb_lookup: irow = imdb_lookup[imdb_code] result['TI'] = _s(irow.get('TI')) result['TO'] = _s(irow.get('TO')) result['ANNO'] = _anno(irow.get('ANNO')) result['REGISTA'] = _s(irow.get('REGISTA')) result['TIPOL'] = _s(irow.get('TIPOL')) result['PAESE'] = _s(irow.get('PAESE')) result['GENERE'] = _s(irow.get('GENERE')) result['EPIS'] = _s(irow.get('EPIS')) result['DURATA'] = _s(irow.get('DURATA')) result['ATTORI'] = _s(irow.get('CAST')) else: results.append((row_idx, None)) continue results.append((row_idx, result)) except Exception as e: logger.error(f"_ImdbOnlyWorker: {e}\n{traceback.format_exc()}", exc_info=True) self.done.emit(results) # ------------------------------------------------------------------ # # Worker thread per precaricamento completo emesso.parquet in cache # # ------------------------------------------------------------------ # class _PreloadWorker(QThread): """Precarica prima E ultima emissione. Se agg_path disponibile: lettura istantanea da emesso_agg.parquet (solo ultima). Altrimenti: scan completo di emesso.parquet con window functions.""" ready = pyqtSignal(object) # emette (df_last, df_first) _GENERALISTE = ('N1', 'N2', 'N3', 'C5', 'I1', 'MC', 'R4') def __init__(self, em_path: str, agg_path: str = None, parent=None): super().__init__(parent) self._em_path = em_path.replace('\\', '/') self._agg_path = agg_path.replace('\\', '/') if agg_path else None self._stop = False def stop(self): self._stop = True def run(self): import pandas as pd import duckdb import traceback try: con = duckdb.connect() con.execute("SET threads TO 1") if self._agg_path: df_last = con.execute(f""" SELECT RIFER, tipo_rete, rete_last AS rete, data_last AS data_emissione, ora_last AS ora_inizio, fascia_last AS fascia, aud_last AS audience, sha_last AS share FROM read_parquet('{self._agg_path}') """).df() df_first = con.execute(f""" SELECT RIFER, tipo_rete, rete_first AS rete, data_first AS data_emissione, ora_first AS ora_inizio, fascia_first AS fascia, aud_first AS audience, sha_first AS share FROM read_parquet('{self._agg_path}') """).df() con.close() self.ready.emit((df_last, df_first)) return nets = ', '.join(f"'{n}'" for n in self._GENERALISTE) df = con.execute(f""" WITH src AS ( SELECT CAST(prodotto AS VARCHAR) AS RIFER, rete, data_emissione, ora_inizio, fascia, audience, share, CASE WHEN rete IN ({nets}) THEN 'G' ELSE 'T' END AS tipo_rete, ROW_NUMBER() OVER ( PARTITION BY CAST(prodotto AS VARCHAR), CASE WHEN rete IN ({nets}) THEN 'G' ELSE 'T' END ORDER BY data_emissione DESC, ora_inizio DESC ) AS rn_last, ROW_NUMBER() OVER ( PARTITION BY CAST(prodotto AS VARCHAR), CASE WHEN rete IN ({nets}) THEN 'G' ELSE 'T' END ORDER BY data_emissione ASC, ora_inizio ASC ) AS rn_first FROM read_parquet('{self._em_path}') ) SELECT RIFER, rete, data_emissione, ora_inizio, fascia, audience, share, tipo_rete, 'L' AS emis_mode FROM src WHERE rn_last = 1 UNION ALL SELECT RIFER, rete, data_emissione, ora_inizio, fascia, audience, share, tipo_rete, 'F' AS emis_mode FROM src WHERE rn_first = 1 """).df() con.close() if self._stop: return drop = ['emis_mode'] df_last = df[df['emis_mode'] == 'L'].drop(columns=drop).reset_index(drop=True) df_first = df[df['emis_mode'] == 'F'].drop(columns=drop).reset_index(drop=True) self.ready.emit((df_last, df_first)) except Exception as e: logger.error(f"_PreloadWorker: {e}\n{traceback.format_exc()}") if not self._stop: import pandas as pd self.ready.emit((pd.DataFrame(), pd.DataFrame())) # ------------------------------------------------------------------ # # FilterBar # # ------------------------------------------------------------------ # # ------------------------------------------------------------------ # # LinkerSheetBase — foglio singolo (base) # # ------------------------------------------------------------------ # class LinkerSheetBase(QWidget): """ Foglio Linker base: griglia, visualizzazione, filtri, sort, auto-fill RIFER. Non contiene logica di aggancio/matching — vedi LinkerSheetAggancio. """ sig_importa_valutazioni = pyqtSignal() sig_cancella_valutazioni = pyqtSignal() sig_toggle_emesso = pyqtSignal(bool) # True = prima emissione sig_avvia_aggancio = pyqtSignal() # richiede avvio aggancio a MainWindow def __init__(self, linker_db, status_fn=None, parent=None): super().__init__(parent) self.linker_db = linker_db self._status = status_fn or (lambda _: None) # Stato runtime self.row_match_source = {} self._rifer_edit_guard = False self._user_edited_imdb_rows: set = set() self._emesso_worker = None self._imdb_fill_worker = None self._imdb_resolve_worker = None self._pending_rifer_map = {} # Cache emesso precaricata self._emesso_cache = None # ultima emissione self._emesso_cache_first = None # prima emissione self._preload_worker = None self._emesso_show_first = False # False = ultima, True = prima self._build_table() # GridFiller condiviso con DiritiTab — stessa logica di fill self._filler = GridFiller(self.table, { 'SD': COL_SD, 'DECOR': COL_DECOR, 'SCAD': COL_SCAD, 'DISTRIBUTORE': COL_DISTRIBUTORE, 'PERC': COL_PERC, 'PASS_CONS': COL_PASS_CONS, 'PASS_EFF': COL_PASS_EFF, 'CAUS': COL_CAUS, 'DECOR_FUT': COL_DECOR_FUT, 'SCAD_FUT': COL_SCAD_FUT, 'RETE_SLOT': COL_RETE_SLOT, 'OTT': COL_OTT, 'RETE': COL_EM_RETE, 'DATA': COL_EM_DATA, 'INIZIO': COL_EM_INIZIO, 'FASCIA': COL_EM_FASCIA, 'AUD': COL_EM_AUD, 'SHA': COL_EM_SHA, 'TEM_RETE': COL_TEM_RETE, 'TEM_DATA': COL_TEM_DATA, 'TEM_INIZIO': COL_TEM_INIZIO, 'TEM_FASCIA': COL_TEM_FASCIA, 'TEM_AUD': COL_TEM_AUD, 'TEM_SHA': COL_TEM_SHA, 'BO_DEBUTTO': COL_BO_DEBUTTO, 'BO_INCASSO': COL_BO_INCASSO, 'BO_SPETT': COL_BO_SPETT, 'BO_DIST': COL_BO_DIST, 'VAL_FORNITORE': COL_VAL_FORNITORE, 'VAL_RDA': COL_VAL_RDA, 'VAL_C5': COL_VAL_C5, 'VAL_I1': COL_VAL_I1, 'VAL_R4': COL_VAL_R4, 'VAL_LA5': COL_VAL_LA5, 'VAL_I2': COL_VAL_I2, 'VAL_IRIS': COL_VAL_IRIS, 'VAL_TOP': COL_VAL_TOP, 'VAL_FOC': COL_VAL_FOC, 'VAL_C20': COL_VAL_C20, 'VAL_CI34': COL_VAL_CI34, 'VAL_C27': COL_VAL_C27, 'VAL_SUPPORTO': COL_VAL_SUPPORTO, 'VAL_DATA': COL_VAL_DATA, 'VAL_GEM_SIN': COL_VAL_GEM_SIN, }) # LinkerRenderer — pipeline fetch parquet condivisa con DiritiTab self._renderer = LinkerRenderer(self.linker_db.catalog) self._filter_bar = FilterBar(self.table, self) self._filter_bar.hide() self._filter_bar.filter_changed.connect(self._apply_filters) _BTN_STYLE = ( "QPushButton {" " background-color: #D8D8D8;" " border: 1px solid #A0A0A0;" " border-radius: 3px;" " padding: 0 10px;" "}" "QPushButton:hover { background-color: #C8C8C8; }" "QPushButton:pressed { background-color: #B8B8B8; }" "QPushButton:checked { background-color: #B0B0B0; border: 1px solid #707070; }" ) self._toolbar = QFrame() self._toolbar.setFrameShape(QFrame.Shape.Box) self._toolbar.setLineWidth(1) self._toolbar.setFixedHeight(38) _tbl = QHBoxLayout(self._toolbar) _tbl.setContentsMargins(8, 4, 8, 4) _tbl.setSpacing(6) from PyQt6.QtGui import QPixmap, QIcon, QPen _arrow_px = QPixmap(14, 14) _arrow_px.fill(Qt.GlobalColor.transparent) _p = QPainter(_arrow_px) _p.setRenderHint(QPainter.RenderHint.Antialiasing) _pen = QPen(Qt.GlobalColor.black) _pen.setWidth(2) _pen.setCapStyle(Qt.PenCapStyle.RoundCap) _pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) _p.setPen(_pen) _p.drawLine(11, 7, 2, 7) _p.drawLine(2, 7, 6, 3) _p.drawLine(2, 7, 6, 11) _p.end() self._btn_back = QPushButton() self._btn_back.setIcon(QIcon(_arrow_px)) self._btn_back.setFixedSize(26, 26) self._btn_back.setStyleSheet(_BTN_STYLE) self._btn_back.setToolTip("Esci dalla modalità aggancio") self._btn_back.hide() _tbl.addWidget(self._btn_back) # ── Menu ≡ — tutte le azioni secondarie in un dropdown ────── from PyQt6.QtWidgets import QToolButton, QMenu from PyQt6.QtGui import QAction as _QAction self._val_shown = False menu = QMenu(self) self._act_filtro_col = _QAction("Filtro colonne (Ctrl+F)", self) self._act_filtro_col.setCheckable(True) self._act_filtro_col.triggered.connect( lambda checked: self._set_filter_bar_visible(checked)) menu.addAction(self._act_filtro_col) menu.addSeparator() self._act_valutazioni = _QAction("Mostra valutazioni", self) self._act_valutazioni.triggered.connect(self._toggle_valutazioni) menu.addAction(self._act_valutazioni) menu.addSeparator() self._act_prima_emis = _QAction("Prima / Ultima emissione", self) self._act_prima_emis.setCheckable(True) self._act_prima_emis.triggered.connect( lambda checked: self.sig_toggle_emesso.emit(checked)) menu.addAction(self._act_prima_emis) menu.addSeparator() act_esporta = _QAction("Esporta", self) act_esporta.triggered.connect(self._esporta_sheet) menu.addAction(act_esporta) act_reset = _QAction("Reset foglio", self) act_reset.triggered.connect(self._reset_sheet) menu.addAction(act_reset) self._btn_menu = QToolButton() self._btn_menu.setText("≡") self._btn_menu.setFont(QFont("Calibri", 11)) self._btn_menu.setFixedHeight(26) self._btn_menu.setFixedWidth(30) self._btn_menu.setStyleSheet( "QToolButton { background-color: #D8D8D8; border: 1px solid #A0A0A0;" " border-radius: 3px; padding: 0 4px; }" "QToolButton:hover { background-color: #C8C8C8; }" "QToolButton::menu-indicator { width: 0; }" ) self._btn_menu.setMenu(menu) self._btn_menu.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) _tbl.addWidget(self._btn_menu) self._btn_avvia_aggancio = QPushButton("▶ Avvia aggancio") self._btn_avvia_aggancio.setFont(QFont("Calibri", 9, QFont.Weight.Bold)) self._btn_avvia_aggancio.setFixedHeight(26) self._btn_avvia_aggancio.setStyleSheet( "QPushButton { background-color: #9B7FD4; color: white;" " border: 1px solid #7A5FB0; border-radius: 3px; padding: 0 10px; }" "QPushButton:hover { background-color: #8A6EC3; }" ) self._btn_avvia_aggancio.hide() self._btn_avvia_aggancio.clicked.connect(self.sig_avvia_aggancio.emit) _tbl.addWidget(self._btn_avvia_aggancio) self._btn_annulla_aggancio = QPushButton("Annulla aggancio") self._btn_annulla_aggancio.setFont(QFont("Calibri", 9)) self._btn_annulla_aggancio.setFixedHeight(26) self._btn_annulla_aggancio.setStyleSheet( "QPushButton { background-color: #9B7FD4; color: white;" " border: 1px solid #7A5FB0; border-radius: 3px; padding: 0 10px; }" "QPushButton:hover { background-color: #8A6EC3; }" ) self._btn_annulla_aggancio.hide() _tbl.addWidget(self._btn_annulla_aggancio) self._btn_salva = QPushButton("Salva agganci") self._btn_salva.setFont(QFont("Calibri", 9, QFont.Weight.Bold)) self._btn_salva.setFixedHeight(26) self._btn_salva.setStyleSheet( "QPushButton { background-color: #9B7FD4; color: white;" " border: 1px solid #7A5FB0; border-radius: 3px; padding: 0 10px; }" "QPushButton:hover { background-color: #8A6EC3; }" ) self._btn_salva.hide() _tbl.addWidget(self._btn_salva) self._mode_label = QLabel() self._mode_label.setFont(QFont("Calibri", 10, QFont.Weight.Bold)) self._mode_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight) _tbl.addWidget(self._mode_label, stretch=1) self._set_mode("LINKER", "#FFE8C0") layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self._toolbar) layout.addWidget(self._filter_bar) layout.addWidget(self.table) QShortcut(QKeySequence("Ctrl+F"), self).activated.connect(self._toggle_filter_bar) self._init_cells() self._build_loading_overlay() self._start_preload() def _set_mode(self, label: str, color: str): self._mode_label.setText(label) self._toolbar.setStyleSheet(f"background-color: {color};") def show_feeder_only(self): """Mostra solo le colonne di input (TI, TO, ANNO, REGISTA, DECORRENZA). Usato da AGG. MULTIPLO — RIFER è output del match, non input dell'utente.""" feeder = {COL_TI, COL_TO, COL_ANNO, COL_REGISTA, COL_DECORRENZA} for col in range(len(COLUMNS)): self.table.setColumnHidden(col, col not in feeder) self._btn_avvia_aggancio.show() def show_all_columns(self): """Ripristina la visibilità standard: mostra tutto tranne VAL e DECORRENZA. DECORRENZA è esclusiva dell'Aggancio — verrà mostrata esplicitamente se serve.""" _hide = set(VAL_COLS_LIST) | {COL_DECORRENZA} for col in range(len(COLUMNS)): self.table.setColumnHidden(col, col in _hide) self._btn_avvia_aggancio.hide() def set_emesso_mode(self, first: bool): """Sincronizza la voce Prima/Ultima emissione con lo stato globale.""" self._act_prima_emis.setChecked(first) def _toggle_valutazioni(self): if self._val_shown: self.sig_cancella_valutazioni.emit() self._val_shown = False self._act_valutazioni.setText("Mostra valutazioni") else: self.sig_importa_valutazioni.emit() self._val_shown = True self._act_valutazioni.setText("Nascondi valutazioni") def _esporta_sheet(self): import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter from PyQt6.QtWidgets import QFileDialog if not any( (self.table.item(r, c) or QTableWidgetItem()).text().strip() for r in range(self.table.rowCount()) for c in INPUT_COLS ): QMessageBox.information(self, "Esporta", "Nessun dato da esportare.") return path, _ = QFileDialog.getSaveFileName( self, "Esporta griglia", "linker_export.xlsx", "Excel (*.xlsx)" ) if not path: return # Mappa colore intestazione — stessa priorità di LinkerHeaderView.paintSection def _hdr_color(col_idx: int) -> str: if col_idx in LinkerHeaderView.FEEDER_COLS: return 'FF91C4' if col_idx in LinkerHeaderView.DIRITTI_COLS: return '90EE90' if col_idx in LinkerHeaderView.EMESSO_COLS: return 'FFB347' if col_idx in LinkerHeaderView.TEMATICHE_COLS: return 'C39BD3' if col_idx in LinkerHeaderView.OTT_COLS: return 'FFAAAA' if col_idx in LinkerHeaderView.CYAN_COLS: return '00BFFF' if col_idx in LinkerHeaderView.ONAIR_COLS: return 'FFB347' if col_idx in LinkerHeaderView.VAL_COLS: return 'FFF59D' return 'FFFF00' # Colonne visibili (esclude nascoste — es. VAL, DECORRENZA in modalità standard) visible_cols = [i for i in range(len(COLUMNS)) if not self.table.isColumnHidden(i)] # Stili _side = Side(style='thin', color='A0A0A0') _border = Border(left=_side, right=_side, top=_side, bottom=_side) _cell_font = Font(name='Calibri', size=8) _hdr_font = Font(name='Calibri', size=8, bold=True) _center = Alignment(horizontal='center', vertical='center') _hdr_align = Alignment(horizontal='center', vertical='center', wrap_text=True) wb = openpyxl.Workbook() ws = wb.active ws.title = "Linker" # Riga intestazione (33px → pt) ws.row_dimensions[1].height = round(33 * 0.75, 2) for ci, col_idx in enumerate(visible_cols, start=1): cell = ws.cell(row=1, column=ci, value=COLUMNS[col_idx]) cell.font = _hdr_font cell.fill = PatternFill('solid', fgColor=_hdr_color(col_idx)) cell.alignment = _hdr_align cell.border = _border # Larghezza dal runtime (include resize utente): px / 7 ≈ Excel char width px = self.table.columnWidth(col_idx) ws.column_dimensions[get_column_letter(ci)].width = max(4.0, round(px / 7, 2)) # Righe dati (17px → pt) — legge sfondo effettivo da ogni cella Qt _row_h = round(17 * 0.75, 2) excel_row = 1 for qt_row in range(self.table.rowCount()): if not any( (self.table.item(qt_row, c) or QTableWidgetItem()).text().strip() for c in INPUT_COLS ): continue excel_row += 1 ws.row_dimensions[excel_row].height = _row_h for ci, col_idx in enumerate(visible_cols, start=1): item = self.table.item(qt_row, col_idx) val = item.text().strip() if item else '' cell = ws.cell(row=excel_row, column=ci, value=val) cell.font = _cell_font cell.alignment = _center cell.border = _border if item: bg = item.background().color() if bg.isValid() and bg.alpha() > 0: hex_bg = bg.name()[1:].upper() if hex_bg not in ('000000', 'FFFFFF', ''): cell.fill = PatternFill('solid', fgColor=hex_bg) # Freeze header + auto-filter ws.freeze_panes = 'A2' ws.auto_filter.ref = ws.dimensions try: wb.save(path) self._status(f"Esportato: {path}") except Exception as e: QMessageBox.critical(self, "Errore esportazione", str(e)) def _reset_sheet(self): risposta = QMessageBox.question( self, "Reset foglio", "Cancellare tutti i record nel foglio?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if risposta == QMessageBox.StandardButton.Yes: self.cancella() def _get_cell_text(self, row: int, col: int) -> str: item = self.table.item(row, col) return item.text().strip() if item else '' # -------------------------------------------------------------- # # Setup griglia # # -------------------------------------------------------------- # def _build_table(self): self.table = LinkerTable(NUM_ROWS, len(COLUMNS), self) self.table.setHorizontalHeaderLabels(COLUMNS) header = LinkerHeaderView(self.table) self.table.setHorizontalHeader(header) for col, width in COL_WIDTHS.items(): self.table.setColumnWidth(col, width) self.table.setFont(QFont("Calibri", 8)) self.table.setVerticalHeader(SelectableVHeader(self.table)) self.table.setRowCount(0) self.table.setRowCount(NUM_ROWS) self.table.setStyleSheet(""" QTableWidget { gridline-color: #C0C0C0; } QTableWidget::item { padding: 0px 3px; } QTableWidget::item:selected { background-color: #CCE8FF; color: #0078D7; } """) self.table.setItemDelegate(RedTriangleDelegate(self.table)) self.table.setItemDelegateForColumn(COL_RIFER, RiferLinkDelegate(self.table, right=True)) self.table.setItemDelegateForColumn(COL_IMDB, RiferLinkDelegate(self.table, center=True)) self.table.setItemDelegateForColumn(COL_SUPERSERIE, RiferLinkDelegate(self.table)) self.table.setItemDelegateForColumn(COL_EPIS, RiferLinkDelegate(self.table, center=True)) self.table.setItemDelegateForColumn(COL_EM_DATA, RiferLinkDelegate(self.table, center=True)) self.table.setItemDelegateForColumn(COL_TEM_DATA, RiferLinkDelegate(self.table, center=True)) _centered = CenteredDelegate(self.table) for col in (COL_ANNO, COL_PAESE, COL_DURATA, COL_VEGFREE, COL_SD, COL_DECORRENZA, COL_DECOR, COL_SCAD, COL_PERC, COL_PASS_CONS, COL_PASS_EFF, COL_CAUS, COL_DECOR_FUT, COL_SCAD_FUT, COL_OTT, COL_EM_RETE, COL_EM_FASCIA, COL_TEM_RETE, COL_TEM_FASCIA, COL_BO_DEBUTTO): self.table.setItemDelegateForColumn(col, _centered) _right = RightAlignedDelegate(self.table) for col in (COL_EM_INIZIO, COL_TEM_INIZIO, COL_EM_AUD, COL_EM_SHA, COL_TEM_AUD, COL_TEM_SHA): self.table.setItemDelegateForColumn(col, _right) _thousands = ThousandsDelegate(self.table) for col in (COL_BO_INCASSO, COL_BO_SPETT): self.table.setItemDelegateForColumn(col, _thousands) _flat_bg = FlatBgDelegate(self.table) for col in VAL_COLS_LIST: self.table.setItemDelegateForColumn(col, _flat_bg) self.table.setMouseTracking(True) self.table.viewport().setMouseTracking(True) self.table.cellDoubleClicked.connect(self._on_cell_double_clicked) self.table.cellClicked.connect(self._on_cell_clicked) self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table.customContextMenuRequested.connect(self._on_context_menu) hh = self.table.horizontalHeader() hh.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) hh.customContextMenuRequested.connect(self._on_hheader_context_menu) vh = self.table.verticalHeader() vh.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) vh.customContextMenuRequested.connect(self._on_vheader_context_menu) self.table.selectionModel().selectionChanged.connect( lambda *_: (self.table.horizontalHeader().viewport().update(), self.table.verticalHeader().viewport().update()) ) self.table.rifers_pasted.connect(self._auto_fill_from_rifers) self.table.imdb_pasted.connect(self._auto_fill_from_imdb) self.table.itemChanged.connect(self._on_rifer_cell_changed) self.table.setColumnHidden(COL_DECORRENZA, True) for col in VAL_COLS_LIST: self.table.setColumnHidden(col, True) self.table.setSortingEnabled(True) # Intercetta Ctrl+V anche quando un editor di cella è aperto paste_sc = QShortcut(QKeySequence.StandardKey.Paste, self.table) paste_sc.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) paste_sc.activated.connect(self.table._handle_paste) def _init_cells(self): """Inizializza tutte le celle; output = non-editabili (RIFER resta editabile).""" self.table.blockSignals(True) try: for row in range(NUM_ROWS): for col in range(len(COLUMNS)): item = QTableWidgetItem('') if col in OUTPUT_COLS and col != COL_IMDB: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) self.table.setItem(row, col, item) finally: self.table.blockSignals(False) def _toggle_filter_bar(self): self._set_filter_bar_visible(not self._filter_bar.isVisible()) def _set_filter_bar_visible(self, visible: bool): self._filter_bar.setVisible(visible) self._act_filtro_col.setChecked(visible) if visible: self._filter_bar._update_geometries() else: self._filter_bar.clear_all() def _apply_filters(self): filters = self._filter_bar.get_filters() for row in range(self.table.rowCount()): if not filters: self.table.setRowHidden(row, False) continue visible = True for col, text in filters.items(): item = self.table.item(row, col) cell_text = (item.text() if item else '').lower() if text.startswith('='): match = cell_text == text[1:] else: match = text in cell_text if not match: visible = False break self.table.setRowHidden(row, not visible) def _build_loading_overlay(self): self._loading_label = QLabel("Caricamento dati in corso...", self) self._loading_label.setStyleSheet(""" QLabel { background-color: rgba(40, 40, 40, 200); color: white; font-size: 14px; padding: 12px 24px; border-radius: 8px; } """) self._loading_label.setVisible(False) self._loading_label.adjustSize() def _show_loading(self, msg: str = "Caricamento dati in corso..."): self._loading_label.setText(msg) self._loading_label.adjustSize() lw, lh = self._loading_label.width(), self._loading_label.height() x = (self.width() - lw) // 2 y = (self.height() - lh) // 2 self._loading_label.move(x, y) self._loading_label.raise_() self._loading_label.setVisible(True) def _hide_loading(self): self._loading_label.setVisible(False) def resizeEvent(self, event): super().resizeEvent(event) if self._loading_label.isVisible(): self._show_loading() def closeEvent(self, event): """Stoppa i worker in sicurezza prima della distruzione.""" self._stop_workers() super().closeEvent(event) def _stop_workers(self): for w in (self._preload_worker, self._emesso_worker, self._imdb_fill_worker): if w and w.isRunning(): for sig_name in ('done', 'ready'): try: getattr(w, sig_name).disconnect() except Exception: pass if hasattr(w, 'stop'): w.stop() w.quit() w.wait(3000) def _start_preload(self): """Avvia il precaricamento completo di emesso.parquet. Se la cache su disco è valida (mtime invariato) la carica direttamente, altrimenti parte il worker in background e salva la cache al termine.""" import json try: em_path = self.linker_db.catalog.parquet_path / 'emesso.parquet' cache_dir = em_path.parent # local_db/parquet/ except Exception: return meta_path = cache_dir / 'emesso_cache_meta.json' last_path = cache_dir / 'emesso_cache_last.pkl' first_path = cache_dir / 'emesso_cache_first.pkl' em_mtime = None try: em_mtime = em_path.stat().st_mtime if meta_path.exists() and last_path.exists() and first_path.exists(): with open(meta_path) as f: meta = json.load(f) if meta.get('emesso_mtime') == em_mtime: import pandas as pd self._emesso_cache = pd.read_pickle(str(last_path)) self._emesso_cache_first = pd.read_pickle(str(first_path)) logger.info( f"Emesso cache da disco: " f"{len(self._emesso_cache)} ultima, " f"{len(self._emesso_cache_first)} prima" ) return # cache valida — nessun worker, nessun loading banner except Exception as e: logger.warning(f"_start_preload disk cache: {e}") # Cache assente o scaduta — scan completo in background self._emesso_cache_dir = cache_dir self._emesso_cache_mtime = em_mtime # può essere None se stat() ha fallito agg_p = self.linker_db.catalog._emesso_agg_path() self._preload_worker = _PreloadWorker( str(em_path), agg_path=str(agg_p) if agg_p else None, parent=self ) self._preload_worker.ready.connect(self._on_preload_done) self._preload_worker.start(QThread.Priority.LowestPriority) self._show_loading("Inizializzazione emesso in corso...") def _on_preload_done(self, result): df_last, df_first = result self._emesso_cache = df_last self._emesso_cache_first = df_first logger.info(f"Emesso cache pronta: {len(df_last)} ultima, {len(df_first)} prima") self._hide_loading() # Salva su disco in background per non bloccare il thread principale cache_dir = getattr(self, '_emesso_cache_dir', None) mtime = getattr(self, '_emesso_cache_mtime', None) if cache_dir is not None and mtime is not None: import threading, json def _save(): try: df_last.to_pickle(str(cache_dir / 'emesso_cache_last.pkl')) df_first.to_pickle(str(cache_dir / 'emesso_cache_first.pkl')) with open(cache_dir / 'emesso_cache_meta.json', 'w') as f: json.dump({'emesso_mtime': mtime}, f) logger.info("Emesso disk cache salvata.") except Exception as e: logger.warning(f"_on_preload_done save cache: {e}") threading.Thread(target=_save, daemon=True).start() # -------------------------------------------------------------- # # API pubblica # # -------------------------------------------------------------- # def cancella(self): """Svuota l'intera griglia.""" self._stop_workers() self._pending_rifer_map = {} self._user_edited_imdb_rows.clear() self.table.verticalHeader().setDefaultSectionSize(17) self.table.setRowCount(0) self.table.setRowCount(NUM_ROWS) self._init_cells() self.hide_valutazioni() self._val_shown = False self.row_match_source.clear() self.table.setColumnHidden(COL_DECORRENZA, True) self._set_mode("Linker", "#EBEBEB") self._status("Foglio cancellato.") def has_data(self) -> bool: """True se almeno una cella input ha contenuto.""" for row in range(self.table.rowCount()): for col in INPUT_COLS: item = self.table.item(row, col) if item and item.text().strip(): return True return False def get_rows_data(self) -> list: """Restituisce i dati del foglio serializzati per session.json.""" rows = [] for row in range(self.table.rowCount()): has_content = any( self.table.item(row, col) and self.table.item(row, col).text().strip() for col in range(len(COLUMNS)) ) if not has_content: continue cells, user_roles, bg_colors = {}, {}, {} for col in range(len(COLUMNS)): item = self.table.item(row, col) if not item: continue text = item.text() if text: cells[str(col)] = text ur = item.data(Qt.ItemDataRole.UserRole) if ur is not None: user_roles[str(col)] = str(ur) bg = item.background() if bg.style() != Qt.BrushStyle.NoBrush: c = bg.color() if c.isValid() and c.name() != '#ffffff': bg_colors[str(col)] = c.name() rows.append({ 'row': row, 'cells': cells, 'user_roles': user_roles, 'bg_colors': bg_colors, 'match_source': self.row_match_source.get(row, ''), }) return rows def restore_rows_data(self, rows: list): """Ripristina il foglio dai dati di session.json.""" if not rows: return self.table.setUpdatesEnabled(False) self.table.blockSignals(True) try: self._restore_rows_data_inner(rows) finally: self.table.blockSignals(False) self.table.setUpdatesEnabled(True) def _restore_rows_data_inner(self, rows: list): # Espandi la tabella se i dati salvati superano le righe attuali max_row = max(r['row'] for r in rows) if max_row >= self.table.rowCount(): old_count = self.table.rowCount() self.table.verticalHeader().setDefaultSectionSize(17) self.table.setRowCount(max_row + 1) for row_idx in range(old_count, max_row + 1): for col in range(len(COLUMNS)): item = QTableWidgetItem('') if col in OUTPUT_COLS and col != COL_IMDB: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) self.table.setItem(row_idx, col, item) for row_data in rows: row = row_data['row'] match_source = row_data.get('match_source', '') if match_source: # Rows restored from a previous session are treated as already-saved. # Fresh aggancio will overwrite this via _apply_match_result. restored = match_source if match_source == 'NOT_FOUND' else 'REPOSITORY' self.row_match_source[row] = restored for col_str, text in row_data.get('cells', {}).items(): col = int(col_str) if col >= len(COLUMNS): continue item = self.table.item(row, col) if item is None: item = QTableWidgetItem('') if col in OUTPUT_COLS and col != COL_IMDB: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) self.table.setItem(row, col, item) item.setText(text) for col_str, orig in row_data.get('user_roles', {}).items(): col = int(col_str) item = self.table.item(row, col) if item: item.setData(Qt.ItemDataRole.UserRole, orig) item.setToolTip(orig) for col_str, hex_color in row_data.get('bg_colors', {}).items(): col = int(col_str) item = self.table.item(row, col) if item: item.setBackground(QColor(hex_color)) def get_export_rows(self): """Restituisce (rows_data, match_sources) per l'export Excel.""" rows_data, match_sources = [], [] for row in range(self.table.rowCount()): row_vals = [] has_content = False for col in range(len(COLUMNS)): item = self.table.item(row, col) val = item.text().strip() if item else '' row_vals.append(val) if col in INPUT_COLS and val: has_content = True if has_content: rows_data.append(row_vals) match_sources.append(self.row_match_source.get(row)) return rows_data, match_sources def get_saveable_records(self) -> list: """Tutte le righe con dati listino (TO o TI) e almeno un risultato (RIFER o IMDB). Il confronto nuovo/cambiato/invariato avviene in save_to_repository contro il DB.""" records = [] for row in range(self.table.rowCount()): to_item = self.table.item(row, COL_TO) ti_item = self.table.item(row, COL_TI) orig_to = ( (to_item.data(Qt.ItemDataRole.UserRole) or to_item.text()).strip() if to_item else '' ) orig_ti = ( (ti_item.data(Qt.ItemDataRole.UserRole) or ti_item.text()).strip() if ti_item else '' ) if not orig_to and not orig_ti: continue # riga senza dati listino rifer_item = self.table.item(row, COL_RIFER) imdb_item = self.table.item(row, COL_IMDB) rifer = rifer_item.text().strip() if rifer_item else '' imdb = imdb_item.text().strip() if imdb_item else '' if not rifer and not imdb: continue # NOT_FOUND senza nessun input manuale anno_item = self.table.item(row, COL_ANNO) regista_item = self.table.item(row, COL_REGISTA) orig_anno = ( (anno_item.data(Qt.ItemDataRole.UserRole) or anno_item.text()).strip() if anno_item else '' ) orig_reg = ( (regista_item.data(Qt.ItemDataRole.UserRole) or regista_item.text()).strip() if regista_item else '' ) if orig_to: titolo, tipo = orig_to, 'TO' else: titolo, tipo = orig_ti, 'TI' records.append({ 'TITOLO': titolo, 'TIPO_TITOLO': tipo, 'ANNO': orig_anno, 'REGISTA': orig_reg, 'RIFER': rifer, 'IMDB_CODICE': imdb, }) return records def mark_saved_rows_as_repository(self): """After a successful save, mark all matched rows as REPOSITORY so they are excluded from the next get_saveable_records call unless re-matched.""" for row in list(self.row_match_source): if self.row_match_source[row] != 'NOT_FOUND': self.row_match_source[row] = 'REPOSITORY' # -------------------------------------------------------------- # # Doppio click # # -------------------------------------------------------------- # def _on_cell_clicked(self, row: int, col: int): if col not in (COL_TI, COL_TO): return text = self._get_cell_text(row, col) if not text: return from app.modules.linker.src.gui.search_dialog import SearchDialog dlg = SearchDialog( search_text=text, database=self.linker_db, source_row=row, parent=self, ) if dlg.exec() != QDialog.DialogCode.Accepted: return rifer = dlg.get_selected_rifer() imdb = dlg.get_selected_imdb_code() if rifer: self._auto_fill_from_rifers([(row, rifer)]) elif imdb: self._auto_fill_from_imdb([(row, imdb)]) def _on_cell_double_clicked(self, row: int, col: int): if col == COL_RIFER: val = self._get_cell_text(row, COL_RIFER) if val: LinkerTable.open_onair(val) return if col == COL_IMDB: val = self._get_cell_text(row, COL_IMDB) if val: LinkerTable.open_imdb(val) return if col == COL_TEM_DATA: rifer = self._get_cell_text(row, COL_RIFER) if rifer: self._show_emissioni_tematiche_popup(row, rifer) return if col == COL_EM_DATA: rifer = self._get_cell_text(row, COL_RIFER) if rifer: self._show_emissioni_popup(row, rifer) return if col == COL_EPIS: rifer = self._get_cell_text(row, COL_RIFER) if rifer: self._show_edizioni_popup(row, rifer) return if col == COL_SUPERSERIE: val = self._get_cell_text(row, COL_SUPERSERIE) if val: self._show_superserie_popup(val) return # In basic mode altri double-click sono no-op def _show_emissioni_tematiche_popup(self, row: int, rifer: str): df = self.linker_db.get_emissioni_tematiche_by_rifer(rifer) if df.empty: QMessageBox.information(self, "Emissioni tematiche", f"Nessuna emissione trovata per RIFER: {rifer}") return ti_item = self.table.item(row, COL_TI) to_item = self.table.item(row, COL_TO) titolo = (ti_item.text().strip() if ti_item else '') or (to_item.text().strip() if to_item else rifer) dlg = EmissioniDialog(f"Emissioni tematiche: {titolo}", df, parent=self) dlg.exec() def _show_emissioni_popup(self, row: int, rifer: str): """Apre dialog con tutte le emissioni generaliste del prodotto.""" df = self.linker_db.get_emissioni_generaliste_by_rifer(rifer) if df.empty: QMessageBox.information(self, "Emissioni", f"Nessuna emissione trovata per RIFER: {rifer}") return ti_item = self.table.item(row, COL_TI) to_item = self.table.item(row, COL_TO) titolo = (ti_item.text().strip() if ti_item else '') or (to_item.text().strip() if to_item else rifer) dlg = EmissioniDialog(f"Emissioni generaliste: {titolo}", df, parent=self) dlg.exec() def _show_edizioni_popup(self, row: int, rifer: str): """Apre dialog con il dettaglio edizioni del prodotto.""" df = self.linker_db.get_edizioni_by_rifer(rifer) if df.empty: QMessageBox.information(self, "Edizioni", f"Nessuna edizione trovata per RIFER: {rifer}") return ti_item = self.table.item(row, COL_TI) to_item = self.table.item(row, COL_TO) titolo = (ti_item.text().strip() if ti_item else '') or (to_item.text().strip() if to_item else rifer) dlg = EdizioniDialog(titolo, df, parent=self) dlg.exec() def _show_superserie_popup(self, superserie_name: str): """Apre dialog con i prodotti della superserie.""" df = self.linker_db.get_prodotti_by_superserie(superserie_name) if df.empty: self._status(f"Superserie: nessun prodotto trovato per '{superserie_name}'.") return dlg = SuperserieDialog(superserie_name, df, parent=self) dlg.exec() # -------------------------------------------------------------- # # Context menu tasto destro # # -------------------------------------------------------------- # def _on_context_menu(self, pos): index = self.table.indexAt(pos) if not index.isValid(): return row = index.row() menu, actions = self._build_context_menu(row, index) action = menu.exec(self.table.viewport().mapToGlobal(pos)) self._handle_context_menu(action, actions, row, index) def _build_context_menu(self, row: int, index): """Costruisce il menu base. Override in aggancio per estenderlo.""" menu = QMenu(self) actions = {} actions['copia'] = menu.addAction("Copia") menu.addSeparator() act_incolla = menu.addAction("Incolla") act_incolla.setEnabled( index.column() in INPUT_COLS or index.column() in (COL_RIFER, COL_IMDB)) actions['incolla'] = act_incolla has_sel = bool(self.table.selectedIndexes()) act_cancella = menu.addAction("Cancella selezione") act_cancella.setEnabled(has_sel) actions['cancella'] = act_cancella rifer_val = self._get_cell_text(row, COL_RIFER) imdb_val = self._get_cell_text(row, COL_IMDB) superserie_val = self._get_cell_text(row, COL_SUPERSERIE) epis_val = self._get_cell_text(row, COL_EPIS) actions['_rifer'] = rifer_val actions['_imdb'] = imdb_val actions['_superserie'] = superserie_val actions['_epis'] = epis_val if rifer_val or imdb_val or superserie_val or epis_val: menu.addSeparator() if rifer_val: actions['onair'] = menu.addAction("Apri su Intranet") if imdb_val: actions['imdb'] = menu.addAction("Apri su IMDB") if superserie_val: actions['superserie'] = menu.addAction(f"Prodotti superserie: {superserie_val}") if epis_val and rifer_val: actions['edizioni'] = menu.addAction("Dettaglio edizioni") if rifer_val: actions['emissioni'] = menu.addAction("Emissioni generaliste") actions['emissioni_tem'] = menu.addAction("Emissioni tematiche") return menu, actions def _copy_selection(self): """Copia le celle selezionate negli appunti (formato TSV).""" ranges = self.table.selectedRanges() if not ranges: return rows = sorted({r for rng in ranges for r in range(rng.topRow(), rng.bottomRow() + 1)}) cols = sorted({c for rng in ranges for c in range(rng.leftColumn(), rng.rightColumn() + 1)}) lines = [] for r in rows: cells = [] for c in cols: item = self.table.item(r, c) cells.append(item.text() if item else '') lines.append('\t'.join(cells)) QApplication.clipboard().setText('\n'.join(lines)) def _on_hheader_context_menu(self, pos): hh = self.table.horizontalHeader() col = hh.logicalIndexAt(pos) if col < 0: return sel_cols = hh.get_selected_cols() if not sel_cols: sel_cols = [col] hi = self.table.horizontalHeaderItem(sel_cols[0]) label = (f'Copia colonna "{hi.text() if hi else ""}"' if len(sel_cols) == 1 else f'Copia {len(sel_cols)} colonne') menu = QMenu(self) act = menu.addAction(label) if menu.exec(hh.mapToGlobal(pos)) == act: self._copy_columns_hdr(sel_cols) def _on_vheader_context_menu(self, pos): row = self.table.verticalHeader().logicalIndexAt(pos) if row < 0: return sel_rows = sorted({i.row() for i in self.table.selectedIndexes()}) if not sel_rows: sel_rows = [row] label = 'Copia riga' if len(sel_rows) == 1 else f'Copia {len(sel_rows)} righe' menu = QMenu(self) act = menu.addAction(label) if menu.exec(self.table.verticalHeader().mapToGlobal(pos)) == act: self._copy_rows_hdr(sel_rows) def _copy_columns_hdr(self, cols: list[int]): hi = self.table.horizontalHeaderItem header = '\t'.join((hi(c).text() if hi(c) else '') for c in cols) lines = [header] for r in range(self.table.rowCount()): if self.table.isRowHidden(r): continue cells = [(self.table.item(r, c).text() if self.table.item(r, c) else '') for c in cols] lines.append('\t'.join(cells)) QApplication.clipboard().setText('\n'.join(lines)) def _copy_rows_hdr(self, rows: list[int]): lines = [] for r in rows: cells = [(self.table.item(r, c).text() if self.table.item(r, c) else '') for c in range(self.table.columnCount())] lines.append('\t'.join(cells)) QApplication.clipboard().setText('\n'.join(lines)) def _cancella_selezione(self): """Svuota le celle selezionate (solo colonne editabili).""" EDITABILI = set(INPUT_COLS) | {COL_RIFER, COL_IMDB} for idx in self.table.selectedIndexes(): if idx.column() not in EDITABILI: continue item = self.table.item(idx.row(), idx.column()) if item: item.setText('') def _handle_context_menu(self, action, actions: dict, row: int, index) -> bool: """Gestisce l'azione selezionata. Ritorna True se gestita.""" if action == actions.get('copia'): self._copy_selection() elif action == actions.get('cancella'): self._cancella_selezione() return True elif action == actions.get('incolla'): self.table.setCurrentIndex(index) self.table._handle_paste() elif action == actions.get('onair'): LinkerTable.open_onair(actions['_rifer']) elif action == actions.get('imdb'): LinkerTable.open_imdb(actions['_imdb']) elif action == actions.get('superserie'): self._show_superserie_popup(actions['_superserie']) elif action == actions.get('edizioni'): self._show_edizioni_popup(row, actions['_rifer']) elif action == actions.get('emissioni'): self._show_emissioni_popup(row, actions['_rifer']) elif action == actions.get('emissioni_tem'): self._show_emissioni_tematiche_popup(row, actions['_rifer']) else: return False return True # -------------------------------------------------------------- # # Auto-fill da RIFER incollato # # -------------------------------------------------------------- # def _auto_fill_from_imdb(self, imdb_rows: list): """ Riceve [(row_idx, imdb_code), ...] dopo un paste su COL_IMDB. Risolve IMDB → RIFER OnAir in background e delega a _auto_fill_from_rifers. """ imdb_codes = [code for _, code in imdb_rows if code.strip()] if not imdb_codes: return self._status("Risoluzione codici IMDB in corso...") is_seriale = getattr(self, 'current_is_seriale', False) parquet_path = str(self.linker_db.catalog.parquet_path) self._imdb_resolve_worker = _ImdbResolveWorker( parquet_path, imdb_rows, is_seriale, parent=self ) self._imdb_resolve_worker.done.connect(self._on_imdb_resolve_done) self._imdb_resolve_worker.start(QThread.Priority.LowPriority) def _on_imdb_resolve_done(self, result): imdb_rows, imdb_to_rifer = result rifer_rows = [] imdb_only_rows = [] self.table.blockSignals(True) try: for row_idx, imdb_code in imdb_rows: imdb_code = imdb_code.strip() if not imdb_code: continue rifer = imdb_to_rifer.get(imdb_code, '') if rifer: item = self.table.item(row_idx, COL_RIFER) if item: item.setText(rifer) else: self.table.setItem(row_idx, COL_RIFER, QTableWidgetItem(rifer)) rifer_rows.append((row_idx, rifer)) else: imdb_only_rows.append((row_idx, imdb_code)) finally: self.table.blockSignals(False) if rifer_rows: self._auto_fill_from_rifers(rifer_rows) if imdb_only_rows: self._start_imdb_fill_worker(imdb_only_rows) elif not rifer_rows: self._status("IMDB lookup: nessun codice trovato.") def _auto_fill_from_rifers(self, rifer_rows: list): """ Riceve [(row_idx, rifer), ...] dopo un paste su COL_RIFER. Batch-query su CUSTOM_ANAGR_ORIZ e popola le colonne di ogni riga. """ rifers = [rv for _, rv in rifer_rows if rv.strip()] if not rifers: return try: QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) df = self.linker_db.get_custom_by_rifers(rifers) rendered = self._renderer.fetch(rifers) except Exception as e: logger.error(f"_auto_fill_from_rifers: {e}", exc_info=True) QMessageBox.critical(self, "Errore", f"Errore lookup RIFER:\n{e}") return finally: QApplication.restoreOverrideCursor() dir_lookup = rendered.diritti bo_lookup = rendered.boxoffice sr_lookup = rendered.rete_slot lookup = {} if not df.empty: for _, crow in df.iterrows(): key = str(crow.get('RIFER', '') or '').strip() if key: lookup[key] = crow found = not_found = 0 NOT_FOUND_COLOR = QColor('#FF9999') self._rifer_edit_guard = True self.table.blockSignals(True) self.table.setUpdatesEnabled(False) try: for row_idx, rifer in rifer_rows: rifer = rifer.strip() if not rifer: continue crow = lookup.get(rifer) if crow is None: rifer_item = self.table.item(row_idx, COL_RIFER) if rifer_item: rifer_item.setBackground(NOT_FOUND_COLOR) self.row_match_source[row_idx] = 'NOT_FOUND' self._fill_diritti_cells(row_idx, []) self._fill_rete_slot_cells(row_idx, '') self._fill_ott_cell(row_idx, '') self._fill_emesso_cells(row_idx, None) self._fill_tematiche_cells(row_idx, None) self._fill_boxoffice_cells(row_idx, None) not_found += 1 continue reg_c = self._safe(crow.get('REGISTA_COGN')) reg_n = self._safe(crow.get('REGISTA_NOME')) attori = ', '.join( self._safe(crow.get(c)) for c in sorted(col for col in crow.index if str(col).upper().startswith('ATTORE')) if self._safe(crow.get(c)) ) result = { 'RIFER': rifer, 'IMDB_CODICE': self._safe(crow.get('IMDB_CODICE')), 'TI': self._safe(crow.get('TI')), 'TO': self._safe(crow.get('TO')), 'ANNO': self._fmt_anno(crow.get('ANNO')), 'REGISTA': (reg_n + ' ' + reg_c).strip(), 'TIPOL': self._safe(crow.get('TIPOL')), 'PAESE': self._safe(crow.get('PAESE')), 'GENERE': self._safe(crow.get('GENERE1')), 'EPIS': self._safe(crow.get('EPIS')), 'DURATA': self._safe(crow.get('DUR')), 'SUPERSERIE': self._safe(crow.get('SUPERSERIE')), 'VEG_FREE': self._safe(crow.get('VEG_FREE')), 'ATTORI': attori, 'match_source': 'ONAIR', 'color': '#FFFFFF', } self._apply_match_result(row_idx, result, '', '', result['ANNO'], result['REGISTA']) self._fill_diritti_cells(row_idx, dir_lookup.get(rifer, [])) self._fill_rete_slot_cells(row_idx, sr_lookup.get(rifer, '')) self._fill_ott_cell(row_idx, 'n/d') self._fill_boxoffice_cells(row_idx, bo_lookup.get(rifer)) found += 1 finally: self._rifer_edit_guard = False self.table.blockSignals(False) self.table.setUpdatesEnabled(True) self.table.viewport().update() self._status(f"RIFER auto-fill: {found} trovati, {not_found} non trovati. Caricamento emesso...") rifer_map = {row_idx: rifer for row_idx, rifer in rifer_rows if rifer.strip()} self._start_emesso_worker(rifer_map) def _start_imdb_fill_worker(self, imdb_only_rows: list): parquet_path = str(self.linker_db.catalog.parquet_path) self._imdb_fill_worker = _ImdbOnlyWorker(parquet_path, imdb_only_rows, parent=self) self._imdb_fill_worker.done.connect(self._on_imdb_fill_done) self._imdb_fill_worker.start(QThread.Priority.LowestPriority) self._status("Caricamento dati IMDB in corso...") def _on_imdb_fill_done(self, results: list): NOT_FOUND_COLOR = QColor('#FF9999') found = 0 self._rifer_edit_guard = True self.table.blockSignals(True) try: for row_idx, result in results: if result is None: imdb_item = self.table.item(row_idx, COL_IMDB) if imdb_item: imdb_item.setBackground(NOT_FOUND_COLOR) continue to_item = self.table.item(row_idx, COL_TO) ti_item = self.table.item(row_idx, COL_TI) anno_item = self.table.item(row_idx, COL_ANNO) reg_item = self.table.item(row_idx, COL_REGISTA) self._apply_match_result( row_idx, result, to_item.text() if to_item else '', ti_item.text() if ti_item else '', anno_item.text() if anno_item else '', reg_item.text() if reg_item else '', ) found += 1 finally: self._rifer_edit_guard = False self.table.blockSignals(False) not_found = len(results) - found msg = f"IMDB fill: {found} trovati" if not_found: msg += f", {not_found} senza dati" self._status(msg + ".") def _on_rifer_cell_changed(self, item: QTableWidgetItem): """Scatta quando l'utente modifica direttamente una cella RIFER o IMDB.""" if self._rifer_edit_guard: return col = item.column() row = item.row() if col == COL_IMDB: import re val = item.text().strip() self._user_edited_imdb_rows.add(row) if not val: rifer_item = self.table.item(row, COL_RIFER) rifer_val = rifer_item.text().strip() if rifer_item else '' if not rifer_val: self._clear_aggregated_data(row) return if re.match(r'^tt\d{1,8}$', val, re.IGNORECASE): self._auto_fill_from_imdb([(row, val)]) return if col != COL_RIFER: return val = item.text().strip() if not val: item.setBackground(QColor('white')) self.row_match_source.pop(row, None) self._clear_aggregated_data(row) return self._auto_fill_from_rifers([(row, val)]) def _clear_aggregated_data(self, row: int): """Svuota tutte le colonne aggregate (diritti, emesso, anagrafica output) per una riga.""" WHITE = QColor('white') self._rifer_edit_guard = True self.table.blockSignals(True) try: for col in OUTPUT_COLS: if col == COL_IMDB: continue it = QTableWidgetItem('') it.setFlags(it.flags() & ~Qt.ItemFlag.ItemIsEditable) it.setBackground(WHITE) self.table.setItem(row, col, it) self.row_match_source.pop(row, None) finally: self._rifer_edit_guard = False self.table.blockSignals(False) # -------------------------------------------------------------- # # Utilità # # -------------------------------------------------------------- # @staticmethod def _safe(val) -> str: import pandas as pd if val is None: return '' try: if pd.isna(val): return '' except (TypeError, ValueError): pass return str(val).strip() @staticmethod def _fmt_anno(val) -> str: import pandas as pd if val is None: return '' try: if pd.isna(val): return '' except (TypeError, ValueError): pass try: return str(int(float(str(val)))) except (ValueError, TypeError): return str(val).strip() if val else '' @staticmethod def _fmt_date(val) -> str: import pandas as pd if val is None: return '' if isinstance(val, float) and pd.isna(val): return '' if hasattr(val, 'day'): return f"{val.day:02d}/{val.month:02d}/{val.year}" s = str(val).strip() if len(s) == 10 and s[4] == '-' and s[7] == '-': return f"{s[8:10]}/{s[5:7]}/{s[0:4]}" return s if s else '' @staticmethod def _fmt_num(val, decimals: int = 1) -> str: import pandas as pd if val is None: return '' try: if pd.isna(val): return '' except (TypeError, ValueError): pass try: f = float(val) return str(int(round(f))) if decimals == 0 else (str(int(f)) if f == int(f) else f'{f:.{decimals}f}') except (ValueError, TypeError): return '' @staticmethod def _to_date(val): import pandas as pd if val is None: return None if isinstance(val, float) and pd.isna(val): return None if hasattr(val, 'date'): return val.date() if hasattr(val, 'day'): return val return None def _fill_diritti_cells(self, row_idx: int, diritti_rows: list): self._filler.fill_diritti(row_idx, diritti_rows) def _fill_diritti_row(self, row_idx: int, rifer: str): if not rifer: return try: df = self.linker_db.get_diritti_full_by_rifer(rifer) rows = [row for _, row in df.iterrows()] if not df.empty else [] self._fill_diritti_cells(row_idx, rows) except Exception as e: logger.error(f"_fill_diritti_row: {e}", exc_info=True) def _batch_fill_diritti(self, rifer_map: dict): """Fill diritti cells for multiple rows. rifer_map = {row_idx: rifer}.""" if not rifer_map: return rifers = list(set(rifer_map.values())) try: df = self.linker_db.get_diritti_full_by_rifers(rifers) except Exception as e: logger.error(f"_batch_fill_diritti: {e}", exc_info=True) df = None dir_lookup = {} if df is not None and not df.empty: for _, dr in df.iterrows(): key = str(dr.get('RIFER', '') or '').strip() if key: dir_lookup.setdefault(key, []).append(dr) for row_idx, rifer in rifer_map.items(): self._fill_diritti_cells(row_idx, dir_lookup.get(rifer, [])) def _fill_emesso_cells(self, row_idx: int, emesso_row): self._filler.fill_emesso(row_idx, emesso_row) def _fill_emesso_row(self, row_idx: int, rifer: str): if not rifer: return try: df = self.linker_db.get_emesso_last_by_rifers([rifer]) row = df.iloc[0] if not df.empty else None self._fill_emesso_cells(row_idx, row) except Exception as e: logger.error(f"_fill_emesso_row: {e}", exc_info=True) def _batch_fill_emesso(self, rifer_map: dict): """Fill emesso cells for multiple rows. rifer_map = {row_idx: rifer}.""" if not rifer_map: return rifers = list(set(rifer_map.values())) try: df = self.linker_db.get_emesso_last_by_rifers(rifers) except Exception as e: logger.error(f"_batch_fill_emesso: {e}", exc_info=True) df = None em_lookup = {} if df is not None and not df.empty: for _, er in df.iterrows(): key = str(er.get('RIFER', '') or '').strip() if key: em_lookup[key] = er for row_idx, rifer in rifer_map.items(): self._fill_emesso_cells(row_idx, em_lookup.get(rifer)) def _fill_tematiche_cells(self, row_idx: int, emesso_row): self._filler.fill_tematiche(row_idx, emesso_row) # -------------------------------------------------------------- # # Valutazioni Gemma # # -------------------------------------------------------------- # def show_valutazioni(self, gemma_lookup: dict, extra_gemma_map: dict | None = None): """Mostra le colonne valutazioni e le popola da gemma_lookup {imdb_code: dict}. extra_gemma_map: {imdb: row_dict} da tabValutazioniExtraGemma — overlay fucsia.""" hdr = self.table.horizontalHeader() sorting_was_enabled = self.table.isSortingEnabled() # Disabilita sort e blocca header/widget PRIMA di qualsiasi op sul modello, # incluso il reveal delle colonne (setColumnHidden può scatenare sort in Qt6). self.table.setSortingEnabled(False) hdr.blockSignals(True) self.table.blockSignals(True) self.table.setUpdatesEnabled(False) try: for col in VAL_COLS_LIST: self.table.setColumnHidden(col, False) for row_idx in range(self.table.rowCount()): imdb_item = self.table.item(row_idx, COL_IMDB) imdb = imdb_item.text().strip() if imdb_item else '' if not imdb: continue gem_row = gemma_lookup.get(imdb) self._fill_valutazioni_cells(row_idx, gem_row) if extra_gemma_map and imdb in extra_gemma_map: extra = extra_gemma_map[imdb] gemma_date = gf_to_comparable_date(gem_row.get('A_DATA') if gem_row else None) proposta_date = gf_to_comparable_date(extra.get('data_creazione')) if not gemma_date or not proposta_date or proposta_date >= gemma_date: self._filler.fill_proposta_giro(row_idx, extra) finally: self.table.blockSignals(False) hdr.blockSignals(False) self.table.setUpdatesEnabled(True) if sorting_was_enabled: # Resetta sort indicator prima di riabilitare: altrimenti Qt # ri-applica immediatamente il sort precedente riordinando le righe. hdr.setSortIndicator(-1, Qt.SortOrder.AscendingOrder) self.table.setSortingEnabled(True) self.table.viewport().repaint() def hide_valutazioni(self): """Svuota e nasconde le colonne valutazioni.""" white = QColor('white') self.table.blockSignals(True) self.table.setUpdatesEnabled(False) try: for row_idx in range(self.table.rowCount()): for col in VAL_COLS_LIST: item = self.table.item(row_idx, col) if item: item.setText('') item.setBackground(white) finally: self.table.blockSignals(False) self.table.setUpdatesEnabled(True) for col in VAL_COLS_LIST: self.table.setColumnHidden(col, True) _VAL_FOUND_BG = QColor('#FFFF99') def refresh_emesso(self): """Ricarica le celle emesso in base al flag _emesso_show_first corrente. Usa la cache precaricata se disponibile — nessuna query a disco.""" rifer_map = {} for row_idx in range(self.table.rowCount()): item = self.table.item(row_idx, COL_RIFER) if item: rifer = item.text().strip() if rifer and self.row_match_source.get(row_idx) in ('ONAIR', 'IMDB', 'DERIVED'): rifer_map[row_idx] = rifer if not rifer_map: return cache = self._emesso_cache_first if self._emesso_show_first else self._emesso_cache if cache is not None and not cache.empty: rifers_set = set(rifer_map.values()) df_filtered = cache[cache['RIFER'].isin(rifers_set)] self._pending_rifer_map = rifer_map self._on_emesso_done(df_filtered) return # Fallback a disco (cache non ancora pronta) rifers = list(set(rifer_map.values())) try: QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) if self._emesso_show_first: df = self.linker_db.get_emesso_both_first_by_rifers(rifers) else: df = self.linker_db.get_emesso_both_last_by_rifers(rifers) except Exception as e: logger.error(f"refresh_emesso: {e}", exc_info=True) return finally: QApplication.restoreOverrideCursor() self._pending_rifer_map = rifer_map self._on_emesso_done(df) def _fill_valutazioni_cells(self, row_idx: int, gr): self._filler.fill_valutazioni(row_idx, gr) def _fill_ott_cell(self, row_idx: int, value: str): self._filler.fill_ott(row_idx, value) def _fill_rete_slot_cells(self, row_idx: int, value: str): self._filler.fill_rete_slot(row_idx, value) def _fill_boxoffice_cells(self, row_idx: int, bo_row): self._filler.fill_boxoffice(row_idx, bo_row) def _fill_tematiche_row(self, row_idx: int, rifer: str): if not rifer: return try: df = self.linker_db.get_emesso_tematiche_last_by_rifers([rifer]) row = df.iloc[0] if not df.empty else None self._fill_tematiche_cells(row_idx, row) except Exception as e: logger.error(f"_fill_tematiche_row: {e}", exc_info=True) def _batch_fill_tematiche(self, rifer_map: dict): """Fill tematiche cells for multiple rows. rifer_map = {row_idx: rifer}.""" if not rifer_map: return rifers = list(set(rifer_map.values())) try: df = self.linker_db.get_emesso_tematiche_last_by_rifers(rifers) except Exception as e: logger.error(f"_batch_fill_tematiche: {e}", exc_info=True) df = None tem_lookup = {} if df is not None and not df.empty: for _, er in df.iterrows(): key = str(er.get('RIFER', '') or '').strip() if key: tem_lookup[key] = er for row_idx, rifer in rifer_map.items(): self._fill_tematiche_cells(row_idx, tem_lookup.get(rifer)) def _start_emesso_worker(self, rifer_map: dict): """Avvia il worker di background per emesso.parquet (o serve dalla cache).""" if not rifer_map: return self._pending_rifer_map = dict(rifer_map) # Cache pronta: filtra in-process senza QThread if self._emesso_cache is not None and not self._emesso_cache.empty: rifers_set = set(rifer_map.values()) df_filtered = self._emesso_cache[ self._emesso_cache['RIFER'].isin(rifers_set) ] self._on_emesso_done(df_filtered) return # Cache non ancora pronta: fallback al worker asincrono if self._emesso_worker and self._emesso_worker.isRunning(): self._emesso_worker.quit() self._emesso_worker.wait(2000) em_path = str(self.linker_db.catalog.parquet_path / 'emesso.parquet') agg_p = self.linker_db.catalog._emesso_agg_path() rifers = list(set(rifer_map.values())) self._emesso_worker = _EmessoWorker( em_path, rifers, agg_path=str(agg_p) if agg_p else None, parent=self ) self._emesso_worker.done.connect(self._on_emesso_done) self._emesso_worker.start(QThread.Priority.LowestPriority) self._show_loading() def _on_emesso_done(self, df_both): """Slot: riceve il DataFrame dal worker e aggiorna le celle in chunk.""" try: em_lookup, tem_lookup = {}, {} if not df_both.empty: for _, er in df_both.iterrows(): key = str(er.get('RIFER', '') or '').strip() if key: if er.get('tipo_rete') == 'G': em_lookup[key] = er else: tem_lookup[key] = er rows = list(self._pending_rifer_map.items()) self._pending_rifer_map = {} self._emesso_fill_queue = (rows, em_lookup, tem_lookup, 0) self.table.blockSignals(True) self.table.setUpdatesEnabled(False) self._emesso_fill_chunk() except Exception as e: logger.error(f"_on_emesso_done: {e}", exc_info=True) self._pending_rifer_map = {} self._hide_loading() self._status("Errore caricamento emesso.") _EMESSO_CHUNK = 200 def _emesso_fill_chunk(self): if not hasattr(self, '_emesso_fill_queue') or self._emesso_fill_queue is None: return rows, em_lookup, tem_lookup, offset = self._emesso_fill_queue chunk = rows[offset: offset + self._EMESSO_CHUNK] for row_idx, rifer in chunk: self._fill_emesso_cells(row_idx, em_lookup.get(rifer)) self._fill_tematiche_cells(row_idx, tem_lookup.get(rifer)) next_offset = offset + self._EMESSO_CHUNK total = len(rows) if next_offset < total: self._emesso_fill_queue = (rows, em_lookup, tem_lookup, next_offset) self._status(f"Emesso: {next_offset}/{total}…") QTimer.singleShot(0, self._emesso_fill_chunk) else: self._emesso_fill_queue = None self.table.blockSignals(False) self.table.setUpdatesEnabled(True) self.table.viewport().update() self._hide_loading() self._status("Dati caricati.")