"""Motor principal de la aplicación.""" import platform import socket from typing import Optional from pathlib import Path from PySide6.QtCore import QObject, Signal, QThreadPool from .file_watcher import FileWatcher, FileStabilityChecker, calculate_file_hash from .restore_worker import RestoreWorker from .. import __version__ from ..db.job_repository import JobRepository from ..db.event_repository import EventRepository from ..db.config_repository import ConfigRepository from ..constants import JobStatus, DEFAULT_CONFIG from ..panel import panel_client from ..utils.logger import app_logger class EngineSignals(QObject): """Señales del motor.""" job_created = Signal(str) # job_id stats_updated = Signal(dict) # stats config_loaded = Signal(dict) # config class RestoreEngine(QObject): """Motor principal de restauración.""" def __init__(self): """Inicializa el motor.""" super().__init__() self.signals = EngineSignals() # Thread pool para workers self._thread_pool = QThreadPool.globalInstance() # File watcher self._file_watcher: Optional[FileWatcher] = None # Estado self._running = False self._paused = False # Configuración self._config = self._load_config() self._report_instance_config_to_panel() app_logger.info("RestoreEngine inicializado") def _load_config(self) -> dict: """Carga la configuración desde la base de datos.""" config = ConfigRepository.get("app_config", DEFAULT_CONFIG.copy()) # Asegurar que tiene todas las claves for key, value in DEFAULT_CONFIG.items(): if key not in config: config[key] = value self.signals.config_loaded.emit(config) return config def save_config(self, config: dict): """ Guarda la configuración. Args: config: Configuración a guardar """ ConfigRepository.set("app_config", config) self._config = config app_logger.info("Configuración guardada") self._report_instance_config_to_panel() # Reconfigurar file watcher si está corriendo if self._running: self._restart_file_watcher() def get_config(self) -> dict: """Obtiene la configuración actual.""" return self._config.copy() def start(self): """Inicia el motor.""" if self._running: app_logger.warning("Motor ya está corriendo") return # Validar configuración if not self._validate_config(): app_logger.error("Configuración inválida, no se puede iniciar el motor") return # Configurar thread pool extract_workers = self._config["concurrency"]["extract_workers"] restore_workers = self._config["concurrency"]["restore_workers"] max_threads = extract_workers + restore_workers self._thread_pool.setMaxThreadCount(max_threads) app_logger.info( f"Thread pool configurado: {max_threads} threads " f"(extract: {extract_workers}, restore: {restore_workers})" ) # Iniciar file watcher if self._config["features"]["auto_scan_enabled"]: self._start_file_watcher() self._running = True self._paused = False self._report_instance_config_to_panel() EventRepository.create("INFO", "Motor iniciado") app_logger.info("Motor iniciado") def stop(self): """Detiene el motor.""" if not self._running: return # Detener file watcher if self._file_watcher: self._file_watcher.stop() self._file_watcher = None # Esperar a que terminen los workers self._thread_pool.waitForDone(msecs=30000) # 30s timeout self._running = False EventRepository.create("INFO", "Motor detenido") app_logger.info("Motor detenido") def pause(self): """Pausa el procesamiento (no toma nuevos jobs).""" self._paused = True if self._file_watcher: self._file_watcher.stop() EventRepository.create("INFO", "Motor pausado") app_logger.info("Motor pausado") def resume(self): """Reanuda el procesamiento.""" if not self._running: return self._paused = False if self._config["features"]["auto_scan_enabled"]: self._start_file_watcher() EventRepository.create("INFO", "Motor reanudado") app_logger.info("Motor reanudado") def is_running(self) -> bool: """Verifica si el motor está corriendo.""" return self._running def is_paused(self) -> bool: """Verifica si el motor está pausado.""" return self._paused def scan_now(self): """Fuerza un escaneo manual de la carpeta de entrada.""" if self._file_watcher: self._file_watcher.scan_now() else: # Crear watcher temporal para un escaneo self._start_file_watcher() if self._file_watcher: self._file_watcher.scan_now() def process_file(self, file_path: str): """ Procesa un archivo manualmente. Args: file_path: Ruta al archivo ZIP """ try: self._on_file_ready(file_path) except Exception as e: app_logger.error(f"Error procesando archivo {file_path}: {e}") def get_stats(self) -> dict: """Obtiene estadísticas del motor.""" stats = JobRepository.get_stats() stats["active_threads"] = self._thread_pool.activeThreadCount() stats["max_threads"] = self._thread_pool.maxThreadCount() stats["running"] = self._running stats["paused"] = self._paused # Tiempos promedio avg_times = JobRepository.get_average_times() stats.update(avg_times) return stats def _report_instance_config_to_panel(self) -> None: """Reporta input_folder al PANEL (best-effort, no bloquea el flujo).""" panel_cfg = self._config.get("panel", {}) api_url = (panel_cfg.get("api_url") or "").strip() api_token = (panel_cfg.get("api_token") or "").strip() if not api_url or not api_token: return input_folder = (self._config.get("paths") or {}).get("input_folder") or "" try: host_name = socket.gethostname() or platform.node() except Exception: host_name = platform.node() instance_key = (panel_cfg.get("instance_key") or "").strip() or None panel_client.report_instance_config( api_url=api_url, api_token=api_token, input_folder=input_folder, host_name=host_name, app_version=__version__, instance_key=instance_key, ) def _validate_config(self) -> bool: """Valida que la configuración sea correcta.""" paths = self._config["paths"] # Validar carpetas requeridas required_paths = ["input_folder", "extract_folder", "data_sql_folder"] for key in required_paths: if not paths.get(key): app_logger.error(f"Falta configurar: {key}") return False path = Path(paths[key]) if not path.exists(): try: path.mkdir(parents=True, exist_ok=True) except Exception as e: app_logger.error(f"No se pudo crear carpeta {key}: {e}") return False # Validar 7-Zip if not paths.get("seven_zip_exe"): app_logger.error("Falta configurar ruta a 7-Zip") return False if not Path(paths["seven_zip_exe"]).exists(): app_logger.error(f"7-Zip no encontrado en: {paths['seven_zip_exe']}") return False return True def _start_file_watcher(self): """Inicia el file watcher.""" if self._file_watcher: self._file_watcher.stop() # Configurar stability checker stability_config = self._config["stability"] stability_checker = FileStabilityChecker( check_interval=stability_config["check_interval_seconds"], stable_duration=stability_config["stable_duration_seconds"], use_ready_marker=stability_config["use_ready_marker"] ) # Crear file watcher self._file_watcher = FileWatcher( watch_folder=self._config["paths"]["input_folder"], on_file_ready=self._on_file_ready, scan_interval=self._config["features"]["scan_interval_seconds"], stability_checker=stability_checker ) self._file_watcher.start() def _restart_file_watcher(self): """Reinicia el file watcher con nueva configuración.""" if self._running and not self._paused: self._start_file_watcher() def _on_file_ready(self, file_path: str): """ Callback cuando un archivo está listo para procesar. Args: file_path: Ruta al archivo """ if self._paused: app_logger.info(f"Motor pausado, ignorando archivo: {file_path}") return try: # Calcular hash para evitar duplicados file_hash = calculate_file_hash(file_path) if JobRepository.exists_by_hash(file_hash): app_logger.warning(f"Archivo ya procesado (hash duplicado): {file_path}") return # Crear job file_name = Path(file_path).name job_id = JobRepository.create(file_path, file_name, file_hash) app_logger.info(f"Job creado: {job_id} para archivo {file_name}") EventRepository.create("INFO", f"Nuevo job creado: {file_name}", job_id) self.signals.job_created.emit(job_id) # Crear worker y encolar worker = RestoreWorker( job_id=job_id, config=self._config, dry_run=self._config["features"]["dry_run_mode"] ) # Conectar señales worker.signals.job_completed.connect(self._on_job_completed) # Ejecutar en thread pool self._thread_pool.start(worker) app_logger.info(f"Worker encolado para job {job_id}") except Exception as e: app_logger.error(f"Error creando job para {file_path}: {e}", exc_info=True) EventRepository.create("ERROR", f"Error creando job: {e}") def _on_job_completed(self, job_id: str, success: bool): """ Callback cuando un job se completa. Args: job_id: ID del job success: Si fue exitoso """ app_logger.info(f"Job {job_id} completado - Éxito: {success}") # Actualizar estadísticas stats = self.get_stats() self.signals.stats_updated.emit(stats)