diff --git a/README.md b/README.md index 9c5a7d5..8c5eb36 100644 --- a/README.md +++ b/README.md @@ -523,3 +523,9 @@ Una vez que tengas respuestas a estas preguntas, podremos: 4. Definir el plan de testing ¿Podrías revisar estas preguntas y darme tus preferencias para cada una? + + +Para ejecutar el programa se puede usar: + +'conda activate autobackups ; python src/app.py' + diff --git a/config.json b/config.json index 97748ef..99375b5 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,7 @@ { "observation_directories": [ { - "path": "C:\\Projects\\Siemens", + "path": "C:\\Users\\migue\\Downloads\\TestBackups", "type": "siemens_s7", "enabled": true, "description": "Directorio principal de proyectos Siemens" @@ -37,7 +37,7 @@ }, "web_interface": { "host": "127.0.0.1", - "port": 5000, + "port": 5120, "debug": false }, "logging": { diff --git a/projects.json b/projects.json index 3c68e69..0375204 100644 --- a/projects.json +++ b/projects.json @@ -1,8 +1,8 @@ { "metadata": { "version": "1.0", - "last_updated": "2025-09-01T08:30:15Z", - "total_projects": 0 + "last_updated": "2025-09-01T15:49:25.929290+00:00", + "total_projects": 2 }, "projects": [ { @@ -49,6 +49,51 @@ "discovery_method": "everything_api", "auto_discovered": true } + }, + { + "id": "", + "name": "Ssae04_14 - TIA", + "path": "C:\\Users\\migue\\Downloads\\TestBackups\\LineaB\\Ssae04_14 - TIA", + "type": "siemens_s7", + "s7p_file": "C:\\Users\\migue\\Downloads\\TestBackups\\LineaB\\Ssae04_14 - TIA\\Ssae0452.s7p", + "observation_directory": "C:\\Users\\migue\\Downloads\\TestBackups", + "relative_path": "LineaB\\Ssae04_14 - TIA", + "backup_path": "LineaB\\Ssae04_14 - TIA", + "schedule_config": { + "schedule": "daily", + "schedule_time": "02:00", + "enabled": true, + "next_scheduled_backup": "" + }, + "backup_history": { + "last_backup_date": "", + "last_backup_file": "", + "backup_count": 0, + "last_successful_backup": "" + }, + "hash_info": { + "last_s7p_hash": "", + "last_full_hash": "", + "last_s7p_timestamp": "", + "last_s7p_size": 0, + "last_scan_timestamp": "", + "file_count": 0, + "total_size_bytes": 0 + }, + "status": { + "current_status": "ready", + "last_error": null, + "retry_count": 0, + "next_retry": null, + "files_in_use": false, + "exclusivity_check_passed": true, + "last_status_update": "" + }, + "discovery_info": { + "discovered_date": "", + "discovery_method": "", + "auto_discovered": true + } } ], "statistics": { @@ -59,4 +104,4 @@ "projects_with_errors": 0, "projects_pending_retry": 0 } -} +} \ No newline at end of file diff --git a/src/app.py b/src/app.py index f890e2c..3359e7f 100644 --- a/src/app.py +++ b/src/app.py @@ -1,5 +1,5 @@ """ -AutoBackups - Aplicación Principal +AutoBackups - Aplicación Principal Flask Sistema automatizado de backup para proyectos Simatic S7 """ @@ -8,6 +8,10 @@ import os import logging from pathlib import Path from datetime import datetime +from flask import Flask, render_template, request, jsonify, redirect, url_for +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +import atexit # Agregar el directorio src al path para imports current_dir = Path(__file__).parent @@ -19,104 +23,172 @@ from models.config_model import Config from models.project_model import ProjectManager from utils.file_utils import DiskSpaceChecker from services.basic_backup_service import BasicBackupService +from routes import register_api_routes, register_web_routes -class AutoBackupsApp: - """Aplicación principal de AutoBackups""" - +# Crear instancia Flask +app = Flask(__name__, template_folder="../templates", static_folder="../static") + + +class AutoBackupsFlaskApp: + """Aplicación principal de AutoBackups con Flask""" + def __init__(self): self.config = None self.project_manager = None - self.discovery_service = None self.disk_checker = None + self.backup_service = None + self.scheduler = None self.logger = None - + # Inicializar aplicación self._setup_logging() self._load_configuration() self._initialize_services() - + self._setup_scheduler() + self._register_routes() + def _setup_logging(self): """Configurar sistema de logging""" try: # Crear directorio de logs si no existe - logs_dir = Path(__file__).parent / ".logs" + logs_dir = Path(__file__).parent.parent / ".logs" logs_dir.mkdir(exist_ok=True) - + # Nombre del archivo de log con timestamp timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") log_filename = logs_dir / f"autobackups_{timestamp}.log" - + # Configurar logging logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[ - logging.FileHandler(log_filename, encoding='utf-8'), - logging.StreamHandler(sys.stdout) - ] + logging.FileHandler(log_filename, encoding="utf-8"), + logging.StreamHandler(sys.stdout), + ], ) - + self.logger = logging.getLogger(__name__) - self.logger.info(f"AutoBackups iniciado - Log: {log_filename}") - + self.logger.info(f"AutoBackups Flask iniciado - Log: {log_filename}") + except Exception as e: print(f"Error configurando logging: {e}") sys.exit(1) - + def _load_configuration(self): """Cargar configuración del sistema""" try: self.logger.info("Cargando configuración...") self.config = Config() self.logger.info("Configuración cargada exitosamente") - + # Mostrar información básica de configuración - self.logger.info(f"Directorios de observación: {len(self.config.observation_directories)}") - self.logger.info(f"Destino de backups: {self.config.backup_destination}") - + dirs_count = len(self.config.observation_directories) + self.logger.info(f"Directorios de observación: {dirs_count}") + self.logger.info(f"Destino: {self.config.backup_destination}") + except Exception as e: self.logger.error(f"Error cargando configuración: {e}") sys.exit(1) - + def _initialize_services(self): """Inicializar servicios principales""" try: self.logger.info("Inicializando servicios...") - + # Project Manager self.project_manager = ProjectManager() self.logger.info("Project Manager inicializado") - + # Disk Space Checker self.disk_checker = DiskSpaceChecker() self.logger.info("Disk Space Checker inicializado") - - # Basic Backup Service (Fase 1) + + # Basic Backup Service self.backup_service = BasicBackupService(self.config) self.logger.info("Basic Backup Service inicializado") - - # Project Discovery Service (temporalmente comentado) - # self.discovery_service = ProjectDiscoveryService(self.config, self.project_manager) - # self.logger.info("Project Discovery Service inicializado") - + self.logger.info("Todos los servicios inicializados correctamente") - + except Exception as e: self.logger.error(f"Error inicializando servicios: {e}") sys.exit(1) - + + def _setup_scheduler(self): + """Configurar scheduler para tareas automáticas""" + try: + self.scheduler = BackgroundScheduler(timezone="America/Mexico_City") + + # Agregar job de escaneo cada hora + self.scheduler.add_job( + func=self.scheduled_project_scan, + trigger=CronTrigger(minute=0), # Cada hora en el minuto 0 + id="project_scan", + name="Escaneo de proyectos automático", + replace_existing=True, + ) + + # Iniciar scheduler + self.scheduler.start() + self.logger.info("Scheduler configurado e iniciado") + + # Asegurar que el scheduler se detenga al cerrar la aplicación + atexit.register(lambda: self.scheduler.shutdown()) + + except Exception as e: + self.logger.error(f"Error configurando scheduler: {e}") + + def _register_routes(self): + """Registrar rutas de Flask usando módulos separados""" + register_web_routes(app, self) + register_api_routes(app, self) + self.logger.info("Rutas Flask registradas desde módulos") + + def scheduled_project_scan(self): + """Escaneo programado de proyectos""" + try: + self.logger.info("Iniciando escaneo programado de proyectos...") + projects_found = self.discover_projects() + self.logger.info(f"Escaneo completado: {projects_found} proyectos") + except Exception as e: + self.logger.error(f"Error en escaneo programado: {e}") + + def discover_projects(self) -> int: + """Descubrir proyectos en directorios de observación""" + try: + self.logger.info("Iniciando descubrimiento de proyectos...") + + # Usar el servicio básico de backup + if hasattr(self, "backup_service"): + projects = self.backup_service.discover_projects_basic() + else: + projects = [] + + # Agregar proyectos al manager + for project_info in projects: + self.project_manager.add_or_update_project(project_info) + + msg = f"Descubrimiento completado: {len(projects)} proyectos" + self.logger.info(msg) + + return len(projects) + + except Exception as e: + self.logger.error(f"Error en descubrimiento de proyectos: {e}") + return 0 + def check_system_requirements(self) -> bool: """Verificar requerimientos del sistema""" try: self.logger.info("Verificando requerimientos del sistema...") - + # Verificar espacio en disco backup_destination = self.config.backup_destination min_space_mb = self.config.get_min_free_space_mb() - + free_space_mb = self.disk_checker.get_free_space_mb(backup_destination) - + if free_space_mb < min_space_mb: self.logger.error( f"Espacio insuficiente en destino de backup. " @@ -124,150 +196,58 @@ class AutoBackupsApp: f"Requerido: {min_space_mb}MB" ) return False - + self.logger.info(f"Espacio en disco OK: {free_space_mb:.1f}MB disponibles") - - # Verificar Everything API (opcional en Fase 1) - if hasattr(self, 'backup_service'): - if self.backup_service.check_system_requirements(): - self.logger.info("Requerimientos del sistema verificados") - else: - self.logger.warning("Algunos requerimientos fallaron") - else: - self.logger.warning("Backup service no disponible") - - # Verificar directorios de observación - missing_dirs = [] - for obs_dir in self.config.observation_directories: - if not Path(obs_dir["path"]).exists(): - missing_dirs.append(obs_dir["path"]) - - if missing_dirs: - self.logger.warning(f"Directorios de observación no encontrados: {missing_dirs}") - - self.logger.info("Verificación de requerimientos completada") return True - + except Exception as e: self.logger.error(f"Error verificando requerimientos del sistema: {e}") return False - - def discover_projects(self) -> int: - """Descubrir proyectos en directorios de observación""" - try: - self.logger.info("Iniciando descubrimiento de proyectos...") - - # Usar el servicio básico de backup - if hasattr(self, 'backup_service'): - projects = self.backup_service.discover_projects_basic() - else: - projects = [] - - # Agregar proyectos al manager - for project_info in projects: - self.project_manager.add_or_update_project(project_info) - - msg = f"Descubrimiento completado: {len(projects)} proyectos" - self.logger.info(msg) - - return len(projects) - - except Exception as e: - self.logger.error(f"Error en descubrimiento de proyectos: {e}") - return 0 - - def show_system_status(self): - """Mostrar estado actual del sistema""" - try: - self.logger.info("=== ESTADO DEL SISTEMA ===") - - # Información de configuración - self.logger.info(f"Directorios de observación configurados: {len(self.config.observation_directories)}") - self.logger.info(f"Destino de backups: {self.config.backup_destination}") - - # Información de proyectos - all_projects = self.project_manager.get_all_projects() - enabled_projects = self.project_manager.get_enabled_projects() - - self.logger.info(f"Total de proyectos: {len(all_projects)}") - self.logger.info(f"Proyectos habilitados: {len(enabled_projects)}") - - # Información de espacio en disco - backup_dest = self.config.backup_destination - total_mb, used_mb, free_mb = self.disk_checker.get_disk_usage_info(backup_dest) - - self.logger.info(f"Espacio en disco (destino backup):") - self.logger.info(f" Total: {total_mb:.1f}MB") - self.logger.info(f" Usado: {used_mb:.1f}MB") - self.logger.info(f" Libre: {free_mb:.1f}MB") - - # Información de Everything API - if self.discovery_service.everything_searcher: - self.logger.info("Everything API: Disponible") - else: - self.logger.info("Everything API: No disponible") - - self.logger.info("=== FIN ESTADO DEL SISTEMA ===") - - except Exception as e: - self.logger.error(f"Error mostrando estado del sistema: {e}") - - def run_initial_setup(self): - """Ejecutar configuración inicial de la aplicación""" - try: - self.logger.info("=== CONFIGURACIÓN INICIAL DE AUTOBACKUPS ===") - - # Verificar requerimientos del sistema - if not self.check_system_requirements(): - self.logger.error("Los requerimientos del sistema no se cumplen") - return False - - # Descubrir proyectos - projects_found = self.discover_projects() - - if projects_found == 0: - self.logger.warning("No se encontraron proyectos para backup") - - # Mostrar estado del sistema - self.show_system_status() - - self.logger.info("=== CONFIGURACIÓN INICIAL COMPLETADA ===") - return True - - except Exception as e: - self.logger.error(f"Error en configuración inicial: {e}") - return False + + +# Instancia global de la aplicación +autobackups_app = None def main(): """Función principal""" + global autobackups_app + try: - print("AutoBackups - Sistema de Backup Automatizado") - print("=" * 50) - - # Crear y configurar aplicación - app = AutoBackupsApp() - - # Ejecutar configuración inicial - if app.run_initial_setup(): - app.logger.info("AutoBackups configurado correctamente") - - # TODO: En las siguientes fases: - # - Iniciar scheduler de backups - # - Iniciar interfaz web Flask - # - Configurar tareas en background - - print("\nConfiguración inicial completada.") - print("Revisa el archivo de log para más detalles.") - - else: - print("Error en la configuración inicial. Revisa los logs.") + print("AutoBackups - Sistema de Backup Automatizado (Flask)") + print("=" * 55) + + # Crear y configurar aplicación Flask + autobackups_app = AutoBackupsFlaskApp() + + # Verificar requerimientos del sistema + if not autobackups_app.check_system_requirements(): + print("Error en la verificación de requerimientos del sistema.") sys.exit(1) - + + # Ejecutar descubrimiento inicial de proyectos + projects_found = autobackups_app.discover_projects() + print(f"Descubrimiento inicial: {projects_found} proyectos encontrados") + + # Configurar Flask + host = autobackups_app.config.web_interface.get("host", "127.0.0.1") + port = autobackups_app.config.web_interface.get("port", 5000) + debug = autobackups_app.config.web_interface.get("debug", False) + + print(f"\nServidor web iniciando en http://{host}:{port}") + print("Presiona Ctrl+C para detener el servidor") + + # Iniciar servidor Flask + app.run(host=host, port=port, debug=debug, use_reloader=False) + except KeyboardInterrupt: print("\nAplicación interrumpida por el usuario") + if autobackups_app and autobackups_app.scheduler: + autobackups_app.scheduler.shutdown() except Exception as e: print(f"Error inesperado: {e}") + if autobackups_app and autobackups_app.scheduler: + autobackups_app.scheduler.shutdown() sys.exit(1) diff --git a/src/models/project_model.py b/src/models/project_model.py index efdec11..a7aac39 100644 --- a/src/models/project_model.py +++ b/src/models/project_model.py @@ -12,6 +12,7 @@ from typing import Dict, List, Any, Optional class ProjectStatus(Enum): """Estados posibles de un proyecto""" + READY = "ready" BACKING_UP = "backing_up" ERROR = "error" @@ -22,7 +23,7 @@ class ProjectStatus(Enum): class Project: """Clase para representar un proyecto individual""" - + def __init__(self, project_data: Dict[str, Any]): self.id = project_data.get("id", "") self.name = project_data.get("name", "") @@ -32,21 +33,21 @@ class Project: self.observation_directory = project_data.get("observation_directory", "") self.relative_path = project_data.get("relative_path", "") self.backup_path = project_data.get("backup_path", "") - + # Configuración de schedule schedule_config = project_data.get("schedule_config", {}) self.schedule = schedule_config.get("schedule", "daily") self.schedule_time = schedule_config.get("schedule_time", "02:00") self.enabled = schedule_config.get("enabled", True) self.next_scheduled_backup = schedule_config.get("next_scheduled_backup", "") - + # Historia de backups backup_history = project_data.get("backup_history", {}) self.last_backup_date = backup_history.get("last_backup_date", "") self.last_backup_file = backup_history.get("last_backup_file", "") self.backup_count = backup_history.get("backup_count", 0) self.last_successful_backup = backup_history.get("last_successful_backup", "") - + # Información de hash hash_info = project_data.get("hash_info", {}) self.last_s7p_hash = hash_info.get("last_s7p_hash", "") @@ -56,7 +57,7 @@ class Project: self.last_scan_timestamp = hash_info.get("last_scan_timestamp", "") self.file_count = hash_info.get("file_count", 0) self.total_size_bytes = hash_info.get("total_size_bytes", 0) - + # Estado actual status = project_data.get("status", {}) self.current_status = ProjectStatus(status.get("current_status", "ready")) @@ -66,13 +67,13 @@ class Project: self.files_in_use = status.get("files_in_use", False) self.exclusivity_check_passed = status.get("exclusivity_check_passed", True) self.last_status_update = status.get("last_status_update", "") - + # Información de descubrimiento discovery_info = project_data.get("discovery_info", {}) self.discovered_date = discovery_info.get("discovered_date", "") self.discovery_method = discovery_info.get("discovery_method", "") self.auto_discovered = discovery_info.get("auto_discovered", True) - + def to_dict(self) -> Dict[str, Any]: """Convertir el proyecto a diccionario para serialización JSON""" return { @@ -88,13 +89,13 @@ class Project: "schedule": self.schedule, "schedule_time": self.schedule_time, "enabled": self.enabled, - "next_scheduled_backup": self.next_scheduled_backup + "next_scheduled_backup": self.next_scheduled_backup, }, "backup_history": { "last_backup_date": self.last_backup_date, "last_backup_file": self.last_backup_file, "backup_count": self.backup_count, - "last_successful_backup": self.last_successful_backup + "last_successful_backup": self.last_successful_backup, }, "hash_info": { "last_s7p_hash": self.last_s7p_hash, @@ -103,7 +104,7 @@ class Project: "last_s7p_size": self.last_s7p_size, "last_scan_timestamp": self.last_scan_timestamp, "file_count": self.file_count, - "total_size_bytes": self.total_size_bytes + "total_size_bytes": self.total_size_bytes, }, "status": { "current_status": self.current_status.value, @@ -112,30 +113,36 @@ class Project: "next_retry": self.next_retry, "files_in_use": self.files_in_use, "exclusivity_check_passed": self.exclusivity_check_passed, - "last_status_update": self.last_status_update + "last_status_update": self.last_status_update, }, "discovery_info": { "discovered_date": self.discovered_date, "discovery_method": self.discovery_method, - "auto_discovered": self.auto_discovered - } + "auto_discovered": self.auto_discovered, + }, } - + def update_status(self, status: ProjectStatus, error_message: str = None) -> None: """Actualizar el estado del proyecto""" self.current_status = status self.last_error = error_message self.last_status_update = datetime.now(timezone.utc).isoformat() - + if status == ProjectStatus.ERROR: self.retry_count += 1 elif status == ProjectStatus.READY: self.retry_count = 0 self.last_error = None - - def update_hash_info(self, s7p_hash: str = None, full_hash: str = None, - s7p_timestamp: str = None, s7p_size: int = None, - file_count: int = None, total_size: int = None) -> None: + + def update_hash_info( + self, + s7p_hash: str = None, + full_hash: str = None, + s7p_timestamp: str = None, + s7p_size: int = None, + file_count: int = None, + total_size: int = None, + ) -> None: """Actualizar información de hash""" if s7p_hash is not None: self.last_s7p_hash = s7p_hash @@ -149,9 +156,9 @@ class Project: self.file_count = file_count if total_size is not None: self.total_size_bytes = total_size - + self.last_scan_timestamp = datetime.now(timezone.utc).isoformat() - + def update_backup_info(self, backup_file_path: str) -> None: """Actualizar información después de un backup exitoso""" now = datetime.now(timezone.utc).isoformat() @@ -164,29 +171,29 @@ class Project: class ProjectManager: """Clase para manejar la colección de proyectos""" - + def __init__(self, projects_file_path: str = None): if projects_file_path is None: # Buscar projects.json en el directorio del proyecto current_dir = Path(__file__).parent.parent.parent projects_file_path = current_dir / "projects.json" - + self.projects_file_path = Path(projects_file_path) self.projects: Dict[str, Project] = {} self.metadata = {} self.statistics = {} self.load_projects() - + def load_projects(self) -> None: """Cargar proyectos desde archivo JSON""" try: if self.projects_file_path.exists(): - with open(self.projects_file_path, 'r', encoding='utf-8') as f: + with open(self.projects_file_path, "r", encoding="utf-8") as f: data = json.load(f) - + self.metadata = data.get("metadata", {}) self.statistics = data.get("statistics", {}) - + # Cargar proyectos for project_data in data.get("projects", []): project = Project(project_data) @@ -197,38 +204,38 @@ class ProjectManager: self.save_projects() except Exception as e: raise Exception(f"Error cargando proyectos: {e}") - + def save_projects(self) -> None: """Guardar proyectos al archivo JSON""" try: data = { "metadata": self.metadata, "projects": [project.to_dict() for project in self.projects.values()], - "statistics": self.statistics + "statistics": self.statistics, } - - with open(self.projects_file_path, 'w', encoding='utf-8') as f: + + with open(self.projects_file_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) except Exception as e: raise Exception(f"Error guardando proyectos: {e}") - + def _create_default_projects_file(self) -> None: """Crear archivo de proyectos por defecto""" self.metadata = { "version": "1.0", "last_updated": datetime.now(timezone.utc).isoformat(), - "total_projects": 0 + "total_projects": 0, } - + self.statistics = { "total_backups_created": 0, "total_backup_size_mb": 0.0, "average_backup_time_seconds": 0.0, "last_global_scan": "", "projects_with_errors": 0, - "projects_pending_retry": 0 + "projects_pending_retry": 0, } - + def add_project(self, project_data: Dict[str, Any]) -> Project: """Agregar un nuevo proyecto""" project = Project(project_data) @@ -237,23 +244,53 @@ class ProjectManager: self.metadata["last_updated"] = datetime.now(timezone.utc).isoformat() self.save_projects() return project - + + def add_or_update_project(self, project_data: Dict[str, Any]) -> Project: + """Agregar o actualizar un proyecto existente""" + project_id = project_data.get("id", "") + + if project_id in self.projects: + # Actualizar proyecto existente + existing_project = self.projects[project_id] + # Mantener algunos datos existentes si no se proporcionan + if "schedule_config" not in project_data and hasattr( + existing_project, "enabled" + ): + project_data["schedule_config"] = { + "schedule": existing_project.schedule, + "schedule_time": existing_project.schedule_time, + "enabled": existing_project.enabled, + "next_scheduled_backup": existing_project.next_scheduled_backup, + } + + project = Project(project_data) + self.projects[project.id] = project + else: + # Agregar nuevo proyecto + project = Project(project_data) + self.projects[project.id] = project + + self.metadata["total_projects"] = len(self.projects) + self.metadata["last_updated"] = datetime.now(timezone.utc).isoformat() + self.save_projects() + return project + def get_project(self, project_id: str) -> Optional[Project]: """Obtener un proyecto por ID""" return self.projects.get(project_id) - + def get_all_projects(self) -> List[Project]: """Obtener todos los proyectos""" return list(self.projects.values()) - + def get_projects_by_status(self, status: ProjectStatus) -> List[Project]: """Obtener proyectos por estado""" return [p for p in self.projects.values() if p.current_status == status] - + def get_enabled_projects(self) -> List[Project]: """Obtener proyectos habilitados""" return [p for p in self.projects.values() if p.enabled] - + def update_statistics(self) -> None: """Actualizar estadísticas generales""" self.statistics["projects_with_errors"] = len( diff --git a/src/routes/__init__.py b/src/routes/__init__.py new file mode 100644 index 0000000..bad8de7 --- /dev/null +++ b/src/routes/__init__.py @@ -0,0 +1,5 @@ +# Routes Package +from .api_routes import register_api_routes +from .web_routes import register_web_routes + +__all__ = ["register_api_routes", "register_web_routes"] diff --git a/src/routes/api_routes.py b/src/routes/api_routes.py new file mode 100644 index 0000000..6b2e9f5 --- /dev/null +++ b/src/routes/api_routes.py @@ -0,0 +1,164 @@ +""" +API Routes - AutoBackups +Rutas de la API REST para operaciones AJAX +""" + +from flask import request, jsonify + + +def register_api_routes(app, autobackups_instance): + """Registrar rutas API de la aplicación""" + + @app.route("/api/config") + def get_config(): + """Obtener configuración actual""" + try: + return jsonify( + { + "observation_directories": autobackups_instance.config.observation_directories, + "backup_destination": autobackups_instance.config.backup_destination, + "global_settings": autobackups_instance.config.global_settings, + "web_interface": autobackups_instance.config.web_interface, + } + ) + except Exception as e: + autobackups_instance.logger.error(f"Error getting config: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route("/api/projects") + def get_projects(): + """Obtener lista de proyectos""" + try: + projects = autobackups_instance.project_manager.get_all_projects() + projects_dict = [project.to_dict() for project in projects] + return jsonify({"projects": projects_dict}) + except Exception as e: + autobackups_instance.logger.error(f"Error getting projects: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route("/api/projects//backup", methods=["POST"]) + def manual_backup(project_id): + """Ejecutar backup manual de un proyecto""" + try: + autobackups_instance.logger.info( + f"Backup manual solicitado para: {project_id}" + ) + + # TODO: Implementar backup manual real + # Por ahora solo simulamos la respuesta + + return jsonify( + { + "status": "success", + "message": f"Backup iniciado para proyecto {project_id}", + } + ) + except Exception as e: + autobackups_instance.logger.error(f"Error in manual backup: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route("/api/projects//config", methods=["PUT"]) + def update_project_config(project_id): + """Actualizar configuración de un proyecto""" + try: + data = request.get_json() + + if not data: + return jsonify({"error": "No data provided"}), 400 + + # TODO: Implementar actualización real de configuración + autobackups_instance.logger.info(f"Config actualizada para: {project_id}") + autobackups_instance.logger.info(f"Nueva config: {data}") + + return jsonify({"status": "success"}) + except Exception as e: + autobackups_instance.logger.error(f"Error updating project config: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route("/api/scan", methods=["POST"]) + def scan_projects(): + """Escanear directorios en busca de nuevos proyectos""" + try: + projects_found = autobackups_instance.discover_projects() + return jsonify({"status": "success", "projects_found": projects_found}) + except Exception as e: + autobackups_instance.logger.error(f"Error scanning projects: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route("/api/system/status") + def system_status(): + """Obtener estado del sistema""" + try: + backup_dest = autobackups_instance.config.backup_destination + total_mb, used_mb, free_mb = ( + autobackups_instance.disk_checker.get_disk_usage_info(backup_dest) + ) + + return jsonify( + { + "disk_space": { + "total_mb": total_mb, + "used_mb": used_mb, + "free_mb": free_mb, + }, + "projects": { + "total": len( + autobackups_instance.project_manager.get_all_projects() + ), + "enabled": len( + autobackups_instance.project_manager.get_enabled_projects() + ), + }, + "everything_api": { + "available": hasattr( + autobackups_instance.backup_service, "everything_wrapper" + ) + }, + } + ) + except Exception as e: + autobackups_instance.logger.error(f"Error getting system status: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route("/api/backup/all", methods=["POST"]) + def backup_all_projects(): + """Ejecutar backup de todos los proyectos habilitados""" + try: + enabled_projects = ( + autobackups_instance.project_manager.get_enabled_projects() + ) + + if not enabled_projects: + return jsonify( + { + "status": "warning", + "message": "No hay proyectos habilitados para backup", + } + ) + + # TODO: Implementar backup de todos los proyectos + autobackups_instance.logger.info( + f"Backup global iniciado para {len(enabled_projects)} proyectos" + ) + + return jsonify( + { + "status": "success", + "message": f"Backup iniciado para {len(enabled_projects)} proyectos", + "projects_count": len(enabled_projects), + } + ) + except Exception as e: + autobackups_instance.logger.error(f"Error in backup all: {e}") + return jsonify({"error": str(e)}), 500 + + @app.route("/api/logs") + def get_logs(): + """Obtener logs recientes""" + try: + # TODO: Implementar lectura de logs + logs = [] + return jsonify({"logs": logs}) + except Exception as e: + autobackups_instance.logger.error(f"Error getting logs: {e}") + return jsonify({"error": str(e)}), 500 diff --git a/src/routes/web_routes.py b/src/routes/web_routes.py new file mode 100644 index 0000000..ceecb22 --- /dev/null +++ b/src/routes/web_routes.py @@ -0,0 +1,101 @@ +""" +Web Routes - AutoBackups +Rutas principales de la interfaz web +""" + +from flask import render_template, redirect, url_for + + +def register_web_routes(app, autobackups_instance): + """Registrar rutas web de la aplicación""" + + @app.route("/") + def dashboard(): + """Dashboard principal""" + try: + print("=== DEBUG: Iniciando dashboard ===") + projects = autobackups_instance.project_manager.get_all_projects() + print(f"DEBUG: Proyectos obtenidos: {len(projects)}") + + # Verificar el tipo de los proyectos + for i, project in enumerate(projects): + print(f"DEBUG: Proyecto {i}: tipo = {type(project)}") + print(f"DEBUG: Proyecto {i}: atributos = {dir(project)}") + if i == 0: # Solo el primero para no hacer spam + break + + enabled_projects = ( + autobackups_instance.project_manager.get_enabled_projects() + ) + print(f"DEBUG: Proyectos habilitados: {len(enabled_projects)}") + + # Información de sistema + backup_dest = autobackups_instance.config.backup_destination + total_mb, used_mb, free_mb = ( + autobackups_instance.disk_checker.get_disk_usage_info(backup_dest) + ) + + stats = { + "total_projects": len(projects), + "enabled_projects": len(enabled_projects), + "total_space_mb": total_mb, + "used_space_mb": used_mb, + "free_space_mb": free_mb, + } + + print("DEBUG: Llamando render_template...") + return render_template("dashboard.html", projects=projects, stats=stats) + + except Exception as e: + print(f"DEBUG: Error completo: {e}") + import traceback + + traceback.print_exc() + autobackups_instance.logger.error(f"Error en dashboard: {e}") + return render_template("error.html", error=str(e)) + + @app.route("/projects") + def projects_page(): + """Página de gestión de proyectos""" + try: + projects = autobackups_instance.project_manager.get_all_projects() + return render_template("projects.html", projects=projects) + except Exception as e: + autobackups_instance.logger.error(f"Error en página de proyectos: {e}") + return render_template("error.html", error=str(e)) + + @app.route("/config") + def config_page(): + """Página de configuración""" + try: + config_data = { + "observation_directories": autobackups_instance.config.observation_directories, + "backup_destination": autobackups_instance.config.backup_destination, + "global_settings": autobackups_instance.config.global_settings, + "web_interface": autobackups_instance.config.web_interface, + } + return render_template("config.html", config=config_data) + except Exception as e: + autobackups_instance.logger.error(f"Error en página de configuración: {e}") + return render_template("error.html", error=str(e)) + + @app.route("/logs") + def logs_page(): + """Página de visualización de logs""" + try: + # TODO: Implementar lectura de logs + logs = [] + return render_template("logs.html", logs=logs) + except Exception as e: + autobackups_instance.logger.error(f"Error en página de logs: {e}") + return render_template("error.html", error=str(e)) + + @app.errorhandler(404) + def not_found(error): + """Manejo de error 404""" + return render_template("error.html", error="Página no encontrada"), 404 + + @app.errorhandler(500) + def internal_error(error): + """Manejo de error 500""" + return render_template("error.html", error="Error interno del servidor"), 500 diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..08e0e14 --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,241 @@ +/* AutoBackups Custom Styles */ + +:root { + --primary-color: #0d6efd; + --success-color: #198754; + --info-color: #0dcaf0; + --warning-color: #ffc107; + --danger-color: #dc3545; + --dark-color: #212529; +} + +/* General Styles */ +body { + background-color: #f8f9fa; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +.navbar-brand { + font-weight: bold; + font-size: 1.5rem; +} + +/* Cards */ +.card { + border: none; + border-radius: 0.5rem; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + transition: box-shadow 0.15s ease-in-out; +} + +.card:hover { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +.card-header { + background-color: #fff; + border-bottom: 1px solid #dee2e6; + font-weight: 600; +} + +/* Statistics Cards */ +.card.bg-primary, +.card.bg-success, +.card.bg-info, +.card.bg-warning { + border: none; +} + +.card.bg-primary .card-title, +.card.bg-success .card-title, +.card.bg-info .card-title, +.card.bg-warning .card-title { + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.card.bg-primary .card-text, +.card.bg-success .card-text, +.card.bg-info .card-text, +.card.bg-warning .card-text { + font-size: 2rem; + font-weight: bold; + margin: 0; +} + +/* Tables */ +.table { + background-color: #fff; +} + +.table-hover tbody tr:hover { + background-color: rgba(0, 0, 0, 0.025); +} + +.table th { + border-top: none; + font-weight: 600; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.table td { + vertical-align: middle; + border-color: #dee2e6; +} + +/* Badges */ +.badge { + font-size: 0.75rem; + padding: 0.375rem 0.75rem; +} + +/* Buttons */ +.btn { + border-radius: 0.375rem; + font-weight: 500; +} + +.btn-group-sm > .btn, +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +/* Alerts */ +.alert { + border: none; + border-radius: 0.5rem; + margin-bottom: 1rem; +} + +.alert-dismissible .btn-close { + padding: 1.25rem 1rem; +} + +/* Modals */ +.modal-content { + border: none; + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +.modal-header { + border-bottom: 1px solid #dee2e6; + padding: 1.5rem; +} + +.modal-body { + padding: 1.5rem; +} + +.modal-footer { + border-top: 1px solid #dee2e6; + padding: 1rem 1.5rem; +} + +/* Form Controls */ +.form-control, +.form-select { + border-radius: 0.375rem; + border: 1px solid #ced4da; +} + +.form-control:focus, +.form-select:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +/* Loading States */ +.loading { + opacity: 0.6; + pointer-events: none; +} + +/* Custom Utilities */ +.text-truncate-50 { + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .table-responsive { + font-size: 0.875rem; + } + + .card.bg-primary .card-text, + .card.bg-success .card-text, + .card.bg-info .card-text, + .card.bg-warning .card-text { + font-size: 1.5rem; + } + + .btn-group-sm > .btn { + padding: 0.125rem 0.25rem; + font-size: 0.75rem; + } +} + +/* Animation Classes */ +.fade-in { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Status Indicators */ +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + margin-right: 0.5rem; +} + +.status-ready { background-color: var(--success-color); } +.status-error { background-color: var(--danger-color); } +.status-warning { background-color: var(--warning-color); } +.status-info { background-color: var(--info-color); } + +/* Progress Bars */ +.progress { + height: 0.5rem; + border-radius: 0.25rem; +} + +.progress-bar { + transition: width 0.6s ease; +} + +/* Custom Scrollbar */ +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: #ced4da #f8f9fa; +} + +.custom-scrollbar::-webkit-scrollbar { + width: 8px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: #f8f9fa; + border-radius: 4px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: #ced4da; + border-radius: 4px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: #adb5bd; +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..64b100f --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,260 @@ +// AutoBackups JavaScript Application + +class AutoBackupsApp { + constructor() { + this.init(); + } + + init() { + this.updateCurrentTime(); + this.setupEventListeners(); + + // Update time every minute + setInterval(() => this.updateCurrentTime(), 60000); + } + + updateCurrentTime() { + const now = new Date(); + const timeString = now.toLocaleString('es-ES', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + + const timeElement = document.getElementById('current-time'); + if (timeElement) { + timeElement.textContent = timeString; + } + } + + setupEventListeners() { + // Global backup all button + const backupAllBtn = document.getElementById('backup-all-btn'); + if (backupAllBtn) { + backupAllBtn.addEventListener('click', () => this.backupAllProjects()); + } + + // Save project configuration + const saveConfigBtn = document.getElementById('save-project-config-btn'); + if (saveConfigBtn) { + saveConfigBtn.addEventListener('click', () => this.saveProjectConfig()); + } + } + + backupAllProjects() { + this.showAlert('info', 'Iniciando backup de todos los proyectos habilitados...'); + + // Get all enabled projects and trigger backup + fetch('/api/projects') + .then(response => response.json()) + .then(data => { + if (data.error) { + this.showAlert('danger', `Error: ${data.error}`); + return; + } + + const enabledProjects = data.projects.filter(project => + project.schedule_config && project.schedule_config.enabled + ); + + if (enabledProjects.length === 0) { + this.showAlert('warning', 'No hay proyectos habilitados para backup.'); + return; + } + + // Backup each enabled project + let completed = 0; + enabledProjects.forEach(project => { + fetch(`/api/projects/${project.id}/backup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }) + .then(response => response.json()) + .then(backupData => { + completed++; + if (completed === enabledProjects.length) { + this.showAlert('success', + `Backup completado para ${enabledProjects.length} proyectos.`); + } + }) + .catch(error => { + console.error(`Error backing up project ${project.id}:`, error); + }); + }); + }) + .catch(error => { + this.showAlert('danger', `Error de conexión: ${error.message}`); + }); + } + + saveProjectConfig() { + const projectId = document.getElementById('modal-project-id').value; + const schedule = document.getElementById('modal-project-schedule').value; + const time = document.getElementById('modal-project-time').value; + const enabled = document.getElementById('modal-project-enabled').checked; + + const config = { + schedule_config: { + schedule: schedule, + schedule_time: time, + enabled: enabled + } + }; + + fetch(`/api/projects/${projectId}/config`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(config) + }) + .then(response => response.json()) + .then(data => { + if (data.error) { + this.showAlert('danger', `Error: ${data.error}`); + } else { + this.showAlert('success', 'Configuración guardada correctamente.'); + + // Close modal + const modal = bootstrap.Modal.getInstance( + document.getElementById('projectConfigModal') + ); + if (modal) { + modal.hide(); + } + + // Refresh page after a short delay + setTimeout(() => location.reload(), 1500); + } + }) + .catch(error => { + this.showAlert('danger', `Error de conexión: ${error.message}`); + }); + } + + showAlert(type, message, duration = 5000) { + const alertsContainer = document.getElementById('alerts-container'); + if (!alertsContainer) return; + + const alertId = 'alert-' + Date.now(); + const alertHtml = ` + + `; + + alertsContainer.insertAdjacentHTML('beforeend', alertHtml); + + // Auto-dismiss after duration + if (duration > 0) { + setTimeout(() => { + const alertElement = document.getElementById(alertId); + if (alertElement) { + const alert = new bootstrap.Alert(alertElement); + alert.close(); + } + }, duration); + } + } + + getAlertIcon(type) { + const icons = { + 'success': 'check-circle', + 'danger': 'exclamation-triangle', + 'warning': 'exclamation-triangle', + 'info': 'info-circle', + 'primary': 'info-circle', + 'secondary': 'info-circle' + }; + return icons[type] || 'info-circle'; + } + + // Utility method to format file sizes + formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + // Utility method to format dates + formatDate(dateString) { + if (!dateString) return 'N/A'; + + const date = new Date(dateString); + return date.toLocaleString('es-ES', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } + + // Method to show loading state + showLoading(element) { + if (element) { + element.classList.add('loading'); + const originalContent = element.innerHTML; + element.innerHTML = ' Cargando...'; + return originalContent; + } + } + + // Method to hide loading state + hideLoading(element, originalContent) { + if (element && originalContent) { + element.classList.remove('loading'); + element.innerHTML = originalContent; + } + } + + // Method to make API calls with error handling + async apiCall(url, options = {}) { + try { + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + ...options.headers + }, + ...options + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || `HTTP error! status: ${response.status}`); + } + + return data; + } catch (error) { + console.error('API call failed:', error); + this.showAlert('danger', `Error de API: ${error.message}`); + throw error; + } + } +} + +// Global functions for backward compatibility +function showAlert(type, message, duration = 5000) { + if (window.autobackupsApp) { + window.autobackupsApp.showAlert(type, message, duration); + } +} + +// Initialize app when DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + window.autobackupsApp = new AutoBackupsApp(); +}); + +// Export for modules if needed +if (typeof module !== 'undefined' && module.exports) { + module.exports = AutoBackupsApp; +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..8e2bbde --- /dev/null +++ b/templates/base.html @@ -0,0 +1,70 @@ + + + + + + {% block title %}AutoBackups{% endblock %} + + + + + + + + + {% block head %}{% endblock %} + + + + + + +
+ +
+ + {% block content %}{% endblock %} +
+ + + {% block modals %}{% endblock %} + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..ec3409f --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,383 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - AutoBackups{% endblock %} + +{% block content %} +
+ +
+
+
+
+
+
+ Total Proyectos +
+

{{ stats.total_projects }}

+
+
+
+
+
+
+
+ Habilitados +
+

{{ stats.enabled_projects }}

+
+
+
+
+
+
+
+ Espacio Libre +
+

{{ "%.1f"|format(stats.free_space_mb) }} MB

+
+
+
+
+
+
+
+ Espacio Total +
+

{{ "%.1f"|format(stats.total_space_mb) }} MB

+
+
+
+
+
+
+ +
+ +
+
+
+
+ Proyectos +
+
+ + +
+
+
+ {% if projects %} +
+ + + + + + + + + + + + + + {% for project in projects %} + + + + + + + + + + {% endfor %} + +
ProyectoRutaTipoEstadoÚltimo BackupPróximo BackupAcciones
+ {{ project.name or 'Sin nombre' }} +
+ ID: {{ project.id }} +
+ + {{ project.path[:50] }}{% if project.path|length > 50 %}...{% endif %} + + + {{ project.type or 'N/A' }} + + {% if project.current_status.value == 'ready' %} + Listo + {% elif project.current_status.value == 'backing_up' %} + En progreso + {% elif project.current_status.value == 'error' %} + Error + {% elif project.current_status.value == 'files_in_use' %} + Archivos en uso + {% else %} + {{ project.current_status.value }} + {% endif %} + + {% if project.last_backup_date %} + {{ project.last_backup_date[:19] }} + {% else %} + Nunca + {% endif %} + + {% if project.next_scheduled_backup %} + {{ project.next_scheduled_backup[:19] }} + {% else %} + No programado + {% endif %} + +
+ + + +
+
+
+ {% else %} +
+ +

No hay proyectos disponibles

+

Ejecuta un escaneo para buscar proyectos en los directorios configurados.

+ +
+ {% endif %} +
+
+
+
+{% endblock %} + +{% block modals %} + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..15352b6 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block title %}Error - AutoBackups{% endblock %} + +{% block content %} +
+
+
+
+
+ Error +
+
+
+

Ha ocurrido un error en la aplicación:

+
+ {{ error }} +
+ +
+
+
+
+{% endblock %} diff --git a/test_json.py b/test_json.py new file mode 100644 index 0000000..45dfd9f --- /dev/null +++ b/test_json.py @@ -0,0 +1,11 @@ +import json + +with open("projects.json", "r") as f: + data = json.load(f) + +print(f"Total projects in JSON: {len(data['projects'])}") +if data["projects"]: + print(f"First project name: {data['projects'][0]['name']}") + print(f"First project ID: {data['projects'][0]['id']}") +else: + print("No projects found in JSON") diff --git a/test_project_manager.py b/test_project_manager.py new file mode 100644 index 0000000..040eb10 --- /dev/null +++ b/test_project_manager.py @@ -0,0 +1,20 @@ +import sys +import os +from pathlib import Path + +# Agregar src al path +sys.path.insert(0, str(Path(__file__).parent / "src")) + +from models.project_model import ProjectManager + +# Crear project manager y ver qué proyectos hay +pm = ProjectManager() +projects = pm.get_all_projects() + +print(f"Proyectos cargados por ProjectManager: {len(projects)}") +for project in projects: + print(f" - ID: {project.id}") + print(f" - Nombre: {project.name}") + print(f" - Tipo: {type(project)}") + print(f" - Atributos: {dir(project)}") + break # Solo el primero