""" ProssimeUsciteSheet — foglio "Boxoffice - prossime uscite". Carica un file Excel Competitive (foglio "Informazioni"), mostra i titoli in uscita con la possibilità di assegnare manualmente un codice IMDB. Gli agganci vengono salvati in COMPETITIVE_IMDB_MAP (linker.db). Menu hamburger: - Aggancia dati : popola colonne ONAIR da CUSTOM_ANAGR_ORIZ (per IMDB noto) - Esporta Excel : esporta la griglia corrente - Reset foglio : svuota la griglia """ import logging from pathlib import Path from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject from PyQt6.QtGui import QColor, QFont, QPainter, QKeySequence, QAction from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView, QApplication, QLabel, QPushButton, QToolButton, QFileDialog, QMessageBox, QMenu, QProgressDialog, ) from app.modules.linker.src.gui.grid_filler import ( paint_std_header_section, HEADER_HEIGHT, HEADER_BORDER_COLOR, HEADER_SEL_COLOR, DateSortItem, ) logger = logging.getLogger(__name__) # ── Colonne base ─────────────────────────────────────────────────────────── _COLS_BASE = ['DATA', 'TITOLO', 'DISTRIBUTORE', 'CREDITS', 'IMDB', 'RIFER'] COL_DATA = 0 COL_TITOLO = 1 COL_DISTR = 2 COL_CREDITS = 3 COL_IMDB = 4 COL_RIFER = 5 # ── Colonne OMDB (nascoste fino ad "Aggancia dati") — AS IS dal legacy ────── _COLS_OMDB = ['title', 'italian title', 'year', 'rated', 'released', 'runtime', 'genre', 'director', 'writer', 'actors', 'plot', 'language', 'country', 'awards', 'poster', 'metascore', 'rating', 'votes', 'type', 'dvd', 'boxoffice', 'production', 'website'] COL_O_TITLE = 6 COL_O_ITTITLE = 7 COL_O_YEAR = 8 COL_O_RATED = 9 COL_O_RELEASED = 10 COL_O_RUNTIME = 11 COL_O_GENRE = 12 COL_O_DIRECTOR = 13 COL_O_WRITER = 14 COL_O_ACTORS = 15 COL_O_PLOT = 16 COL_O_LANGUAGE = 17 COL_O_COUNTRY = 18 COL_O_AWARDS = 19 COL_O_POSTER = 20 COL_O_METASCORE = 21 COL_O_RATING = 22 COL_O_VOTES = 23 COL_O_TYPE = 24 COL_O_DVD = 25 COL_O_BOXOFFICE = 26 COL_O_PROD = 27 COL_O_WEBSITE = 28 # ── Colonne ONAIR (nascoste fino ad "Aggancia dati") ─────────────────────── _COLS_ONAIR = ['TIPOL', 'TI', 'TO', 'ANNO', 'PAESE', 'GENERE1', 'GENERE2', 'DUR', 'REGISTA', 'ATTORE 1', 'ATTORE 2', 'ATTORE 3'] COL_TIPOL = 29 COL_TI = 30 COL_TO = 31 COL_ANNO = 32 COL_PAESE = 33 COL_GENERE = 34 COL_GENERE2 = 35 COL_DUR = 36 COL_REGISTA = 37 COL_ATT1 = 38 COL_ATT2 = 39 COL_ATT3 = 40 _COLUMNS = _COLS_BASE + _COLS_OMDB + _COLS_ONAIR OMDB_COLS = list(range(COL_O_TITLE, COL_O_WEBSITE + 1)) ONAIR_COLS = list(range(COL_TIPOL, COL_ATT3 + 1)) EXTRA_COLS = OMDB_COLS + ONAIR_COLS # tutti quelli nascosti _OMDB_API_KEY = '123637e0' _OMDB_URL = 'http://www.omdbapi.com/' # chiave OMDB JSON → indice colonna (aggiornato con nuovi indici) _OMDB_COL_MAP = { 'Title': COL_O_TITLE, 'italian_title': COL_O_ITTITLE, # sempre vuoto — OMDB non lo fornisce 'Year': COL_O_YEAR, 'Rated': COL_O_RATED, 'Released': COL_O_RELEASED, 'Runtime': COL_O_RUNTIME, 'Genre': COL_O_GENRE, 'Director': COL_O_DIRECTOR, 'Writer': COL_O_WRITER, 'Actors': COL_O_ACTORS, 'Plot': COL_O_PLOT, 'Language': COL_O_LANGUAGE, 'Country': COL_O_COUNTRY, 'Awards': COL_O_AWARDS, 'Poster': COL_O_POSTER, 'Metascore': COL_O_METASCORE, 'imdbRating': COL_O_RATING, 'imdbVotes': COL_O_VOTES, 'Type': COL_O_TYPE, 'DVD': COL_O_DVD, 'BoxOffice': COL_O_BOXOFFICE, 'Production': COL_O_PROD, 'Website': COL_O_WEBSITE, } _COL_WIDTHS = { 'DATA': 140, 'TITOLO': 240, 'DISTRIBUTORE': 130, 'CREDITS': 200, 'IMDB': 90, 'RIFER': 70, 'TIPOL': 80, 'TI': 200, 'TO': 200, 'ANNO': 50, 'PAESE': 50, 'GENERE1': 90, 'GENERE2': 90, 'DUR': 45, 'REGISTA': 140, 'ATTORE 1': 130, 'ATTORE 2': 130, 'ATTORE 3': 130, 'title': 200, 'italian title': 150, 'year': 50, 'rated': 60, 'released': 90, 'runtime': 65, 'genre': 130, 'director': 140, 'writer': 180, 'actors': 200, 'plot': 300, 'language': 80, 'country': 80, 'awards': 200, 'poster': 120, 'metascore': 65, 'rating': 60, 'votes': 80, 'type': 70, 'dvd': 80, 'boxoffice': 90, 'production': 150, 'website': 150, } _BG_HEADER_DEFAULT = QColor('#B8CCE4') _BG_HEADER_IMDB = QColor('#00BFFF') _BG_HEADER_RIFER = QColor('#92D050') _BG_HEADER_ONAIR = QColor('#FFE699') _BG_HEADER_OMDB = QColor('#D9B3FF') _BG_ROW_EVEN = QColor('#FFFFFF') _BG_ROW_ODD = QColor('#F5F5F5') _BG_IMDB_FILLED = QColor('#E8F5E9') _BG_ONAIR_FILLED = QColor('#FFFDE7') _BG_OMDB_FILLED = QColor('#F3E5FF') _TOOLBAR_COLOR = '#C5E0B4' _BTN_STYLE = ( "QPushButton { background-color: #538135; color: white;" " border: 1px solid #375623; border-radius: 3px; padding: 0 10px; }" "QPushButton:hover { background-color: #427029; }" ) _HAMBURGER_STYLE = ( "QToolButton { background-color: #538135; color: white;" " border: 1px solid #375623; border-radius: 3px; padding: 0 4px; }" "QToolButton:hover { background-color: #427029; }" "QToolButton::menu-indicator { width: 0; }" ) _CELL_FONT = QFont('Calibri', 8) _INITIAL_ROWS = 100 # ── Header colorato ──────────────────────────────────────────────────────── class _PUHeader(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 == COL_IMDB: bg = _BG_HEADER_IMDB elif logicalIndex == COL_RIFER: bg = _BG_HEADER_RIFER elif logicalIndex in ONAIR_COLS: bg = _BG_HEADER_ONAIR elif logicalIndex in OMDB_COLS: bg = _BG_HEADER_OMDB else: bg = _BG_HEADER_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() # ── Tabella con paste ────────────────────────────────────────────────────── class _PUTable(QTableWidget): def keyPressEvent(self, event): if event.matches(QKeySequence.StandardKey.Paste): self._handle_paste() return super().keyPressEvent(event) def contextMenuEvent(self, event): menu = QMenu(self) act_paste = QAction('Incolla', self) act_paste.triggered.connect(self._handle_paste) menu.addAction(act_paste) menu.exec(event.globalPos()) def _handle_paste(self): clipboard = QApplication.clipboard().text() if not clipboard: return sel = self.selectedIndexes() if not sel: return start_row = sel[0].row() lines = clipboard.splitlines() for i, line in enumerate(lines): row = start_row + i if row >= self.rowCount(): break val = line.split('\t')[0].strip() item = self.item(row, COL_IMDB) if item is None: item = QTableWidgetItem() self.setItem(row, COL_IMDB, item) item.setText(val) # ── Worker ONAIR + OMDB fetch ────────────────────────────────────────────── class _FetchWorker(QObject): progress = pyqtSignal(int, int) # current, total row_done = pyqtSignal(int, dict) # row_idx, merged_data finished = pyqtSignal() def __init__(self, catalog, rows): super().__init__() self._catalog = catalog self._rows = rows # list of (row_idx, imdb_code) def run(self): import requests total = len(self._rows) for i, (row_idx, imdb) in enumerate(self._rows): self.progress.emit(i + 1, total) data: dict = {} # ONAIR da parquet (CUSTOM_ANAGR_ORIZ) try: df = self._catalog.get_custom_by_imdb(imdb) if not df.empty: data.update(df.iloc[0].to_dict()) except Exception as e: logger.warning(f"fetch onair {imdb}: {e}") # OMDB API — tutti i campi (AS IS dal legacy) try: resp = requests.get( _OMDB_URL, params={'i': imdb, 'plot': 'short', 'r': 'json', 'apikey': _OMDB_API_KEY}, timeout=8, ) omdb = resp.json() if omdb.get('Response') == 'True': data['_omdb'] = omdb # payload grezzo, usato in _on_row_fetched except Exception as e: logger.warning(f"fetch omdb {imdb}: {e}") if data: self.row_done.emit(row_idx, data) self.finished.emit() # ── Sheet principale ─────────────────────────────────────────────────────── class ProssimeUsciteSheet(QWidget): """Tab 'Boxoffice - prossime uscite' nel contenitore Linker.""" app_type = 'prossime_uscite' def __init__(self, linker_db, parent=None): super().__init__(parent) self.linker_db = linker_db self._file_path: Path | None = None self._loading = False self._onair_visible = False linker_db.ensure_competitive_imdb_map() self._setup_ui() # ── UI ───────────────────────────────────────────────────────────── def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self._build_toolbar()) layout.addWidget(self._build_table()) def _build_toolbar(self) -> QWidget: bar = QWidget() bar.setFixedHeight(32) bar.setStyleSheet(f'background:{_TOOLBAR_COLOR};') h = QHBoxLayout(bar) h.setContentsMargins(6, 2, 6, 2) h.setSpacing(8) # ≡ hamburger menu = QMenu(bar) act_aggancia = QAction('Aggancia dati', bar) act_aggancia.triggered.connect(self._aggancia_dati) menu.addAction(act_aggancia) act_export = QAction('Esporta Excel', bar) act_export.triggered.connect(self._esporta_excel) menu.addAction(act_export) menu.addSeparator() act_reset = QAction('Reset foglio', bar) act_reset.triggered.connect(self._reset_foglio) menu.addAction(act_reset) btn_menu = QToolButton(bar) btn_menu.setText('≡') btn_menu.setFont(QFont('Calibri', 11)) btn_menu.setFixedHeight(26) btn_menu.setFixedWidth(30) btn_menu.setStyleSheet(_HAMBURGER_STYLE) btn_menu.setMenu(menu) btn_menu.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) h.addWidget(btn_menu) # Carica file btn = QPushButton('Carica file Competitive…') btn.setFixedHeight(24) btn.setStyleSheet(_BTN_STYLE) btn.clicked.connect(self._on_carica) h.addWidget(btn) self._lbl_file = QLabel('Nessun file caricato') self._lbl_file.setStyleSheet('color:#333; font: 8pt Calibri;') h.addWidget(self._lbl_file) h.addStretch() self._lbl_count = QLabel('') self._lbl_count.setStyleSheet('color:#333; font: 8pt Calibri;') h.addWidget(self._lbl_count) return bar def _build_table(self) -> QTableWidget: self.table = _PUTable(self) self.table.setColumnCount(len(_COLUMNS)) self.table.setHorizontalHeader(_PUHeader(self.table)) self.table.setHorizontalHeaderLabels(_COLUMNS) self.table.verticalHeader().setMinimumSectionSize(17) self.table.verticalHeader().setDefaultSectionSize(17) self.table.verticalHeader().hide() self.table.setAlternatingRowColors(False) self.table.setSortingEnabled(True) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectItems) self.table.setEditTriggers( QTableWidget.EditTrigger.DoubleClicked | QTableWidget.EditTrigger.SelectedClicked | QTableWidget.EditTrigger.AnyKeyPressed ) self.table.verticalHeader().setDefaultSectionSize(17) self.table.setRowCount(_INITIAL_ROWS) self.table.setFont(_CELL_FONT) self.table.setStyleSheet(""" QTableWidget { gridline-color: #C0C0C0; } QTableWidget::item { padding: 0px 3px; } QTableWidget::item:selected { background-color: #CCE8FF; color: #0078D7; } """) for col, name in enumerate(_COLUMNS): self.table.setColumnWidth(col, _COL_WIDTHS[name]) # Nascondi colonne ONAIR + OMDB finché non si aggancia for col in EXTRA_COLS: self.table.setColumnHidden(col, True) self.table.cellChanged.connect(self._on_cell_changed) self.table.cellDoubleClicked.connect(self._on_cell_clicked) self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table.customContextMenuRequested.connect(self._on_context_menu) hh = self.table.horizontalHeader() hh.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) hh.customContextMenuRequested.connect(self._on_hheader_context_menu) self.table.selectionModel().selectionChanged.connect( lambda *_: hh.viewport().update() ) self.table.verticalHeader().setDefaultSectionSize(17) self.table.setRowCount(0) self.table.setRowCount(_INITIAL_ROWS) return self.table 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 _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 _on_cell_clicked(self, row: int, col: int): if col != COL_IMDB: return imdb_item = self.table.item(row, COL_IMDB) if imdb_item and imdb_item.text().strip(): return # già agganciato — non aprire la dialog titolo_item = self.table.item(row, COL_TITOLO) titolo = titolo_item.text().strip() if titolo_item else '' if not titolo: return # riga vuota credits_item = self.table.item(row, COL_CREDITS) credits = credits_item.text().strip() if credits_item else '' from app.modules.linker.src.gui.search_dialog import SearchDialog dlg = SearchDialog( search_text=titolo, credits=credits, database=self.linker_db, source_row=row, parent=self, ) from PyQt6.QtWidgets import QDialog if dlg.exec() != QDialog.DialogCode.Accepted: return imdb = dlg.get_selected_imdb_code() rifer = dlg.get_selected_rifer() if imdb: item = self.table.item(row, COL_IMDB) or QTableWidgetItem() item.setText(imdb) self.table.setItem(row, COL_IMDB, item) elif rifer: item = self.table.item(row, COL_RIFER) or QTableWidgetItem() item.setText(rifer) self.table.setItem(row, COL_RIFER, item) def _on_context_menu(self, pos): row = self.table.rowAt(pos.y()) if row < 0: return col = self.table.columnAt(pos.x()) menu = QMenu(self) act_copia = menu.addAction('Copia') act_copia.setEnabled(bool(self.table.selectedRanges())) act_incolla = menu.addAction('Incolla') act_incolla.setEnabled(col == COL_IMDB and bool(QApplication.clipboard().text())) chosen = menu.exec(self.table.viewport().mapToGlobal(pos)) if chosen == act_copia: self._copy_selection() elif chosen == act_incolla: self.table._handle_paste() # ── Carica file ──────────────────────────────────────────────────── def _on_carica(self): path, _ = QFileDialog.getOpenFileName( self, 'Carica file Competitive', '', 'Excel (*.xlsx *.xls)' ) if not path: return self._file_path = Path(path) self._lbl_file.setText(self._file_path.name) self._load_file() def _load_file(self): if self._file_path is None: return import pandas as pd try: QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) xl = pd.ExcelFile(str(self._file_path)) sheet_name = next( (s for s in xl.sheet_names if 'informazioni' in s.lower()), None ) or xl.sheet_names[0] df = xl.parse(sheet_name, header=None) except Exception as e: QApplication.restoreOverrideCursor() QMessageBox.critical(self, 'Errore lettura file', str(e)) return def _cell(v): if v is None or (not isinstance(v, str) and pd.isna(v)): return '' s = str(v).strip() return '' if s in ('nan', 'NaN') else s rows = [] for _, row in df.iterrows(): titolo = row.iloc[1] if len(row) > 1 else None if titolo is None or (not isinstance(titolo, str) and pd.isna(titolo)): continue t = _cell(titolo) if not t or t in ('nan', 'NaN'): continue rows.append(( _cell(row.iloc[0]), t, _cell(row.iloc[2]) if len(row) > 2 else '', _cell(row.iloc[3]) if len(row) > 3 else '', )) # Mappa agganci IMDB noti try: df_map = self.linker_db.get_competitive_imdb_map() imdb_map = { (str(r['TITOLO']).strip().lower(), str(r['DISTRIBUTORE']).strip().lower()): str(r['IMDB_CODE'] or '').strip() for _, r in df_map.iterrows() } except Exception: imdb_map = {} # RIFER da parquet per IMDB già noti known_imbds = [v for v in imdb_map.values() if v.startswith('tt')] rifer_map: dict = {} if known_imbds: try: df_r = self.linker_db.catalog.get_rifer_by_imdb_codes(known_imbds) if not df_r.empty: rifer_map = { str(r['IMDB_CODICE']).strip(): str(r['RIFER']).strip() for _, r in df_r.iterrows() } except Exception: pass QApplication.restoreOverrideCursor() self._loading = True self.table.setSortingEnabled(False) try: # Nascondi di nuovo le colonne ONAIR + OMDB for col in EXTRA_COLS: self.table.setColumnHidden(col, True) self._onair_visible = False self.table.verticalHeader().setDefaultSectionSize(17) self.table.setRowCount(0) self.table.setRowCount(len(rows)) for r, (data, titolo, distr, credits) in enumerate(rows): imdb = imdb_map.get((titolo.lower(), distr.lower()), '') rifer = rifer_map.get(imdb, '') if imdb else '' self._set_row(r, data, titolo, distr, credits, imdb, rifer) finally: self._loading = False self.table.setSortingEnabled(True) self._lbl_count.setText(f'{len(rows)} titoli') logger.info(f"ProssimeUscite: {len(rows)} titoli da {self._file_path.name}") def _set_row(self, r: int, data: str, titolo: str, distr: str, credits: str, imdb: str, rifer: str): bg = _BG_ROW_EVEN if r % 2 == 0 else _BG_ROW_ODD def _item(val, editable=False, bg_override=None): it = DateSortItem(str(val) if val is not None else '') it.setFont(_CELL_FONT) it.setBackground(bg_override or bg) if not editable: it.setFlags(it.flags() & ~Qt.ItemFlag.ItemIsEditable) return it self.table.setItem(r, COL_DATA, _item(data)) self.table.setItem(r, COL_TITOLO, _item(titolo)) self.table.setItem(r, COL_DISTR, _item(distr)) self.table.setItem(r, COL_CREDITS, _item(credits)) self.table.setItem(r, COL_IMDB, _item(imdb, editable=True, bg_override=_BG_IMDB_FILLED if imdb else None)) self.table.setItem(r, COL_RIFER, _item(rifer)) # Colonne ONAIR + OMDB vuote for col in EXTRA_COLS: self.table.setItem(r, col, _item('')) # ── Modifica cella IMDB ──────────────────────────────────────────── def _on_cell_changed(self, row: int, col: int): if self._loading or col != COL_IMDB: return imdb_item = self.table.item(row, COL_IMDB) titolo_item = self.table.item(row, COL_TITOLO) distr_item = self.table.item(row, COL_DISTR) if not titolo_item or not distr_item: return imdb = (imdb_item.text().strip() if imdb_item else '') titolo = titolo_item.text().strip() distr = distr_item.text().strip() if not titolo: return # Sfondo IMDB if imdb_item: bg = _BG_ROW_EVEN if row % 2 == 0 else _BG_ROW_ODD imdb_item.setBackground(_BG_IMDB_FILLED if imdb else bg) # Salva in DB try: self.linker_db.upsert_competitive_imdb(titolo, distr, imdb) except Exception as e: logger.error(f"upsert_competitive_imdb: {e}") # RIFER rifer = '' if imdb.startswith('tt'): try: df_r = self.linker_db.catalog.get_rifer_by_imdb_codes([imdb]) if not df_r.empty: rifer = str(df_r.iloc[0]['RIFER']).strip() except Exception: pass self._loading = True try: rifer_item = self.table.item(row, COL_RIFER) if rifer_item is None: rifer_item = QTableWidgetItem() rifer_item.setFont(_CELL_FONT) rifer_item.setFlags(rifer_item.flags() & ~Qt.ItemFlag.ItemIsEditable) self.table.setItem(row, COL_RIFER, rifer_item) rifer_item.setText(rifer) finally: self._loading = False # ── Aggancia dati (ONAIR da parquet) ─────────────────────────────── def _aggancia_dati(self): if not self.table.rowCount(): return # Raccogli righe con IMDB fetch_rows = [] for r in range(self.table.rowCount()): item = self.table.item(r, COL_IMDB) if item: imdb = item.text().strip() if imdb.startswith('tt'): fetch_rows.append((r, imdb)) if not fetch_rows: QMessageBox.information(self, 'Aggancia dati', 'Nessuna riga con codice IMDB valido.') return # Mostra colonne ONAIR + OMDB for col in EXTRA_COLS: self.table.setColumnHidden(col, False) self._onair_visible = True # Progress dialog prog = QProgressDialog('Aggancio dati in corso…', None, 0, len(fetch_rows), self) prog.setWindowTitle('Aggancia dati') prog.setWindowModality(Qt.WindowModality.WindowModal) prog.setMinimumDuration(0) prog.setValue(0) # Worker in thread self._fetch_thread = QThread() self._fetch_worker = _FetchWorker(self.linker_db.catalog, fetch_rows) self._fetch_worker.moveToThread(self._fetch_thread) self._fetch_worker.progress.connect(prog.setValue) self._fetch_worker.row_done.connect(self._on_row_fetched) self._fetch_worker.finished.connect(self._fetch_thread.quit) self._fetch_worker.finished.connect(prog.close) self._fetch_worker.finished.connect(self._on_fetch_done) self._fetch_thread.started.connect(self._fetch_worker.run) self._fetch_thread.start() def _on_row_fetched(self, row: int, data: dict): def _s(v): return '' if v is None or str(v).strip() in ('None', 'nan', 'N/A', '') else str(v).strip() regista = ' '.join(filter(None, [_s(data.get('REGISTA_COGN')), _s(data.get('REGISTA_NOME'))])) onair_vals = { COL_TIPOL: _s(data.get('TIPOL')), COL_TI: _s(data.get('TI')), COL_TO: _s(data.get('TO')), COL_ANNO: _s(data.get('ANNO')), COL_PAESE: _s(data.get('PAESE')), COL_GENERE: _s(data.get('GENERE1')), COL_GENERE2: _s(data.get('GENERE2')), COL_DUR: _s(data.get('DUR')), COL_REGISTA: regista, COL_ATT1: _s(data.get('ATTORE1')), COL_ATT2: _s(data.get('ATTORE2')), COL_ATT3: _s(data.get('ATTORE3')), } omdb_raw = data.get('_omdb', {}) omdb_vals = { col_idx: _s(omdb_raw.get(json_key, '')) for json_key, col_idx in _OMDB_COL_MAP.items() } def _fill(vals_dict, bg): for col, val in vals_dict.items(): item = self.table.item(row, col) if item is None: item = QTableWidgetItem() item.setFont(_CELL_FONT) item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) self.table.setItem(row, col, item) item.setText(val) if val: item.setBackground(bg) _fill(onair_vals, _BG_ONAIR_FILLED) _fill(omdb_vals, _BG_OMDB_FILLED) def _on_fetch_done(self): n = sum( 1 for r in range(self.table.rowCount()) if (self.table.item(r, COL_TIPOL) or QTableWidgetItem()).text().strip() ) logger.info(f"Aggancio dati completato: {n} righe con dati ONAIR") # ── Esporta Excel ────────────────────────────────────────────────── def _esporta_excel(self): if not self.table.rowCount(): QMessageBox.information(self, 'Esporta', 'Nessun dato da esportare.') return path, _ = QFileDialog.getSaveFileName( self, 'Esporta Excel', 'prossime_uscite.xlsx', 'Excel (*.xlsx)' ) if not path: return try: import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.utils import get_column_letter wb = openpyxl.Workbook() ws = wb.active ws.title = 'Prossime Uscite' # Colonne visibili visible_cols = [c for c in range(len(_COLUMNS)) if not self.table.isColumnHidden(c)] _font_std = Font(name='Calibri', size=8) _font_bold = Font(name='Calibri', size=8, bold=True) _font_link = Font(name='Calibri', size=8, color='0563C1', underline='single') _thin = Side(style='thin') _border = Border(left=_thin, right=_thin, top=_thin, bottom=_thin) # Header (altezza doppia) ws.row_dimensions[1].height = 30 for out_col, col in enumerate(visible_cols, start=1): name = _COLUMNS[col] cell = ws.cell(row=1, column=out_col, value=name) cell.font = _font_bold cell.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) cell.border = _border if col == COL_IMDB: cell.fill = PatternFill('solid', fgColor='00BFFF') elif col == COL_RIFER: cell.fill = PatternFill('solid', fgColor='92D050') elif col in OMDB_COLS: cell.fill = PatternFill('solid', fgColor='D9B3FF') elif col in ONAIR_COLS: cell.fill = PatternFill('solid', fgColor='FFE699') else: cell.fill = PatternFill('solid', fgColor='B8CCE4') # Dati _row_h = round(17 * 0.75, 2) data_row = 2 for r in range(self.table.rowCount()): titolo_item = self.table.item(r, COL_TITOLO) if not titolo_item or not titolo_item.text().strip(): continue ws.row_dimensions[data_row].height = _row_h for out_col, col in enumerate(visible_cols, start=1): item = self.table.item(r, col) val = item.text() if item else '' cell = ws.cell(row=data_row, column=out_col, value=val) cell.border = _border cell.alignment = Alignment(horizontal='center', vertical='center') if col == COL_IMDB and val.startswith('tt'): cell.hyperlink = f'https://www.imdb.com/title/{val}' cell.font = _font_link else: cell.font = _font_std 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) data_row += 1 # Larghezze colonne dal runtime (include resize utente) for out_col, col in enumerate(visible_cols, start=1): px = self.table.columnWidth(col) ws.column_dimensions[get_column_letter(out_col)].width = max(4.0, round(px / 7, 2)) ws.freeze_panes = 'A2' ws.auto_filter.ref = ws.dimensions wb.save(path) logger.info(f"Esportato: {path}") except PermissionError: QMessageBox.warning(self, 'Esporta', 'Il file è già aperto in Excel. Chiuderlo e riprovare.') except Exception as e: QMessageBox.critical(self, 'Errore esportazione', str(e)) logger.error(f"Errore esporta: {e}", exc_info=True) # ── Reset foglio ─────────────────────────────────────────────────── def _reset_foglio(self): if QMessageBox.question( self, 'Reset foglio', 'Svuotare il foglio?', QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) != QMessageBox.StandardButton.Yes: return self._loading = True self.table.setSortingEnabled(False) try: self.table.verticalHeader().setDefaultSectionSize(17) self.table.setRowCount(0) self.table.setRowCount(_INITIAL_ROWS) for r in range(_INITIAL_ROWS): for c in range(len(_COLUMNS)): self.table.setItem(r, c, QTableWidgetItem()) for col in EXTRA_COLS: self.table.setColumnHidden(col, True) self._onair_visible = False finally: self._loading = False self.table.setSortingEnabled(True) self._file_path = None self._lbl_file.setText('Nessun file caricato') self._lbl_count.setText('') # ── Utility ──────────────────────────────────────────────────────── def has_data(self) -> bool: return self.table.rowCount() > 0 and bool( self.table.item(0, COL_TITOLO) and self.table.item(0, COL_TITOLO).text().strip() )