import json import os from PyQt6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, QComboBox, QMessageBox, QFrame, QSizePolicy, QApplication, ) from PyQt6.QtCore import QThread, pyqtSignal, Qt from loguru import logger _SESSION_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "session.json") from client.session import GemmaSession, SessionExpiredError from client.search import search, SearchResult from client.detail import get_detail, save_detail from client.distributori import fetch_distributori from app.detail_dialog import DetailDialog from app.session_dialog import SessionDialog from app.searchable_combo import SearchableCombo from app.bulk_distrib_dialog import BulkDistribDialog class _DistributoriWorker(QThread): done = pyqtSignal(list) error = pyqtSignal(str) def __init__(self, session): super().__init__() self._session = session def run(self): try: self.done.emit(fetch_distributori(self._session)) except Exception as e: self.error.emit(str(e)) class _SearchWorker(QThread): done = pyqtSignal(list, list, int, int) # headers, results, totale, totale_pagine error = pyqtSignal(str) def __init__(self, session, titolo, tipo, distributore_id="", pagina=1): super().__init__() self._session = session self._titolo = titolo self._tipo = tipo self._distributore_id = distributore_id self._pagina = pagina def run(self): try: headers, results, total, total_pages = search( self._session, self._titolo, self._tipo, self._distributore_id, self._pagina ) self.done.emit(headers, results, total, total_pages) except SessionExpiredError as e: self.error.emit(str(e)) except Exception as e: self.error.emit(f"Errore ricerca: {e}") class _DetailWorker(QThread): done = pyqtSignal(dict) error = pyqtSignal(str) def __init__(self, session, product_id): super().__init__() self._session = session self._product_id = product_id def run(self): try: data = get_detail(self._session, self._product_id) self.done.emit(data) except SessionExpiredError as e: self.error.emit(str(e)) except Exception as e: self.error.emit(f"Errore caricamento dettaglio: {e}") class _BulkUpdateWorker(QThread): step = pyqtSignal(int, int) done = pyqtSignal(int, int) error = pyqtSignal(str) def __init__(self, session, product_ids: list[str], dist_id: str, dist_name: str): super().__init__() self._session = session self._product_ids = product_ids self._dist_id = dist_id self._dist_name = dist_name def run(self): ok = 0 errors = 0 total = len(self._product_ids) for i, pid in enumerate(self._product_ids, 1): try: form_data = get_detail(self._session, pid) form_data["distributore.id"] = self._dist_id form_data["distributore.ragioneSociale"] = self._dist_name save_detail(self._session, form_data) ok += 1 logger.info(f"Bulk update {i}/{total}: id={pid} OK") except Exception as e: errors += 1 logger.error(f"Bulk update {i}/{total}: id={pid} ERRORE — {e}") self.step.emit(i, total) self.done.emit(ok, errors) class MainWindow(QMainWindow): def __init__(self): super().__init__() self._session = GemmaSession() self._result_ids: list[str] = [] self._distributori: list[tuple[str, str]] = [] self._search_worker: _SearchWorker | None = None self._detail_worker: _DetailWorker | None = None self._distrib_worker: _DistributoriWorker | None = None self._bulk_worker: _BulkUpdateWorker | None = None self._current_page: int = 1 self._total_pages: int = 1 self._init_ui() def _init_ui(self): self.setWindowTitle("GemmaClient — Anagrafica Prodotti e Prestiti") self.setMinimumSize(1100, 650) central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) layout.setSpacing(8) layout.setContentsMargins(12, 12, 12, 12) layout.addLayout(self._build_session_bar()) sep = QFrame() sep.setFrameShape(QFrame.Shape.HLine) sep.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(sep) layout.addLayout(self._build_search_bar()) layout.addWidget(self._build_table()) layout.addLayout(self._build_pagination_bar()) self.statusBar().showMessage("Avvio in corso...") self._auto_connect() def _build_session_bar(self) -> QHBoxLayout: row = QHBoxLayout() self._status_label = QLabel("● Disconnesso") self._status_label.setStyleSheet("color: #c0392b; font-weight: bold;") row.addWidget(self._status_label) row.addStretch() self._session_btn = QPushButton("Inserisci chiave sessione") self._session_btn.setFixedWidth(200) self._session_btn.clicked.connect(self._open_session_dialog) row.addWidget(self._session_btn) return row def _build_search_bar(self) -> QVBoxLayout: outer = QVBoxLayout() outer.setSpacing(4) row1 = QHBoxLayout() row1.addWidget(QLabel("Titolo:")) self._titolo_input = QLineEdit() self._titolo_input.setPlaceholderText("es. PHREAKER") self._titolo_input.returnPressed.connect(self._search) row1.addWidget(self._titolo_input, stretch=1) row1.addWidget(QLabel("Tipo:")) self._tipo_combo = QComboBox() self._tipo_combo.addItem("Contiene", "contiene") self._tipo_combo.addItem("Uguale", "uguale") self._tipo_combo.addItem("Inizia per", "inizia") row1.addWidget(self._tipo_combo) self._search_btn = QPushButton("Cerca") self._search_btn.setFixedWidth(80) self._search_btn.setEnabled(False) self._search_btn.clicked.connect(self._search) row1.addWidget(self._search_btn) self._clear_btn = QPushButton("Pulisci") self._clear_btn.setFixedWidth(80) self._clear_btn.clicked.connect(self._pulisci) row1.addWidget(self._clear_btn) outer.addLayout(row1) row2 = QHBoxLayout() row2.addWidget(QLabel("Distributore:")) self._distributore_input = SearchableCombo() self._distributore_input.setPlaceholderText("Digita almeno 3 lettere per filtrare (lascia vuoto = tutti)") self._distributore_input.setMinimumWidth(380) self._distributore_input.setEnabled(False) row2.addWidget(self._distributore_input) row2.addStretch() self._bulk_btn = QPushButton("Cambia distributore...") self._bulk_btn.setFixedWidth(180) self._bulk_btn.setEnabled(False) self._bulk_btn.setToolTip("Seleziona una o più righe dalla griglia, poi clicca qui") self._bulk_btn.clicked.connect(self._on_bulk_distrib) row2.addWidget(self._bulk_btn) outer.addLayout(row2) return outer def _build_table(self) -> QTableWidget: self._table = QTableWidget() self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self._table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) self._table.setAlternatingRowColors(True) self._table.horizontalHeader().setStretchLastSection(True) self._table.verticalHeader().setVisible(False) self._table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self._table.itemDoubleClicked.connect(self._on_row_double_clicked) self._table.itemSelectionChanged.connect(self._on_selection_changed) return self._table def _build_pagination_bar(self) -> QHBoxLayout: row = QHBoxLayout() self._total_label = QLabel("") row.addWidget(self._total_label) row.addStretch() self._prev_btn = QPushButton("← Precedente") self._prev_btn.setFixedWidth(120) self._prev_btn.setEnabled(False) self._prev_btn.clicked.connect(self._go_prev) row.addWidget(self._prev_btn) self._page_label = QLabel("") self._page_label.setStyleSheet("margin: 0 8px;") row.addWidget(self._page_label) self._next_btn = QPushButton("Successivo →") self._next_btn.setFixedWidth(120) self._next_btn.setEnabled(False) self._next_btn.clicked.connect(self._go_next) row.addWidget(self._next_btn) return row # ── Sessione ──────────────────────────────────────────────────────────── def _auto_connect(self): try: with open(_SESSION_FILE) as f: jsid = json.load(f).get("jsessionid", "") if jsid: self._do_connect(jsid) return except FileNotFoundError: pass except Exception as e: logger.warning(f"Impossibile leggere session.json: {e}") self._open_session_dialog() def _open_session_dialog(self): dlg = SessionDialog(parent=self) try: with open(_SESSION_FILE) as f: saved = json.load(f).get("jsessionid", "") dlg.set_jsessionid(saved) except Exception: pass if dlg.exec() and dlg.jsessionid(): self._do_connect(dlg.jsessionid()) def _do_connect(self, jsid: str): self._session.connect(jsid) self._save_jsessionid(jsid) self._set_connected(True) self._load_distributori() def _save_jsessionid(self, jsid: str): try: with open(_SESSION_FILE, "w") as f: json.dump({"jsessionid": jsid}, f) except Exception as e: logger.warning(f"Impossibile salvare session.json: {e}") def _set_connected(self, ok: bool): if ok: self._status_label.setText("● Connesso") self._status_label.setStyleSheet("color: #27ae60; font-weight: bold;") self._search_btn.setEnabled(True) self.statusBar().showMessage("Connesso — cerca un prodotto") else: self._status_label.setText("● Disconnesso") self._status_label.setStyleSheet("color: #c0392b; font-weight: bold;") self._search_btn.setEnabled(False) self._distributore_input.setEnabled(False) self.statusBar().showMessage("Sessione scaduta") def _load_distributori(self): self._distrib_worker = _DistributoriWorker(self._session) self._distrib_worker.done.connect(self._on_distributori_loaded) self._distrib_worker.error.connect(lambda e: logger.warning(f"Distributori non caricati: {e}")) self._distrib_worker.start() def _on_distributori_loaded(self, items: list): self._distributori = items self._distributore_input.set_items(items) self._distributore_input.setEnabled(True) logger.info(f"Combo distributori popolata con {len(items)} voci") # ── Ricerca ───────────────────────────────────────────────────────────── def _get_distributore_id(self) -> str: return self._distributore_input.selected_id() def _pulisci(self): self._titolo_input.clear() self._tipo_combo.setCurrentIndex(0) self._distributore_input.reset() self._table.clearContents() self._table.setRowCount(0) self._table.setColumnCount(0) self._result_ids = [] self._current_page = 1 self._total_pages = 1 self._bulk_btn.setEnabled(False) self._update_pagination(0, 1, 1) self.statusBar().showMessage("Campi azzerati") def _search(self): titolo = self._titolo_input.text().strip() distributore_id = self._get_distributore_id() if not titolo and not distributore_id: QMessageBox.warning(self, "Attenzione", "Inserisci almeno un titolo o seleziona un distributore") return if not self._session.connected: QMessageBox.warning(self, "Attenzione", "Connettiti prima di cercare") return self._current_page = 1 self._run_search(self._current_page) def _run_search(self, pagina: int): titolo = self._titolo_input.text().strip() distributore_id = self._get_distributore_id() tipo = self._tipo_combo.currentData() self._search_btn.setEnabled(False) self._prev_btn.setEnabled(False) self._next_btn.setEnabled(False) self.statusBar().showMessage(f"Ricerca pagina {pagina}...") QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) self._search_worker = _SearchWorker(self._session, titolo, tipo, distributore_id, pagina) self._search_worker.done.connect(self._on_search_done) self._search_worker.error.connect(self._on_error) self._search_worker.start() def _go_prev(self): if self._current_page > 1: self._current_page -= 1 self._run_search(self._current_page) def _go_next(self): if self._current_page < self._total_pages: self._current_page += 1 self._run_search(self._current_page) def _on_search_done(self, headers: list, results: list[SearchResult], total: int, total_pages: int): QApplication.restoreOverrideCursor() self._search_btn.setEnabled(True) self._total_pages = total_pages self._result_ids = [r.product_id for r in results] self._bulk_btn.setEnabled(False) self._table.clear() if not results: self._table.setColumnCount(0) self._table.setRowCount(0) self._update_pagination(0, 1, 1) self.statusBar().showMessage("Nessun risultato trovato") return display_headers = [h for h in headers if h] self._table.setColumnCount(len(display_headers)) self._table.setHorizontalHeaderLabels(display_headers) self._table.setRowCount(len(results)) for row_idx, result in enumerate(results): for col_idx, cell in enumerate(result.columns[:len(display_headers)]): self._table.setItem(row_idx, col_idx, QTableWidgetItem(cell)) self._table.resizeColumnsToContents() self._update_pagination(total, self._current_page, total_pages) self.statusBar().showMessage( f"Pagina {self._current_page}/{total_pages} — doppio click per il dettaglio, Ctrl+click per selezione multipla" ) def _update_pagination(self, total: int, current: int, total_pages: int): if total > 0: self._total_label.setText(f"{total} risultati totali") else: self._total_label.setText("") if total_pages > 1: self._page_label.setText(f"Pagina {current} di {total_pages}") else: self._page_label.setText("") self._prev_btn.setEnabled(current > 1) self._next_btn.setEnabled(current < total_pages) def _on_selection_changed(self): selected = len(self._table.selectionModel().selectedRows()) self._bulk_btn.setEnabled(selected > 0 and bool(self._distributori)) # ── Dettaglio singolo ──────────────────────────────────────────────────── def _on_row_double_clicked(self, item: QTableWidgetItem): row = item.row() if row >= len(self._result_ids): return product_id = self._result_ids[row] if not product_id: QMessageBox.warning(self, "Attenzione", "ID prodotto non trovato per questa riga") return self.statusBar().showMessage(f"Caricamento dettaglio id={product_id}...") self._detail_worker = _DetailWorker(self._session, product_id) self._detail_worker.done.connect(self._on_detail_loaded) self._detail_worker.error.connect(self._on_error) self._detail_worker.start() def _on_detail_loaded(self, form_data: dict): self.statusBar().showMessage("Dettaglio caricato") dlg = DetailDialog(form_data, self._session, self._distributori, parent=self) dlg.saved.connect(self._refresh_search) dlg.exec() def _refresh_search(self): titolo = self._titolo_input.text().strip() distributore_id = self._get_distributore_id() if not titolo and not distributore_id: return self._run_search(self._current_page) # ── Cambia distributore multiplo ───────────────────────────────────────── def _on_bulk_distrib(self): selected_rows = sorted(set( idx.row() for idx in self._table.selectionModel().selectedRows() )) product_ids = [self._result_ids[r] for r in selected_rows if r < len(self._result_ids)] product_ids = [pid for pid in product_ids if pid] if not product_ids: return dlg = BulkDistribDialog(len(product_ids), self._distributori, parent=self) if dlg.exec() != BulkDistribDialog.DialogCode.Accepted: return dist_name, dist_id = dlg.result_distributore() if not dist_id: return self._bulk_btn.setEnabled(False) self._search_btn.setEnabled(False) self.statusBar().showMessage(f"Aggiornamento 0/{len(product_ids)}...") self._bulk_worker = _BulkUpdateWorker(self._session, product_ids, dist_id, dist_name) self._bulk_worker.step.connect(self._on_bulk_step) self._bulk_worker.done.connect(self._on_bulk_done) self._bulk_worker.error.connect(self._on_error) self._bulk_worker.start() def _on_bulk_step(self, current: int, total: int): self.statusBar().showMessage(f"Aggiornamento {current}/{total}...") def _on_bulk_done(self, ok: int, errors: int): self._search_btn.setEnabled(True) if errors: QMessageBox.warning(self, "Completato con errori", f"{ok} prodotti aggiornati, {errors} errori (vedi log).") else: QMessageBox.information(self, "Completato", f"{ok} prodott{'o aggiornato' if ok == 1 else 'i aggiornati'} con successo.") self._refresh_search() # ── Errori ─────────────────────────────────────────────────────────────── def _on_error(self, msg: str): QApplication.restoreOverrideCursor() self._search_btn.setEnabled(self._session.connected) logger.error(msg) if "scaduta" in msg.lower(): self._set_connected(False) self._open_session_dialog() else: self.statusBar().showMessage(f"Errore: {msg}") QMessageBox.critical(self, "Errore", msg)