# MyICR_Suite/app/modules/mediatrack/backend/server.py import sys if hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(encoding='utf-8', errors='replace') from flask import Flask, render_template, send_from_directory, jsonify, request, make_response, session, redirect, send_file from . import database_logic from functools import wraps import os import socket from loguru import logger app = Flask( __name__, static_folder='../frontend', template_folder='../frontend' ) # Configurazione sessioni app.secret_key = 'mediatrack_secret_key_2025_change_in_production' # IMPORTANTE: cambiare in produzione! app.config['SESSION_COOKIE_HTTPONLY'] = True app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # ================================================================= # >>> SYNC PERIODICO IN BACKGROUND # ================================================================= def _start_periodic_sync(interval_seconds=300): """Avvia un thread demone che fa push_offline_inserts ogni N secondi (default 5 min). Gira solo quando la rete è disponibile. Silenzioso — non blocca mai l'app.""" import threading, json as _json def _sync_loop(): import time # Attesa iniziale: lascia avviare Flask prima di partire time.sleep(30) while True: try: from app.config_loader import get_app_root flag = get_app_root() / "launcher_cache" / "network_status.json" online = True if flag.exists(): online = _json.loads(flag.read_text(encoding="utf-8")).get("online", True) if online: from launcher_src.shared_db_sync import push_offline_inserts n = push_offline_inserts() if n > 0: print(f"[sync] Push periodico: {n} prodotti sincronizzati") except Exception: pass # non bloccante time.sleep(interval_seconds) t = threading.Thread(target=_sync_loop, daemon=True, name="periodic-sync") t.start() if os.environ.get("MYICR_DEV_MODE") != "1": _start_periodic_sync() # ================================================================= # >>> PRE-WARM DUCKDB # ================================================================= def _prewarm_duckdb(): """Esegue query leggere sui parquet principali in background al boot per azzerare la latenza del primo accesso reale (diritti/emesso nella scheda prodotto).""" import threading, time def _warm(): time.sleep(5) # lascia completare il boot Flask try: from app.database.connection import db_manager if db_manager is None: return for view in ('imdb', 'diritti', 'emesso'): try: db_manager.execute_query(f'SELECT 1 FROM "{view}" LIMIT 1') logger.info(f"[prewarm] {view} OK") except Exception as e: logger.warning(f"[prewarm] {view}: {e}") except Exception as e: logger.warning(f"[prewarm] errore generale: {e}") threading.Thread(target=_warm, daemon=True, name="duckdb-prewarm").start() _prewarm_duckdb() # ================================================================= # >>> SYNC ALLEGATI IN BACKGROUND # ================================================================= def _sync_attachments(): """Copia in locale tutti gli allegati referenziati nel DB (fallback offline).""" import threading, time, shutil from pathlib import Path def _attachment_local_path(file_path: str, attachments_dir: Path) -> Path: """Mappa un path di rete al corrispondente path locale.""" p = Path(file_path) # Rimuove la lettera di drive (es. 'I:\') e usa il resto come path relativo parts = p.parts[1:] # es. ('MERCATI', 'Cannes 2026', 'file.pdf') return attachments_dir.joinpath(*parts) def _sync(): time.sleep(45) # dopo prewarm e sync DB try: from app.config_loader import get_app_root attachments_dir = get_app_root() / 'local_db' / 'attachments' attachments_dir.mkdir(parents=True, exist_ok=True) paths = database_logic.get_all_allegati_paths() copied, skipped, missing = 0, 0, 0 for src in paths: src_path = Path(src) if not src_path.exists(): missing += 1 continue dst = _attachment_local_path(src, attachments_dir) if dst.exists() and dst.stat().st_mtime >= src_path.stat().st_mtime: skipped += 1 continue dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(str(src_path), str(dst)) copied += 1 logger.info(f"[attachments] sync: {copied} copiati, {skipped} già aggiornati, {missing} non raggiungibili") except Exception as e: logger.warning(f"[attachments] errore sync: {e}") threading.Thread(target=_sync, daemon=True, name="attachments-sync").start() _sync_attachments() # ================================================================= # La logica è stata riorganizzata per mettere le rotte più specifiche prima # di quelle più generiche. Questo risolve il bug del 404. # ================================================================= # ================================================================= # >>> AUTHENTICATION MIDDLEWARE & FUNCTIONS # ================================================================= def get_windows_user(): """Ritorna il nome utente Windows della macchina corrente""" return os.environ.get('USERNAME') or os.environ.get('USER') or socket.gethostname() def login_required(f): """Decorator no-op: accesso libero, utente identificato da Windows username""" @wraps(f) def decorated_function(*args, **kwargs): return f(*args, **kwargs) return decorated_function # ================================================================= # >>> AUTHENTICATION ENDPOINTS # ================================================================= @app.route('/api/auth/check', methods=['GET']) def api_check_auth(): """Sempre autenticato: restituisce l'utente Windows corrente""" return jsonify({ "authenticated": True, "user": { "username": get_windows_user(), "ruolo": "user" } }) @app.route('/api/auth/logout', methods=['POST']) @app.route('/api/logout', methods=['POST']) def api_logout(): """No-op: non esiste più il login, mantiene compatibilità con eventuali chiamate""" return jsonify({"success": True}) @app.route('/api/network-status', methods=['GET']) def api_network_status(): """Stato rete: online/offline + data ultimo sync + prodotti offline pendenti.""" try: from app.config_loader import get_app_root import json as _json, datetime as _dt cache_dir = get_app_root() / "launcher_cache" online = True flag = cache_dir / "network_status.json" if flag.exists(): online = _json.loads(flag.read_text(encoding="utf-8")).get("online", True) # Data ultimo sync dal pull cache (mtime del file come proxy) last_sync = None pull_cache = cache_dir / "shared_db_pull.json" if pull_cache.exists(): ts = pull_cache.stat().st_mtime last_sync = _dt.datetime.fromtimestamp(ts).strftime("%d/%m/%Y %H:%M") pending = database_logic.get_pending_offline_count() if not online else 0 return jsonify({"online": online, "pending_offline": pending, "last_sync": last_sync}) except Exception: return jsonify({"online": True, "pending_offline": 0, "last_sync": None}) @app.route('/api/current-user', methods=['GET']) def api_current_user(): """Ritorna l'utente Windows corrente""" return jsonify({ "username": get_windows_user(), "windows_host": socket.gethostname(), "role": "user" }) # ================================================================= # >>> API GESTIONE CONTESTI # ================================================================= @app.route('/api/contesti', methods=['GET']) @login_required def api_get_contesti(): try: return jsonify({'contesti': database_logic.get_contesti()}) except Exception as e: return jsonify({'error': str(e)}), 500 @app.route('/api/contesti', methods=['POST']) @login_required def api_create_contesto(): try: data = request.get_json() ok, msg = database_logic.aggiungi_contesto( data.get('nome', ''), data.get('gemma_supporto'), data.get('ordine', 0), data.get('id_tipo_evento_default') or None ) return jsonify({'success': ok, 'message': msg}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/contesti/', methods=['PUT']) @login_required def api_update_contesto(id_contesto): try: data = request.get_json() ok, msg = database_logic.aggiorna_contesto( id_contesto, data.get('nome', ''), data.get('gemma_supporto'), data.get('attivo', 1), data.get('ordine', 0), data.get('id_tipo_evento_default') or None ) return jsonify({'success': ok, 'message': msg}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 @app.route('/api/contesti/', methods=['DELETE']) @login_required def api_delete_contesto(id_contesto): try: ok, msg = database_logic.elimina_contesto(id_contesto) return jsonify({'success': ok, 'message': msg}) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 # >>> API GESTIONE TIPI EVENTO # ================================================================= @app.route('/api/event-types', methods=['GET']) @login_required def api_get_event_types(): """GET: Recupera tutti i tipi evento (accessibile a tutti gli utenti autenticati)""" try: event_types = database_logic.get_all_event_types() return jsonify({"eventTypes": event_types}) except Exception as e: print(f"[ERROR] Errore get event types: {e}") return jsonify({"error": "Errore recupero tipi evento"}), 500 @app.route('/api/event-types', methods=['POST']) def api_create_event_type(): """POST: Crea un nuovo tipo evento""" try: data = request.json print(f"[DEBUG] Dati ricevuti: {data}") nome_evento = data.get('nome_evento') colore_hex = data.get('colore_hex') icona = data.get('icona', 'fa-calendar') descrizione = data.get('descrizione') funzione_speciale = data.get('funzione_speciale', 'none') campi_visibili = data.get('campi_visibili') print(f"[DEBUG] nome_evento: {nome_evento}, colore: {colore_hex}, icona: {icona}") if not nome_evento or not colore_hex: return jsonify({"success": False, "error": "Nome e colore sono obbligatori"}), 400 success, message, id_tipo_evento = database_logic.create_event_type( nome_evento=nome_evento, colore_hex=colore_hex, icona=icona, descrizione=descrizione, funzione_speciale=funzione_speciale, campi_visibili=campi_visibili, visibilita=data.get('visibilita', 'entrambi') ) print(f"[DEBUG] Risultato: success={success}, message={message}") if success: return jsonify({"success": True, "message": message, "id": id_tipo_evento}) else: return jsonify({"success": False, "error": message}), 400 except Exception as e: print(f"[ERROR] Errore create event type: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/event-types/', methods=['PUT']) def api_update_event_type(id_tipo_evento): """PUT: Aggiorna un tipo evento esistente""" try: data = request.json success, message = database_logic.update_event_type( id_tipo_evento=id_tipo_evento, nome_evento=data.get('nome_evento'), colore_hex=data.get('colore_hex'), icona=data.get('icona'), descrizione=data.get('descrizione'), funzione_speciale=data.get('funzione_speciale'), campi_visibili=data.get('campi_visibili'), visibilita=data.get('visibilita') ) if success: return jsonify({"success": True, "message": message}) else: return jsonify({"success": False, "error": message}), 400 except Exception as e: print(f"[ERROR] Errore update event type: {e}") return jsonify({"success": False, "error": "Errore aggiornamento tipo evento"}), 500 @app.route('/api/event-types/', methods=['DELETE']) def api_delete_event_type(id_tipo_evento): """DELETE: Elimina (disattiva) un tipo evento""" try: success, message = database_logic.delete_event_type(id_tipo_evento) if success: return jsonify({"success": True, "message": message}) else: return jsonify({"success": False, "error": message}), 400 except Exception as e: print(f"[ERROR] Errore delete event type: {e}") return jsonify({"success": False, "error": "Errore eliminazione tipo evento"}), 500 # --- 1. ROTTE API SPECIFICHE PER I DATI --- @app.route('/api/client-log', methods=['POST']) def api_client_log(): """Riceve log dal frontend JS e li scrive nel log server.""" try: data = request.json or {} msg = data.get('msg', '') extra = data.get('extra', {}) logger.info(f"[JS] {msg} | {extra}") return jsonify({'ok': True}) except Exception: return jsonify({'ok': False}), 500 @app.route('/api/filter-data') def api_get_filter_data(): """Endpoint API per recuperare i dati per i menu a tendina dei filtri.""" try: # Recupera il parametro includi_gemma dalla query string includi_gemma = request.args.get('includi_gemma', 'false').lower() == 'true' anno_min_gemma = request.args.get('anno_min_gemma', '') distributori = database_logic.get_distributori_unici(includi_gemma, anno_min_gemma) contesti = database_logic.get_contesti_unici() buyers = database_logic.get_buyers_unici() tipologie = database_logic.get_tipologie_uniche() return jsonify({ 'distributors': distributori, 'contexts': contesti, 'buyers': buyers, 'tipologie': tipologie }) except Exception as e: print(f"Errore nel recuperare i dati per i filtri: {e}") return jsonify({"error": "Impossibile caricare i dati dei filtri"}), 500 @app.route('/api/products') def api_get_products(): """Endpoint API per recuperare la lista dei prodotti filtrata.""" titolo = request.args.get('titolo', '') distributore = request.args.get('distributore', '') contesto = request.args.get('contesto', '') tipo_evento = request.args.get('tipo_evento', '') codice_imdb = request.args.get('codice_imdb', '') includi_gemma = request.args.get('includi_gemma', 'false').lower() == 'true' anno_min_gemma = request.args.get('anno_min_gemma', '') tipologia = request.args.get('tipologia', '') try: logger.info(f"[api/products] contesto={contesto!r} tipo_evento={tipo_evento!r} titolo={titolo!r} gemma={includi_gemma}") products = database_logic.get_dati_griglia_filtrati(titolo, distributore, contesto, includi_gemma, codice_imdb, anno_min_gemma, tipologia, tipo_evento) logger.info(f"[api/products] risultati={len(products)}") return jsonify(products) except Exception as e: logger.error(f"[api/products] Errore: {e} | params: titolo={titolo!r} distrib={distributore!r} contesto={contesto!r} tipo_evento={tipo_evento!r} gemma={includi_gemma} imdb={codice_imdb!r} anno={anno_min_gemma!r}") import traceback logger.debug(traceback.format_exc()) return jsonify({"error": str(e)}), 500 @app.route('/api/product/') def api_get_product_details(media_id: str): """Endpoint API per recuperare i dati completi di un singolo prodotto.""" print(f"==> Richiesta dettaglio per ID: {media_id}") # Debug # Se l'ID inizia con G_, è un prodotto Gemma if media_id.startswith('G_'): gemma_id = media_id[2:] # Rimuovi il prefisso G_ gemma_product = database_logic.get_prodotto_gemma_dettaglio(gemma_id) if gemma_product is None: return jsonify({"error": "Prodotto Gemma non trovato"}), 404 # Recupera anche i logs per i prodotti Gemma (ora supportati) _, logs, _ = database_logic.get_storia_prodotto(media_id) # Recupera dati OMDB per codice_imdb se disponibile omdb_data = None codice_imdb = gemma_product.get('codice_imdb') if gemma_product else None if codice_imdb: import sqlite3 as _sq con = database_logic._conn() con.row_factory = _sq.Row cur = con.cursor() cur.execute("SELECT * FROM OmdbData WHERE codice_imdb = ? LIMIT 1", (codice_imdb,)) row = cur.fetchone() con.close() if row: omdb_data = dict(row) return jsonify({'anagrafica': gemma_product, 'logs': logs, 'omdb_data': omdb_data}) # Altrimenti è un prodotto normale anagrafica, logs, omdb_data = database_logic.get_storia_prodotto(media_id) if anagrafica is None: return jsonify({"error": "Prodotto non trovato"}), 404 return jsonify({'anagrafica': anagrafica, 'logs': logs, 'omdb_data': omdb_data}) # ================================================================= # >>> DIRITTI E EMESSO (OnAir via DuckDB/Parquet) # ================================================================= def _get_prod_from_imdb(codice_imdb: str): """Bridge: codice_imdb → prod OnAir via imdb.parquet. Ritorna None se non trovato.""" from app.database.connection import db_manager if db_manager is None: return None try: df = db_manager.execute_query( "SELECT codice AS prod FROM imdb WHERE riferimento_imdb = ? LIMIT 1", (codice_imdb,) ) return str(df.iloc[0]['prod']) if not df.empty else None except Exception as e: logger.warning(f"[bridge imdb→prod] {e}") return None def _get_codice_imdb_for_media(media_id: str): """Legge codice_imdb da SQLite (MediaTrack) o gemma.parquet (Gemma) per un media_id.""" if media_id.startswith('G_'): gdf = database_logic._gemma_df( where=f"A_ID = '{media_id[2:].replace(chr(39), chr(39)+chr(39))}'", columns='A_COD_IMDB' ) if not gdf.empty: val = gdf.iloc[0]['A_COD_IMDB'] return val if val and str(val).strip() else None return None else: import sqlite3 as _sq con = database_logic._conn() con.row_factory = _sq.Row cur = con.cursor() cur.execute("SELECT codice_imdb FROM AnagraficaMedia WHERE id_media = ?", (media_id,)) row = cur.fetchone() con.close() return row['codice_imdb'] if row and row['codice_imdb'] else None @app.route('/api/product//diritti') def api_get_product_diritti(media_id: str): from app.database.connection import db_manager import pandas as pd if db_manager is None: return jsonify({"error": "Database parquet non disponibile"}), 500 codice_imdb = _get_codice_imdb_for_media(media_id) if not codice_imdb: return jsonify({"not_in_onair": True, "rows": []}) prod = _get_prod_from_imdb(codice_imdb) if not prod: return jsonify({"not_in_onair": True, "rows": []}) try: df = db_manager.execute_query(""" SELECT CAST(decr AS VARCHAR) AS decr, CAST(scad AS VARCHAR) AS scad, ragsoc_distr, perc, pass_cons_tot, pass_eff_tot, causale FROM diritti WHERE prod = ? ORDER BY decr DESC """, (prod,)) rows = df.where(pd.notnull(df), None).to_dict(orient='records') return jsonify({"not_in_onair": False, "prod": prod, "rows": rows}) except Exception as e: logger.error(f"[diritti query] {e}") return jsonify({"error": str(e)}), 500 @app.route('/api/product//emesso') def api_get_product_emesso(media_id: str): from app.database.connection import db_manager from datetime import date import pandas as pd if db_manager is None: return jsonify({"error": "Database parquet non disponibile"}), 500 codice_imdb = _get_codice_imdb_for_media(media_id) if not codice_imdb: return jsonify({"not_in_onair": True, "rows": [], "total": 0}) prod = _get_prod_from_imdb(codice_imdb) if not prod: return jsonify({"not_in_onair": True, "rows": [], "total": 0}) all_data = request.args.get('all', 'false').lower() == 'true' today = date.today() cutoff = date(today.year - 3, today.month, today.day).isoformat() try: if all_data: df = db_manager.execute_query(""" SELECT CAST(data_emissione AS VARCHAR) AS data_emissione, rete, CAST(ora_inizio AS VARCHAR) AS ora_inizio, fascia, audience, share FROM emesso WHERE prodotto = ? ORDER BY data_emissione DESC """, (prod,)) else: df = db_manager.execute_query(""" SELECT CAST(data_emissione AS VARCHAR) AS data_emissione, rete, CAST(ora_inizio AS VARCHAR) AS ora_inizio, fascia, audience, share FROM emesso WHERE prodotto = ? AND data_emissione >= ? ORDER BY data_emissione DESC """, (prod, cutoff)) rows = df.where(pd.notnull(df), None).to_dict(orient='records') return jsonify({ "not_in_onair": False, "prod": prod, "rows": rows, "filtered": not all_data, "cutoff": cutoff }) except Exception as e: logger.error(f"[emesso query] {e}") return jsonify({"error": str(e)}), 500 # ================================================================= # >>> MANUTENZIONE OMDB # ================================================================= @app.route('/api/omdb/purge-stubs', methods=['POST']) def api_purge_omdb_stubs(): """Elimina i record OmdbData stub (tutti i campi chiave N/A).""" try: deleted = database_logic.purge_omdb_stubs() return jsonify({"success": True, "deleted": deleted, "message": f"Eliminati {deleted} record stub OmdbData."}) except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 # ================================================================= # >>> NUOVO ENDPOINT per scaricare dati da OMDB # ================================================================= @app.route('/api/product//import-omdb', methods=['POST']) def api_import_omdb_data(media_id: str): """ Endpoint API per importare i dati da OMDB e salvarli direttamente nel DB. """ imdb_id = request.json.get('imdb_id') if not imdb_id: return jsonify({"success": False, "message": "ID IMDb non fornito."}), 400 try: print(f"[DEBUG] Importazione OMDB per media_id={media_id}, imdb_id={imdb_id}") successo, messaggio = database_logic.importa_e_salva_da_omdb(media_id, imdb_id) print(f"[DEBUG] Risultato: successo={successo}, messaggio={messaggio}") if successo: return jsonify({"success": True, "message": messaggio}), 200 else: return jsonify({"success": False, "message": messaggio}), 500 except Exception as e: print(f"[ERROR] Eccezione in api_import_omdb_data: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "message": str(e)}), 500 # ================================================================= # >>> NUOVO ENDPOINT per modificare l'anagrafica # ================================================================= @app.route('/api/product/', methods=['PUT']) def api_update_product_anagrafica(media_id: str): """Endpoint API per modificare i dati anagrafici di un prodotto.""" dati_modificati = request.json print(f"==> Ricevuta richiesta PUT per modificare anagrafica di {media_id}") successo, messaggio = database_logic.modifica_anagrafica(media_id, dati_modificati) if successo: return jsonify({"success": True, "message": messaggio}), 200 else: return jsonify({"success": False, "message": messaggio}), 500 @app.route('/api/product//merge-into/', methods=['POST']) def api_merge_products(mt_id: str, gemma_id: str): """Riconcilia un prodotto MT con uno Gemma: sposta gli eventi e cancella il record MT.""" successo, messaggio, n_eventi = database_logic.riconcilia_prodotti(mt_id, gemma_id) if successo: return jsonify({"success": True, "message": messaggio, "n_eventi": n_eventi}), 200 return jsonify({"success": False, "message": messaggio}), 400 @app.route('/api/product/', methods=['DELETE']) def api_delete_product(media_id: str): """ Elimina completamente un prodotto MediaTrack (CASCADE). ATTENZIONE: Operazione IRREVERSIBILE! Elimina: - Anagrafica prodotto - Tutti gli eventi associati - Dati OMDb - Associazioni sceneggiature - Sgancia items carrello (stato -> 'orphaned') Response (200): { "success": true, "message": "Prodotto 'Titolo' (FILM-123) eliminato con successo", "stats": { "eventi": 5, "omdb": 1, "sceneggiature": 2, "carrello_items": 0 } } Response (400): { "success": false, "message": "Impossibile eliminare prodotti Gemma (read-only)" } Response (404): { "success": false, "message": "Prodotto FILM-123 non trovato" } """ print(f"==> Ricevuta richiesta DELETE per eliminare prodotto {media_id}") successo, messaggio, stats = database_logic.elimina_prodotto(media_id) if successo: return jsonify({ "success": True, "message": messaggio, "stats": stats }), 200 else: # Determina status code appropriato if "non trovato" in messaggio.lower(): status_code = 404 elif "impossibile" in messaggio.lower() or "gemma" in messaggio.lower(): status_code = 400 else: status_code = 500 return jsonify({ "success": False, "message": messaggio }), status_code @app.route('/api/product//logs', methods=['POST']) def api_add_log_to_product(media_id: str): """ Endpoint API per aggiungere un nuovo evento (log) a un prodotto. Riceve i dati del nuovo evento nel corpo della richiesta come JSON. """ # request.json contiene i dati inviati dal frontend dati_nuovo_evento = request.json print(f"==> Ricevuta richiesta POST per aggiungere evento a {media_id}: {dati_nuovo_evento}") # NOTA: Dal 31/10/2025 LogAggiornamenti non ha più FK verso AnagraficaMedia # Quindi possiamo creare eventi sia per prodotti MediaTrack che Gemma # senza necessità di importarli prima successo, messaggio = database_logic.aggiungi_aggiornamento(media_id, dati_nuovo_evento) if successo: return jsonify({"success": True, "message": messaggio}), 201 # 201 = Created else: return jsonify({"success": False, "message": messaggio}), 500 # 500 = Internal Server Error @app.route('/api/logs/', methods=['PUT']) def api_update_log(log_id: int): """ Endpoint API per modificare un evento esistente. 'PUT' è il metodo standard per l'aggiornamento di una risorsa esistente. """ dati_modificati = request.json print(f"==> Ricevuta richiesta PUT per modificare log {log_id}: {dati_modificati}") successo, messaggio = database_logic.modifica_aggiornamento(log_id, dati_modificati) if successo: return jsonify({"success": True, "message": messaggio}), 200 # 200 = OK else: return jsonify({"success": False, "message": messaggio}), 500 @app.route('/api/logs/', methods=['DELETE']) def api_delete_log(log_id: int): """ Endpoint API per eliminare un evento esistente. 'DELETE' è il metodo standard per la cancellazione. """ print(f"==> Ricevuta richiesta DELETE per eliminare log {log_id}") successo, messaggio = database_logic.elimina_aggiornamento(log_id) if successo: return jsonify({"success": True, "message": messaggio}), 200 # 200 = OK else: # Se l'evento non è stato trovato, potremmo restituire un 404 if "Nessun evento trovato" in messaggio: return jsonify({"success": False, "message": messaggio}), 404 return jsonify({"success": False, "message": messaggio}), 500 # ================================================================= # >>> ENDPOINT PER DISTRIBUTORI # ================================================================= @app.route('/api/distributors') def api_get_distributors(): """ Endpoint API per recuperare distributori. Se viene passato il parametro 'nome', restituisce un singolo distributore (lookup esatto). Altrimenti restituisce la lista completa filtrata. Query Parameters: - nome (string, opzionale): Nome distributore per lookup esatto - includi_gemma (bool, opzionale): Include distributori da Gemma Response per nome singolo: { "id_distributore": 5, "nome": "Blue Fox" } Response per lista: [ {"id_distributore": 1, "nome": "Fox", ...}, {"id_distributore": 2, "nome": "Eagle", ...} ] """ nome = request.args.get('nome', '') includi_gemma = request.args.get('includi_gemma', 'false').lower() == 'true' # Restituisci lista filtrata (usa LIKE per ricerca parziale) distributors = database_logic.get_distributori_lista(nome, includi_gemma) return jsonify(distributors) @app.route('/api/lineup-contesti') def api_lineup_contesti(): """Restituisce i contesti che hanno almeno un evento LINE-UP (+ flag per quelli senza contesto).""" rows = database_logic.get_contesti_con_lineup() return jsonify(rows) @app.route('/api/lineup-by-contesto') def api_lineup_by_contesto(): """Restituisce tutti gli eventi LINE-UP per un dato contesto (mercato). Passare contesto=__nessuno__ per estrarre LINE-UP senza contesto.""" contesto = request.args.get('contesto', '').strip() if not contesto: return jsonify([]) rows = database_logic.get_lineup_by_contesto(contesto) return jsonify(rows) @app.route('/api/distributor/') def api_get_distributor_details(distributor_id: int): """Endpoint API per recuperare i dettagli completi di un distributore.""" print(f"==> Richiesta dettaglio distributore ID: {distributor_id}") # Parametro opzionale per includere eventi prodotti include_prodotti = request.args.get('include_prodotti', 'false').lower() == 'true' distributore = database_logic.get_dettaglio_distributore(distributor_id) if distributore is None: return jsonify({"error": "Distributore non trovato"}), 404 prodotti_mt = database_logic.get_prodotti_by_distributore_nome(distributore['nome']) prodotti_gemma = database_logic.get_prodotti_distributore_gemma(distributore['nome']) prodotti = sorted( prodotti_mt + prodotti_gemma, key=lambda p: (p.get('titolo_ufficiale') or '').lower() ) eventi = database_logic.get_eventi_distributore(distributor_id, include_prodotti) return jsonify({ 'distributore': distributore, 'prodotti': prodotti, 'eventi': eventi }) @app.route('/api/distributor-by-name') def api_get_distributor_by_name(): """Endpoint API per recuperare i dettagli di un distributore per nome.""" try: nome_distributore = request.args.get('nome', '') fonte = request.args.get('fonte', 'mediatrack') print(f"==> Richiesta dettaglio distributore per nome: {nome_distributore}, fonte: {fonte}") if fonte == 'gemma': distributore = database_logic.get_dettaglio_distributore_gemma(nome_distributore) if distributore is None: return jsonify({"error": "Distributore non trovato nel database Gemma"}), 404 prodotti_gemma = database_logic.get_prodotti_distributore_gemma(nome_distributore) prodotti_mt = database_logic.get_prodotti_by_distributore_nome(nome_distributore) prodotti = sorted( prodotti_gemma + prodotti_mt, key=lambda p: (p.get('titolo_ufficiale') or '').lower() ) distributore['num_prodotti'] = len(prodotti) eventi = database_logic.get_eventi_by_distributore_nome(nome_distributore) return jsonify({ 'distributore': distributore, 'prodotti': prodotti, 'eventi': eventi }) elif fonte == 'entrambi': # Cerca in MediaTrack e Gemma prodotti_mt = database_logic.get_prodotti_by_distributore_nome(nome_distributore) prodotti_gemma = database_logic.get_prodotti_distributore_gemma(nome_distributore) # Combina e ordina per titolo (case-insensitive) prodotti = sorted( prodotti_mt + prodotti_gemma, key=lambda p: (p.get('titolo_ufficiale') or '').lower() ) # Recupera id_distributore da MediaTrack dist_record = database_logic.get_distributore_by_nome(nome_distributore) # Crea un oggetto distributore aggregato distributore = { 'nome': nome_distributore, 'fonte': 'entrambi', 'num_prodotti': len(prodotti), 'id_distributore': dist_record.get('id_distributore') if dist_record else None } # Eventi solo da MediaTrack eventi = database_logic.get_eventi_by_distributore_nome(nome_distributore) return jsonify({ 'distributore': distributore, 'prodotti': prodotti, 'eventi': eventi }) else: # Solo MediaTrack prodotti_mt = database_logic.get_prodotti_by_distributore_nome(nome_distributore) if not prodotti_mt: return jsonify({"error": "Distributore non trovato in MediaTrack"}), 404 dist_record = database_logic.get_distributore_by_nome(nome_distributore) prodotti_gemma = database_logic.get_prodotti_distributore_gemma(nome_distributore) prodotti = sorted( prodotti_mt + prodotti_gemma, key=lambda p: (p.get('titolo_ufficiale') or '').lower() ) distributore = { 'nome': nome_distributore, 'fonte': 'mediatrack', 'num_prodotti': len(prodotti), 'id_distributore': dist_record.get('id_distributore') if dist_record else None } print(f"==> Chiamata get_eventi_by_distributore_nome per {nome_distributore}") eventi = database_logic.get_eventi_by_distributore_nome(nome_distributore) print(f"==> Trovati {len(eventi)} eventi") return jsonify({ 'distributore': distributore, 'prodotti': prodotti, 'eventi': eventi }) except Exception as e: import traceback error_trace = traceback.format_exc() print(f"==> ERRORE in api_get_distributor_by_name: {str(e)}") print(error_trace) return jsonify({"error": f"Errore interno del server: {str(e)}"}), 500 @app.route('/api/distributor-gemma/') def api_get_distributor_gemma_details(nome_distributore: str): """Endpoint API per recuperare i dettagli di un distributore dal database Gemma.""" print(f"==> Richiesta dettaglio distributore Gemma: {nome_distributore}") distributore = database_logic.get_dettaglio_distributore_gemma(nome_distributore) if distributore is None: return jsonify({"error": "Distributore non trovato nel database Gemma"}), 404 prodotti = database_logic.get_prodotti_distributore_gemma(nome_distributore) return jsonify({ 'distributore': distributore, 'prodotti': prodotti, 'eventi': [] # Nessun evento per distributori Gemma }) @app.route('/api/distributor/', methods=['PUT']) def api_update_distributor(distributor_id: int): """Endpoint API per modificare i dati di un distributore.""" dati_modificati = request.json print(f"==> Ricevuta richiesta PUT per modificare distributore {distributor_id}") successo, messaggio = database_logic.modifica_distributore(distributor_id, dati_modificati) if successo: return jsonify({"success": True, "message": messaggio}), 200 else: return jsonify({"success": False, "message": messaggio}), 500 @app.route('/api/distributor//event', methods=['POST']) def api_add_distributor_event(distributor_id: int): """Endpoint API per aggiungere un evento a livello distributore.""" dati_evento = request.json print(f"==> Ricevuta richiesta POST per aggiungere evento al distributore {distributor_id}") successo, messaggio = database_logic.aggiungi_evento_distributore(distributor_id, dati_evento) if successo: return jsonify({"success": True, "message": messaggio}), 201 else: return jsonify({"success": False, "message": messaggio}), 500 @app.route('/api/distributor-by-name/event', methods=['POST']) def api_add_distributor_event_by_name(): """Aggiunge un evento a un distributore identificato per nome. Crea automaticamente il record in Distributori se non esiste.""" nome_distributore = request.args.get('nome', '') dati_evento = request.json print(f"==> Aggiunta evento per distributore (by name): {nome_distributore}") try: id_distributore = database_logic.get_or_create_distributore(nome_distributore) successo, messaggio = database_logic.aggiungi_evento_distributore(id_distributore, dati_evento) if successo: return jsonify({"success": True, "message": messaggio, "id_distributore": id_distributore}), 201 else: return jsonify({"success": False, "message": messaggio}), 500 except Exception as e: return jsonify({"success": False, "message": str(e)}), 500 @app.route('/api/event/', methods=['PUT']) def api_update_event(event_id: int): """Endpoint API per modificare un evento (prodotto o distributore).""" dati_evento = request.json print(f"==> Ricevuta richiesta PUT per modificare evento {event_id}") successo, messaggio = database_logic.modifica_aggiornamento(event_id, dati_evento) if successo: return jsonify({"success": True, "message": messaggio}), 200 else: return jsonify({"success": False, "message": messaggio}), 500 @app.route('/api/event/', methods=['DELETE']) def api_delete_event(event_id: int): """Endpoint API per eliminare un evento (prodotto o distributore).""" print(f"==> Ricevuta richiesta DELETE per eliminare evento {event_id}") successo, messaggio = database_logic.elimina_aggiornamento(event_id) if successo: return jsonify({"success": True, "message": messaggio}), 200 else: return jsonify({"success": False, "message": messaggio}), 500 # ================================================================= # >>> ENDPOINT PER INSERIMENTO PRODOTTI (MILESTONE 2 & 3) # ================================================================= @app.route('/api/products', methods=['POST']) @app.route('/api/product/insert', methods=['POST']) # Alias per retrocompatibilità def api_create_product(): """ Crea un nuovo prodotto con anagrafica ed evento iniziale. Supporta due formati di request body: Formato 1 (strutturato): { "anagrafica": { "titolo_ufficiale": "XENO", "tipo": "Film", "distributore_nome": "BLUE FOX", "codice_imdb": "tt27629220", "budget_produzione": "7mln" }, "evento": { "id_tipo_evento_fk": 1, "data_evento": "2025-02-17", "dettaglio_contesto": "EFM 2025", "costo_richiesto_ita": 75000.0, "note": "...", "buyer_contatto": "Andrea" } } Formato 2 (flat - retrocompatibilità): { "titolo_ufficiale": "XENO", "tipo": "Film", "distributore_nome": "BLUE FOX", ... } Response Success (201): { "success": true, "message": "Prodotto creato con successo.", "id_media": "FILM-1759744791528" } Response Error (400/500): { "success": false, "message": "Errore: [descrizione]" } """ try: dati = request.json print(f"==> POST /api/products: Creazione nuovo prodotto") # Validazione base if not dati: return jsonify({"success": False, "message": "Request body mancante"}), 400 # Supporta due formati: strutturato (anagrafica/evento) o flat if 'anagrafica' in dati and 'evento' in dati: # Formato strutturato — evento esplicito dati_anagrafica = dati['anagrafica'] dati_evento = dati['evento'] skip_evento = False else: # Formato flat — crea evento solo se almeno un campo evento è valorizzato dati_anagrafica = { 'titolo_ufficiale': dati.get('titolo_ufficiale'), 'tipo': dati.get('tipo', 'Film'), 'distributore_nome': dati.get('distributore_nome'), 'codice_imdb': dati.get('codice_imdb'), 'budget_produzione': dati.get('budget_produzione'), 'formato': dati.get('formato'), 'piattaforma': dati.get('piattaforma'), 'anno_produzione': dati.get('anno_produzione'), 'durata': dati.get('durata'), 'paesi': dati.get('paesi'), 'generi': dati.get('generi'), 'registi': dati.get('registi'), 'attori': dati.get('attori'), 'trama': dati.get('trama'), } dati_evento = { 'id_tipo_evento_fk': dati.get('id_tipo_evento_fk', 1), 'data_evento': dati.get('data_evento'), 'dettaglio_contesto': dati.get('dettaglio_contesto'), 'costo_richiesto_ita': dati.get('costo_richiesto_ita'), 'costo_richiesto_spa': dati.get('costo_richiesto_spa'), 'note': dati.get('note'), 'buyer_contatto': dati.get('buyer_contatto'), 'percorso_file': dati.get('percorso_file') } evento_fields = ('data_evento', 'dettaglio_contesto', 'costo_richiesto_ita', 'costo_richiesto_spa', 'note', 'buyer_contatto') skip_evento = not any(dati.get(f) for f in evento_fields) # Validazione titolo obbligatorio if not dati_anagrafica.get('titolo_ufficiale'): return jsonify({"success": False, "message": "Campo 'titolo_ufficiale' obbligatorio"}), 400 # Validazione distributore: deve esistere in Gemma distributore_nome = dati_anagrafica.get('distributore_nome') if distributore_nome: if not database_logic.is_distributore_in_gemma(distributore_nome): return jsonify({ "success": False, "message": f"Il distributore '{distributore_nome}' non esiste in Gemma. Seleziona un distributore valido dalla lista." }), 400 successo, messaggio, id_media = database_logic.inserisci_prodotto_completo(dati_anagrafica, dati_evento, skip_evento=skip_evento) if successo: return jsonify({"success": True, "message": messaggio, "id_media": id_media}), 201 else: return jsonify({"success": False, "message": messaggio}), 400 except Exception as e: print(f"Errore in api_create_product: {e}") return jsonify({"success": False, "message": f"Errore server: {str(e)}"}), 500 @app.route('/api/products/search') @app.route('/api/product/search-duplicates') # Alias per retrocompatibilità def api_search_products(): """ Cerca prodotti per matching duplicati. Query Parameters: - titolo (string, opzionale) - distributore (string, opzionale) - codice_imdb (string, opzionale) Esempio: /api/products/search?titolo=xeno&distributore=blue Response (200): { "results": [ { "id_media": "FILM-1759744791528", "titolo_ufficiale": "XENO", "tipo": "Film", "distributore_nome": "BLUE FOX", "codice_imdb": "tt27629220", "ultimo_asking_ita": 75000.0, "ultimo_asking_spa": null } ] } Response Error (500): { "error": "Errore nella ricerca" } """ try: titolo = request.args.get('titolo', '') distributore = request.args.get('distributore', '') codice_imdb = request.args.get('codice_imdb', '') includi_gemma = request.args.get('includi_gemma', 'false').lower() == 'true' risultati = database_logic.cerca_prodotto_per_matching( titolo=titolo if titolo else None, distributore=distributore if distributore else None, codice_imdb=codice_imdb if codice_imdb else None, includi_gemma=includi_gemma ) return jsonify({"results": risultati}), 200 except Exception as e: print(f"Errore in api_search_products: {e}") return jsonify({"error": "Errore nella ricerca"}), 500 @app.route('/api/buyers') def api_get_buyers(): """ Recupera lista buyer unici da LogAggiornamenti. Response (200): { "buyers": ["Andrea", "Clemente", "Mercedes"] } Response Error (500): { "error": "Errore nel recupero buyer" } """ try: buyers = database_logic.get_buyers_unici() return jsonify({"buyers": buyers}), 200 except Exception as e: print(f"Errore in api_get_buyers: {e}") return jsonify({"error": "Errore nel recupero buyer"}), 500 # ================================================================= # >>> ENDPOINT PER MANUTENZIONE (MILESTONE 3) # ================================================================= @app.route('/api/maintenance/products-no-imdb') def api_get_products_no_imdb(): """ Endpoint per ottenere la lista di prodotti senza codice IMDb. """ try: prodotti = database_logic.get_prodotti_senza_imdb() return jsonify(prodotti), 200 except Exception as e: print(f"Errore in api_get_products_no_imdb: {e}") return jsonify({"error": "Errore nel recupero dei prodotti"}), 500 @app.route('/api/omdb/search') def api_omdb_search(): """ Endpoint per cercare titoli su OMDB. Restituisce una lista di risultati. """ titolo = request.args.get('title') tipo = request.args.get('type') # Nuovo parametro if not titolo: return jsonify({"error": "Parametro 'title' mancante"}), 400 try: # Passiamo anche il tipo alla funzione di logica risultati = database_logic.cerca_imdb_per_titolo(titolo, tipo=tipo) if risultati is None: return jsonify([]), 200 # Restituisce lista vuota se OMDB non trova nulla return jsonify(risultati), 200 except Exception as e: print(f"Errore in api_omdb_search: {e}") return jsonify({"error": f"Errore server: {str(e)}"}), 500 @app.route('/api/omdb/lookup') def api_omdb_lookup(): """Fetch dati OMDB per IMDb ID senza salvare nel DB.""" imdb_id = request.args.get('imdb_id', '').strip() if not imdb_id: return jsonify({"error": "Parametro 'imdb_id' mancante"}), 400 try: dati = database_logic.scarica_dati_omdb(imdb_id) if not dati or dati.get('Response') == 'False': return jsonify({"found": False, "error": dati.get('Error', 'Non trovato')}), 200 return jsonify({"found": True, "data": dati}), 200 except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/api/maintenance/pre-scan-imdb', methods=['POST']) def api_pre_scan_imdb(): """ Avvia la scansione preliminare per contrassegnare i prodotti non trovabili su IMDb. """ try: print("==> Avvio scansione preliminare IMDb (fast, solo DB)...") prodotti = database_logic.get_prodotti_senza_imdb() report = {"checked": len(prodotti), "flagged": 0} print(f"==> Scansione preliminare completata: {report}") return jsonify({ "success": True, "message": "Scansione preliminare completata.", "report": report }), 200 except Exception as e: print(f"Errore in api_pre_scan_imdb: {e}") return jsonify({"success": False, "message": f"Errore server: {str(e)}"}), 500 @app.route('/api/maintenance/mark-imdb-not-found', methods=['POST']) def api_mark_imdb_not_found(): """Marca un prodotto come non trovabile su OMDB (imposta status = -1).""" try: data = request.json or {} id_media = data.get('id_media') if not id_media: return jsonify({"success": False, "message": "Campo 'id_media' mancante"}), 400 successo, messaggio = database_logic.marca_imdb_non_trovabile(id_media) status = 200 if successo else 404 return jsonify({"success": successo, "message": messaggio}), status except Exception as e: return jsonify({"success": False, "message": f"Errore server: {str(e)}"}), 500 @app.route('/api/maintenance/mark-imdb-found', methods=['POST']) def api_mark_imdb_found(): """Marca un prodotto come trovabile su OMDB (imposta status = 1).""" try: data = request.json or {} id_media = data.get('id_media') if not id_media: return jsonify({"success": False, "message": "Campo 'id_media' mancante"}), 400 successo, messaggio = database_logic.marca_imdb_match_trovato(id_media) status = 200 if successo else 404 return jsonify({"success": successo, "message": messaggio}), status except Exception as e: return jsonify({"success": False, "message": f"Errore server: {str(e)}"}), 500 # ================================================================= # >>> SCREENPLAY MANAGEMENT ENDPOINTS # ================================================================= @app.route('/api/screenplays/scan', methods=['POST']) @login_required def api_scan_screenplays(): """ Scansiona una directory per trovare tutti i file PDF sceneggiature. Request body: { "root_directory": "C:\\Path\\To\\Screenplays" } Response: { "success": true, "pdf_count": 42, "pdfs": [ { "filename": "Inception.pdf", "full_path": "C:\\..\\Inception.pdf", "relative_path": "Fornitor\\Inception.pdf", "parent_folder": "FernitoreA", "size_mb": 2.5 }, ... ] } """ try: data = request.json or {} root_directory = data.get('root_directory') if not root_directory: return jsonify({ "success": False, "error": "Directory radice mancante" }), 400 print(f"[SCREENPLAY] Richiesta scansione directory: {root_directory}") # Chiama funzione di scansione result = database_logic.scan_pdf_directory(root_directory) if result['success']: print(f"[SCREENPLAY] Trovati {result['pdf_count']} PDF") # Recupera i percorsi già associati in LogAggiornamenti conn = database_logic._conn() cur = conn.cursor() cur.execute(""" SELECT DISTINCT percorso_file FROM LogAggiornamenti WHERE percorso_file IS NOT NULL AND percorso_file != '' """) associated_paths = {row[0] for row in cur.fetchall()} conn.close() # Marca i PDF già associati associated_count = 0 for pdf in result.get('pdfs', []): pdf['associated'] = pdf['full_path'] in associated_paths if pdf['associated']: associated_count += 1 result['associated_count'] = associated_count print(f"[SCREENPLAY] Di cui già associati: {associated_count}") return jsonify(result), 200 else: return jsonify(result), 400 except Exception as e: print(f"[ERROR] api_scan_screenplays: {e}") import traceback traceback.print_exc() return jsonify({ "success": False, "error": str(e) }), 500 @app.route('/api/screenplays/associate', methods=['POST']) @login_required def api_associate_screenplay(): """ Associa una sceneggiatura PDF a un prodotto MediaTrack/Gemma. Crea un evento di tipo SCENEGGIATURA (id_tipo_evento_fk=3). Request body: { "pdf_path": "C:\\Path\\To\\screenplay.pdf", "product_id": "MT_00123", "product_title": "Inception" } Response: { "success": true, "event_id": 456, "message": "Sceneggiatura associata con successo" } """ try: data = request.json or {} pdf_path = data.get('pdf_path') product_id = data.get('product_id') product_title = data.get('product_title') # Validazione if not pdf_path or not product_id or not product_title: return jsonify({ "success": False, "error": "Parametri mancanti: pdf_path, product_id, product_title richiesti" }), 400 # Verifica che il file esista import os if not os.path.exists(pdf_path): return jsonify({ "success": False, "error": f"File PDF non trovato: {pdf_path}" }), 404 print(f"[SCREENPLAY] Associazione sceneggiatura: {pdf_path} -> {product_title} ({product_id})") # Prodotti Gemma: auto-import silenzioso in AnagraficaMedia se non ancora presenti if product_id.startswith('G_'): a_id = product_id[2:] # strip 'G_' conn = database_logic._conn() cur = conn.cursor() cur.execute("SELECT id_media FROM AnagraficaMedia WHERE id_media = ?", (product_id,)) if not cur.fetchone(): # Leggi dati da gemma.parquet via DuckDB a_id_esc = a_id.replace("'", "''") gdf = database_logic._gemma_df( where=f"A_ID = '{a_id_esc}'", columns='A_ID, A_TITOLO, A_TIPOLOGIA, A_DISTRIBUTORE, A_COD_IMDB' ) if gdf.empty: conn.close() return jsonify({"success": False, "error": f"Prodotto Gemma '{product_title}' non trovato"}), 404 r = gdf.iloc[0] cur.execute( "INSERT OR IGNORE INTO AnagraficaMedia (id_media, titolo_ufficiale, tipo, distributore_nome, codice_imdb) VALUES (?,?,?,?,?)", ( 'G_' + str(r['A_ID']), r.get('A_TITOLO'), str(r.get('A_TIPOLOGIA') or 'FILM').upper(), r.get('A_DISTRIBUTORE'), r.get('A_COD_IMDB'), ) ) conn.commit() if cur.rowcount == 0: conn.close() return jsonify({"success": False, "error": f"Prodotto Gemma '{product_title}' non trovato"}), 404 print(f"[SCREENPLAY] Auto-import prodotto Gemma {product_id} in AnagraficaMedia") conn.close() # Cerca il tipo evento SCENEGGIATURA per nome from datetime import datetime conn = database_logic._conn() cur = conn.cursor() cur.execute(""" SELECT id_tipo_evento FROM TipiEvento WHERE UPPER(nome_evento) LIKE '%SCENEGGIATURA%' OR UPPER(nome_evento) LIKE '%SCREENPLAY%' LIMIT 1 """) row = cur.fetchone() conn.close() if not row: return jsonify({ "success": False, "error": "Tipo evento 'Sceneggiatura' non trovato nel database. Crealo prima in Editor Tipi Evento." }), 400 id_tipo_sceneggiatura = row[0] import json as _json today = datetime.now().strftime('%Y-%m-%d') # Se esiste già un evento SCENEGGIATURA per questo prodotto oggi, aggiungi il file a quello conn = database_logic._conn() cur = conn.cursor() cur.execute(""" SELECT id_aggiornamento, percorso_file FROM LogAggiornamenti WHERE id_media_fk = ? AND id_tipo_evento_fk = ? AND date(data_evento) = ? ORDER BY id_aggiornamento DESC LIMIT 1 """, (product_id, id_tipo_sceneggiatura, today)) existing = cur.fetchone() if existing: existing_id, existing_files = existing try: files = _json.loads(existing_files) if existing_files and existing_files.strip().startswith('[') else ([existing_files] if existing_files else []) except Exception: files = [existing_files] if existing_files else [] if pdf_path not in files: files.append(pdf_path) cur.execute("UPDATE LogAggiornamenti SET percorso_file = ? WHERE id_aggiornamento = ?", (_json.dumps(files), existing_id)) conn.commit() conn.close() print(f"[SCREENPLAY] File aggiunto a evento esistente {existing_id} per {product_id}") return jsonify({ "success": True, "message": f"Sceneggiatura aggiunta a evento esistente di {product_title}", "product_id": product_id }), 200 conn.close() dati_evento = { 'id_tipo_evento_fk': id_tipo_sceneggiatura, 'data_evento': today, 'percorso_file': pdf_path, 'note': f"Associato a: {product_title} ({product_id})", 'dettaglio_contesto': None, 'buyer_contatto': None, 'costo_richiesto_ita': None, 'costo_richiesto_spa': None, 'budget_produzione': None, 'data_reminder': None, 'preferito': 0, 'venduto_a': None, 'privato': 0 } # Crea evento success, message = database_logic.aggiungi_aggiornamento(product_id, dati_evento) if success: print(f"[SCREENPLAY] Evento creato con successo per {product_id}") return jsonify({ "success": True, "message": f"Sceneggiatura associata a {product_title}", "product_id": product_id }), 200 else: print(f"[SCREENPLAY] Errore creazione evento: {message}") return jsonify({ "success": False, "error": message }), 500 except Exception as e: print(f"[ERROR] api_associate_screenplay: {e}") import traceback traceback.print_exc() return jsonify({ "success": False, "error": str(e) }), 500 @app.route('/api/screenplays/stats', methods=['GET']) @login_required def api_screenplay_stats(): """ Restituisce statistiche sulle sceneggiature associate. Response: { "success": true, "total_count": 42, "unique_products": 38 } """ try: # Query per contare eventi SCENEGGIATURA (id_tipo_evento_fk=3) conn = database_logic._conn() cursor = conn.cursor() # Conta totale eventi sceneggiatura cursor.execute(""" SELECT COUNT(*) FROM LogAggiornamenti WHERE id_tipo_evento_fk = 3 """) total_count = cursor.fetchone()[0] # Conta prodotti unici con sceneggiature cursor.execute(""" SELECT COUNT(DISTINCT id_media_fk) FROM LogAggiornamenti WHERE id_tipo_evento_fk = 3 """) unique_products = cursor.fetchone()[0] conn.close() return jsonify({ "success": True, "total_count": total_count, "unique_products": unique_products }), 200 except Exception as e: print(f"[ERROR] api_screenplay_stats: {e}") import traceback traceback.print_exc() return jsonify({ "success": False, "error": str(e) }), 500 @app.route('/api/screenplays/search-by-distributor', methods=['GET']) @login_required def api_search_by_distributor(): """ Cerca tutti i prodotti (MediaTrack e Gemma) per distributore. Query Parameters: - distributor (string, required, min 2 caratteri) - limit (int, opzionale, default 50) Response: { "success": true, "results": [ { "id": "MT_123" o "GEM_456", "titolo": "Inception", "source": "mediatrack" o "gemma", "distributore": "Warner Bros", "anno": "2010", "codice_prodotto": "..." } ], "count": 42 } """ try: distributor_query = request.args.get('distributor', '').strip() limit = int(request.args.get('limit', 50)) if len(distributor_query) < 2: return jsonify({ "success": False, "error": "Nome distributore minimo 2 caratteri" }), 400 conn = database_logic._conn() cursor = conn.cursor() results = [] # Cerca in AnagraficaMedia (prodotti MediaTrack, esclusi G_ che sono mirror Gemma) cursor.execute(""" SELECT id_media, titolo_ufficiale, distributore_nome, tipo FROM AnagraficaMedia WHERE LOWER(distributore_nome) LIKE LOWER(?) AND id_media NOT LIKE 'G_%' ORDER BY titolo_ufficiale LIMIT ? """, (f'%{distributor_query}%', limit)) for row in cursor.fetchall(): results.append({ 'id': row[0], 'titolo': row[1], 'distributore': row[2], 'tipo': row[3], 'source': 'mediatrack' }) conn.close() # Cerca anche in gemma.parquet via DuckDB (distributori Gemma-only non sono in AnagraficaMedia) dq_esc = distributor_query.replace("'", "''") gdf = database_logic._gemma_df( where=f"LOWER(A_DISTRIBUTORE) LIKE LOWER('%{dq_esc}%')", columns='A_ID, A_TITOLO, A_DISTRIBUTORE, A_TIPOLOGIA' ) if not gdf.empty: gdf = gdf.sort_values('A_TITOLO').head(limit) for _, row in gdf.iterrows(): results.append({ 'id': 'G_' + str(row['A_ID']), 'titolo': row.get('A_TITOLO'), 'distributore': row.get('A_DISTRIBUTORE'), 'tipo': row.get('A_TIPOLOGIA'), 'source': 'gemma' }) results.sort(key=lambda r: (r['titolo'] or '').lower()) return jsonify({ "success": True, "results": results[:limit], "count": len(results) }), 200 except Exception as e: print(f"[ERROR] api_search_by_distributor: {e}") import traceback traceback.print_exc() return jsonify({ "success": False, "error": str(e) }), 500 # ================================================================= # >>> ENDPOINT PER SESSIONI E CARRELLO IMPORT (MILESTONE 4) # ================================================================= @app.route('/api/products/search-smart') def api_search_products_smart(): """ Ricerca intelligente real-time per search-as-you-type. Cerca in MediaTrack e Gemma con substring matching. AGGIORNATO: Post-migrazione V11 - usa distributore_nome (fonte unica: Gemma) Query Parameters: - titolo (string, required, min 2 caratteri) - distributore_nome (string, opzionale) - Nome distributore per filtrare - distributore_id (int, DEPRECATO) - Ignorato per backward compatibility - limit (int, opzionale, default 10) Esempio: /api/products/search-smart?titolo=xen&distributore_nome=Blue+Fox Response (200): { "mediatrack": [ { "id_media": "FILM-123", "titolo_ufficiale": "XENO", "tipo": "Film", "anno": "2024", "distributore_nome": "Blue Fox", "codice_imdb": "tt12345" } ], "gemma": [ { "id": "G_ABC123", "titolo": "XENO", "distributore": "Blue Fox", "tipo": "Film", "codice_imdb": "tt12345" } ] } Response Error (400): { "error": "Titolo minimo 2 caratteri" } """ try: titolo = request.args.get('titolo', '') distributore_nome = request.args.get('distributore_nome', default=None) # distributore_id deprecato ma mantenuto per backward compatibility (ignorato) limit = request.args.get('limit', default=10, type=int) # Validazione if not titolo or len(titolo) < 2: return jsonify({"error": "Titolo minimo 2 caratteri"}), 400 risultati = database_logic.ricerca_prodotto_smart( titolo=titolo, distributore_nome=distributore_nome, limit=limit ) return jsonify(risultati), 200 except Exception as e: print(f"Errore in api_search_products_smart: {e}") import traceback traceback.print_exc() return jsonify({"error": f"Errore server: {str(e)}"}), 500 # ================================================================= # >>> POSTER ENDPOINT # ================================================================= @app.route('/api/poster/') def api_poster(codice_imdb: str): """ Serve la locandina locale (local_db/posters/{codice_imdb}.jpg). Se non ancora scaricata, redirect all'URL OMDB dal DB. """ from pathlib import Path poster_path = Path(app.root_path).parents[3] / 'local_db' / 'posters' / f'{codice_imdb}.jpg' if poster_path.exists(): return send_file(str(poster_path), mimetype='image/jpeg') # Fallback: leggi url_poster dal DB e redirect url = database_logic.get_poster_url_by_imdb(codice_imdb) if url: return redirect(url) return '', 404 # ================================================================= # >>> DASHBOARD ENDPOINTS # ================================================================= @app.route('/api/dashboard/stats') def api_get_dashboard_stats(): """Endpoint API per recuperare le statistiche della dashboard.""" try: stats = database_logic.get_dashboard_stats() return jsonify(stats) except Exception as e: print(f"[ERROR] Errore recupero stats dashboard: {e}") import traceback traceback.print_exc() return jsonify({"error": "Impossibile caricare statistiche"}), 500 @app.route('/api/dashboard/activity') def api_get_dashboard_activity(): """Endpoint API per recuperare l'attività recente per la dashboard.""" try: limit = int(request.args.get('limit', 10)) activities = database_logic.get_recent_activity(limit) return jsonify(activities) except Exception as e: print(f"[ERROR] Errore recupero activity: {e}") import traceback traceback.print_exc() return jsonify({"error": "Impossibile caricare attività"}), 500 # --- 2. ROTTE SPECIFICHE PER LE PAGINE HTML --- @app.route('/favicon.ico') def favicon(): """Serve un favicon vuoto per evitare errori 404.""" from flask import make_response response = make_response('', 204) return response @app.route('/') def index(): """Serve la pagina principale (la griglia).""" return render_template('index.html') @app.route('/detail.html') def detail_page(): """Serve la pagina di dettaglio del prodotto.""" resp = make_response(render_template('detail.html')) resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' return resp @app.route('/distributor-detail.html') def distributor_detail_page(): """Serve la pagina di dettaglio del distributore.""" return render_template('distributor-detail.html') @app.route('/insert-product.html') def insert_product_page(): """Serve la pagina di inserimento manuale prodotto.""" return render_template('insert-product.html') @app.route('/maintenance.html') def maintenance_page(): """Serve la pagina di manutenzione.""" return render_template('maintenance.html') @app.route('/imdb-linker.html') def imdb_linker_page(): """Serve la pagina dello strumento di associazione IMDb.""" return render_template('imdb-linker.html') @app.route('/insert-flow.html') def insert_flow_page(): """Serve la pagina del workflow inserimento guidato.""" return render_template('insert-flow.html') # ================================================================= # >>> PREFERITI E REMINDER ENDPOINTS # ================================================================= @app.route('/api/favorites', methods=['GET']) @login_required def api_get_favorites(): """ Recupera tutti gli eventi marcati come preferiti. Rispetta la privacy: mostra solo eventi pubblici + eventi privati dell'utente corrente. """ try: user_id = session.get('user_id') print(f"[DEBUG] /api/favorites chiamata da user_id: {user_id}") favorites = database_logic.get_favorite_events(user_id) print(f"[DEBUG] Preferiti trovati: {len(favorites)}") if len(favorites) > 0: print(f"[DEBUG] Primo preferito: {favorites[0]}") return jsonify({ "success": True, "favorites": favorites, "count": len(favorites) }), 200 except Exception as e: print(f"[ERROR] Errore recupero preferiti: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/reminders', methods=['GET']) @login_required def api_get_reminders(): """ Recupera tutti gli eventi con reminder attivi. Rispetta la privacy: mostra solo eventi pubblici + eventi privati dell'utente corrente. """ try: user_id = session.get('user_id') print(f"[DEBUG] /api/reminders chiamata da user_id: {user_id}") reminders = database_logic.get_reminder_events(user_id) print(f"[DEBUG] Reminder trovati: {len(reminders)}") if len(reminders) > 0: print(f"[DEBUG] Primo reminder: {reminders[0]}") return jsonify({ "success": True, "reminders": reminders, "count": len(reminders) }), 200 except Exception as e: print(f"[ERROR] Errore recupero reminder: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/favorites/count', methods=['GET']) @login_required def api_get_favorites_count(): """Recupera solo il conteggio dei preferiti per l'utente corrente.""" try: user_id = session.get('user_id') count = database_logic.count_favorite_events(user_id) return jsonify({ "success": True, "count": count }), 200 except Exception as e: print(f"[ERROR] Errore conteggio preferiti: {e}") return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/reminders/count', methods=['GET']) @login_required def api_get_reminders_count(): """Recupera solo il conteggio dei reminder per l'utente corrente.""" try: user_id = session.get('user_id') count = database_logic.count_reminder_events(user_id) return jsonify({ "success": True, "count": count }), 200 except Exception as e: print(f"[ERROR] Errore conteggio reminder: {e}") return jsonify({"success": False, "error": str(e)}), 500 # ================================================================= # >>> EXCEL EXPORT ENDPOINTS # ================================================================= @app.route('/api/export/excel', methods=['POST']) @login_required def api_export_excel(): """ Genera file Excel con prodotti filtrati. Request Body: { "filtri": { "distributori": ["Warner Bros", "Universal"], // opzionale "tipologia": "Film", // opzionale "data_da": "2025-01-01" // opzionale }, "includi_gemma": true, // default: false "preset_id": 5 // opzionale (per update last_used) } Response (200): - File Excel scaricabile - Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet - Content-Disposition: attachment; filename="MediaTrack_Export_20251209_143522.xlsx" """ try: from . import excel_generator dati = request.json or {} filtri = dati.get('filtri', {}) includi_gemma = dati.get('includi_gemma', False) preset_id = dati.get('preset_id') print(f"[EXPORT API] Received request:") print(f" filtri: {filtri}") print(f" includi_gemma: {includi_gemma} (type: {type(includi_gemma)})") print(f" preset_id: {preset_id}") # Aggiorna data_ultimo_uso se preset_id fornito if preset_id: user_id = session.get('user_id') database_logic.update_preset_last_used(preset_id, user_id) # Recupera dati da database products = database_logic.get_export_data(filtri, includi_gemma) if not products: return jsonify({ "success": False, "message": "Nessun prodotto trovato con i filtri selezionati" }), 404 # Genera Excel excel_file = excel_generator.generate_export_excel(products, filtri, includi_gemma) # Nome file filename = excel_generator.get_export_filename(filtri) + ".xlsx" print(f"[EXPORT] Generato Excel con {len(products)} prodotti: {filename}") # Salva in cartella temporanea e apri direttamente (PyQt6 WebEngine non gestisce blob download) import tempfile, os, threading tmp_dir = tempfile.gettempdir() tmp_path = os.path.join(tmp_dir, filename) with open(tmp_path, 'wb') as f: f.write(excel_file.read()) threading.Thread(target=lambda: os.startfile(tmp_path), daemon=True).start() return jsonify({"success": True, "message": f"File aperto: {filename}", "filename": filename}) except Exception as e: print(f"[ERROR] Errore export Excel: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/export/presets', methods=['GET']) @login_required def api_get_export_presets(): """ Recupera tutti i preset export dell'utente corrente. Response (200): { "success": true, "presets": [ { "id_preset": 1, "nome_preset": "Export Settimanale Warner", "filtri_json": "{\"distributori\": [\"Warner Bros\"], \"tipologia\": \"Film\"}", "includi_gemma": false, "is_default": true, "data_creazione": "2025-12-09 10:30:00", "data_ultimo_uso": "2025-12-09 14:00:00" }, ... ] } """ try: user_id = session.get('user_id') presets = database_logic.get_user_export_presets(user_id) return jsonify({ "success": True, "presets": presets }), 200 except Exception as e: print(f"[ERROR] Errore recupero preset: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/export/presets', methods=['POST']) @login_required def api_create_export_preset(): """ Crea nuovo preset export. Request Body: { "nome_preset": "Export Settimanale Warner", "filtri": { "distributori": ["Warner Bros"], "tipologia": "Film", "data_da": "2025-01-01" }, "includi_gemma": false, "is_default": false } Response (200): { "success": true, "message": "Preset salvato con successo", "preset_id": 5 } """ try: import json user_id = session.get('user_id') dati = request.json or {} nome_preset = dati.get('nome_preset', '').strip() filtri = dati.get('filtri', {}) includi_gemma = dati.get('includi_gemma', False) is_default = dati.get('is_default', False) # Validazione if not nome_preset: return jsonify({ "success": False, "message": "Nome preset obbligatorio" }), 400 # Serializza filtri a JSON filtri_json = json.dumps(filtri) # Crea preset success, message, preset_id = database_logic.create_export_preset( user_id, nome_preset, filtri_json, includi_gemma, is_default ) if success: return jsonify({ "success": True, "message": message, "preset_id": preset_id }), 200 else: return jsonify({ "success": False, "message": message }), 400 except Exception as e: print(f"[ERROR] Errore creazione preset: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/export/presets/', methods=['PUT']) @login_required def api_update_export_preset(preset_id): """ Aggiorna preset esistente (con verifica ownership). Request Body: { "nome_preset": "Export Settimanale Warner (Aggiornato)", // opzionale "filtri": {...}, // opzionale "includi_gemma": true, // opzionale "is_default": true // opzionale } Response (200): { "success": true, "message": "Preset aggiornato con successo" } """ try: import json user_id = session.get('user_id') dati = request.json or {} nome_preset = dati.get('nome_preset') filtri = dati.get('filtri') includi_gemma = dati.get('includi_gemma') is_default = dati.get('is_default') # Serializza filtri se forniti filtri_json = json.dumps(filtri) if filtri is not None else None # Aggiorna preset success, message = database_logic.update_export_preset( preset_id, user_id, nome_preset, filtri_json, includi_gemma, is_default ) if success: return jsonify({ "success": True, "message": message }), 200 else: return jsonify({ "success": False, "message": message }), 400 except Exception as e: print(f"[ERROR] Errore aggiornamento preset: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 @app.route('/api/export/presets/', methods=['DELETE']) @login_required def api_delete_export_preset(preset_id): """ Elimina preset (con verifica ownership). Response (200): { "success": true, "message": "Preset eliminato con successo" } """ try: user_id = session.get('user_id') success, message = database_logic.delete_export_preset(preset_id, user_id) if success: return jsonify({ "success": True, "message": message }), 200 else: return jsonify({ "success": False, "message": message }), 400 except Exception as e: print(f"[ERROR] Errore eliminazione preset: {e}") import traceback traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 # --- 3. ROTTA GENERICA PER I FILE STATICI (CSS, JS) - LASCIATA PER ULTIMA --- @app.route('/') def serve_static(filename): """Serve i file statici come CSS e JS.""" return send_from_directory('../frontend', filename) if __name__ == '__main__': # Use False for safety (debug should be controlled via run.py context) app.run(debug=False, port=5000)