""" LinkerSheetAggancio — modalità aggancio multiplo/listino. Estende LinkerSheetBase con il matching engine, triangolini e SearchDialog. """ import logging import time from PyQt6.QtWidgets import QApplication, QMessageBox, QTableWidgetItem from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QColor from app.modules.linker.src.gui.linker_sheet import ( LinkerSheetBase, ProgressDialog, COLUMNS, INPUT_COLS, OUTPUT_COLS, COL_RIFER, COL_IMDB, COL_TI, COL_TO, COL_ANNO, COL_REGISTA, COL_TIPOL, COL_PAESE, COL_GENERE, COL_EPIS, COL_DURATA, COL_SUPERSERIE, COL_VEGFREE, COL_ATTORI, COL_TEM_DATA, COL_EM_DATA, COL_DECORRENZA, ) logger = logging.getLogger(__name__) class LinkerSheetAggancio(LinkerSheetBase): """ Estende LinkerSheetBase con il workflow di aggancio: matching engine, triangolini rossi, SearchDialog manuale. """ sig_salva_richiesto = pyqtSignal() def __init__(self, linker_db, status_fn=None, parent=None): super().__init__(linker_db, status_fn, parent) self.current_fornitore = None self.current_is_listino = False self._in_aggancio = False self._wizard_mode = False self._btn_annulla_aggancio.clicked.connect(self.annulla) self._btn_salva.clicked.connect(self._on_salva_clicked) self._btn_back.clicked.connect(self.esci) # Wizard bar — inserita subito sotto la toolbar (indice 1) from app.modules.linker.src.gui.wizard_bar import WizardBar from app.modules.linker.src.gui.wizard_hint_panel import WizardHintPanel self._wizard_bar = WizardBar(self) self._wizard_bar.hide() self._wizard_bar.step_requested.connect(self._on_wizard_step_requested) self.layout().insertWidget(1, self._wizard_bar) # Hint panel — inserito subito sotto la wizard bar (indice 2) self._wizard_hint = WizardHintPanel(self) self._wizard_hint.hide() self.layout().insertWidget(2, self._wizard_hint) # ------------------------------------------------------------------ # # Wizard API # # ------------------------------------------------------------------ # def start_wizard(self): """Attiva la modalità wizard e mostra la barra step.""" self._wizard_mode = True self._wizard_bar.reset() self._wizard_bar.show() self._wizard_hint.set_step(0) def wizard_advance(self, step: int): """Avanza la barra wizard al passo indicato (0=carica, 1=aggancio, 2=revisiona, 3=esporta).""" if self._wizard_mode: self._wizard_bar.set_step(step) self._wizard_bar.show() # assicura visibilità anche dopo show_all_columns self._wizard_hint.set_step(step) def _on_salva_clicked(self): self.sig_salva_richiesto.emit() def _on_wizard_step_requested(self, index: int): """L'utente ha cliccato uno step completato — naviga indietro se sensato.""" if index == 0 and self._in_aggancio: r = QMessageBox.question( self, "Torna al passo 1", "Tornare al passo 1 cancellerà i risultati dell'aggancio corrente. Continuare?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if r == QMessageBox.StandardButton.Yes: self.annulla() self._wizard_bar.set_step(0) # ------------------------------------------------------------------ # # API pubblica aggancio # # ------------------------------------------------------------------ # def aggancia(self, is_listino: bool, fornitore, is_seriale: bool = True): """Avvia il matching a cascata.""" self.current_is_seriale = is_seriale self.table.setSortingEnabled(False) self.current_is_listino = is_listino self.current_fornitore = fornitore self.show_all_columns() # ripristina visibilità completa prima del matching if is_listino: self.table.setColumnHidden(COL_DECORRENZA, False) self._in_aggancio = True self._set_mode("AGG. MULTIPLO", "#DDD0F8") self._btn_annulla_aggancio.show() self._btn_salva.show() self.wizard_advance(1) # step 2 — Aggancio in corso try: self._run_matching(is_listino, fornitore, is_seriale) except Exception as e: import traceback logger.error(f"Errore _run_matching: {e}\n{traceback.format_exc()}") QApplication.restoreOverrideCursor() QMessageBox.critical(self, "Errore aggancio", f"Errore durante l'aggancio:\n{e}") return self.wizard_advance(2) # step 3 — Revisiona def esci(self): """Esce dalla modalità aggancio senza toccare i dati già agganciati.""" r = QMessageBox.question( self, "Esci dalla modalità aggancio", "Uscire dalla modalità aggancio?\nGli agganci già effettuati verranno mantenuti.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if r != QMessageBox.StandardButton.Yes: return self._in_aggancio = False self.current_fornitore = None self.current_is_listino = False self.table.setSortingEnabled(True) self._btn_annulla_aggancio.hide() self._btn_salva.hide() self._set_mode("Linker", "#EBEBEB") self._status("Uscito dalla modalità aggancio.") def annulla(self): """Ripristina tutte le righe al loro stato pre-matching.""" self.table.blockSignals(True) try: for row in range(self.table.rowCount()): for col in INPUT_COLS: item = self.table.item(row, col) if item: orig = item.data(Qt.ItemDataRole.UserRole) if orig is not None: item.setText(str(orig)) item.setData(Qt.ItemDataRole.UserRole, None) item.setToolTip('') rifer_item = QTableWidgetItem('') self.table.setItem(row, COL_RIFER, rifer_item) for col in OUTPUT_COLS: item = QTableWidgetItem('') if col != COL_IMDB: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) self.table.setItem(row, col, item) for col in range(len(COLUMNS)): item = self.table.item(row, col) if item: item.setBackground(QColor('white')) finally: self.table.blockSignals(False) self.row_match_source.clear() self._in_aggancio = False self.current_fornitore = None self.current_is_listino = False self.table.setSortingEnabled(True) self._btn_annulla_aggancio.hide() self._btn_salva.hide() self._set_mode("Linker", "#EBEBEB") self._status("Aggancio annullato.") def cancella(self): super().cancella() self.current_fornitore = None self.current_is_listino = False self.table.setSortingEnabled(True) # ------------------------------------------------------------------ # # Matching engine # # ------------------------------------------------------------------ # def _run_matching(self, is_listino: bool, fornitore, is_seriale: bool = True): from app.modules.linker.src.engine.linker_engine import LinkerEngine engine = LinkerEngine(self.linker_db, fornitore if is_listino else None, is_seriale=is_seriale) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: self._status("Caricamento dati in corso...") QApplication.processEvents() engine.load_tables() except Exception as e: QApplication.restoreOverrideCursor() QMessageBox.critical(self, "Errore", f"Errore caricamento dati:\n{e}") logger.error(f"Errore load_tables: {e}", exc_info=True) return active_rows = [] for row in range(self.table.rowCount()): to_item = self.table.item(row, COL_TO) ti_item = self.table.item(row, COL_TI) anno_item = self.table.item(row, COL_ANNO) reg_item = self.table.item(row, COL_REGISTA) # Use UserRole (original listino title) when available — the canonical title # written by _apply_match_result may differ from what LINKER_REPOSITORY stores. to_val = ( (to_item.data(Qt.ItemDataRole.UserRole) or to_item.text()).strip() if to_item else '' ) ti_val = ( (ti_item.data(Qt.ItemDataRole.UserRole) or ti_item.text()).strip() if ti_item else '' ) anno_val = anno_item.text().strip() if anno_item else '' reg_val = reg_item.text().strip() if reg_item else '' if to_val or ti_val: active_rows.append((row, to_val, ti_val, anno_val, reg_val)) progress = ProgressDialog(total=len(active_rows), parent=self) progress.show() QApplication.processEvents() matched = not_found = 0 t_start = time.monotonic() rifer_map = {} self._current_aggancio_rows = {row for row, *_ in active_rows} logger.info(f"[aggancio] avvio loop: {len(active_rows)} righe attive") self.table.blockSignals(True) try: for i, (row, to_val, ti_val, anno_val, reg_val) in enumerate(active_rows): result = engine.match_row(to_val, ti_val, anno_val, reg_val) self._apply_match_result(row, result, to_val, ti_val, anno_val, reg_val) if result['match_source'] == 'NOT_FOUND': not_found += 1 else: matched += 1 progress.update(i + 1, time.monotonic() - t_start) logger.info(f"[aggancio] loop completato: {matched} match, {not_found} not_found") for row in range(self.table.rowCount()): item = self.table.item(row, COL_RIFER) if item: r = item.text().strip() if r: rifer_map[row] = r logger.info(f"[aggancio] _batch_fill_diritti: {len(rifer_map)} rifer") self._batch_fill_diritti(rifer_map) logger.info(f"[aggancio] _batch_fill_diritti done") finally: self.table.blockSignals(False) QApplication.restoreOverrideCursor() progress.close() self._start_emesso_worker(rifer_map) mode = (f"LISTINO [{fornitore}]" if is_listino and fornitore else "LISTINO [nessun fornitore]" if is_listino else "NON-LISTINO") self._status( f"Aggancio completato ({mode}): {matched} trovati, {not_found} non trovati.") def _apply_match_result(self, row: int, result: dict, orig_to: str, orig_ti: str, orig_anno: str, orig_reg: str): color = QColor(result.get('color', '#FFFFFF')) match_source = result.get('match_source', 'NOT_FOUND') self.row_match_source[row] = match_source WHITE = QColor('white') if match_source == 'NOT_FOUND': for col in OUTPUT_COLS: item = QTableWidgetItem('') if col != COL_IMDB: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) item.setBackground(color if col in (COL_RIFER, COL_IMDB) else WHITE) self.table.setItem(row, col, item) for col in INPUT_COLS: item = self.table.item(row, col) if item: item.setBackground(WHITE) return RIFER_DERIVED_COLOR = QColor('#FFFF99') rifer_derived = result.get('rifer_derived', False) def set_out(col, val): s = str(val) if val else '' item = QTableWidgetItem(s) if col not in (COL_RIFER, COL_IMDB): item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) if col == COL_RIFER and rifer_derived and s: item.setBackground(RIFER_DERIVED_COLOR) elif col in (COL_RIFER, COL_IMDB) and s: item.setBackground(color) else: item.setBackground(WHITE) self.table.setItem(row, col, item) set_out(COL_RIFER, result.get('RIFER', '')) set_out(COL_IMDB, result.get('IMDB_CODICE', '')) set_out(COL_TIPOL, result.get('TIPOL', '')) set_out(COL_PAESE, result.get('PAESE', '')) set_out(COL_GENERE, result.get('GENERE', '')) set_out(COL_EPIS, result.get('EPIS', '')) set_out(COL_DURATA, result.get('DURATA', '')) set_out(COL_SUPERSERIE, result.get('SUPERSERIE', '')) _veg = result.get('VEG_FREE', '') if isinstance(_veg, str) and _veg.upper() == 'DA ATTRIBUIRE': _veg = 'N.C.' set_out(COL_VEGFREE, _veg) set_out(COL_ATTORI, result.get('ATTORI', '')) def set_inp(col, new_val, orig_val): new_s = str(new_val) if new_val else '' orig_s = str(orig_val) if orig_val else '' item = QTableWidgetItem(new_s or orig_s) item.setBackground(WHITE) if new_s: item.setData(Qt.ItemDataRole.UserRole, orig_s) if orig_s and new_s.lower() != orig_s.lower(): item.setToolTip(orig_s) self.table.setItem(row, col, item) set_inp(COL_TI, result.get('TI'), orig_ti) set_inp(COL_TO, result.get('TO'), orig_to) set_inp(COL_ANNO, result.get('ANNO'), orig_anno) set_inp(COL_REGISTA, result.get('REGISTA'), orig_reg) # ------------------------------------------------------------------ # # Double-click — override per aggiungere SearchDialog # # ------------------------------------------------------------------ # def _on_cell_double_clicked(self, row: int, col: int): # Popups base (DATA, EPIS, SUPERSERIE, RIFER→OnAir) — delega sempre a LinkerSheetBase if col in (COL_TEM_DATA, COL_EM_DATA, COL_EPIS, COL_SUPERSERIE, COL_RIFER): super()._on_cell_double_clicked(row, col) return # Funzionalità aggancio solo in modalità AGGANCIO MULTIPLO if not self._in_aggancio: return # IMDB → cerca rifer if col == COL_IMDB: self._cerca_rifer(row) return if self.row_match_source.get(row) not in ('NOT_FOUND', 'ONAIR', 'IMDB', 'DERIVED'): return from app.modules.linker.src.gui.search_dialog import SearchDialog to_item = self.table.item(row, COL_TO) ti_item = self.table.item(row, COL_TI) anno_item = self.table.item(row, COL_ANNO) reg_item = self.table.item(row, COL_REGISTA) to_val = to_item.text().strip() if to_item else '' ti_val = ti_item.text().strip() if ti_item else '' anno_val = anno_item.text().strip() if anno_item else '' reg_val = reg_item.text().strip() if reg_item else '' if col == COL_REGISTA and reg_val: search_text = reg_val credits = ' | '.join(v for v in [to_val or ti_val, anno_val] if v) search_mode = 'REGISTA' else: search_text = to_val or ti_val credits = ' | '.join(v for v in [anno_val, reg_val] if v) search_mode = 'TITOLO' dlg = SearchDialog( search_text=search_text, credits=credits, database=self.linker_db, source_row=row, search_mode=search_mode, parent=self, ) if dlg.exec() != dlg.DialogCode.Accepted: return rifer = dlg.get_selected_rifer() if rifer: self._apply_manual_match_from_rifer(row, rifer) return imdb_code = dlg.get_selected_imdb_code() if imdb_code: self._apply_manual_match(row, imdb_code) # ------------------------------------------------------------------ # # Context menu — override per aggiungere azioni aggancio # # ------------------------------------------------------------------ # def _build_context_menu(self, row: int, index): menu, actions = super()._build_context_menu(row, index) if not self._in_aggancio: return menu, actions source = self.row_match_source.get(row) if source: menu.addSeparator() if source in ('ONAIR', 'IMDB', 'DERIVED'): actions['rimuovi'] = menu.addAction("Rimuovi aggancio") actions['cerca'] = menu.addAction("Cerca manualmente...") return menu, actions def _handle_context_menu(self, action, actions: dict, row: int, index) -> bool: if super()._handle_context_menu(action, actions, row, index): return True if action == actions.get('rimuovi'): self._rimuovi_aggancio_riga(row) elif action == actions.get('cerca'): self._on_cell_double_clicked(row, index.column()) else: return False return True # ------------------------------------------------------------------ # # Match manuale # # ------------------------------------------------------------------ # def _cerca_rifer(self, row: int): from app.modules.linker.src.gui.search_dialog import SearchDialog to_item = self.table.item(row, COL_TO) ti_item = self.table.item(row, COL_TI) anno_item = self.table.item(row, COL_ANNO) to_val = to_item.text().strip() if to_item else '' ti_val = ti_item.text().strip() if ti_item else '' anno_val = anno_item.text().strip() if anno_item else '' dlg = SearchDialog( search_text=to_val or ti_val, credits=anno_val, database=self.linker_db, source_row=row, search_mode='TITOLO', parent=self, ) if dlg.exec() != dlg.DialogCode.Accepted: return rifer = dlg.get_selected_rifer() if rifer: self._apply_manual_match_from_rifer(row, rifer) return imdb_code = dlg.get_selected_imdb_code() if imdb_code: self._apply_manual_match(row, imdb_code) def _apply_manual_match(self, row: int, imdb_code: str): try: custom_df = self.linker_db.get_custom_by_imdb(imdb_code) imdb_df = self.linker_db.get_imdb_by_code(imdb_code) result = { 'IMDB_CODICE': imdb_code, 'RIFER': '', 'TI': '', 'TO': '', 'ANNO': '', 'REGISTA': '', 'TIPOL': '', 'PAESE': '', 'GENERE': '', 'EPIS': '', 'DURATA': '', 'SUPERSERIE': '', 'VEG_FREE': '', 'ATTORI': '', } if not custom_df.empty: crow = custom_df.iloc[0] tipol = self._safe(crow.get('TIPOL')).upper() _TIPOL_SINGOLO = {'FILM', 'TV MOVIE', 'TVM', 'TV-MOVIE', 'TVMOVIE'} if tipol in _TIPOL_SINGOLO: result['RIFER'] = str(crow.get('RIFER', '') or '').strip() result['TI'] = self._safe(crow.get('TI')) result['TO'] = self._safe(crow.get('TO')) result['ANNO'] = self._fmt_anno(crow.get('ANNO')) reg_c = self._safe(crow.get('REGISTA_COGN')) reg_n = self._safe(crow.get('REGISTA_NOME')) result['REGISTA'] = (reg_n + ' ' + reg_c).strip() result['TIPOL'] = self._safe(crow.get('TIPOL')) result['PAESE'] = self._safe(crow.get('PAESE')) result['GENERE'] = self._safe(crow.get('GENERE1')) result['EPIS'] = self._safe(crow.get('EPIS')) result['DURATA'] = self._safe(crow.get('DUR')) result['SUPERSERIE'] = self._safe(crow.get('SUPERSERIE')) result['VEG_FREE'] = self._safe(crow.get('VEG')) result['ATTORI'] = ', '.join( self._safe(crow[c]) for c in sorted(col for col in crow.index if str(col).upper().startswith('ATTORE')) if self._safe(crow[c]) ) elif not imdb_df.empty: irow = imdb_df.iloc[0] result['TI'] = self._safe(irow.get('TI')) result['TO'] = self._safe(irow.get('TO')) result['ANNO'] = self._fmt_anno(irow.get('ANNO')) result['REGISTA'] = self._safe(irow.get('REGISTA')) result['TIPOL'] = self._safe(irow.get('TIPOL')) result['PAESE'] = self._safe(irow.get('PAESE')) result['GENERE'] = self._safe(irow.get('GENERE')) result['EPIS'] = self._safe(irow.get('EPIS')) result['DURATA'] = self._safe(irow.get('DURATA')) result['ATTORI'] = self._safe(irow.get('CAST')) result['match_source'] = 'IMDB' result['color'] = '#FFFF99' to_item = self.table.item(row, COL_TO) ti_item = self.table.item(row, COL_TI) anno_item = self.table.item(row, COL_ANNO) reg_item = self.table.item(row, COL_REGISTA) self.table.blockSignals(True) try: self._apply_match_result( row, result, to_item.text() if to_item else '', ti_item.text() if ti_item else '', anno_item.text() if anno_item else '', reg_item.text() if reg_item else '', ) finally: self.table.blockSignals(False) if result.get('RIFER'): rifer = result['RIFER'] self._fill_diritti_row(row, rifer) rendered = self._renderer.fetch([rifer]) self._fill_rete_slot_cells(row, rendered.rete_slot.get(rifer, '')) self._fill_ott_cell(row, 'n/d') self._fill_boxoffice_cells(row, rendered.boxoffice.get(rifer)) self._start_emesso_worker({row: rifer}) except Exception as e: QMessageBox.critical(self, "Errore", f"Errore applicazione match manuale:\n{e}") logger.error(f"Errore _apply_manual_match: {e}", exc_info=True) def _apply_manual_match_from_rifer(self, row: int, rifer: str): try: custom_df = self.linker_db.get_custom_by_rifer(rifer) result = { 'IMDB_CODICE': '', 'RIFER': rifer, 'TI': '', 'TO': '', 'ANNO': '', 'REGISTA': '', 'TIPOL': '', 'PAESE': '', 'GENERE': '', 'EPIS': '', 'DURATA': '', 'SUPERSERIE': '', 'VEG_FREE': '', 'ATTORI': '', } if not custom_df.empty: crow = custom_df.iloc[0] result['IMDB_CODICE'] = self._safe(crow.get('IMDB_CODICE')) result['TI'] = self._safe(crow.get('TI')) result['TO'] = self._safe(crow.get('TO')) result['ANNO'] = self._fmt_anno(crow.get('ANNO')) reg_c = self._safe(crow.get('REGISTA_COGN')) reg_n = self._safe(crow.get('REGISTA_NOME')) result['REGISTA'] = (reg_n + ' ' + reg_c).strip() result['TIPOL'] = self._safe(crow.get('TIPOL')) result['PAESE'] = self._safe(crow.get('PAESE')) result['GENERE'] = self._safe(crow.get('GENERE1')) result['EPIS'] = self._safe(crow.get('EPIS')) result['DURATA'] = self._safe(crow.get('DUR')) result['SUPERSERIE'] = self._safe(crow.get('SUPERSERIE')) result['VEG_FREE'] = self._safe(crow.get('VEG')) result['ATTORI'] = ', '.join( self._safe(crow[c]) for c in sorted(col for col in crow.index if str(col).upper().startswith('ATTORE')) if self._safe(crow[c]) ) result['match_source'] = 'ONAIR' result['color'] = '#FFFF99' to_item = self.table.item(row, COL_TO) ti_item = self.table.item(row, COL_TI) anno_item = self.table.item(row, COL_ANNO) reg_item = self.table.item(row, COL_REGISTA) self.table.blockSignals(True) try: self._apply_match_result( row, result, to_item.text() if to_item else '', ti_item.text() if ti_item else '', anno_item.text() if anno_item else '', reg_item.text() if reg_item else '', ) finally: self.table.blockSignals(False) self._fill_diritti_row(row, rifer) rendered = self._renderer.fetch([rifer]) self._fill_rete_slot_cells(row, rendered.rete_slot.get(rifer, '')) self._fill_ott_cell(row, 'n/d') self._fill_boxoffice_cells(row, rendered.boxoffice.get(rifer)) self._start_emesso_worker({row: rifer}) except Exception as e: QMessageBox.critical(self, "Errore", f"Errore applicazione match manuale (OnAir):\n{e}") logger.error(f"Errore _apply_manual_match_from_rifer: {e}", exc_info=True) def _rimuovi_aggancio_riga(self, row: int): WHITE = QColor('white') NOT_FOUND_COLOR = QColor('#FF9999') self._rifer_edit_guard = True try: for col in INPUT_COLS: item = self.table.item(row, col) if item: orig = item.data(Qt.ItemDataRole.UserRole) if orig is not None: item.setText(str(orig)) item.setData(Qt.ItemDataRole.UserRole, None) item.setToolTip('') item.setBackground(WHITE) # RIFER: svuota e sfondo rosso per indicare "non agganciato" rifer_item = self.table.item(row, COL_RIFER) if rifer_item: rifer_item.setText('') rifer_item.setBackground(NOT_FOUND_COLOR) for col in OUTPUT_COLS: item = QTableWidgetItem('') if col != COL_IMDB: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) self.table.setItem(row, col, item) finally: self._rifer_edit_guard = False self.row_match_source[row] = 'NOT_FOUND' self._status(f"Aggancio rimosso (riga {row + 1}).")