""" Tab "Diritti in Scadenza" per la finestra principale Linker. Griglia read-only con dati anagrafici + diritti + emesso + box office + valutazioni Gemma. Struttura colonne AS IS rispetto alla modalità standard Linker. """ import logging from datetime import date as date_type import pandas as pd from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QAction, QColor, QFont, QKeySequence, QPainter, QShortcut from PyQt6.QtWidgets import ( QApplication, QCheckBox, QDialog, QDialogButtonBox, QFileDialog, QFrame, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from app.modules.linker.src.database.diritti_db import DiritiDatabase, VEG_COL_CANDIDATES from app.modules.linker.src.database.linker_renderer import LinkerRenderer from app.modules.linker.src.gui.filter_bar import FilterBar from app.modules.linker.src.gui.grid_filler import ( GridFiller, gf_safe, gf_to_date, paint_std_header_section, ) logger = logging.getLogger(__name__) # ------------------------------------------------------------------ # # Parser data decorrenza fornitore # # ------------------------------------------------------------------ # _MESI_IT = { 'gen': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'mag': 5, 'giu': 6, 'lug': 7, 'ago': 8, 'set': 9, 'ott': 10, 'nov': 11, 'dic': 12, } def _parse_decorrenza(s: str): try: parts = str(s).strip().lower().split('-') if len(parts) != 3: return None day_s, month_s, year_s = parts month = _MESI_IT.get(month_s) if not month: return None day = int(day_s) year = int(year_s) if year < 100: year += 2000 return date_type(year, month, day) except (ValueError, TypeError): return None def _safe_str(val) -> str: try: if pd.isna(val): return '' except (TypeError, ValueError): pass if hasattr(val, 'strftime'): return val.strftime('%d/%m/%Y') s = str(val).strip() if s.endswith('.0'): s = s[:-2] return s def _safe_rifer(val) -> str: s = _safe_str(val) return s[:-2] if s.endswith('.0') else s # ------------------------------------------------------------------ # # Item con ordinamento per data # # ------------------------------------------------------------------ # DATE_SORT_COLUMNS = { 'DECOR', 'SCAD', 'DECOR_FORN', 'DECOR_FUTURA', 'SCAD_FUTURA', 'DATA', 'DATA.T', 'DATA VAL.', 'DECORRENZA', 'DEBUTTO', } def _to_iso_sort_key(val) -> str: try: if pd.isna(val): return '' except (TypeError, ValueError): pass if hasattr(val, 'strftime'): return val.strftime('%Y-%m-%d') parsed = _parse_decorrenza(str(val)) if parsed: return parsed.strftime('%Y-%m-%d') return '' class DateSortItem(QTableWidgetItem): def __lt__(self, other): my_key = self.data(Qt.ItemDataRole.UserRole) or '' other_key = (other.data(Qt.ItemDataRole.UserRole) or '') if isinstance(other, QTableWidgetItem) else '' return my_key < other_key # ------------------------------------------------------------------ # # Definizione colonne # # ------------------------------------------------------------------ # # Valutazioni Gemma (0-15) — stesse del foglio standard VAL_COLS = [ 'FORNITORE', 'RDA', 'C5', 'I1', 'R4', 'LA5', 'I2', 'IRIS', 'TOP CRIME', 'FOCUS', 'C20', 'CINE34', 'TWENTY SEVEN', 'SUPPORTO', 'DATA VAL.', 'GEM SIN', ] _VAL_COUNT = len(VAL_COLS) # 16 # Anagrafica (16-29) ANAGR_COLUMNS = [ 'RIFER', 'IMDB', 'TIPOL', 'TI', 'TO', 'ANNO', 'PAESE', 'GENERE', 'EPIS', 'DURATA', 'SUPERSERIE', 'VEG FREE', 'REGISTA', 'ATTORI', ] _ANAGR_COUNT = len(ANAGR_COLUMNS) # 14 # Diritti (30-43) — include colonne specifiche "in scadenza" DIRITTI_COLUMNS = [ 'SD', 'FL', 'DECOR', 'SCAD', 'DECOR_FORN', 'DELTA', 'DISTRIBUTORE', 'PERC', 'PASS_CONS', 'PASS_EFF', 'CAUS', 'DECOR_FUTURA', 'SCAD_FUTURA', 'MOTIVO', ] _DIRITTI_COUNT = len(DIRITTI_COLUMNS) # 14 # Extra (44-62) — stesse del foglio standard EXTRA_COLUMNS = [ 'RETE SLOT', 'OTT', 'RETE', 'DATA', 'INIZIO', 'FASCIA', 'AUD', 'SHA', 'RETE.T', 'DATA.T', 'INIZIO.T', 'FASCIA.T', 'AUD.T', 'SHA.T', 'DECORRENZA', 'DEBUTTO', 'INCASSO', 'SPETTATORI', 'DIST. CINEMA', ] ALL_COLUMNS = VAL_COLS + ANAGR_COLUMNS + DIRITTI_COLUMNS + EXTRA_COLUMNS # Indici colonne 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 # Indici colonne anagrafiche (16-29) FIRST_ANAGR_COL = _VAL_COUNT 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 # Indici colonne diritti (30-43) FIRST_DIRITTI_COL = FIRST_ANAGR_COL + _ANAGR_COUNT COL_SD = 30 COL_FL = 31 COL_DECOR = 32 COL_SCAD = 33 COL_DECOR_FORN = 34 # derivata da Linker COL_DELTA = 35 # derivata calcolata COL_DISTRIBUTORE = 36 COL_PERC = 37 COL_PASS_CONS = 38 COL_PASS_EFF = 39 COL_CAUS = 40 COL_DECOR_FUTURA = 41 COL_SCAD_FUTURA = 42 COL_MOTIVO = 43 # derivata: MANCANTE / DATE DIRITTI # Indici colonne extra (44-62) FIRST_EXTRA_COL = FIRST_DIRITTI_COL + _DIRITTI_COUNT COL_RETE_SLOT = 44 COL_OTT = 45 COL_EM_RETE = 46 COL_EM_DATA = 47 COL_EM_INIZIO = 48 COL_EM_FASCIA = 49 COL_EM_AUD = 50 COL_EM_SHA = 51 COL_TEM_RETE = 52 COL_TEM_DATA = 53 COL_TEM_INIZIO = 54 COL_TEM_FASCIA = 55 COL_TEM_AUD = 56 COL_TEM_SHA = 57 COL_DECORRENZA = 58 COL_BO_DEBUTTO = 59 COL_BO_INCASSO = 60 COL_BO_SPETT = 61 COL_BO_DIST = 62 # Colonne derivate (grigio intestazione) DERIVED_COL_INDICES = {COL_DECOR_FORN, COL_DELTA, COL_MOTIVO, COL_DECORRENZA} # Gruppi colonne per colorazione intestazione DIRITTI_COL_INDICES = {COL_SD, COL_FL, COL_DECOR, COL_SCAD, COL_DISTRIBUTORE, COL_PERC, COL_PASS_CONS, COL_PASS_EFF, COL_CAUS, COL_DECOR_FUTURA, COL_SCAD_FUTURA} EMESSO_COL_INDICES = {COL_EM_RETE, COL_EM_DATA, COL_EM_INIZIO, COL_EM_FASCIA, COL_EM_AUD, COL_EM_SHA} TEMATICHE_COL_INDICES = {COL_TEM_RETE, COL_TEM_DATA, COL_TEM_INIZIO, COL_TEM_FASCIA, COL_TEM_AUD, COL_TEM_SHA} BOXOFFICE_COL_INDICES = {COL_BO_DEBUTTO, COL_BO_INCASSO, COL_BO_SPETT, COL_BO_DIST} OTT_COL_INDICES = {COL_RETE_SLOT, COL_OTT} # Mapping nome colonna display → nome campo nel DataFrame (_xxx = arricchito in _do_load) COL_FIELD_MAP = { # VAL 'FORNITORE': '_VAL_FORNITORE', 'RDA': '_VAL_RDA', 'C5': '_VAL_C5', 'I1': '_VAL_I1', 'R4': '_VAL_R4', 'LA5': '_VAL_LA5', 'I2': '_VAL_I2', 'IRIS': '_VAL_IRIS', 'TOP CRIME': '_VAL_TOP', 'FOCUS': '_VAL_FOC', 'C20': '_VAL_C20', 'CINE34': '_VAL_CI34', 'TWENTY SEVEN': '_VAL_C27', 'SUPPORTO': '_VAL_SUPPORTO', 'DATA VAL.': '_VAL_DATA', 'GEM SIN': '_VAL_GEM_SIN', # ANAGR 'RIFER': 'RIFER', 'IMDB': 'IMDB_CODICE', 'TIPOL': 'TIPOL', 'TI': 'TI', 'TO': 'TO', 'ANNO': 'ANNO', 'PAESE': 'PAESE', 'GENERE': 'GENERE1', 'EPIS': 'EPIS', 'DURATA': 'DUR', 'SUPERSERIE': 'SUPERSERIE', 'VEG FREE': None, # rilevato dinamicamente 'REGISTA': None, # REGISTA_COGN + REGISTA_NOME 'ATTORI': None, # ATTORE1+2+3 # DIRITTI — dati da diritti.parquet via _enrich (prefisso _D_) # v_custom_anagr contiene solo DF_RAGSOC_DISTR; il resto viene dal parquet 'SD': '_D_SD', 'FL': '_D_FL', 'DECOR': '_D_DECR', 'SCAD': '_D_SCAD', 'DISTRIBUTORE': '_D_DISTR', 'PERC': '_D_PERC', 'PASS_CONS': '_D_PASS_CONS', 'PASS_EFF': '_D_PASS_EFF', 'CAUS': '_D_CAUS', 'DECOR_FORN': '_DECOR_FORN', 'DECOR_FUTURA': '_D_FUT_DECR', 'SCAD_FUTURA': '_D_FUT_SCAD', 'DELTA': '_DELTA', 'MOTIVO': '_MOTIVO', # EXTRA 'RETE SLOT': '_RETE_SLOT', 'OTT': '_OTT', 'RETE': '_EM_RETE', 'DATA': '_EM_DATA', 'INIZIO': '_EM_INIZIO', 'FASCIA': '_EM_FASCIA', 'AUD': '_EM_AUD', 'SHA': '_EM_SHA', 'RETE.T': '_TEM_RETE', 'DATA.T': '_TEM_DATA', 'INIZIO.T': '_TEM_INIZIO', 'FASCIA.T': '_TEM_FASCIA', 'AUD.T': '_TEM_AUD', 'SHA.T': '_TEM_SHA', 'DECORRENZA': '_DECORRENZA', 'DEBUTTO': '_BO_DEBUTTO', 'INCASSO': '_BO_INCASSO', 'SPETTATORI': '_BO_SPETT', 'DIST. CINEMA': '_BO_DIST', } COL_WIDTHS = { # VAL COL_VAL_FORNITORE: 120, COL_VAL_RDA: 120, COL_VAL_C5: 45, COL_VAL_I1: 45, COL_VAL_R4: 45, COL_VAL_LA5: 45, COL_VAL_I2: 45, COL_VAL_IRIS: 45, COL_VAL_TOP: 70, COL_VAL_FOC: 55, COL_VAL_C20: 45, COL_VAL_CI34: 55, COL_VAL_C27: 80, COL_VAL_SUPPORTO: 120, COL_VAL_DATA: 80, COL_VAL_GEM_SIN: 50, # ANAGR COL_RIFER: 85, COL_IMDB: 95, COL_TIPOL: 75, COL_TI: 200, COL_TO: 200, COL_ANNO: 55, COL_PAESE: 80, COL_GENERE: 100, COL_EPIS: 50, COL_DURATA: 60, COL_SUPERSERIE: 110, COL_VEGFREE: 75, COL_REGISTA: 160, COL_ATTORI: 200, # DIRITTI COL_SD: 35, COL_FL: 90, COL_DECOR: 90, COL_SCAD: 90, COL_DECOR_FORN: 100, COL_DELTA: 60, COL_DISTRIBUTORE: 120, COL_PERC: 60, COL_PASS_CONS: 90, COL_PASS_EFF: 90, COL_CAUS: 80, COL_DECOR_FUTURA: 100, COL_SCAD_FUTURA: 100, COL_MOTIVO: 120, # EXTRA COL_RETE_SLOT: 89, COL_OTT: 80, COL_EM_RETE: 45, COL_EM_DATA: 68, COL_EM_INIZIO: 55, COL_EM_FASCIA: 47, COL_EM_AUD: 55, COL_EM_SHA: 50, COL_TEM_RETE: 45, COL_TEM_DATA: 68, COL_TEM_INIZIO: 55, COL_TEM_FASCIA: 47, COL_TEM_AUD: 55, COL_TEM_SHA: 50, COL_DECORRENZA: 100, COL_BO_DEBUTTO: 68, COL_BO_INCASSO: 61, COL_BO_SPETT: 61, COL_BO_DIST: 120, } # Colori intestazione COLOR_VAL_HEADER = QColor('#FFF0B3') # giallo chiaro — valutazioni Gemma COLOR_ANAGR_YELLOW = QColor('#FFFF00') # giallo — anagrafica COLOR_ANAGR_CYAN = QColor('#00BFFF') # ciano — IMDB COLOR_DIRITTI_HEADER = QColor('#BDD7EE') # blu chiaro — diritti COLOR_DERIVED_HEADER = QColor('#D0D0D0') # grigio — colonne derivate COLOR_EMESSO_HEADER = QColor('#FFB347') # arancione — emesso COLOR_TEMATICHE_HDR = QColor('#C39BD3') # viola — tematiche COLOR_BOXOFFICE_HDR = QColor('#90EE90') # verde — box office COLOR_OTT_HEADER = QColor('#FFAAAA') # rosso chiaro — RETE SLOT / OTT COLOR_BORDER = QColor('#A0A0A0') # ------------------------------------------------------------------ # # Dialog filtri # # ------------------------------------------------------------------ # class DiritiFilterDialog(QDialog): def __init__(self, options: dict, imdb_codes: set = None, rifer_codes: set = None, rifer_decorrenza: dict = None, previous_filters: dict = None, parent=None): super().__init__(parent) self.setWindowTitle("Diritti in Scadenza — Filtri") self.setMinimumWidth(700) self._imdb_codes_in_linker = imdb_codes or set() self._rifer_codes_in_linker = rifer_codes or set() self._rifer_decorrenza = rifer_decorrenza or {} prev = previous_filters or {} prev_tipol = set(prev['tipologie']) if 'tipologie' in prev else None prev_anni = {str(a) for a in prev['anni']} if 'anni' in prev else None prev_distr = set(prev['distributori']) if 'distributori' in prev else None self._tipologie_list = self._make_list(options.get('tipologie', []), prev_tipol) self._anni_list = self._make_list( [str(a) for a in options.get('anni', [])], prev_anni ) self._distributori_list = self._make_list(options.get('distributori', []), prev_distr) lists_layout = QHBoxLayout() lists_layout.addWidget(self._make_group("Tipologia", self._tipologie_list)) lists_layout.addWidget(self._make_group("Anno scadenza", self._anni_list)) lists_layout.addWidget(self._make_group("Distributore", self._distributori_list)) self._chk_escludi_futura = QCheckBox( "Escludi titoli con diritti futuri valorizzati (DECOR_FUTURA)" ) self._chk_escludi_futura.setChecked(prev.get('escludi_decor_futura', True)) self._chk_verifica_contig = QCheckBox( "Verifica contiguità decorrenza/scadenza " "(evidenzia titoli non contigui con DELTA giorni e MOTIVO)" ) self._chk_verifica_contig.setChecked(prev.get('verifica_contiguita', False)) if not self._rifer_decorrenza: self._chk_verifica_contig.setEnabled(False) self._chk_verifica_contig.setToolTip( "Nessun RIFER con DECORRENZA presente nei fogli Linker" ) else: self._chk_verifica_contig.setToolTip( f"{len(self._rifer_decorrenza)} RIFER con DECORRENZA presenti nei fogli Linker" ) self._chk_escludi_imdb = QCheckBox( "Escludi titoli già presenti in un foglio (per codice IMDB)" ) self._chk_escludi_imdb.setChecked(prev.get('escludi_in_linker_imdb', False)) if not self._imdb_codes_in_linker: self._chk_escludi_imdb.setEnabled(False) self._chk_escludi_imdb.setToolTip("Nessun codice IMDB presente nei fogli Linker") else: self._chk_escludi_imdb.setToolTip( f"{len(self._imdb_codes_in_linker)} codici IMDB presenti nei fogli Linker" ) self._chk_escludi_rifer = QCheckBox( "Escludi titoli già presenti in un foglio (per RIFER)" ) self._chk_escludi_rifer.setChecked(prev.get('escludi_in_linker_rifer', False)) if not self._rifer_codes_in_linker: self._chk_escludi_rifer.setEnabled(False) self._chk_escludi_rifer.setToolTip("Nessun RIFER presente nei fogli Linker") else: self._chk_escludi_rifer.setToolTip( f"{len(self._rifer_codes_in_linker)} RIFER presenti nei fogli Linker" ) buttons = QDialogButtonBox() btn_carica = QPushButton("Carica") btn_annulla = QPushButton("Annulla") btn_carica.setDefault(True) buttons.addButton(btn_carica, QDialogButtonBox.ButtonRole.AcceptRole) buttons.addButton(btn_annulla, QDialogButtonBox.ButtonRole.RejectRole) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) main_layout = QVBoxLayout(self) main_layout.addLayout(lists_layout) main_layout.addWidget(self._chk_escludi_futura) main_layout.addWidget(self._chk_verifica_contig) main_layout.addWidget(self._chk_escludi_imdb) main_layout.addWidget(self._chk_escludi_rifer) main_layout.addWidget(buttons) @staticmethod def _make_list(values: list, selected: set = None) -> QListWidget: lw = QListWidget() lw.setSelectionMode(QListWidget.SelectionMode.MultiSelection) for v in values: item = QListWidgetItem(str(v)) lw.addItem(item) item.setSelected(selected is None or str(v) in selected) return lw def _make_group(self, title: str, lw: QListWidget) -> QGroupBox: box = QGroupBox(title) btn_all = QPushButton("Seleziona tutto") btn_none = QPushButton("Deseleziona tutto") btn_all.setFixedHeight(22) btn_none.setFixedHeight(22) btn_all.clicked.connect(lambda: self._select_all(lw, True)) btn_none.clicked.connect(lambda: self._select_all(lw, False)) btn_row = QHBoxLayout() btn_row.addWidget(btn_all) btn_row.addWidget(btn_none) layout = QVBoxLayout(box) layout.addWidget(lw) layout.addLayout(btn_row) return box @staticmethod def _select_all(lw: QListWidget, select: bool): for i in range(lw.count()): lw.item(i).setSelected(select) def get_filters(self) -> dict: tipologie = [lw.item(i).text() for lw in [self._tipologie_list] for i in range(lw.count()) if lw.item(i).isSelected()] anni = [int(lw.item(i).text()) for lw in [self._anni_list] for i in range(lw.count()) if lw.item(i).isSelected()] distributori = [lw.item(i).text() for lw in [self._distributori_list] for i in range(lw.count()) if lw.item(i).isSelected()] return { 'tipologie': tipologie, 'anni': anni, 'distributori': distributori, 'escludi_decor_futura': self._chk_escludi_futura.isChecked(), 'verifica_contiguita': self._chk_verifica_contig.isChecked(), 'escludi_in_linker_imdb': self._chk_escludi_imdb.isChecked(), 'escludi_in_linker_rifer': self._chk_escludi_rifer.isChecked(), 'imdb_codes_in_linker': self._imdb_codes_in_linker, 'rifer_codes_in_linker': self._rifer_codes_in_linker, 'rifer_decorrenza': self._rifer_decorrenza, } # ------------------------------------------------------------------ # # Header personalizzato # # ------------------------------------------------------------------ # class DiritiHeaderView(QHeaderView): def __init__(self, parent=None): super().__init__(Qt.Orientation.Horizontal, parent) self.setSectionsClickable(True) self.setSectionResizeMode(QHeaderView.ResizeMode.Interactive) self.setFixedHeight(33) self.setDefaultSectionSize(100) def paintSection(self, painter: QPainter, rect, logicalIndex: int): painter.save() if logicalIndex in DERIVED_COL_INDICES: bg = COLOR_DERIVED_HEADER elif logicalIndex in DIRITTI_COL_INDICES: bg = COLOR_DIRITTI_HEADER elif logicalIndex in EMESSO_COL_INDICES: bg = COLOR_EMESSO_HEADER elif logicalIndex in TEMATICHE_COL_INDICES: bg = COLOR_TEMATICHE_HDR elif logicalIndex in BOXOFFICE_COL_INDICES: bg = COLOR_BOXOFFICE_HDR elif logicalIndex in OTT_COL_INDICES: bg = COLOR_OTT_HEADER elif logicalIndex == COL_IMDB: bg = COLOR_ANAGR_CYAN elif logicalIndex < FIRST_ANAGR_COL: bg = COLOR_VAL_HEADER else: bg = COLOR_ANAGR_YELLOW text = self.model().headerData( logicalIndex, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole ) paint_std_header_section(painter, rect, str(text) if text else '', bg, COLOR_BORDER) painter.restore() # ------------------------------------------------------------------ # # Widget # # ------------------------------------------------------------------ # class DiritiTab(QWidget): """Widget per la tab 'In Scadenza'.""" sig_filtro_richiesto = pyqtSignal() def __init__(self, diritti_db: DiritiDatabase, parent=None): super().__init__(parent) self.diritti_db = diritti_db self._loaded = False self._veg_col = None self._setup_ui() def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # ── Toolbar ────────────────────────────────────────────────── from PyQt6.QtWidgets import QToolButton, QMenu from PyQt6.QtGui import QAction as _QAction toolbar = QFrame() toolbar.setFrameShape(QFrame.Shape.Box) toolbar.setLineWidth(1) toolbar.setFixedHeight(38) toolbar.setStyleSheet("background-color: #C8EED8;") tbl = QHBoxLayout(toolbar) tbl.setContentsMargins(8, 4, 8, 4) tbl.setSpacing(6) # ── Menu ≡ ──────────────────────────────────────────────────── 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() act_par = _QAction("Parametri", self) act_par.triggered.connect(self.sig_filtro_richiesto.emit) menu.addAction(act_par) menu.addSeparator() self._act_valutazioni = _QAction("Mostra valutazioni", self) self._act_valutazioni.triggered.connect(self._toggle_valutazioni) menu.addAction(self._act_valutazioni) menu.addSeparator() act_esporta = _QAction("Esporta", self) act_esporta.triggered.connect(self._esporta_tab) menu.addAction(act_esporta) 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) tbl.addStretch(1) app_label = QLabel("IN SCADENZA") app_label.setFont(QFont("Calibri", 10, QFont.Weight.Bold)) tbl.addWidget(app_label) layout.addWidget(toolbar) # ── Tabella ─────────────────────────────────────────────────── self.table = QTableWidget(0, len(ALL_COLUMNS), self) self.table.setHorizontalHeaderLabels(ALL_COLUMNS) self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.table.setSelectionMode(QTableWidget.SelectionMode.ContiguousSelection) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectItems) self.table.setAlternatingRowColors(True) header = DiritiHeaderView(self.table) header.setStretchLastSection(False) self.table.setHorizontalHeader(header) for col, width in COL_WIDTHS.items(): self.table.setColumnWidth(col, width) # Colonne VAL nascoste di default (come in modalità standard) for col in range(_VAL_COUNT): self.table.setColumnHidden(col, True) # Colonne derivate (DECOR_FORN, DELTA, MOTIVO) appartengono al workflow # di aggancio — nascoste di default, visibili solo con verifica contiguità for col in DERIVED_COL_INDICES: self.table.setColumnHidden(col, True) vh = self.table.verticalHeader() vh.setMinimumSectionSize(17) vh.setDefaultSectionSize(17) vh.setMinimumWidth(38) vh.setFont(QFont("Calibri", 7)) vh.setSectionResizeMode(QHeaderView.ResizeMode.Fixed) self.table.setFont(QFont("Calibri", 8)) self.table.setStyleSheet(""" QTableWidget { gridline-color: #C0C0C0; } QTableWidget::item { padding: 0px 3px; } QTableWidget::item:selected { background-color: #CCE8FF; color: #0078D7; } """) # FilterBar — barra filtri colonne (stessa di LinkerSheet) 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) # LinkerRenderer — pipeline fetch parquet condivisa self._renderer = LinkerRenderer(self.diritti_db.catalog) # GridFiller con indici DiritiTab 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_FUTURA, 'SCAD_FUT': COL_SCAD_FUTURA, '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, 'DECORRENZA': COL_DECORRENZA, '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, }) self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table.customContextMenuRequested.connect(self._on_context_menu) self.table.installEventFilter(self) self.table.model().layoutChanged.connect(self._fix_row_heights) def _fix_row_heights(self): for i in range(self.table.rowCount()): self.table.setRowHeight(i, 17) # ---------------------------------------------------------------- # # Selezione / tasto destro # # ---------------------------------------------------------------- # def eventFilter(self, obj, event): from PyQt6.QtCore import QEvent if obj is self.table and event.type() == QEvent.Type.KeyPress: if event.matches(QKeySequence.StandardKey.Copy): self._copy_selection() return True if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace): return True # read-only: ignora Delete silenziosamente return super().eventFilter(obj, event) 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 = [] 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_context_menu(self, pos): from PyQt6.QtWidgets import QMenu from PyQt6.QtGui import QAction index = self.table.indexAt(pos) if not index.isValid(): return has_sel = bool(self.table.selectedIndexes()) menu = QMenu(self) act_copia = QAction('Copia', self) act_copia.setEnabled(has_sel) act_copia.triggered.connect(self._copy_selection) menu.addAction(act_copia) rifer_val = '' imdb_val = '' item_rifer = self.table.item(index.row(), COL_RIFER) item_imdb = self.table.item(index.row(), COL_IMDB) if item_rifer: rifer_val = item_rifer.text().strip() if item_imdb: imdb_val = item_imdb.text().strip() if rifer_val or imdb_val: menu.addSeparator() if rifer_val: act_onair = QAction('Apri su Intranet', self) act_onair.triggered.connect(lambda: self._open_onair(rifer_val)) menu.addAction(act_onair) if imdb_val: act_imdb = QAction('Apri su IMDB', self) act_imdb.triggered.connect(lambda: self._open_imdb(imdb_val)) menu.addAction(act_imdb) menu.exec(self.table.viewport().mapToGlobal(pos)) def _open_onair(self, rifer: str): from PyQt6.QtCore import QUrl from PyQt6.QtGui import QDesktopServices url = ( 'https://onairinquiry.apps.mediaset.it/InquiryOnair/Login.do' f'?ActionMethod=Link&codProd={rifer}&tipProd=F&tipRicerca=P&activeTab=1' ) QDesktopServices.openUrl(QUrl(url)) def _open_imdb(self, code: str): from PyQt6.QtCore import QUrl from PyQt6.QtGui import QDesktopServices QDesktopServices.openUrl(QUrl(f'https://www.imdb.com/it/title/{code}/')) # ---------------------------------------------------------------- # # FilterBar # # ---------------------------------------------------------------- # 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 _toggle_valutazioni(self): if self._val_shown: for col in range(_VAL_COUNT): self.table.setColumnHidden(col, True) self._val_shown = False self._act_valutazioni.setText("Mostra valutazioni") else: for col in range(_VAL_COUNT): self.table.setColumnHidden(col, False) self._val_shown = True self._act_valutazioni.setText("Nascondi valutazioni") def _esporta_tab(self): try: import openpyxl from openpyxl.styles import Font, Alignment from openpyxl.utils import get_column_letter from PyQt6.QtWidgets import QFileDialog rows_data = self.get_export_data() if not rows_data: QMessageBox.information(self, "Esporta", "Nessun dato da esportare.") return path, _ = QFileDialog.getSaveFileName( self, "Esporta In Scadenza", "in_scadenza.xlsx", "Excel (*.xlsx)" ) if not path: return wb = openpyxl.Workbook() ws = wb.active ws.title = "In Scadenza" hf = Font(bold=True) for ci, label in enumerate(ALL_COLUMNS, start=1): cell = ws.cell(row=1, column=ci, value=label) cell.font = hf cell.alignment = Alignment(horizontal='center') ws.column_dimensions[get_column_letter(ci)].width = max(10, len(label) + 4) for ri, row_vals in enumerate(rows_data, start=2): for ci, val in enumerate(row_vals, start=1): ws.cell(row=ri, column=ci, value=val) wb.save(path) except Exception as e: QMessageBox.critical(self, "Errore esportazione", str(e)) def _apply_filters(self): filters = self._filter_bar.get_filters() for row in range(self.table.rowCount()): visible = True for col, text in filters.items(): item = self.table.item(row, col) cell = item.text().lower() if item else '' if text not in cell: visible = False break self.table.setRowHidden(row, not visible) # ---------------------------------------------------------------- # # Caricamento # # ---------------------------------------------------------------- # def load(self): if self._loaded: return self._loaded = True self._do_load() def load_with_filters(self, filters: dict): self._loaded = False self.table.setRowCount(0) # Colonne derivate visibili solo quando la verifica contiguità è attiva show_derived = filters.get('verifica_contiguita', False) for col in DERIVED_COL_INDICES: self.table.setColumnHidden(col, not show_derived) self._do_load(filters=filters) self._loaded = True def reload(self): self._loaded = False self.table.setRowCount(0) self._do_load() self._loaded = True def _do_load(self, filters: dict = None): tipologie = filters.get('tipologie') if filters else None anni = filters.get('anni') if filters else None _distr_raw = filters.get('distributori') if filters else None # "Seleziona tutto" == nessun filtro distributore: tratta la lista piena come None if _distr_raw: _all_distr = set(self.diritti_db.catalog.get_distributori()) distributori = None if set(_distr_raw) >= _all_distr else _distr_raw else: distributori = None escludi_fut = filters.get('escludi_decor_futura', True) if filters else True verifica_contig = filters.get('verifica_contiguita', False) if filters else False escludi_imdb = filters.get('escludi_in_linker_imdb', False) if filters else False escludi_rifer = filters.get('escludi_in_linker_rifer', False) if filters else False imdb_in_linker = filters.get('imdb_codes_in_linker', set()) if filters else set() rifer_in_linker = filters.get('rifer_codes_in_linker', set()) if filters else set() rifer_decorrenza = filters.get('rifer_decorrenza', {}) if filters else {} QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: # ── Step 1: RIFER "in scadenza" per tipologia ── rifer_list = self.diritti_db.get_diritti_rifer(tipologie=tipologie) if not rifer_list: QApplication.restoreOverrideCursor() return rifer_list = [_safe_rifer(r) for r in rifer_list if _safe_rifer(r)] # ── Step 2: diritti dettaglio (per filtri anni/distr/futura) ── dir_lookup = self._build_dir_lookup(rifer_list) # ── Step 3: filtra RIFER per anni / distributore / futura ── filtered = self._filter_rifers( rifer_list, dir_lookup, anni, distributori, escludi_fut ) if not filtered: QApplication.restoreOverrideCursor() return # ── Step 4: anagrafica per i RIFER filtrati ── df = self.diritti_db.get_anagr_and_diritti(set(filtered)) if df.empty: QApplication.restoreOverrideCursor() return # ── Step 5: DECOR_FORN + verifica contiguità ── rifer_norm = df['RIFER'].apply(_safe_rifer) if 'RIFER' in df.columns \ else pd.Series([''] * len(df)) df['_DECOR_FORN'] = rifer_norm.map(lambda r: rifer_decorrenza.get(r, '')) df['_DELTA'] = '' df['_MOTIVO'] = '' if verifica_contig and rifer_decorrenza: for idx, rifer in rifer_norm.items(): decor_date = _parse_decorrenza(rifer_decorrenza.get(rifer, '')) rows = dir_lookup.get(rifer, []) scad_date = gf_to_date(rows[0].get('scad')) if rows else None if rifer not in rifer_decorrenza: df.at[idx, '_MOTIVO'] = 'MANCANTE' elif decor_date and scad_date: gap = (decor_date - scad_date).days - 1 if gap <= 0: df.at[idx, '_DELTA'] = 'OK' df.at[idx, '_MOTIVO'] = 'CONTIGUO' else: df.at[idx, '_DELTA'] = str(gap) df.at[idx, '_MOTIVO'] = 'DATE DIRITTI' else: df.at[idx, '_DELTA'] = '?' df.at[idx, '_MOTIVO'] = 'DATE DIRITTI' before = len(df) df = df[df['_MOTIVO'].isin(['DATE DIRITTI', 'MANCANTE'])].copy() logger.info(f"Diritti: verifica contiguità — {before - len(df)} contigui esclusi") else: if escludi_imdb and imdb_in_linker and 'IMDB_CODICE' in df.columns: before = len(df) df = df[~df['IMDB_CODICE'].isin(imdb_in_linker)] logger.info(f"Diritti: esclusi {before - len(df)} per IMDB già in Linker") if escludi_rifer and rifer_in_linker: before = len(df) df = df[~rifer_norm.isin(rifer_in_linker)] logger.info(f"Diritti: esclusi {before - len(df)} per RIFER già in Linker") if df.empty: QApplication.restoreOverrideCursor() return # ── Step 6: lookup emesso / BO / rete_slot / gemma ── rifers_final = [_safe_rifer(r) for r in df['RIFER'].tolist() if _safe_rifer(r)] if 'RIFER' in df.columns else [] imdb_codes = [str(v).strip() for v in df.get('IMDB_CODICE', pd.Series()).tolist() if str(v).strip() not in ('', '0', 'nan')] \ if 'IMDB_CODICE' in df.columns else [] rendered = self._renderer.fetch(rifers_final, imdb_codes) em_g, em_t, bo, sr, gemma = ( rendered.emesso_g, rendered.emesso_t, rendered.boxoffice, rendered.rete_slot, rendered.gemma, ) self._veg_col = next( (c for c in VEG_COL_CANDIDATES if c in df.columns), None ) QApplication.restoreOverrideCursor() self._populate(df, dir_lookup, em_g, em_t, bo, sr, gemma) except Exception as e: QApplication.restoreOverrideCursor() logger.error(f"Errore caricamento Diritti: {e}", exc_info=True) QMessageBox.critical( self.window(), "Errore", f"Errore caricamento Diritti in Scadenza:\n{e}" ) # ---------------------------------------------------------------- # # Helpers dati # # ---------------------------------------------------------------- # def _build_dir_lookup(self, rifers: list) -> dict: """Restituisce {rifer: [list of row dicts]} da diritti.parquet.""" result: dict = {} try: dir_df = self.diritti_db.catalog.get_diritti_full_by_rifers(rifers) logger.info(f"_build_dir_lookup: {len(rifers)} rifers → {len(dir_df)} righe diritti") if not dir_df.empty: for _, r in dir_df.iterrows(): k = _safe_rifer(r.get('RIFER', '')) if k: result.setdefault(k, []).append(r) except Exception as e: logger.warning(f"_build_dir_lookup: {e}", exc_info=True) logger.info(f"_build_dir_lookup: {len(result)} chiavi nel lookup") return result def _filter_rifers(self, rifer_list, dir_lookup, anni, distributori, escludi_fut) -> list: """Filtra la lista RIFER per anni di scadenza, distributore, presenza diritti futuri.""" import datetime today = datetime.date.today() out = [] for rifer in rifer_list: rows = dir_lookup.get(rifer, []) current, future, expired = [], [], [] for r in rows: d = gf_to_date(r.get('decr')) s = gf_to_date(r.get('scad')) if d and s: if d <= today <= s: current.append(r) elif d > today: future.append(r) else: expired.append(r) expired.sort(key=lambda r: gf_to_date(r.get('scad')) or datetime.date.min, reverse=True) prim = current[0] if current else (expired[0] if expired else None) if anni: if prim is None: continue scad = gf_to_date(prim.get('scad')) if not scad or scad.year not in anni: continue if distributori and prim is not None: distr = gf_safe(prim.get('ragsoc_distr')) if distr not in distributori: continue if escludi_fut and future: continue out.append(rifer) return out # ---------------------------------------------------------------- # # Rendering # # ---------------------------------------------------------------- # def _populate(self, df, dir_lookup, em_g, em_t, bo, sr, gemma): COLOR_MANCANTE = QColor('#FFD0D0') COLOR_DATE_PROB = QColor('#FFE0A0') # precomputed index map for derived columns col_idx = {name: i for i, name in enumerate(ALL_COLUMNS)} self.table.setSortingEnabled(False) self.table.verticalHeader().setDefaultSectionSize(17) self.table.setRowCount(0) self.table.setRowCount(len(df)) filler = self._filler for row_idx, (_, row) in enumerate(df.iterrows()): motivo = str(row.get('_MOTIVO', '')) row_color = (COLOR_MANCANTE if motivo == 'MANCANTE' else COLOR_DATE_PROB if motivo == 'DATE DIRITTI' else None) rifer = _safe_rifer(row.get('RIFER', '')) imdb = gf_safe(row.get('IMDB_CODICE', '')) # ── Anagrafica (colonne 16-29) ── for ci in range(FIRST_ANAGR_COL, FIRST_DIRITTI_COL): val = self._get_anagr_value(row, ALL_COLUMNS[ci]) item = QTableWidgetItem(val) item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) if row_color: item.setBackground(row_color) self.table.setItem(row_idx, ci, item) # ── Diritti via GridFiller ── filler.fill_diritti(row_idx, dir_lookup.get(rifer, [])) # Colonne "in scadenza" derivate da df: DECOR_FORN, DELTA, MOTIVO for key, field in (('DECOR_FORN', '_DECOR_FORN'), ('DELTA', '_DELTA'), ('MOTIVO', '_MOTIVO')): val = gf_safe(row.get(field, '')) ci = col_idx[key] if key == 'DECOR_FORN' and key in DATE_SORT_COLUMNS: item = DateSortItem(val) item.setData(Qt.ItemDataRole.UserRole, _to_iso_sort_key(row.get(field, ''))) else: item = QTableWidgetItem(val) item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) self.table.setItem(row_idx, ci, item) # ── Extra: emesso / BO / rete_slot / OTT / decorrenza ── filler.fill_emesso(row_idx, em_g.get(rifer)) filler.fill_tematiche(row_idx, em_t.get(rifer)) filler.fill_boxoffice(row_idx, bo.get(rifer)) filler.fill_rete_slot(row_idx, sr.get(rifer, '')) filler.fill_ott(row_idx, 'n/d') filler._set(row_idx, 'DECORRENZA', '') # ── Valutazioni Gemma ── filler.fill_valutazioni(row_idx, gemma.get(imdb) if imdb else None) # ── Applica row_color su tutto (ha priorità su gemma bg) ── if row_color: for ci in range(len(ALL_COLUMNS)): it = self.table.item(row_idx, ci) if it: it.setBackground(row_color) self.table.setSortingEnabled(True) def _get_anagr_value(self, row, col_name: str) -> str: """Legge un valore di colonna anagrafica dal DataFrame row.""" if col_name == 'VEG FREE': return gf_safe(row[self._veg_col]) if self._veg_col and self._veg_col in row.index else '' if col_name == 'REGISTA': cogn = gf_safe(row.get('REGISTA_COGN', '')) nome = gf_safe(row.get('REGISTA_NOME', '')) return (nome + ' ' + cogn).strip() if col_name == 'ATTORI': cols = sorted(c for c in row.index if str(c).upper().startswith('ATTORE')) return ', '.join(gf_safe(row[c]) for c in cols if gf_safe(row[c])) field = COL_FIELD_MAP.get(col_name) return gf_safe(row.get(field, '')) if field else '' # ---------------------------------------------------------------- # # Esportazione # # ---------------------------------------------------------------- # def get_export_data(self) -> list: rows = [] for row in range(self.table.rowCount()): vals = [] for col in range(self.table.columnCount()): item = self.table.item(row, col) vals.append(item.text() if item else '') rows.append(vals) return rows # ------------------------------------------------------------------ # # Finestra standalone # # ------------------------------------------------------------------ # class DiritiWindow(QMainWindow): def __init__(self, diritti_db: DiritiDatabase, filters: dict = None, parent=None): super().__init__(parent) self.setWindowTitle("A.Library — Diritti in Scadenza") self.resize(1600, 700) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self._tab = DiritiTab(diritti_db, self) self.setCentralWidget(self._tab) menubar = self.menuBar() comandi_menu = menubar.addMenu("Comandi") act_export = QAction("Esporta", self) act_export.triggered.connect(self._esporta) comandi_menu.addAction(act_export) act_reload = QAction("Ricarica", self) act_reload.triggered.connect(self._tab.reload) comandi_menu.addAction(act_reload) if filters is not None: self._tab.load_with_filters(filters) else: self._tab.load() def _esporta(self): import openpyxl from openpyxl.styles import Alignment, Font from openpyxl.utils import get_column_letter rows_data = self._tab.get_export_data() if not rows_data: QMessageBox.information(self, "Esporta", "Nessun dato da esportare.") return path, _ = QFileDialog.getSaveFileName( self, "Esporta Diritti in Scadenza", "diritti_scadenza.xlsx", "Excel (*.xlsx)" ) if not path: return wb = openpyxl.Workbook() ws = wb.active ws.title = "Diritti in Scadenza" header_font = Font(bold=True) for col_idx, label in enumerate(ALL_COLUMNS, start=1): cell = ws.cell(row=1, column=col_idx, value=label) cell.font = header_font cell.alignment = Alignment(horizontal='center') from openpyxl.styles import PatternFill logical = col_idx - 1 if logical in DIRITTI_COL_INDICES: cell.fill = PatternFill('solid', fgColor='BDD7EE') elif logical in EMESSO_COL_INDICES: cell.fill = PatternFill('solid', fgColor='FFB347') elif logical in TEMATICHE_COL_INDICES: cell.fill = PatternFill('solid', fgColor='C39BD3') elif logical in BOXOFFICE_COL_INDICES: cell.fill = PatternFill('solid', fgColor='90EE90') for out_row, row_vals in enumerate(rows_data, start=2): for col_idx, val in enumerate(row_vals, start=1): ws.cell(row=out_row, column=col_idx, value=val) for col_idx, label in enumerate(ALL_COLUMNS, start=1): ws.column_dimensions[get_column_letter(col_idx)].width = max(10, len(label) + 4) try: wb.save(path) self.statusBar().showMessage(f"Esportato: {path}") except Exception as e: QMessageBox.critical(self, "Errore esportazione", str(e)) logger.error(f"Errore esporta DiritiWindow: {e}", exc_info=True)