#!/usr/bin/env python # -*- coding: utf-8 -*- """ MyICR Suite Bootstrap ====================== Aggiorna l'app dal server e la avvia. Percorsi fissi: LOCAL_ROOT = C:\\PYTHON_LOCAL\\MyICR_Suite\\ SERVER_ROOT = derivato da sys.argv[0] se script gira da rete, altrimenti fallback UNC PYTHON_EXE = LOCAL_ROOT\\python\\pythonw.exe """ import sys import subprocess import json from pathlib import Path # Flag: --mediatrack → avvia MediaTrack direttamente (shortcut offline) DIRECT_MEDIATRACK = "--mediatrack" in sys.argv # Flag: --dev → modalità sandbox (no sync verso LOCAL o server DB) DEV_MODE = "--dev" in sys.argv # ---------------------------------------------------------------- # Percorsi fissi # ---------------------------------------------------------------- LOCAL_ROOT = Path(r"C:\PYTHON_LOCAL\MyICR_Suite") PYTHON_EXE = LOCAL_ROOT / "python" / "pythonw.exe" START_SCRIPT = LOCAL_ROOT / "app" / "start.py" # SERVER_ROOT: derivato dal path dello script quando gira dal server # (via run_CLIENT_SRV.bat → sys.argv[0] = I:\...\bootstrap.py). # Quando gira in locale (shortcut --mediatrack) usa lo stesso path server. # In questo modo il deploy su I:\ è sempre la fonte di verità, # evitando problemi di DFS con il path UNC (che può puntare a replica diversa). _script_dir = Path(sys.argv[0]).resolve().parent _i_fallback = Path(r"I:\SOFTWARE\PYTHON_SRV\PYTHON_LOCAL\MyICR_Suite") SERVER_ROOT = _script_dir if _script_dir != LOCAL_ROOT else _i_fallback # Assicura che LOCAL_ROOT sia nel path per import app.* dopo robocopy sys.path.insert(0, str(LOCAL_ROOT)) def _read_version() -> str: """Legge la versione da config.json (server se disponibile, locale come fallback).""" for cfg_path in [SERVER_ROOT / "config.json", LOCAL_ROOT / "config.json"]: try: return json.loads(cfg_path.read_text(encoding="utf-8")).get("version", "") except Exception: pass return "" APP_VERSION = _read_version() APP_TITLE = f"MyICR Suite v{APP_VERSION}" if APP_VERSION else "MyICR Suite" # ---------------------------------------------------------------- # GUI PyQt6 # ---------------------------------------------------------------- try: from PyQt6.QtWidgets import QApplication, QDialog, QVBoxLayout, QLabel, QProgressBar from PyQt6.QtCore import Qt, QThread, pyqtSignal from PyQt6.QtGui import QFont HAS_QT = True except ImportError: HAS_QT = False class _UpdateThread(QThread): progress = pyqtSignal(int, str) done = pyqtSignal(bool, str) # success, error_msg def run(self): try: if DEV_MODE: self.progress.emit(5, "Avvio in modalità DEV (sandbox, no sync)...") try: _sync_parquet_l3(progress_cb=lambda pct, msg: self.progress.emit(pct, msg)) except Exception as e: print(f"[bootstrap] L3 fallito (non critico): {e}") self.progress.emit(100, "Avvio applicazione...") self.done.emit(True, "") return server_ok = SERVER_ROOT.exists() if server_ok: server_sig = _server_signature() cached_sig = _cached_signature() app_changed = server_sig != cached_sig req_changed = ( 'requirements.txt' in cached_sig and server_sig.get('requirements.txt') != cached_sig.get('requirements.txt') ) if app_changed: self.progress.emit(10, "Aggiornamento app...") _robocopy(SERVER_ROOT / "app", LOCAL_ROOT / "app") if req_changed: self.progress.emit(25, "Aggiornamento pacchetti Python...") _sync_site_packages() self.progress.emit(30, "Aggiornamento launcher_src...") _robocopy(SERVER_ROOT / "launcher_src", LOCAL_ROOT / "launcher_src") self.progress.emit(50, "Aggiornamento file di avvio...") import shutil for fname in ("bootstrap.py", "run_CLIENT_SRV.bat", "config.json"): src = SERVER_ROOT / fname if src.exists(): shutil.copy2(src, LOCAL_ROOT / fname) _save_signature(server_sig) else: self.progress.emit(50, "App aggiornata — nessuna modifica dal server.") # Verifica Python env — indipendente da app_changed self.progress.emit(55, "Verifica ambiente Python...") _sync_python_env_if_needed() self.progress.emit(70, "Aggiornamento dati Parquet...") _sync_parquet(progress_cb=lambda pct, msg: self.progress.emit(pct, msg)) self.progress.emit(82, "Verifica dati aggregati...") try: _sync_parquet_l3(progress_cb=lambda pct, msg: self.progress.emit(pct, msg)) except Exception as e: import traceback print(f"[bootstrap] L3 fallito (non critico): {e}\n{traceback.format_exc()}") self.progress.emit(88, "Sincronizzazione locandine...") _sync_posters() else: self.progress.emit(60, "Server non raggiungibile — avvio con dati locali") try: _backup_local_db() except Exception: pass # non bloccante # Scrivi flag rete per il layer Flask (banner offline + mark inserimenti) try: _write_network_status(server_ok) except Exception: pass if server_ok: # Crea/aggiorna shortcut desktop "MediaTrack" e run_mediatrack.bat try: _ensure_local_shortcuts() except Exception: pass if server_ok: self.progress.emit(90, "Sincronizzazione database condiviso...") try: from launcher_src.shared_db_sync import pull_and_preserve_local pull_and_preserve_local() except Exception: pass # non bloccante try: _sync_linker_db() except Exception: pass # non bloccante self.progress.emit(100, "Avvio applicazione...") self.done.emit(True, "") except Exception as e: self.done.emit(False, str(e)) class BootstrapWindow(QDialog): def __init__(self): super().__init__() self.setWindowTitle(APP_TITLE) self.setFixedSize(480, 140) self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) layout = QVBoxLayout(self) layout.setContentsMargins(24, 20, 24, 20) layout.setSpacing(12) self._label = QLabel("Avvio in corso...") font = QFont("Segoe UI", 10) self._label.setFont(font) layout.addWidget(self._label) self._bar = QProgressBar() self._bar.setRange(0, 100) self._bar.setValue(0) self._bar.setTextVisible(False) self._bar.setStyleSheet(""" QProgressBar { border: 1px solid #D2D0CE; border-radius: 2px; background: #F3F2F1; height: 8px; } QProgressBar::chunk { background: #0078D4; border-radius: 1px; } """) layout.addWidget(self._bar) self._thread = _UpdateThread() self._thread.progress.connect(self._on_progress) self._thread.done.connect(self._on_done) self._thread.start() def _on_progress(self, value: int, message: str): self._bar.setValue(value) self._label.setText(message) def _on_done(self, success: bool, error: str): if success: self.accept() if DIRECT_MEDIATRACK: _launch_mediatrack_direct() else: _launch_app() else: from PyQt6.QtWidgets import QMessageBox QMessageBox.critical(self, "Errore avvio", f"Impossibile avviare MyICR Suite:\n\n{error}") self.reject() # ---------------------------------------------------------------- # Logica di update # ---------------------------------------------------------------- def _sync_site_packages(): """Copia nuovi pacchetti da server → locale (solo file mancanti, /XO). Non rimuove pacchetti esistenti — aggiorna solo in avanti.""" src = SERVER_ROOT / "python" / "Lib" / "site-packages" dst = LOCAL_ROOT / "python" / "Lib" / "site-packages" if not src.exists(): return dst.mkdir(parents=True, exist_ok=True) cmd = [ "robocopy", str(src), str(dst), "/E", "/XO", "/NFL", "/NDL", "/NP", "/NJS", "/NJH", ] result = subprocess.run(cmd, capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW) if result.returncode >= 8: raise RuntimeError(f"Robocopy site-packages fallito (exit {result.returncode}): {result.stderr.decode(errors='replace')}") def _sync_python_env_if_needed(): """Sincronizza site-packages se il Python env sul server è cambiato. Meccanismo: file python_env.sig sul server (pre-calcolato dal deploy bat). - Il deploy rigenera python_env.sig dopo ogni robocopy del Python embedded. - Bootstrap legge solo quel file (< 1ms) — nessuna listing di rete a runtime. - Indipendente da app_changed: funziona anche se il codice non è cambiato. - Automatico: non richiede intervento manuale dopo l'aggiunta di un pacchetto. """ server_sig_file = SERVER_ROOT / "python_env.sig" local_sig_cache = LOCAL_ROOT / "launcher_cache" / "python_env_sig.txt" if not server_sig_file.exists(): return # deploy non ancora aggiornato — skip silenzioso server_sig = server_sig_file.read_text(encoding="utf-8").strip() local_sig = local_sig_cache.read_text(encoding="utf-8").strip() if local_sig_cache.exists() else "" if server_sig != local_sig: _sync_site_packages() local_sig_cache.parent.mkdir(parents=True, exist_ok=True) local_sig_cache.write_text(server_sig, encoding="utf-8") def _robocopy(src: Path, dst: Path): """Sincronizza src → dst con Robocopy (mirror, senza DB e cache).""" dst.mkdir(parents=True, exist_ok=True) cmd = [ "robocopy", str(src), str(dst), "/MIR", "/XD", "__pycache__", "local_db", "sync_db", "python", "/XF", "*.pyc", "*.db", "BRIEFING.md", "/NFL", "/NDL", "/NP", "/NJS", "/NJH", ] result = subprocess.run(cmd, capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW) # Robocopy exit codes: 0-3 = OK, >=8 = errore if result.returncode >= 8: raise RuntimeError(f"Robocopy fallito (exit {result.returncode}): {result.stderr.decode(errors='replace')}") def _hash_cache_path() -> Path: return LOCAL_ROOT / "launcher_cache" / "local_db_hash.json" def _server_signature() -> dict: """Firma del server per rilevare deploy. - requirements.txt: solo size (mtime su share di rete è instabile — evita di triggerare sync site-packages ad ogni avvio) - altri file chiave: size + mtime (rileva qualsiasi modifica al codice) """ sig = {} # requirements.txt: size only — package sync è lento, triggeriamo solo se cambia davvero req = SERVER_ROOT / "requirements.txt" if req.exists(): sig['requirements.txt'] = req.stat().st_size # file codice: size + mtime — rileva anche modifiche a parità di dimensione for f in [SERVER_ROOT / "bootstrap.py", SERVER_ROOT / "config.json", SERVER_ROOT / "app" / "main.py"]: if f.exists(): st = f.stat() sig[f.name] = (st.st_size, round(st.st_mtime)) return sig def _cached_signature() -> dict: cache = LOCAL_ROOT / "launcher_cache" / "server_sig.json" if cache.exists(): try: return json.loads(cache.read_text(encoding="utf-8")) except Exception: pass return {} def _save_signature(sig: dict): cache = LOCAL_ROOT / "launcher_cache" / "server_sig.json" cache.parent.mkdir(parents=True, exist_ok=True) cache.write_text(json.dumps(sig), encoding="utf-8") def _sync_posters(): """Sincronizza le locandine dal server con Robocopy /MIR (solo file nuovi/modificati).""" server_posters = SERVER_ROOT / "local_db" / "posters" local_posters = LOCAL_ROOT / "local_db" / "posters" if not server_posters.exists(): return local_posters.mkdir(parents=True, exist_ok=True) cmd = [ "robocopy", str(server_posters), str(local_posters), "/MIR", "/NFL", "/NDL", "/NP", "/NJS", "/NJH", ] result = subprocess.run(cmd, capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW) if result.returncode >= 8: raise RuntimeError(f"Robocopy posters fallito (exit {result.returncode}): {result.stderr.decode(errors='replace')}") def _sync_parquet(progress_cb=None): """Sincronizza local_db/parquet/ dal server — solo file nuovi o modificati (per-file size hash). Rimuove Parquet obsoleti in locale. Esclude db_user.db e posters/ (gestiti separatamente).""" import shutil server_parquet = SERVER_ROOT / "local_db" / "parquet" local_parquet = LOCAL_ROOT / "local_db" / "parquet" if not server_parquet.exists(): return # Indice per-file: {nome: dimensione} — stat su rete, nessun MD5 # Solo *.parquet — gemma_unified.db viene rigenerato al volo da database_logic server_files = { f.name: f.stat().st_size for f in sorted(server_parquet.iterdir()) if f.is_file() and f.suffix == '.parquet' } cache_file = _hash_cache_path() cached_files: dict = {} if cache_file.exists(): try: cached_files = json.loads(cache_file.read_text(encoding="utf-8")).get("parquet", {}) except Exception: pass # Determina quali file copiare (nuovi o dimensione cambiata) to_copy = [ name for name, size in server_files.items() if cached_files.get(name) != size or not (local_parquet / name).exists() ] if not to_copy and local_parquet.exists(): return # nessuna modifica local_parquet.mkdir(parents=True, exist_ok=True) # Rimuovi parquet obsoleti in locale (gemma_unified.db non va toccato — rigenerato al volo) for f in list(local_parquet.iterdir()): if f.is_file() and f.suffix == '.parquet' and f.name not in server_files: f.unlink() # Copia solo i file cambiati, con progress total = len(to_copy) or 1 for i, name in enumerate(to_copy): if progress_cb: pct = 70 + int(12 * i / total) progress_cb(pct, f"Aggiornamento dati: {name}...") shutil.copy2(server_parquet / name, local_parquet / name) # Aggiorna cache hash cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_text(json.dumps({"parquet": server_files}), encoding="utf-8") def _sync_parquet_l3(progress_cb=None): """Delega a launcher_src.parquet_l3 (logica condivisa con linker/launcher.py).""" from launcher_src.parquet_l3 import sync_parquet_l3 sync_parquet_l3(LOCAL_ROOT, SERVER_ROOT, progress_cb) def _sync_linker_db(): """Copia linker.db dal server a locale se assente o più vecchio. Prima di sovrascrivere crea un backup giornaliero (ultimi 3 giorni conservati). """ import shutil, json as _json, datetime linker_config = LOCAL_ROOT / "app" / "modules" / "linker" / "config.json" if not linker_config.exists(): linker_config = SERVER_ROOT / "app" / "modules" / "linker" / "config.json" try: server_path = Path(_json.loads(linker_config.read_text(encoding='utf-8')).get('linker_db_path', '')) except Exception: return if not server_path or not server_path.exists(): return local_path = LOCAL_ROOT / "sync_db" / "linker.db" local_path.parent.mkdir(parents=True, exist_ok=True) if (not local_path.exists() or server_path.stat().st_mtime > local_path.stat().st_mtime): # Backup del locale prima di sovrascrivere if local_path.exists(): backup_dir = LOCAL_ROOT / "sync_db" / "backups" backup_dir.mkdir(parents=True, exist_ok=True) today = datetime.date.today().strftime("%Y%m%d") backup_file = backup_dir / f"linker_{today}.db" if not backup_file.exists(): shutil.copy2(str(local_path), str(backup_file)) for old in sorted(backup_dir.glob("linker_*.db"))[:-3]: old.unlink(missing_ok=True) shutil.copy2(str(server_path), str(local_path)) def _backup_local_db(): """Backup del DB locale all'avvio offline — un backup al giorno, conserva gli ultimi 3.""" import shutil, datetime local_db = LOCAL_ROOT / "sync_db" / "mediatrack.db" if not local_db.exists(): return backup_dir = LOCAL_ROOT / "sync_db" / "backups" backup_dir.mkdir(parents=True, exist_ok=True) today = datetime.date.today().strftime("%Y%m%d") backup_file = backup_dir / f"mediatrack_{today}.db" if not backup_file.exists(): shutil.copy2(local_db, backup_file) # Conserva solo gli ultimi 3 backup (3 giorni di trasferta coperti) backups = sorted(backup_dir.glob("mediatrack_*.db")) for old in backups[:-3]: old.unlink(missing_ok=True) def _write_network_status(online: bool): """Scrive il flag online/offline per il layer Flask (banner + mark inserimenti offline).""" status_file = LOCAL_ROOT / "launcher_cache" / "network_status.json" status_file.parent.mkdir(parents=True, exist_ok=True) status_file.write_text(json.dumps({"online": online}), encoding="utf-8") def _launch_app(): """Lancia app/start.py con pythonw.exe embedded.""" import os as _os run_root = SERVER_ROOT if DEV_MODE else LOCAL_ROOT python_exe = run_root / "python" / "pythonw.exe" start_script = run_root / "app" / "start.py" env = _os.environ.copy() if DEV_MODE: env["MYICR_DEV_MODE"] = "1" subprocess.Popen( [str(python_exe), str(start_script)], cwd=str(run_root), env=env, ) def _launch_mediatrack_direct(): """Lancia MediaTrack standalone (home dashboard + modalità Mercato attiva), senza la MainWindow PyQt6.""" import os as _os run_root = SERVER_ROOT if DEV_MODE else LOCAL_ROOT python_exe = run_root / "python" / "pythonw.exe" run_script = run_root / "app" / "modules" / "mediatrack" / "run.py" env = _os.environ.copy() if DEV_MODE: env["MYICR_DEV_MODE"] = "1" subprocess.Popen( [str(python_exe), str(run_script), "--start-url", "http://127.0.0.1:5000/dashboard.html?mercato=true"], cwd=str(run_root), env=env, ) def _ensure_local_shortcuts(): """Crea/aggiorna run_mediatrack.bat e il collegamento desktop 'MediaTrack' in locale. Viene chiamato ad ogni avvio online — mantiene i file locali sincronizzati.""" # 1. Bat di avvio diretto MediaTrack bat_content = ( "@echo off\r\n" f'"{PYTHON_EXE}" "{LOCAL_ROOT / "bootstrap.py"}" --mediatrack\r\n' ) bat_path = LOCAL_ROOT / "run_mediatrack.bat" try: bat_path.write_text(bat_content, encoding="utf-8") except Exception: pass # 2. Collegamento desktop "MediaTrack offline" try: import tempfile icon_custom = LOCAL_ROOT / "app" / "assets" / "icons" / "mediatrack_offline.ico" icon_path = icon_custom if icon_custom.exists() else LOCAL_ROOT / "logo.ico" icon_line = f'$s.IconLocation = "{icon_path}"' if icon_path.exists() else "" # Usa [Environment]::GetFolderPath per il path desktop reale (gestisce OneDrive redirect) # Punta direttamente a pythonw.exe — nessuna finestra CMD ps_script = ( f'$desktop = [Environment]::GetFolderPath("Desktop")\n' f'$s = (New-Object -ComObject WScript.Shell).CreateShortcut("$desktop\\MediaTrack offline.lnk")\n' f'$s.TargetPath = "{PYTHON_EXE}"\n' f'$s.Arguments = \'"{LOCAL_ROOT / "bootstrap.py"}" --mediatrack\'\n' f'$s.WorkingDirectory = "{LOCAL_ROOT}"\n' f'{icon_line}\n' f'$s.Save()\n' ) with tempfile.NamedTemporaryFile(mode='w', suffix='.ps1', delete=False, encoding='utf-8') as tf: tf.write(ps_script) ps1_path = tf.name subprocess.run( ["powershell", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ps1_path], capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW, ) Path(ps1_path).unlink(missing_ok=True) except Exception: pass # ---------------------------------------------------------------- # Entry point # ---------------------------------------------------------------- def main(): if HAS_QT: app = QApplication(sys.argv) app.setApplicationName(APP_TITLE) window = BootstrapWindow() window.show() sys.exit(app.exec()) else: # Fallback headless (debug) print("[bootstrap] Avvio senza GUI (PyQt6 non disponibile)") if DEV_MODE: print("[bootstrap] Modalità DEV — nessun sync.") else: server_ok = SERVER_ROOT.exists() if server_ok: server_sig = _server_signature() cached_sig = _cached_signature() app_changed = server_sig != cached_sig req_changed = server_sig.get('requirements.txt') != cached_sig.get('requirements.txt') if app_changed: print("[bootstrap] Robocopy app...") _robocopy(SERVER_ROOT / "app", LOCAL_ROOT / "app") if req_changed: print("[bootstrap] Sync site-packages...") _sync_site_packages() print("[bootstrap] Robocopy launcher_src...") _robocopy(SERVER_ROOT / "launcher_src", LOCAL_ROOT / "launcher_src") import shutil for fname in ("bootstrap.py", "run_CLIENT_SRV.bat", "config.json"): src = SERVER_ROOT / fname if src.exists(): shutil.copy2(src, LOCAL_ROOT / fname) _save_signature(server_sig) else: print("[bootstrap] App aggiornata — nessuna modifica dal server.") print("[bootstrap] Verifica ambiente Python...") _sync_python_env_if_needed() _sync_parquet(progress_cb=lambda pct, msg: print(f"[{pct}%] {msg}")) try: _sync_parquet_l3(progress_cb=lambda pct, msg: print(f"[{pct}%] {msg}")) except Exception as e: print(f"[bootstrap] L3 fallito (non critico): {e}") print("[bootstrap] Sync posters...") _sync_posters() if server_ok: try: from launcher_src.shared_db_sync import pull_and_preserve_local pull_and_preserve_local() except Exception: pass if DIRECT_MEDIATRACK: _launch_mediatrack_direct() else: _launch_app() if __name__ == "__main__": main()