""" OttSheet — foglio OTT (finestre di pubblicazione streaming). Carica un file Excel Qlik/Marketing (formato standard 15 colonne), salva in OTT_REPOSITORY (linker.db), poi aggancia dati da parquet (ONAIR, Diritti, Emesso, Box Office). Filtri: Movie Filtrato, Show Filtrato, Movie Completo, Show Completo """ import logging import threading from pathlib import Path from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject, QUrl from PyQt6.QtGui import QColor, QFont, QPainter, QAction, QDesktopServices, QKeySequence, QShortcut from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView, QApplication, QLabel, QDialog, QInputDialog, QFileDialog, QMessageBox, QToolButton, QMenu, ) from app.modules.linker.src.gui.delegates import RiferLinkDelegate, CenteredDelegate, FlatBgDelegate, FlatLinkDelegate from app.modules.linker.src.gui.column_defs import HC, fl, fw from app.modules.linker.src.gui.filter_bar import FilterBar from app.modules.linker.src.gui.grid_filler import ( GridFiller, FlatBgDelegate, DateSortItem, paint_std_header_section, HEADER_HEIGHT, HEADER_BORDER_COLOR, HEADER_SEL_COLOR, SelectableVHeader, gf_safe, gf_fmt_num, gf_to_comparable_date, ) logger = logging.getLogger(__name__) # ── Gruppo Valutazioni Gemma (0-15, nascoste di default) ───────────────── 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_OTT = list(range(COL_VAL_FORNITORE, COL_VAL_GEM_SIN + 1)) # [0..15] # ── Gruppo OTT — dati da Excel (16-31) ─────────────────────────────────── COL_DATA_IMP = 16 # DATA IMPORTAZIONE (auto = data_creazione batch) COL_SEGNALAZ = 17 # SEGNALAZIONI (auto: "S" se smontato + note) COL_TITOLO = 18 COL_PROVIDER = 19 COL_TIPO_FIN = 20 # Tipo finestra COL_MONET = 21 # Monetization COL_DATA_INI = 22 # Data Inizio COL_DATA_FINE = 23 # Data fine COL_TIPO = 24 # movie/show COL_ANNO_OTT = 25 COL_STAG = 26 # Nr Stagioni COL_EPIS_OTT = 27 # Tot Episodi COL_REGISTA_OTT = 28 COL_GENERI_OTT = 29 COL_PAESI_OTT = 30 COL_CODICE = 31 # Mediaset_id # ── Gruppo ICR — annotazioni (32-35) ──────────────────────────────────── COL_NOTA_ICR = 32 # editable — da implementare (OTT-specifico) COL_PROP_GIRO = 33 # editable — da implementare (OTT-specifico) COL_NETWORK_FREE= 34 # da implementare (OTT-specifico) COL_FORN_DIR = 35 # da implementare (OTT-specifico) # ── Gruppo IMP — IMDB (36-37) ──────────────────────────────────────────── COL_VOTO = 36 COL_VOTANTI = 37 # ── Gruppo ONAIR — da parquet (38-52) ──────────────────────────────────── COL_RIFER = 38 COL_IMDB = 39 COL_TIPOL = 40 COL_TI = 41 COL_TO = 42 COL_ANNO_ON = 43 COL_PAESE = 44 COL_GENERE = 45 COL_EPIS_ON = 46 COL_DURATA = 47 COL_SUPERSERIE = 48 COL_VEG_FREE = 49 COL_REGISTA_ON = 50 COL_ATTORI = 51 COL_SEP = 52 # separatore vuoto # ── Gruppo Diritti (53-62) ──────────────────────────────────────────────── COL_DECOR = 53 COL_SCAD = 54 COL_DISTRIBUTORE= 55 COL_PERC = 56 COL_PASS_CONS = 57 COL_PASS_EFF = 58 COL_CAUS = 59 COL_DECOR_FUT = 60 COL_SCAD_FUT = 61 COL_RETE_SLOT = 62 # ── Gruppo Emesso Generaliste (63-68) ──────────────────────────────────── COL_RETE = 63 COL_DATA_EM = 64 COL_INIZIO = 65 COL_FASCIA = 66 COL_AUD = 67 COL_SHA = 68 # ── Gruppo Emesso Tematiche (69-74) ────────────────────────────────────── COL_TEM_RETE = 69 COL_TEM_DATA = 70 COL_TEM_INIZIO = 71 COL_TEM_FASCIA = 72 COL_TEM_AUD = 73 COL_TEM_SHA = 74 # ── Gruppo Box Office (75-78) ───────────────────────────────────────────── COL_BO_DEB = 75 COL_BO_INCASSO = 76 COL_BO_SPETT = 77 COL_BO_DIST = 78 _N_COLS = 79 _COLUMNS = [ # Valutazioni Gemma (0-15) 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'), # OTT (16-31) — specifici di questo foglio 'DATA IMPORTAZIONE', 'SEGNALAZ.', 'TITOLO', 'PROVIDER', 'TIPO FIN.', 'MONETIZATION', 'DATA INIZIO', 'DATA FINE', 'TIPO', 'ANNO OTT', 'STAG.', 'EPIS. OTT', 'REGISTA OTT', 'GENERI', 'PAESI', 'CODICE', # ICR (32-35) — specifici di questo foglio 'NOTA ICR', 'PROPOSTA GIRO ICR', 'NETWORK FREE', 'FORNITORE DIRITTI', # IMP (36-37) 'VOTO', 'VOTANTI', # ONAIR (38-51) 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'), # Diritti (52-62) fl('SD'), 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'), # Emesso Gen (63-68) fl('EM_RETE'), fl('EM_DATA'), fl('EM_INIZIO'), fl('EM_FASCIA'), fl('EM_AUD'), fl('EM_SHA'), # Emesso Tem (69-74) fl('TEM_RETE'), fl('TEM_DATA'), fl('TEM_INIZIO'), fl('TEM_FASCIA'), fl('TEM_AUD'), fl('TEM_SHA'), # Box Office (75-78) fl('BO_DEB'), fl('BO_INCASSO'), fl('BO_SPETT'), fl('BO_DIST'), ] _OTT_COLS = list(range(COL_DATA_IMP, COL_CODICE + 1)) _ICR_COLS = list(range(COL_NOTA_ICR, COL_FORN_DIR + 1)) _IMP_COLS = [COL_VOTO, COL_VOTANTI] _ONAIR_COLS = list(range(COL_RIFER, COL_ATTORI + 1)) _DIRITTI_COLS = list(range(COL_SEP, COL_RETE_SLOT + 1)) _EMGEN_COLS = list(range(COL_RETE, COL_SHA + 1)) _EMTEM_COLS = list(range(COL_TEM_RETE, COL_TEM_SHA + 1)) _BO_COLS = list(range(COL_BO_DEB, COL_BO_DIST + 1)) _COL_INDICES = { # diritti 'SD': COL_SEP, '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, # emesso gen 'RETE': COL_RETE, 'DATA': COL_DATA_EM, 'INIZIO': COL_INIZIO, 'FASCIA': COL_FASCIA, 'AUD': COL_AUD, 'SHA': COL_SHA, # emesso tem '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, # box office 'BO_DEBUTTO': COL_BO_DEB, 'BO_INCASSO': COL_BO_INCASSO, 'BO_SPETT': COL_BO_SPETT, 'BO_DIST': COL_BO_DIST, # valutazioni '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, 'OTT': -1, } _COL_WIDTHS = { # OTT-specifici COL_DATA_IMP: 85, COL_SEGNALAZ: 70, COL_TITOLO: 230, COL_PROVIDER: 130, COL_TIPO_FIN: 90, COL_MONET: 100, COL_DATA_INI: 80, COL_DATA_FINE: 80, COL_TIPO: 55, COL_ANNO_OTT: 45, COL_STAG: 45, COL_EPIS_OTT: 45, COL_REGISTA_OTT: 120, COL_GENERI_OTT: 110, COL_PAESI_OTT: 80, COL_CODICE: 80, COL_NOTA_ICR: 120, COL_PROP_GIRO: 120, COL_NETWORK_FREE: 90, COL_FORN_DIR: 120, COL_VOTO: 50, COL_VOTANTI: 65, COL_SEP: 8, # Condivisi — da column_defs.FIELDS COL_RIFER: fw('RIFER'), COL_IMDB: fw('IMDB'), COL_TIPOL: fw('TIPOL'), COL_TI: fw('TI'), COL_TO: fw('TO'), COL_ANNO_ON: fw('ANNO'), COL_PAESE: fw('PAESE'), COL_GENERE: fw('GENERE'), COL_EPIS_ON: fw('EPIS'), COL_DURATA: fw('DURATA'), COL_SUPERSERIE: fw('SUPERSERIE'), COL_VEG_FREE: fw('VEG_FREE'), COL_REGISTA_ON: fw('REGISTA'), COL_ATTORI: fw('ATTORI'), 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_RETE: fw('EM_RETE'), COL_DATA_EM: fw('EM_DATA'), COL_INIZIO: fw('EM_INIZIO'), COL_FASCIA: fw('EM_FASCIA'), COL_AUD: fw('EM_AUD'), COL_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_DEB: fw('BO_DEB'), COL_BO_INCASSO: fw('BO_INCASSO'), COL_BO_SPETT: fw('BO_SPETT'), COL_BO_DIST: fw('BO_DIST'), 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'), } # colori intestazioni — da column_defs.HC _BG_ROW_EVEN = QColor('#FFFFFF') _BG_ROW_ODD = QColor('#FFFFFF') _BG_EDITABLE = QColor('#D6F2FA') # azzurrino tenue — celle editabili dall'utente _TOOLBAR_COLOR = '#9DC3E6' _CELL_FONT = QFont('Calibri', 8) _EXCEL_COL_MAP = { 'mediaset_id': 'mediaset_id', 'titolo': 'titolo', 'tipo': 'tipo', 'imdb_id': 'imdb_id', 'anno': 'anno', 'nr stagioni': 'nr_stagioni', 'tot episodi': 'tot_episodi', 'provider': 'provider', 'tipo finestra': 'tipo_finestra', 'monetization_type': 'monetization_type', 'data inizio': 'data_inizio', 'data fine': 'data_fine', 'regista': 'regista', 'generi': 'generi', 'paesi di produzione': 'paesi', } # ── Header colorato ──────────────────────────────────────────────────────── class _OttHeader(QHeaderView): def __init__(self, parent=None): super().__init__(Qt.Orientation.Horizontal, parent) self._table = parent self._sel_cols: set[int] = set() self.setSectionsClickable(True) self.setSortIndicatorShown(True) self.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) self.setFixedHeight(HEADER_HEIGHT) 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 (COL_DATA_IMP, COL_SEGNALAZ): bg = HC.SEGNALAZ elif logicalIndex in _OTT_COLS: bg = HC.OTT elif logicalIndex in _ICR_COLS or logicalIndex == COL_IMDB: bg = HC.EDITABILE elif logicalIndex in _IMP_COLS: bg = HC.GREY elif logicalIndex in _DIRITTI_COLS: bg = HC.DIRITTI elif logicalIndex in _EMGEN_COLS: bg = HC.ORANGE elif logicalIndex in _EMTEM_COLS: bg = HC.PURPLE elif logicalIndex in _ONAIR_COLS: bg = HC.ORANGE elif logicalIndex in _BO_COLS: bg = HC.YELLOW elif logicalIndex in _VAL_COLS_OTT: bg = HC.VAL else: bg = HC.DEFAULT text = self.model().headerData( logicalIndex, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole ) paint_std_header_section(painter, rect, str(text) if text else '', bg, HEADER_BORDER_COLOR) if 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() # ── Worker unico: DB load + fetch parquet ───────────────────────────────── class _SingleRowFetchWorker(QObject): """Fetcha dati parquet per un singolo codice IMDB e li restituisce per una riga.""" done = pyqtSignal(int, str, dict) # (row, imdb, merged) finished = pyqtSignal() def __init__(self, linker_db, row: int, imdb: str): super().__init__() self._db = linker_db self._row = row self._imdb = imdb def run(self): try: imdb = self._imdb merged: dict = {'RIFER': ''} df_rifer = self._db.get_rifer_by_imdb_codes([imdb]) rifer = str(df_rifer.iloc[0]['RIFER']) if not df_rifer.empty else '' merged['RIFER'] = rifer if rifer: df_custom = self._db.get_custom_by_rifers([rifer]) if not df_custom.empty: merged.update(df_custom.iloc[0].to_dict()) df_diritti = self._db.get_diritti_full_by_rifers([rifer]) merged['_diritti'] = df_diritti.to_dict('records') if not df_diritti.empty else [] df_em = self._db.get_emesso_both_last_by_rifers([rifer]) if not df_em.empty: emg = df_em[df_em['tipo_rete'] == 'G'].drop(columns=['tipo_rete']) emt = df_em[df_em['tipo_rete'] == 'T'].drop(columns=['tipo_rete']) merged['_emgen'] = emg.iloc[0].to_dict() if not emg.empty else None merged['_emtem'] = emt.iloc[0].to_dict() if not emt.empty else None else: merged['_emgen'] = merged['_emtem'] = None df_bo = self._db.get_boxoffice_by_rifers([rifer]) merged['_bo'] = df_bo.iloc[0].to_dict() if not df_bo.empty else None df_slot = self._db.get_scelte_rete_by_rifers([rifer]) merged['_rete_slot'] = (df_slot.iloc[0].get('rete_slot', '') if not df_slot.empty else '') df_imdb = self._db.get_imdb_data_by_codes([imdb]) merged['_imdb_data'] = df_imdb.iloc[0].to_dict() if not df_imdb.empty else None self.done.emit(self._row, imdb, merged) except Exception as e: logger.error(f"_SingleRowFetchWorker: {e}", exc_info=True) self.done.emit(self._row, self._imdb, {}) finally: self.finished.emit() class _LoadWorker(QObject): """Carica OTT filtrato e aggancia i parquet in un unico passaggio.""" all_done = pyqtSignal(object, str, dict) # (DataFrame | None, last_date, fetch_cache) debug_done = pyqtSignal(object, dict) # (DataFrame | None, {step: seconds}) — solo in debug_mode progress = pyqtSignal(int) # 0-100 finished = pyqtSignal() def __init__(self, linker_db, mode: str, fetch_aggregati: bool = False, debug_mode: bool = False): super().__init__() self._db = linker_db self._mode = mode self._fetch_aggregati = fetch_aggregati self._debug_mode = debug_mode def run(self): import time from datetime import date as _date try: today = _date.today().isoformat() anno_min = int(today[:4]) - 5 self.progress.emit(5) t_pre0 = time.perf_counter() is_filtrato = self._mode.endswith('_filtrato') last = self._db.get_ott_ext_last_date() if is_filtrato else None cutoff = self._db.get_ott_ext_last_dates(3) if is_filtrato else None t_pre = time.perf_counter() - t_pre0 t0 = time.perf_counter() df = self._db.get_ott_joined_for_mode(self._mode, anno_min, cutoff) t_join = time.perf_counter() - t0 self.progress.emit(15) fetch_cache: dict = {} t_agg = None agg_err_msg = None splits = None run_agg = not self._debug_mode and not df.empty and ( self._mode.endswith('_filtrato') or self._fetch_aggregati) if run_agg: splits = None t1 = time.perf_counter() try: fetch_cache = self._fetch_aggregated(df) except Exception as agg_err: agg_err_msg = str(agg_err) logger.error(f"OTT aggregation fallita (non critica): {agg_err}", exc_info=True) finally: t_agg = time.perf_counter() - t1 self.progress.emit(100) if self._debug_mode: self.debug_done.emit(df if not df.empty else None, { 't_pre': t_pre, 't_join': t_join, 't_agg': t_agg, 'agg_err': agg_err_msg, 'splits': splits or {}, 'n_record': len(df), }) else: self.all_done.emit(df if not df.empty else None, last or '', fetch_cache) except Exception as e: logger.error(f"OTT _LoadWorker: {e}", exc_info=True) if self._debug_mode: self.debug_done.emit(None, {'error': str(e)}) else: self.all_done.emit(None, '', {}) finally: self.finished.emit() def _fetch_aggregated(self, df, splits: dict | None = None) -> dict: """Costruisce fetch_cache {imdb: merged_dict} con i dati parquet. Per _filtrato usa IN clause (pochi record). Per _completo usa scan full dei L3 + join in memoria (evita IN clause enormi). """ imdb_codes = [ s.strip() for s in df['imdb_id'].dropna() if str(s).strip().startswith('tt') ] if not imdb_codes: return {} unique_codes = list(set(imdb_codes)) is_completo = self._mode.endswith('_completo') if is_completo: return self._fetch_aggregated_full(unique_codes, splits=splits) else: return self._fetch_aggregated_filtered(unique_codes) def _fetch_aggregated_filtered(self, unique_codes: list) -> dict: """Aggregazione per _filtrato: pochi record, IN clause normale.""" df_rifer = self._db.get_rifer_by_imdb_codes(unique_codes) self.progress.emit(25) imdb_to_rifer: dict = {} if not df_rifer.empty: imdb_to_rifer = { str(r['IMDB_CODICE']): str(r['RIFER']) for r in df_rifer.to_dict('records') } rifers = list(set(imdb_to_rifer.values())) # movie_filtrato: escludi film già emessi su Mediaset if self._mode == 'movie_filtrato' and rifers: df_emfirst = self._db.get_emesso_both_first_by_rifers(rifers) rifers_emessi = set(df_emfirst['RIFER'].astype(str)) if not df_emfirst.empty else set() if rifers_emessi: rifers = [r for r in rifers if r not in rifers_emessi] unique_codes = [c for c in unique_codes if imdb_to_rifer.get(c) in set(rifers)] imdb_to_rifer = {k: v for k, v in imdb_to_rifer.items() if v in set(rifers)} df_custom = self._db.get_custom_by_rifers(rifers) if rifers else None self.progress.emit(35) df_diritti = self._db.get_diritti_full_by_rifers(rifers) if rifers else None self.progress.emit(50) df_emitted_last = self._db.get_emesso_both_last_by_rifers(rifers) if rifers else None self.progress.emit(67) if df_emitted_last is not None and not df_emitted_last.empty: df_emgen = df_emitted_last[df_emitted_last['tipo_rete'] == 'G'].drop(columns=['tipo_rete']) df_emtem = df_emitted_last[df_emitted_last['tipo_rete'] == 'T'].drop(columns=['tipo_rete']) else: df_emgen = df_emtem = None df_bo = self._db.get_boxoffice_by_rifers(rifers) if rifers else None self.progress.emit(82) df_slot = self._db.get_scelte_rete_by_rifers(rifers) if rifers else None self.progress.emit(90) df_imdb = self._db.get_imdb_data_by_codes(unique_codes) self.progress.emit(96) return _build_fetch_cache(unique_codes, imdb_to_rifer, df_custom, df_diritti, df_emgen, df_emtem, df_bo, df_slot, df_imdb) def _fetch_aggregated_full(self, unique_codes: list, splits: dict | None = None) -> dict: """Aggregazione per _completo: scan full L3 + join in memoria (evita IN clause enormi).""" import time def _t(key, fn): t0 = time.perf_counter() try: return fn() finally: if splits is not None: splits[key] = time.perf_counter() - t0 self.progress.emit(20) imdb_to_rifer_full = _t('imdb_map', self._db.get_all_imdb_rifer_map) unique_set = set(unique_codes) imdb_to_rifer = _t('imdb_filter', lambda: {k: v for k, v in imdb_to_rifer_full.items() if k in unique_set}) self.progress.emit(30) df_custom = _t('custom', self._db.get_all_custom) self.progress.emit(45) df_emitted_last = _t('emesso_agg', self._db.get_all_emesso_agg) self.progress.emit(65) if df_emitted_last is not None and not df_emitted_last.empty: df_emgen = _t('em_split_G', lambda: df_emitted_last[df_emitted_last['tipo_rete'] == 'G'].drop(columns=['tipo_rete'])) df_emtem = _t('em_split_T', lambda: df_emitted_last[df_emitted_last['tipo_rete'] == 'T'].drop(columns=['tipo_rete'])) else: df_emgen = df_emtem = None self.progress.emit(90) df_imdb = _t('imdb_data', lambda: self._db.get_imdb_data_by_codes(unique_codes)) self.progress.emit(96) result = _t('build_cache', lambda: _build_fetch_cache( unique_codes, imdb_to_rifer, df_custom, None, df_emgen, df_emtem, None, None, df_imdb)) return result def _build_fetch_cache(unique_codes, imdb_to_rifer, df_custom, df_diritti, df_emgen, df_emtem, df_bo, df_slot, df_imdb) -> dict: custom_map = _df_to_map(df_custom, 'RIFER') diritti_map = _df_to_list_map(df_diritti, 'RIFER') emgen_map = _df_to_map(df_emgen, 'RIFER') emtem_map = _df_to_map(df_emtem, 'RIFER') bo_map = _df_to_map(df_bo, 'RIFER') slot_map = _df_to_map(df_slot, 'RIFER') imdb_map = _df_to_map(df_imdb, 'IMDB_CODICE') fetch_cache: dict = {} for imdb in unique_codes: rifer = imdb_to_rifer.get(imdb) merged: dict = {'RIFER': rifer or ''} if rifer: merged.update(custom_map.get(rifer, {})) merged['_diritti'] = diritti_map.get(rifer, []) merged['_emgen'] = emgen_map.get(rifer) merged['_emtem'] = emtem_map.get(rifer) merged['_bo'] = bo_map.get(rifer) merged['_rete_slot'] = slot_map.get(rifer, {}).get('rete_slot', '') merged['_imdb_data'] = imdb_map.get(imdb) fetch_cache[imdb] = merged return fetch_cache def _iso_to_dmy(s: str) -> str: """Converte yyyy-mm-dd → gg/mm/aaaa per il display. Passthrough se formato sconosciuto.""" v = (s or '')[:10] if len(v) == 10 and v[4] == '-' and v[7] == '-': return f"{v[8:10]}/{v[5:7]}/{v[:4]}" return v def _df_to_map(df, key_col: str) -> dict: if df is None or df.empty or key_col not in df.columns: return {} df2 = df.drop_duplicates(subset=[key_col], keep='last') keys = df2[key_col].astype(str) vals = df2.drop(columns=[key_col]).to_dict('records') return dict(zip(keys, vals)) def _df_to_list_map(df, key_col: str) -> dict: if df is None or df.empty or key_col not in df.columns: return {} df = df.copy() df[key_col] = df[key_col].astype(str) result: dict = {} for rec in df.to_dict('records'): result.setdefault(rec[key_col], []).append(rec) return result # ── OttSheet ─────────────────────────────────────────────────────────────── class OttSheet(QWidget): app_type = 'ott' def __init__(self, linker_db, dev_mode: bool = False, ott_parquet_server_path: str = '', linker_server_path: str = '', parent=None): super().__init__(parent) self._db = linker_db self._dev_mode = dev_mode self._ott_parquet_server_path = ott_parquet_server_path self._linker_server_path = linker_server_path self._rows: list[dict] = [] self._pending_mode: str = 'movie_filtrato' self._val_ext_map: dict = {} self._annot_imdb_map: dict = {} self._annot_ott_map: dict = {} self._load_thread = None self._load_worker = None self._single_row_threads: set = set() self._user_sort_col: int = -1 # -1 = nessun sort utente self._user_sort_order = Qt.SortOrder.AscendingOrder self._setup_ui() def _ott_save(self, fn, *args, **kwargs): """Esegue una write OTT con clessidra; mostra warning se server non raggiunge in tempo.""" from PyQt6.QtWidgets import QApplication QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: result = fn(*args, **kwargs) finally: QApplication.restoreOverrideCursor() if self._db.server_con is not None and not self._db.last_server_write_ok: QMessageBox.warning( self, 'Rete lenta o assente', 'Il dato è stato salvato in locale ma non è stato possibile\n' 'raggiungere il server entro i tempi previsti.\n\n' 'Verificare la connessione di rete.' ) return result # ── UI ───────────────────────────────────────────────────────────────── def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self._build_toolbar()) self.table = QTableWidget(50, _N_COLS) self.table.setHorizontalHeader(_OttHeader(self.table)) self.table.setHorizontalHeaderLabels(_COLUMNS) self.table.setVerticalHeader(SelectableVHeader(self.table)) self.table.setFont(_CELL_FONT) self.table.setEditTriggers(QTableWidget.EditTrigger.DoubleClicked | QTableWidget.EditTrigger.AnyKeyPressed) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectItems) self.table.setAlternatingRowColors(False) self.table.setSortingEnabled(True) self.table.setWordWrap(False) self.table.setStyleSheet(""" QTableWidget { gridline-color: #C0C0C0; } QTableWidget::item { padding: 0px 3px; } QTableWidget::item:selected { background-color: #CCE8FF; color: #0078D7; } """) for col, w in _COL_WIDTHS.items(): self.table.setColumnWidth(col, w) for col in _VAL_COLS_OTT: self.table.setColumnHidden(col, True) # Celle editabili — sfondo piatto senza interferenza CSS _flat = FlatBgDelegate(self.table) for col in (COL_NOTA_ICR, COL_PROP_GIRO, COL_NETWORK_FREE, COL_FORN_DIR): self.table.setItemDelegateForColumn(col, _flat) # Delegate link — identici al foglio Linker self.table.setItemDelegateForColumn(COL_RIFER, RiferLinkDelegate(self.table, right=True)) self.table.setItemDelegateForColumn(COL_IMDB, FlatLinkDelegate(self.table, center=True)) self.table.setItemDelegateForColumn(COL_SUPERSERIE, RiferLinkDelegate(self.table)) self.table.setItemDelegateForColumn(COL_EPIS_ON, RiferLinkDelegate(self.table, center=True)) self.table.setItemDelegateForColumn(COL_DATA_EM, 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_ON, COL_PAESE, COL_DURATA, COL_VEG_FREE, COL_EPIS_OTT, COL_ANNO_OTT, COL_STAG, COL_VOTO, COL_VOTANTI): self.table.setItemDelegateForColumn(col, centered) _flat_bg = FlatBgDelegate(self.table) for col in _VAL_COLS_OTT: self.table.setItemDelegateForColumn(col, _flat_bg) self.table.cellDoubleClicked.connect(self._on_cell_double_clicked) self.table.itemChanged.connect(self._on_annotation_item_changed) 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) hh.sortIndicatorChanged.connect(self._on_sort_indicator_changed) vh = self.table.verticalHeader() vh.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) vh.customContextMenuRequested.connect(self._on_vheader_context_menu) self.table.selectionModel().selectionChanged.connect( lambda *_: (hh.viewport().update(), vh.viewport().update()) ) # Cursore a mano + tooltip su COL_PROP_GIRO self.table.viewport().setMouseTracking(True) self.table.viewport().installEventFilter(self) self.table.horizontalHeader().setToolTip('') # reset globale self.table.model().setHeaderData( COL_PROP_GIRO, Qt.Orientation.Horizontal, 'Doppio click per aprire le valutazioni reti', Qt.ItemDataRole.ToolTipRole, ) self.table.verticalHeader().setDefaultSectionSize(17) self.table.setRowCount(0) self.table.setRowCount(50) self._filter_bar = FilterBar(self.table, self) self._filter_bar.hide() self._filter_bar.filter_changed.connect(self._apply_filters) layout.addWidget(self._filter_bar) layout.addWidget(self.table) QShortcut(QKeySequence("Ctrl+F"), self).activated.connect(self._toggle_filter_bar) self._loading_label = QLabel("Elaborazione in corso…") self._loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._loading_label.setStyleSheet( "font-size: 14px; color: #555; background: white;" ) self._loading_label.hide() layout.addWidget(self._loading_label) _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 eventFilter(self, obj, event): if obj is self.table.viewport(): from PyQt6.QtCore import QEvent if event.type() == QEvent.Type.MouseMove: pos = event.pos() col = self.table.columnAt(pos.x()) if col == COL_PROP_GIRO: self.table.viewport().setCursor(Qt.CursorShape.PointingHandCursor) elif col == COL_IMDB: row_at = self.table.rowAt(pos.y()) imdb_item = self.table.item(row_at, COL_IMDB) if row_at >= 0 else None if not (imdb_item and imdb_item.text().strip()): self.table.viewport().setCursor(Qt.CursorShape.PointingHandCursor) else: self.table.viewport().unsetCursor() else: self.table.viewport().unsetCursor() elif event.type() == QEvent.Type.Leave: self.table.viewport().unsetCursor() return super().eventFilter(obj, event) def _on_cell_double_clicked(self, row: int, col: int): item = self.table.item(row, col) val = item.text().strip() if item else '' if col == COL_RIFER: if val: QDesktopServices.openUrl(QUrl(self._ONAIR_URL.format(rifer=val))) elif col == COL_IMDB: if val: QDesktopServices.openUrl(QUrl(self._IMDB_URL.format(code=val))) else: self._inserisci_imdb(row) elif col == COL_PROP_GIRO: self._open_valutazioni_dialog(row) elif col in (COL_NOTA_ICR, COL_NETWORK_FREE, COL_FORN_DIR): self._open_annotation_dialog(row, col) 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 _copy_selection(self): 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 = [((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 _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 _on_context_menu(self, pos): row = self.table.rowAt(pos.y()) if row < 0: return codice_item = self.table.item(row, COL_CODICE) if not (codice_item and codice_item.text().strip()): return menu = QMenu(self) act_copia = menu.addAction('Copia') act_copia.setEnabled(bool(self.table.selectedRanges())) menu.addSeparator() imdb_item = self.table.item(row, COL_IMDB) current = imdb_item.text().strip() if imdb_item else '' label = 'Modifica codice IMDB' if current else 'Inserisci codice IMDB' act_imdb = menu.addAction(label) action = menu.exec(self.table.viewport().mapToGlobal(pos)) if action == act_copia: self._copy_selection() elif action == act_imdb: self._inserisci_imdb(row) def _inserisci_imdb(self, row: int): from PyQt6.QtWidgets import QInputDialog codice_item = self.table.item(row, COL_CODICE) mediaset_id = codice_item.text().strip() if codice_item else '' provider_item = self.table.item(row, COL_PROVIDER) provider = provider_item.text().strip() if provider_item else '' if not mediaset_id or not provider: return imdb_item = self.table.item(row, COL_IMDB) current = imdb_item.text().strip() if imdb_item else '' code, ok = QInputDialog.getText( self, 'Codice IMDB', 'Codice IMDB (es. tt1234567)\nLasciare vuoto per rimuovere:', text=current, ) if not ok: return code = code.strip() if code and (not code.startswith('tt') or not code[2:].isdigit()): QMessageBox.warning(self, 'Codice IMDB', 'Formato non valido. Usare il formato tt1234567.') return self._ott_save(self._db.save_imdb_override, mediaset_id, provider, code or None) # Aggiorna cella it = self.table.item(row, COL_IMDB) if it: it.setText(code) else: it = QTableWidgetItem(code) it.setFlags(it.flags() & ~Qt.ItemFlag.ItemIsEditable) self.table.setItem(row, COL_IMDB, it) it.setBackground(_BG_EDITABLE) # Aggiorna il record in _rows per coerenza if row < len(self._rows): self._rows[row]['imdb_id'] = code or None self._rows[row]['imdb_override'] = code or None # Fetch parquet in background solo in modalità filtrato if code and self._pending_mode.endswith('_filtrato'): self._launch_single_row_fetch(row, code) def _launch_single_row_fetch(self, row: int, imdb: str): worker = _SingleRowFetchWorker(self._db, row, imdb) thread = QThread() worker.moveToThread(thread) thread.started.connect(worker.run) worker.done.connect(self._on_single_row_done) worker.finished.connect(thread.quit) worker.finished.connect(worker.deleteLater) thread.finished.connect(thread.deleteLater) thread.finished.connect(lambda: self._single_row_threads.discard(thread)) self._single_row_threads.add(thread) thread.start() def _on_single_row_done(self, row: int, imdb: str, merged: dict): if not merged: return self._on_fetch_done([(row, imdb)], {imdb: merged}) def _open_valutazioni_dialog(self, row: int): imdb_item = self.table.item(row, COL_IMDB) imdb = imdb_item.text().strip() if imdb_item else '' if not imdb: QMessageBox.warning(self, 'Valutazioni Reti', 'Codice IMDB non disponibile per questa riga.') return titolo_item = self.table.item(row, COL_TITOLO) titolo = titolo_item.text().strip() if titolo_item else '' ambito = 'OTT' existing = self._db.get_valutazioni_extra_gemma(imdb, ambito) from app.modules.linker.src.gui.valutazioni_extra_gemma_dialog import ValutazioniExtraGemmaDialog dlg = ValutazioniExtraGemmaDialog(imdb, titolo, ambito, existing, parent=self) result_code = dlg.exec() if result_code == 2: # delete self._ott_save(self._db.delete_valutazioni_extra_gemma, imdb, ambito) self._val_ext_map.pop(imdb, None) self._update_prop_giro_cell(row, imdb) elif result_code == QDialog.DialogCode.Accepted: data = dlg.result_data if data is not None: self._ott_save(self._db.save_valutazioni_extra_gemma, imdb, ambito, data) saved = self._db.get_valutazioni_extra_gemma(imdb, ambito) self._val_ext_map[imdb] = (saved.get('SintesiPropostaGiro') or '') if saved else '' self._update_prop_giro_cell(row, imdb) def _update_prop_giro_cell(self, row: int, imdb: str): summary = self._val_ext_map.get(imdb, '') item = self.table.item(row, COL_PROP_GIRO) if item: item.setText(summary) else: it = QTableWidgetItem(summary) it.setFlags(it.flags() & ~Qt.ItemFlag.ItemIsEditable) it.setBackground(_BG_EDITABLE) self.table.setItem(row, COL_PROP_GIRO, it) def _open_annotation_dialog(self, row: int, col: int): imdb_item = self.table.item(row, COL_IMDB) imdb = imdb_item.text().strip() if imdb_item else '' rifer_item = self.table.item(row, COL_RIFER) rifer = rifer_item.text().strip() if rifer_item else '' titolo_item = self.table.item(row, COL_TITOLO) titolo = titolo_item.text().strip() if titolo_item else '' if not imdb and not rifer: QMessageBox.warning(self, 'Annotazioni', 'Nessuna chiave disponibile (IMDB / RIFER).') return _col_meta = { COL_NOTA_ICR: ('Nota ICR', 'nota_icr'), COL_NETWORK_FREE: ('Network Free', 'network_free'), COL_FORN_DIR: ('Fornitore Diritti', 'fornitore_diritti'), } label, field = _col_meta[col] existing = self._db.get_product_annotation( rifer=int(rifer) if rifer and rifer.isdigit() else None, imdb_code=imdb or None ) or {} current_val = existing.get(field) or '' text, ok = QInputDialog.getText( self, f'{label} — {titolo}', label + ':', text=current_val ) if not ok: return new_val = text.strip() or None # Costruisce il dict aggiornato preservando gli altri campi updated = { 'nota_icr': existing.get('nota_icr'), 'network_free': existing.get('network_free'), 'fornitore_diritti': existing.get('fornitore_diritti'), } updated[field] = new_val saved = self._ott_save( self._db.save_product_annotation, rifer=int(rifer) if rifer and rifer.isdigit() else None, imdb_code=imdb or None, **updated ) if not saved: QMessageBox.warning(self, 'Annotazioni', 'Errore nel salvataggio.') return if imdb: self._annot_imdb_map[imdb] = updated self.table.blockSignals(True) it = self.table.item(row, col) if it: it.setText(new_val or '') else: new_it = QTableWidgetItem(new_val or '') new_it.setFlags(new_it.flags() | Qt.ItemFlag.ItemIsEditable) self.table.setItem(row, col, new_it) self.table.blockSignals(False) _ANNOT_FIELD_MAP = { COL_NOTA_ICR: 'nota_icr', COL_NETWORK_FREE: 'network_free', COL_FORN_DIR: 'fornitore_diritti', } def _on_annotation_item_changed(self, item): """Saves inline-edited annotation cells (NOTA ICR / NETWORK FREE / FORN DIR) to SQLite.""" col = item.column() if col not in self._ANNOT_FIELD_MAP: return row = item.row() if row >= len(self._rows): return rec = self._rows[row] imdb_id = str(rec.get('imdb_id', '') or '').strip() mediaset_id = str(rec.get('mediaset_id', '') or '').strip() if not imdb_id and not mediaset_id: return field = self._ANNOT_FIELD_MAP[col] new_val = item.text().strip() or None if imdb_id: ann = dict(self._annot_imdb_map.get(imdb_id, {})) if ann.get(field) == new_val: return ann[field] = new_val self._db.save_product_annotation( rifer=None, imdb_code=imdb_id, nota_icr=ann.get('nota_icr'), network_free=ann.get('network_free'), fornitore_diritti=ann.get('fornitore_diritti'), ) self._annot_imdb_map[imdb_id] = ann else: ann = dict(self._annot_ott_map.get(mediaset_id, {})) if ann.get(field) == new_val: return ann[field] = new_val self._db.save_product_annotation( rifer=None, imdb_code=None, ott_mediaset_id=mediaset_id, nota_icr=ann.get('nota_icr'), network_free=ann.get('network_free'), fornitore_diritti=ann.get('fornitore_diritti'), ) self._annot_ott_map[mediaset_id] = ann def _esporta_sheet(self): import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter visible_cols = [c for c in range(_N_COLS) if not self.table.isColumnHidden(c)] has_data = any( not self.table.isRowHidden(r) and any(self.table.item(r, c) and self.table.item(r, c).text() for c in visible_cols) for r in range(self.table.rowCount()) ) if not has_data: QMessageBox.information(self, 'Esporta', 'Nessun dato da esportare.') return path, _ = QFileDialog.getSaveFileName( self, 'Esporta griglia OTT', 'ott_export.xlsx', 'Excel (*.xlsx)' ) if not path: return def _hdr_color(col_idx: int) -> str: if col_idx in (COL_DATA_IMP, COL_SEGNALAZ): return 'AAFF00' if col_idx in _OTT_COLS: return '9DC3E6' if col_idx in _ICR_COLS or col_idx == COL_IMDB: return '00E5FF' if col_idx in _IMP_COLS: return 'D9D9D9' if col_idx in _DIRITTI_COLS: return '90EE90' if col_idx in _EMGEN_COLS: return 'FFB347' if col_idx in _EMTEM_COLS: return 'C39BD3' if col_idx in _ONAIR_COLS: return 'FFB347' if col_idx in _BO_COLS: return 'FFFF00' if col_idx in _VAL_COLS_OTT: return 'FFF59D' return 'CCCCCC' _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 = 'OTT' ws.row_dimensions[1].height = round(33 * 0.75, 2) for ci, col_idx in enumerate(visible_cols, start=1): h_item = self.table.horizontalHeaderItem(col_idx) label = (h_item.text() if h_item else '').replace('\n', '\n') cell = ws.cell(row=1, column=ci, value=label) cell.font = _hdr_font cell.fill = PatternFill('solid', fgColor=_hdr_color(col_idx)) cell.alignment = _hdr_align cell.border = _border px = self.table.columnWidth(col_idx) ws.column_dimensions[get_column_letter(ci)].width = max(4.0, round(px / 7, 2)) _row_h = round(17 * 0.75, 2) excel_row = 1 for r in range(self.table.rowCount()): if self.table.isRowHidden(r): 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(r, col_idx) val = item.text() 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) ws.freeze_panes = 'A2' ws.auto_filter.ref = ws.dimensions try: wb.save(path) except Exception as e: QMessageBox.critical(self, 'Errore esportazione', str(e)) def _build_toolbar(self) -> QWidget: bar = QWidget() bar.setFixedHeight(32) bar.setStyleSheet(f"background-color: {_TOOLBAR_COLOR};") h = QHBoxLayout(bar) h.setContentsMargins(6, 2, 6, 2) h.setSpacing(6) # ≡ hamburger menu = QMenu(bar) act_importa = QAction('Importa', bar) act_importa.triggered.connect(self._carica_file) menu.addAction(act_importa) act_esporta = QAction('Esporta', bar) act_esporta.triggered.connect(self._esporta_sheet) menu.addAction(act_esporta) menu.addSeparator() self._act_filtro_col = QAction('Filtro colonne (Ctrl+F)', bar) 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', bar) self._act_valutazioni.triggered.connect(self._toggle_valutazioni) menu.addAction(self._act_valutazioni) self._val_shown = False btn_menu = QToolButton(bar) btn_menu.setText('≡') btn_menu.setFont(QFont('Calibri', 11)) btn_menu.setFixedHeight(26) btn_menu.setFixedWidth(30) btn_menu.setStyleSheet( "QToolButton { background-color: #2E74B5; color: white;" " border: 1px solid #1F4E79; border-radius: 3px; padding: 0 4px; }" "QToolButton:hover { background-color: #1F5C9A; }" "QToolButton::menu-indicator { width: 0; }" ) btn_menu.setMenu(menu) btn_menu.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) h.addWidget(btn_menu) h.addSpacing(6) # Split button Movie (verde) menu_movie = QMenu(bar) act_movie_filtrato = QAction('Filtrato', bar) act_movie_filtrato.triggered.connect(lambda: self._apply_filter('movie_filtrato')) act_movie_completo = QAction('Elenco completo', bar) act_movie_completo.triggered.connect(lambda: self._apply_filter('movie_completo')) menu_movie.addAction(act_movie_filtrato) menu_movie.addAction(act_movie_completo) self._btn_movie = QToolButton(bar) self._btn_movie.setText('🎬 Movie') self._btn_movie.setFont(QFont('Calibri', 8, QFont.Weight.Bold)) self._btn_movie.setFixedHeight(26) self._btn_movie.setMinimumWidth(100) self._btn_movie.setCheckable(True) self._btn_movie.setToolTip( "Clic: carica Movie filtrati\n" "Menu ▼: scegli filtro" ) self._btn_movie.setStyleSheet( "QToolButton { background: #70AD47; color: white;" " border: 1px solid #507E32; border-radius: 3px; padding: 0 10px; }" "QToolButton:hover { background: #548235; }" "QToolButton:checked { background: #375623; border: 2px solid #FFFF66;" " color: #FFFF99; }" "QToolButton::menu-button { width: 18px; border-left: 1px solid #507E32; }" "QToolButton::menu-button:hover { background: #548235; }" ) self._btn_movie.setMenu(menu_movie) self._btn_movie.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) self._btn_movie.clicked.connect(lambda: self._apply_filter('movie_filtrato')) h.addWidget(self._btn_movie) h.addSpacing(4) # Split button Show (arancione) menu_show = QMenu(bar) act_show_filtrato = QAction('Filtrato', bar) act_show_filtrato.triggered.connect(lambda: self._apply_filter('show_filtrato')) act_show_completo = QAction('Elenco completo', bar) act_show_completo.triggered.connect(lambda: self._apply_filter('show_completo')) menu_show.addAction(act_show_filtrato) menu_show.addAction(act_show_completo) self._btn_show = QToolButton(bar) self._btn_show.setText('📺 Show') self._btn_show.setFont(QFont('Calibri', 8, QFont.Weight.Bold)) self._btn_show.setFixedHeight(26) self._btn_show.setMinimumWidth(100) self._btn_show.setCheckable(True) self._btn_show.setToolTip( "Clic: carica Show filtrati\n" "Menu ▼: scegli filtro" ) self._btn_show.setStyleSheet( "QToolButton { background: #ED7D31; color: white;" " border: 1px solid #C55A11; border-radius: 3px; padding: 0 10px; }" "QToolButton:hover { background: #C55A11; }" "QToolButton:checked { background: #7B3000; border: 2px solid #FFFF66;" " color: #FFFF99; }" "QToolButton::menu-button { width: 18px; border-left: 1px solid #C55A11; }" "QToolButton::menu-button:hover { background: #C55A11; }" ) self._btn_show.setMenu(menu_show) self._btn_show.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) self._btn_show.clicked.connect(lambda: self._apply_filter('show_filtrato')) h.addWidget(self._btn_show) h.addSpacing(10) self._lbl_batch = QLabel("") self._lbl_batch.setStyleSheet("color: #1F4E79; font-style: italic;") h.addWidget(self._lbl_batch) h.addStretch() self._lbl_count = QLabel("0 titoli") h.addWidget(self._lbl_count) return bar # ── Caricamento / filtro ──────────────────────────────────────────────── def _load_filtered(self, mode: str, fetch_aggregati: bool = False, debug_mode: bool = False): """Avvia caricamento filtrato + aggancio parquet in background.""" import time if self._load_thread is not None and self._load_thread.isRunning(): return self._pending_mode = mode self._rows = [] self._lbl_batch.setText("") self._lbl_count.setText("") self.table.hide() self._loading_label.show() if debug_mode: self._debug_t_start = time.perf_counter() self._load_worker = _LoadWorker( self._db, mode, fetch_aggregati=fetch_aggregati, debug_mode=debug_mode, ) self._load_thread = QThread() self._load_worker.moveToThread(self._load_thread) self._load_thread.started.connect(self._load_worker.run) self._load_worker.progress.connect( lambda pct: self._loading_label.setText(f"Elaborazione in corso… {pct}%") ) self._load_worker.all_done.connect(self._on_all_done) self._load_worker.debug_done.connect(self._on_debug_done) self._load_worker.finished.connect(self._load_thread.quit) self._load_worker.finished.connect(self._load_worker.deleteLater) self._load_thread.finished.connect(self._load_thread.deleteLater) self._load_thread.finished.connect(self._clear_load_thread) self._load_thread.start() def _clear_load_thread(self): self._load_thread = None self._load_worker = None def _on_all_done(self, df, last_date: str, fetch_cache: dict): self._rows = df.to_dict('records') if df is not None and not df.empty else [] self._val_ext_map = self._db.get_valutazioni_extra_gemma_map() self._annot_imdb_map = self._db.get_product_annotations_imdb_map() self._annot_ott_map = self._db.get_product_annotations_ott_map() lbl = f"Batch: {last_date[:10]}" if last_date and last_date[:4].isdigit() else "" self._lbl_batch.setText(lbl) n = len(self._rows) self._loading_label.hide() self.table.show() if not n: self._lbl_count.setText("Nessun dato — importa un file OTT") self._populate_table() return self._lbl_count.setText(f"{n} titol{'o' if n == 1 else 'i'}") # aggiorna testo pulsante attivo col contatore suffix = ' — Completo' if self._pending_mode.endswith('_completo') else '' if self._pending_mode.startswith('movie'): self._btn_movie.setText(f'🎬 Movie ({n}){suffix}') elif self._pending_mode.startswith('show'): self._btn_show.setText(f'📺 Show ({n}){suffix}') self._populate_table() if fetch_cache: imdb_pairs = [ (row_idx, rec.get('imdb_id', '').strip()) for row_idx, rec in enumerate(self._rows) if rec.get('imdb_id', '').strip().startswith('tt') ] self._on_fetch_done(imdb_pairs, fetch_cache) def _on_debug_done(self, df, timings: dict): import time self._loading_label.hide() self.table.show() if 'error' in timings: QMessageBox.critical(self, "Debug OTT", f"Errore: {timings['error']}") return t_d0 = time.perf_counter() self._rows = df.to_dict('records') if df is not None else [] t_todict = time.perf_counter() - t_d0 t0 = time.perf_counter() self._populate_table() self.table.viewport().repaint() # sincrono: blocca fino al paint reale t_render = time.perf_counter() - t0 t_wall = time.perf_counter() - self._debug_t_start t_pre = timings.get('t_pre', 0) t_join = timings.get('t_join', 0) t_agg = timings.get('t_agg') agg_err = timings.get('agg_err') splits = timings.get('splits', {}) n = timings.get('n_record', 0) lines = [ f"Record restituiti: {n}", f"Pre-query (DB): {t_pre:.2f}s", f"SQL JOIN: {t_join:.2f}s", ] if t_agg is not None: err_tag = ' [ERR]' if agg_err else '' lines.append(f"Aggregazione{err_tag}: {t_agg:.2f}s") for k, v in splits.items(): lines.append(f" · {k:<16} {v:.2f}s") lines.append(f"to_dict: {t_todict:.2f}s") lines.append(f"Rendering + paint: {t_render:.2f}s") lines.append(f"─────────────────────────") lines.append(f"Totale wall-clock: {t_wall:.2f}s") if agg_err: lines.append(f"\nAgg error: {agg_err}") QMessageBox.information(self, "Debug OTT — tempi", "\n".join(lines)) def _update_btn_states(self, mode: str): """Aggiorna stato visivo e testo dei pulsanti Movie/Show.""" is_movie = mode.startswith('movie') is_show = mode.startswith('show') self._btn_movie.setChecked(is_movie) self._btn_show.setChecked(is_show) suffix_movie = ' — Completo' if mode == 'movie_completo' else '' suffix_show = ' — Completo' if mode == 'show_completo' else '' self._btn_movie.setText(f'🎬 Movie{suffix_movie}') self._btn_show.setText(f'📺 Show{suffix_show}') def _apply_filter(self, mode: str): """Cambia filtro — ricarica sempre dal DB con la nuova query.""" self._update_btn_states(mode) self._val_shown = False self._act_valutazioni.setText('Mostra valutazioni') self._user_sort_col = -1 is_completo = mode.endswith('_completo') # Valutazioni: sempre nascoste finché non richieste for col in _VAL_COLS_OTT: self.table.setColumnHidden(col, True) load_aggregati = False debug_mode = False if is_completo: ret = QMessageBox.information( self, "Elenco completo", "Carica tutti i titoli SVOD con dati aggregati (ONAIR, Emesso, BO).\n\n" "L'elaborazione potrebbe richiedere alcuni secondi.", QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, ) if ret != QMessageBox.StandardButton.Ok: return load_aggregati = True # Nascondi colonne parquet se completo senza aggregati # COL_IMDB escluso — arriva da OTT direttamente _parquet_cols = [c for c in _ICR_COLS + _IMP_COLS + _ONAIR_COLS + _DIRITTI_COLS + _EMGEN_COLS + _EMTEM_COLS + _BO_COLS if c != COL_IMDB] hide_parquet = is_completo and not load_aggregati for col in _parquet_cols: self.table.setColumnHidden(col, hide_parquet) self._load_filtered(mode, fetch_aggregati=load_aggregati, debug_mode=debug_mode) def _populate_table(self): self.table.blockSignals(True) self.table.setUpdatesEnabled(False) self.table.verticalHeader().setDefaultSectionSize(17) self.table.setRowCount(0) self.table.setRowCount(len(self._rows)) visible = {c for c in range(_N_COLS) if not self.table.isColumnHidden(c)} for row_idx, rec in enumerate(self._rows): row_bg = _BG_ROW_ODD if row_idx % 2 else _BG_ROW_EVEN def _put(col, val, editable=False, bg=None): if col not in visible: return s = '' if val in (None, 'None', 'nan') else str(val).strip() it = DateSortItem(s) if not editable: it.setFlags(it.flags() & ~Qt.ItemFlag.ItemIsEditable) it.setBackground(bg if bg is not None else (_BG_EDITABLE if editable else row_bg)) self.table.setItem(row_idx, col, it) # OTT group _put(COL_DATA_IMP, _iso_to_dmy(rec.get('data_creazione', ''))) _put(COL_SEGNALAZ, rec.get('segnalazioni_automatiche', '') or '') _put(COL_TITOLO, rec.get('titolo')) _put(COL_PROVIDER, rec.get('provider')) _put(COL_TIPO_FIN, rec.get('tipo_finestra')) _put(COL_MONET, rec.get('monetization_type')) _put(COL_DATA_INI, _fmt_iso_date(rec.get('data_inizio'))) _put(COL_DATA_FINE, _fmt_iso_date(rec.get('data_fine'))) _put(COL_TIPO, rec.get('tipo')) _put(COL_ANNO_OTT, rec.get('anno')) _put(COL_STAG, rec.get('nr_stagioni')) _put(COL_EPIS_OTT, rec.get('tot_episodi')) _put(COL_REGISTA_OTT, rec.get('regista')) _put(COL_GENERI_OTT, rec.get('generi')) _put(COL_PAESI_OTT, rec.get('paesi')) _put(COL_CODICE, rec.get('mediaset_id')) # ICR group imdb_id = str(rec.get('imdb_id', '') or '').strip() mediaset_id = str(rec.get('mediaset_id', '') or '').strip() if imdb_id: _ann = self._annot_imdb_map.get(imdb_id, {}) else: _ann = self._annot_ott_map.get(mediaset_id, {}) _put(COL_NOTA_ICR, _ann.get('nota_icr') or '', editable=True) _put(COL_PROP_GIRO, self._val_ext_map.get(imdb_id, ''), bg=_BG_EDITABLE) _put(COL_NETWORK_FREE, _ann.get('network_free') or '', editable=True) _put(COL_FORN_DIR, _ann.get('fornitore_diritti') or '', editable=True) # IMP group — sempre vuote for c in _IMP_COLS: _put(c, '') # ONAIR group — IMDB da Excel (già noto), resto da parquet dopo aggancia _put(COL_IMDB, rec.get('imdb_id'), bg=_BG_EDITABLE) for c in [COL_RIFER] + list(range(COL_TIPOL, COL_SEP + 1)): _put(c, '') # Diritti, Emesso, BO — vuoti fino ad aggancia for c in _DIRITTI_COLS + _EMGEN_COLS + _EMTEM_COLS + _BO_COLS: _put(c, '') # Valutazioni — sempre vuote (populate da show_valutazioni) for c in _VAL_COLS_OTT: _put(c, '') self.table.setUpdatesEnabled(True) self.table.blockSignals(False) # ── Valutazioni Gemma ──────────────────────────────────────────────────── def _toggle_valutazioni(self): if self._val_shown: self.hide_valutazioni() self._val_shown = False self._act_valutazioni.setText('Mostra valutazioni') else: self._importa_valutazioni() def _importa_valutazioni(self): imdb_codes = [] for row in range(self.table.rowCount()): item = self.table.item(row, COL_IMDB) if item: val = item.text().strip() if val and val.lower().startswith('tt'): imdb_codes.append(val) if not imdb_codes: QMessageBox.information(self, "Valutazioni", "Nessun codice IMDB nel foglio.") return try: QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) df = self._db.get_gemma_valutazioni_by_imdb_codes(list(set(imdb_codes))) except Exception as e: logger.error(f"OTT importa_valutazioni: {e}", exc_info=True) QMessageBox.critical(self, "Errore", f"Errore caricamento Gemma:\n{e}") return finally: QApplication.restoreOverrideCursor() gemma_lookup = {} if not df.empty: for _, row in df.iterrows(): key = str(row.get('A_COD_IMDB', '') or '').strip() if key: gemma_lookup[key] = row.to_dict() self.show_valutazioni(gemma_lookup) self._val_shown = True self._act_valutazioni.setText('Nascondi valutazioni') found = len(gemma_lookup) logger.info(f"OTT valutazioni: {found} codici trovati su {len(set(imdb_codes))}.") def _move_val_cols_after_codice(self): """Sposta visivamente le colonne VAL (0-15) dopo COL_CODICE usando moveSection. Le costanti logiche rimangono invariate — solo l'ordine visivo cambia. Precondizione: ordine visivo == logico (cioè dopo reset o al primo avvio).""" hdr = self.table.horizontalHeader() n_val = len(_VAL_COLS_OTT) # 16 # Muove col[n_val+i] (sempre a visual n_val+i) in visual i # → le colonne data 16..31 vanno in visual 0..15 # → le VAL cols 0..15 scivolano in visual 16..31 for i in range(n_val): hdr.moveSection(n_val + i, i) def _reset_val_cols_to_front(self): """Riporta l'ordine visivo uguale a quello logico (VAL cols di nuovo in testa).""" hdr = self.table.horizontalHeader() for logical in range(self.table.columnCount()): vis = hdr.visualIndex(logical) if vis != logical: hdr.moveSection(vis, logical) def _on_sort_indicator_changed(self, logical_index: int, order): self._user_sort_col = logical_index self._user_sort_order = order def _restore_sort(self, sort_col, sort_order): hdr = self.table.horizontalHeader() if sort_col >= 0: hdr.setSortIndicator(sort_col, sort_order) else: hdr.setSortIndicator(-1, Qt.SortOrder.AscendingOrder) self.table.setSortingEnabled(True) if sort_col >= 0: self.table.sortItems(sort_col, sort_order) def show_valutazioni(self, gemma_lookup: dict): sort_col, sort_order = self._user_sort_col, self._user_sort_order self.table.setSortingEnabled(False) for col in _VAL_COLS_OTT: self.table.setColumnHidden(col, False) self._move_val_cols_after_codice() extra_map = self._db.get_valutazioni_extra_gemma_full_map() filler = GridFiller(self.table, _COL_INDICES) self.table.blockSignals(True) self.table.setUpdatesEnabled(False) try: 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 '' gem_row = gemma_lookup.get(imdb) if imdb else None filler.fill_valutazioni(row_idx, gem_row) if imdb and imdb in extra_map: extra = extra_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: filler.fill_proposta_giro(row_idx, extra) finally: self.table.blockSignals(False) self.table.setUpdatesEnabled(True) self.table.viewport().repaint() self._restore_sort(sort_col, sort_order) def hide_valutazioni(self): sort_col, sort_order = self._user_sort_col, self._user_sort_order self.table.setSortingEnabled(False) 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_OTT: 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_OTT: self.table.setColumnHidden(col, True) self._reset_val_cols_to_front() self._restore_sort(sort_col, sort_order) # ── Carica file Excel ──────────────────────────────────────────────────── def _carica_file(self): if self._dev_mode: resp = QMessageBox.warning( self, "Importa OTT — Modalità DEV", "Sei in modalità DEV, ma questa operazione agisce sul server.\n\n" "Verrà eseguito:\n" " 1. Pull di linker.db dal server → locale\n" " 2. Scrittura di ott.parquet con il nuovo file\n" " 3. Push di linker.db + ott.parquet sul server\n\n" "Continuare?", QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, ) if resp != QMessageBox.StandardButton.Ok: return path, _ = QFileDialog.getOpenFileName( self, "Seleziona file OTT", "", "Excel (*.xlsx *.xls);;Tutti i file (*.*)" ) if not path: return try: import shutil, pandas as _pd from datetime import date as _d QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) # Step 1 (DEV): pull linker.db dal server per avere OTT_EXT aggiornato if self._dev_mode and self._linker_server_path: import sqlite3 as _sqlite3 try: src = _sqlite3.connect(self._linker_server_path) src.backup(self._db.sqlite_con) src.close() logger.info("_carica_file: pull linker.db dal server completato") except Exception as pull_err: logger.warning(f"_carica_file: pull linker.db fallito: {pull_err}") records = self._parse_excel(path) if not records: QApplication.restoreOverrideCursor() QMessageBox.warning(self, "File OTT", "Nessun record trovato nel file.") return data_creazione = _d.today().isoformat() # Scrivi ott.parquet (replace-all) ott_path = self._db.catalog.ott_parquet_path ott_path.parent.mkdir(parents=True, exist_ok=True) _pd.DataFrame(records).to_parquet(str(ott_path), index=False) saved = len(records) # Aggiorna OTT_EXT in SQLite + ricarica cache DuckDB # (bulk: server write fire-and-forget, nessun check rete) self._db.save_ott_ext_batch(records, data_creazione) self._db.recalcola_segnalazioni_smontato(records) self._db.catalog.refresh_ott_ext(self._db.sqlite_con) QApplication.restoreOverrideCursor() # Push ott.parquet sul server in background op = self._ott_parquet_server_path if op and ott_path.exists(): def _push_ott(): try: shutil.copy2(str(ott_path), op) logger.info(f"ott.parquet pushato su {op}") except Exception as e: logger.warning(f"Push ott.parquet fallito: {e}") threading.Thread(target=_push_ott, daemon=True).start() except Exception as e: QApplication.restoreOverrideCursor() QMessageBox.critical(self, "Errore caricamento", str(e)) logger.error(f"OTT carica file: {e}", exc_info=True) return # Reset e ricarica movie filtrato di default self._pending_mode = 'movie_filtrato' self._lbl_batch.setText(f"{Path(path).name} — {saved} record caricati") self._load_filtered(self._pending_mode) def _parse_excel(self, path: str) -> list[dict]: import openpyxl from datetime import date today = date.today().isoformat() wb = openpyxl.load_workbook(path, read_only=True, data_only=True) ws = wb.worksheets[0] rows_iter = ws.iter_rows(values_only=True) # intestazioni dalla prima riga headers = [str(c).strip().lower() if c is not None else '' for c in next(rows_iter)] records = [] for row_vals in rows_iter: row = dict(zip(headers, row_vals)) rec: dict = {} for excel_key, internal_key in _EXCEL_COL_MAP.items(): val = row.get(excel_key) # normalizza a stringa if val is None: val = '' elif hasattr(val, 'strftime'): # datetime/date da openpyxl val = val.strftime('%Y-%m-%d') else: val = str(val).strip() if val in ('-', 'nan', 'NaN', 'None', 'none'): val = '' if internal_key in ('data_inizio', 'data_fine') and val: val = _parse_date_str(val) rec[internal_key] = val data_fine = rec.get('data_fine', '') rec['smontato'] = 1 if data_fine and data_fine < today else 0 records.append(rec) wb.close() return records def _on_fetch_done(self, imdb_rows: list, data: dict): filler = GridFiller(self.table, _COL_INDICES) for row_idx, imdb in imdb_rows: merged = data.get(imdb) if not merged: continue rifer = merged.get('RIFER', '') # IMP — VOTO, VOTANTI (sempre da imdb_full se disponibile) imdb_data = merged.get('_imdb_data') if imdb_data: _set(self.table, row_idx, COL_VOTO, gf_fmt_num(imdb_data.get('VOTO'), 1)) _set(self.table, row_idx, COL_VOTANTI, gf_fmt_num(imdb_data.get('VOTANTI'))) # ONAIR — livello 1: OnAir catalog (ha RIFER, EPIS, SUPERSERIE, VEG_FREE, ecc.) # livello 2: imdb_full fallback per titoli non in catalogo if rifer: _set(self.table, row_idx, COL_RIFER, rifer) _set(self.table, row_idx, COL_TIPOL, gf_safe(merged.get('TIPOL'))) _set(self.table, row_idx, COL_TI, gf_safe(merged.get('TI'))) _set(self.table, row_idx, COL_TO, gf_safe(merged.get('TO'))) _set(self.table, row_idx, COL_ANNO_ON, gf_fmt_num(merged.get('ANNO'))) _set(self.table, row_idx, COL_PAESE, gf_safe(merged.get('PAESE'))) _set(self.table, row_idx, COL_GENERE, gf_safe(merged.get('GENERE1'))) _set(self.table, row_idx, COL_EPIS_ON, gf_fmt_num(merged.get('EPIS'))) _set(self.table, row_idx, COL_DURATA, gf_fmt_num(merged.get('DUR'))) _set(self.table, row_idx, COL_SUPERSERIE,gf_safe(merged.get('SUPERSERIE'))) _set(self.table, row_idx, COL_VEG_FREE, gf_safe(merged.get('VEG_FREE'))) regista = ' '.join(filter(None, [ gf_safe(merged.get('REGISTA_COGN')), gf_safe(merged.get('REGISTA_NOME')), ])) _set(self.table, row_idx, COL_REGISTA_ON, regista) attori = ', '.join(filter(None, [ gf_safe(merged.get('ATTORE1')), gf_safe(merged.get('ATTORE2')), gf_safe(merged.get('ATTORE3')), ])) _set(self.table, row_idx, COL_ATTORI, attori) elif imdb_data: # Fallback imdb_full — campi disponibili: TI, TO, TIPOL, ANNO, GENERE, DUR, REGISTA, CAST _set(self.table, row_idx, COL_TIPOL, gf_safe(imdb_data.get('TIPOL'))) _set(self.table, row_idx, COL_TI, gf_safe(imdb_data.get('TI'))) _set(self.table, row_idx, COL_TO, gf_safe(imdb_data.get('TO'))) _set(self.table, row_idx, COL_ANNO_ON, gf_fmt_num(imdb_data.get('ANNO'))) _set(self.table, row_idx, COL_GENERE, gf_safe(imdb_data.get('GENERE'))) _set(self.table, row_idx, COL_DURATA, gf_fmt_num(imdb_data.get('DUR'))) _set(self.table, row_idx, COL_REGISTA_ON,gf_safe(imdb_data.get('REGISTA'))) _set(self.table, row_idx, COL_ATTORI, gf_safe(imdb_data.get('CAST'))) # Diritti filler.fill_diritti(row_idx, merged.get('_diritti') or []) filler.fill_rete_slot(row_idx, merged.get('_rete_slot') or '') # Emesso filler.fill_emesso(row_idx, merged.get('_emgen')) filler.fill_tematiche(row_idx, merged.get('_emtem')) # Box Office filler.fill_boxoffice(row_idx, merged.get('_bo')) # ── Helpers ──────────────────────────────────────────────────────────────── def _set(table: QTableWidget, row: int, col: int, text: str, bg: QColor = None): s = str(text) if text else '' item = table.item(row, col) if item is not None: item.setText(s) if bg is not None: item.setBackground(bg) else: it = QTableWidgetItem(s) it.setFlags(it.flags() & ~Qt.ItemFlag.ItemIsEditable) if bg is not None: it.setBackground(bg) table.setItem(row, col, it) def _fmt_iso_date(val) -> str: if not val: return '' s = str(val).strip() if len(s) >= 10 and s[4] == '-': return f"{s[8:10]}/{s[5:7]}/{s[0:4]}" return s def _parse_date_str(val: str) -> str: val = val.strip() if not val: return '' if len(val) >= 10 and val[4] == '-': return val[:10] if len(val) == 10 and val[2] == '/' and val[5] == '/': return f"{val[6:10]}-{val[3:5]}-{val[0:2]}" try: import pandas as pd return pd.to_datetime(val, dayfirst=True).strftime('%Y-%m-%d') except Exception: return val