Initial commit: CloudRestoreAS v1.0.0 - Aplicación completa de restauración automática SQL Server

This commit is contained in:
2026-01-25 16:53:00 -07:00
commit 854ffa116f
43 changed files with 6357 additions and 0 deletions

4
app/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""CloudRestoreAS - Aplicación de restauración automática de bases de datos SQL Server."""
__version__ = "1.0.0"
__author__ = "Aduanasoft"

82
app/constants.py Normal file
View File

@@ -0,0 +1,82 @@
"""Constantes globales de la aplicación."""
from pathlib import Path
# Rutas base
APP_DIR = Path(__file__).parent.parent
DATA_DIR = APP_DIR / "data"
LOGS_DIR = APP_DIR / "logs"
# Base de datos
DB_PATH = DATA_DIR / "app.db"
# Estados de jobs
class JobStatus:
QUEUED = "queued"
EXTRACTING = "extracting"
RESTORING = "restoring"
CLEANING = "cleaning"
COMPLETED = "completed"
FAILED = "failed"
FAILED_RESTART = "failed_restart"
CANCELLED = "cancelled"
# Niveles de log
class LogLevel:
DEBUG = "DEBUG"
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"
# Tipos de paso de job
class StepType:
STABILITY_CHECK = "stability_check"
NODE_MAPPING = "node_mapping"
EXTRACT = "extract"
LOCATE_BAK = "locate_bak"
SQL_CONNECT = "sql_connect"
FILELIST = "filelist"
RESTORE = "restore"
CLEANUP = "cleanup"
# Configuración por defecto
DEFAULT_CONFIG = {
"paths": {
"input_folder": "",
"processed_folder": "",
"failed_folder": "",
"extract_folder": "",
"data_sql_folder": "",
"seven_zip_exe": ""
},
"sql": {
"server": "localhost",
"use_windows_auth": True,
"username": "",
"password_encrypted": ""
},
"concurrency": {
"extract_workers": 1,
"restore_workers": 1
},
"stability": {
"check_enabled": True,
"check_interval_seconds": 5,
"stable_duration_seconds": 10,
"use_ready_marker": False
},
"timeouts": {
"extract_minutes": 30,
"restore_minutes": 60
},
"retries": {
"max_attempts": 3,
"retry_delay_seconds": 60
},
"features": {
"dry_run_mode": False,
"auto_scan_enabled": True,
"scan_interval_seconds": 30
}
}

1
app/db/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Capa de acceso a datos."""

View File

@@ -0,0 +1,69 @@
"""Repositorio para gestionar configuración."""
from typing import Optional, Any
import json
from datetime import datetime
from .database import db
class ConfigRepository:
"""Repositorio para operaciones con configuración."""
@staticmethod
def get(key: str, default: Any = None) -> Any:
"""
Obtiene un valor de configuración.
Args:
key: Clave de configuración
default: Valor por defecto si no existe
Returns:
Valor de configuración (deserializado de JSON)
"""
row = db.fetchone("SELECT value FROM config WHERE key = ?", (key,))
if not row:
return default
try:
return json.loads(row["value"])
except:
return row["value"]
@staticmethod
def set(key: str, value: Any):
"""
Establece un valor de configuración.
Args:
key: Clave de configuración
value: Valor a guardar (se serializa a JSON)
"""
now = datetime.utcnow().isoformat()
value_str = json.dumps(value) if not isinstance(value, str) else value
db.execute("""
INSERT OR REPLACE INTO config (key, value, updated_at)
VALUES (?, ?, ?)
""", (key, value_str, now))
@staticmethod
def get_all() -> dict:
"""
Obtiene toda la configuración.
Returns:
Diccionario con todas las claves y valores
"""
rows = db.fetchall("SELECT key, value FROM config")
config = {}
for row in rows:
try:
config[row["key"]] = json.loads(row["value"])
except:
config[row["key"]] = row["value"]
return config
@staticmethod
def delete(key: str):
"""Elimina una clave de configuración."""
db.execute("DELETE FROM config WHERE key = ?", (key,))

140
app/db/database.py Normal file
View File

@@ -0,0 +1,140 @@
"""Esquema y gestión de la base de datos SQLite."""
import sqlite3
from pathlib import Path
from typing import Optional
from datetime import datetime
from ..constants import DB_PATH
class DatabaseManager:
"""Gestor de la base de datos SQLite."""
def __init__(self, db_path: Optional[Path] = None):
"""
Inicializa el gestor de base de datos.
Args:
db_path: Ruta a la base de datos (usa DB_PATH por defecto)
"""
self.db_path = db_path or DB_PATH
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._initialize_schema()
def get_connection(self) -> sqlite3.Connection:
"""Obtiene una conexión a la base de datos."""
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def _initialize_schema(self):
"""Crea las tablas si no existen."""
with self.get_connection() as conn:
cursor = conn.cursor()
# Tabla de jobs
cursor.execute("""
CREATE TABLE IF NOT EXISTS jobs (
job_id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
source_path TEXT NOT NULL,
source_name TEXT NOT NULL,
source_hash TEXT,
node_name TEXT,
db_name TEXT,
status TEXT NOT NULL,
attempts INTEGER DEFAULT 0,
last_error TEXT,
started_at TEXT,
finished_at TEXT,
total_ms INTEGER,
extract_ms INTEGER,
restore_ms INTEGER,
filelist_ms INTEGER
)
""")
# Tabla de pasos de jobs
cursor.execute("""
CREATE TABLE IF NOT EXISTS job_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL,
step TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
duration_ms INTEGER,
exit_code INTEGER,
stdout TEXT,
stderr TEXT,
error TEXT,
FOREIGN KEY (job_id) REFERENCES jobs(job_id)
)
""")
# Tabla de nodos (mapeo node_name -> db_name)
cursor.execute("""
CREATE TABLE IF NOT EXISTS nodes (
node_name TEXT PRIMARY KEY,
db_name TEXT NOT NULL,
active INTEGER DEFAULT 1,
notes TEXT,
updated_at TEXT NOT NULL
)
""")
# Tabla de eventos
cursor.execute("""
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
level TEXT NOT NULL,
job_id TEXT,
message TEXT NOT NULL,
FOREIGN KEY (job_id) REFERENCES jobs(job_id)
)
""")
# Tabla de configuración
cursor.execute("""
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
)
""")
# Índices
cursor.execute("CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_job ON events(job_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_job_steps_job ON job_steps(job_id)")
conn.commit()
def execute(self, query: str, params: tuple = ()) -> sqlite3.Cursor:
"""Ejecuta una consulta SQL."""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(query, params)
conn.commit()
return cursor
def fetchone(self, query: str, params: tuple = ()) -> Optional[sqlite3.Row]:
"""Ejecuta una consulta y devuelve una fila."""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(query, params)
return cursor.fetchone()
def fetchall(self, query: str, params: tuple = ()) -> list[sqlite3.Row]:
"""Ejecuta una consulta y devuelve todas las filas."""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(query, params)
return cursor.fetchall()
# Instancia global
db = DatabaseManager()

View File

@@ -0,0 +1,77 @@
"""Repositorio para gestionar eventos."""
from typing import Optional, List
from datetime import datetime
from dataclasses import dataclass
from .database import db
@dataclass
class Event:
"""Modelo de Event."""
id: int
created_at: str
level: str
job_id: Optional[str]
message: str
class EventRepository:
"""Repositorio para operaciones con eventos."""
@staticmethod
def create(
level: str,
message: str,
job_id: Optional[str] = None
) -> int:
"""
Crea un nuevo evento.
Args:
level: Nivel del evento (DEBUG, INFO, WARNING, ERROR, CRITICAL)
message: Mensaje del evento
job_id: ID del job asociado (opcional)
Returns:
ID del evento creado
"""
now = datetime.utcnow().isoformat()
cursor = db.execute("""
INSERT INTO events (created_at, level, job_id, message)
VALUES (?, ?, ?, ?)
""", (now, level, job_id, message))
return cursor.lastrowid
@staticmethod
def get_all(
level: Optional[str] = None,
job_id: Optional[str] = None,
limit: int = 100
) -> List[Event]:
"""
Obtiene eventos con filtros opcionales.
Args:
level: Filtrar por nivel
job_id: Filtrar por job
limit: Límite de resultados
Returns:
Lista de eventos
"""
query = "SELECT * FROM events WHERE 1=1"
params = []
if level:
query += " AND level = ?"
params.append(level)
if job_id:
query += " AND job_id = ?"
params.append(job_id)
query += f" ORDER BY created_at DESC LIMIT {limit}"
rows = db.fetchall(query, tuple(params))
return [Event(**dict(row)) for row in rows]

232
app/db/job_repository.py Normal file
View File

@@ -0,0 +1,232 @@
"""Repositorio para gestionar jobs."""
from typing import Optional, List
from datetime import datetime
from dataclasses import dataclass
import uuid
from .database import db
from ..constants import JobStatus
@dataclass
class Job:
"""Modelo de Job."""
job_id: str
created_at: str
updated_at: str
source_path: str
source_name: str
source_hash: Optional[str]
node_name: Optional[str]
db_name: Optional[str]
status: str
attempts: int
last_error: Optional[str]
started_at: Optional[str]
finished_at: Optional[str]
total_ms: Optional[int]
extract_ms: Optional[int]
restore_ms: Optional[int]
filelist_ms: Optional[int]
class JobRepository:
"""Repositorio para operaciones con jobs."""
@staticmethod
def create(source_path: str, source_name: str, source_hash: str) -> str:
"""
Crea un nuevo job.
Args:
source_path: Ruta completa del archivo fuente
source_name: Nombre del archivo
source_hash: Hash del archivo
Returns:
job_id del job creado
"""
job_id = str(uuid.uuid4())
now = datetime.utcnow().isoformat()
db.execute("""
INSERT INTO jobs (
job_id, created_at, updated_at, source_path, source_name,
source_hash, status, attempts
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (job_id, now, now, source_path, source_name, source_hash, JobStatus.QUEUED, 0))
return job_id
@staticmethod
def get(job_id: str) -> Optional[Job]:
"""Obtiene un job por ID."""
row = db.fetchone("SELECT * FROM jobs WHERE job_id = ?", (job_id,))
if not row:
return None
return Job(**dict(row))
@staticmethod
def get_all(
status: Optional[str] = None,
limit: Optional[int] = None,
offset: int = 0
) -> List[Job]:
"""
Obtiene todos los jobs con filtros opcionales.
Args:
status: Filtrar por estado
limit: Límite de resultados
offset: Desplazamiento
Returns:
Lista de jobs
"""
query = "SELECT * FROM jobs"
params = []
if status:
query += " WHERE status = ?"
params.append(status)
query += " ORDER BY created_at DESC"
if limit:
query += f" LIMIT {limit} OFFSET {offset}"
rows = db.fetchall(query, tuple(params))
return [Job(**dict(row)) for row in rows]
@staticmethod
def update_status(
job_id: str,
status: str,
error: Optional[str] = None,
increment_attempts: bool = False
):
"""Actualiza el estado de un job."""
now = datetime.utcnow().isoformat()
updates = ["status = ?", "updated_at = ?"]
params = [status, now]
if error is not None:
updates.append("last_error = ?")
params.append(error)
if increment_attempts:
updates.append("attempts = attempts + 1")
if status == JobStatus.EXTRACTING and not db.fetchone(
"SELECT started_at FROM jobs WHERE job_id = ?", (job_id,)
)["started_at"]:
updates.append("started_at = ?")
params.append(now)
if status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED]:
updates.append("finished_at = ?")
params.append(now)
params.append(job_id)
db.execute(
f"UPDATE jobs SET {', '.join(updates)} WHERE job_id = ?",
tuple(params)
)
@staticmethod
def update_node_and_db(job_id: str, node_name: str, db_name: str):
"""Actualiza el nodo y base de datos de un job."""
now = datetime.utcnow().isoformat()
db.execute(
"UPDATE jobs SET node_name = ?, db_name = ?, updated_at = ? WHERE job_id = ?",
(node_name, db_name, now, job_id)
)
@staticmethod
def update_timing(
job_id: str,
total_ms: Optional[int] = None,
extract_ms: Optional[int] = None,
restore_ms: Optional[int] = None,
filelist_ms: Optional[int] = None
):
"""Actualiza los tiempos de ejecución."""
updates = []
params = []
if total_ms is not None:
updates.append("total_ms = ?")
params.append(total_ms)
if extract_ms is not None:
updates.append("extract_ms = ?")
params.append(extract_ms)
if restore_ms is not None:
updates.append("restore_ms = ?")
params.append(restore_ms)
if filelist_ms is not None:
updates.append("filelist_ms = ?")
params.append(filelist_ms)
if updates:
params.append(job_id)
db.execute(
f"UPDATE jobs SET {', '.join(updates)} WHERE job_id = ?",
tuple(params)
)
@staticmethod
def exists_by_hash(source_hash: str) -> bool:
"""Verifica si existe un job con el hash dado."""
row = db.fetchone(
"SELECT COUNT(*) as count FROM jobs WHERE source_hash = ?",
(source_hash,)
)
return row["count"] > 0
@staticmethod
def get_stats() -> dict:
"""Obtiene estadísticas de jobs."""
stats = {
"total": 0,
"queued": 0,
"running": 0,
"completed": 0,
"failed": 0
}
rows = db.fetchall("SELECT status, COUNT(*) as count FROM jobs GROUP BY status")
for row in rows:
status = row["status"]
count = row["count"]
stats["total"] += count
if status == JobStatus.QUEUED:
stats["queued"] = count
elif status in [JobStatus.EXTRACTING, JobStatus.RESTORING, JobStatus.CLEANING]:
stats["running"] += count
elif status == JobStatus.COMPLETED:
stats["completed"] = count
elif status in [JobStatus.FAILED, JobStatus.FAILED_RESTART, JobStatus.CANCELLED]:
stats["failed"] += count
return stats
@staticmethod
def get_average_times() -> dict:
"""Obtiene tiempos promedio de ejecución."""
row = db.fetchone("""
SELECT
AVG(total_ms) as avg_total,
AVG(extract_ms) as avg_extract,
AVG(restore_ms) as avg_restore
FROM jobs
WHERE status = ? AND total_ms IS NOT NULL
""", (JobStatus.COMPLETED,))
return {
"total_ms": int(row["avg_total"] or 0),
"extract_ms": int(row["avg_extract"] or 0),
"restore_ms": int(row["avg_restore"] or 0)
}

View File

@@ -0,0 +1,81 @@
"""Repositorio para gestionar job steps."""
from typing import Optional, List
from datetime import datetime
from dataclasses import dataclass
from .database import db
@dataclass
class JobStep:
"""Modelo de JobStep."""
id: int
job_id: str
step: str
started_at: str
finished_at: Optional[str]
duration_ms: Optional[int]
exit_code: Optional[int]
stdout: Optional[str]
stderr: Optional[str]
error: Optional[str]
class JobStepRepository:
"""Repositorio para operaciones con job steps."""
@staticmethod
def create(job_id: str, step: str) -> int:
"""
Crea un nuevo step para un job.
Args:
job_id: ID del job
step: Nombre del paso
Returns:
ID del step creado
"""
now = datetime.utcnow().isoformat()
cursor = db.execute("""
INSERT INTO job_steps (job_id, step, started_at)
VALUES (?, ?, ?)
""", (job_id, step, now))
return cursor.lastrowid
@staticmethod
def complete(
step_id: int,
exit_code: Optional[int] = None,
stdout: Optional[str] = None,
stderr: Optional[str] = None,
error: Optional[str] = None
):
"""Marca un step como completado."""
now = datetime.utcnow().isoformat()
# Obtener tiempo de inicio para calcular duración
row = db.fetchone("SELECT started_at FROM job_steps WHERE id = ?", (step_id,))
if row:
started = datetime.fromisoformat(row["started_at"])
finished = datetime.fromisoformat(now)
duration_ms = int((finished - started).total_seconds() * 1000)
else:
duration_ms = None
db.execute("""
UPDATE job_steps
SET finished_at = ?, duration_ms = ?, exit_code = ?,
stdout = ?, stderr = ?, error = ?
WHERE id = ?
""", (now, duration_ms, exit_code, stdout, stderr, error, step_id))
@staticmethod
def get_by_job(job_id: str) -> List[JobStep]:
"""Obtiene todos los steps de un job."""
rows = db.fetchall(
"SELECT * FROM job_steps WHERE job_id = ? ORDER BY id ASC",
(job_id,)
)
return [JobStep(**dict(row)) for row in rows]

133
app/db/node_repository.py Normal file
View File

@@ -0,0 +1,133 @@
"""Repositorio para gestionar nodos."""
from typing import Optional, List
from datetime import datetime
from dataclasses import dataclass
from .database import db
@dataclass
class Node:
"""Modelo de Node."""
node_name: str
db_name: str
active: bool
notes: Optional[str]
updated_at: str
class NodeRepository:
"""Repositorio para operaciones con nodos."""
@staticmethod
def create(node_name: str, db_name: str, notes: Optional[str] = None):
"""
Crea o actualiza un nodo.
Args:
node_name: Nombre del nodo (case-insensitive)
db_name: Nombre de la base de datos
notes: Notas opcionales
"""
now = datetime.utcnow().isoformat()
node_name_upper = node_name.upper()
db.execute("""
INSERT OR REPLACE INTO nodes (node_name, db_name, active, notes, updated_at)
VALUES (?, ?, 1, ?, ?)
""", (node_name_upper, db_name, notes, now))
@staticmethod
def get(node_name: str) -> Optional[Node]:
"""Obtiene un nodo por nombre (case-insensitive)."""
node_name_upper = node_name.upper()
row = db.fetchone("SELECT * FROM nodes WHERE node_name = ?", (node_name_upper,))
if not row:
return None
return Node(
node_name=row["node_name"],
db_name=row["db_name"],
active=bool(row["active"]),
notes=row["notes"],
updated_at=row["updated_at"]
)
@staticmethod
def get_all(active_only: bool = False) -> List[Node]:
"""
Obtiene todos los nodos.
Args:
active_only: Si True, solo devuelve nodos activos
Returns:
Lista de nodos
"""
query = "SELECT * FROM nodes"
if active_only:
query += " WHERE active = 1"
query += " ORDER BY node_name"
rows = db.fetchall(query)
return [Node(
node_name=row["node_name"],
db_name=row["db_name"],
active=bool(row["active"]),
notes=row["notes"],
updated_at=row["updated_at"]
) for row in rows]
@staticmethod
def update(
node_name: str,
db_name: Optional[str] = None,
active: Optional[bool] = None,
notes: Optional[str] = None
):
"""Actualiza un nodo."""
node_name_upper = node_name.upper()
updates = []
params = []
if db_name is not None:
updates.append("db_name = ?")
params.append(db_name)
if active is not None:
updates.append("active = ?")
params.append(1 if active else 0)
if notes is not None:
updates.append("notes = ?")
params.append(notes)
if updates:
now = datetime.utcnow().isoformat()
updates.append("updated_at = ?")
params.append(now)
params.append(node_name_upper)
db.execute(
f"UPDATE nodes SET {', '.join(updates)} WHERE node_name = ?",
tuple(params)
)
@staticmethod
def delete(node_name: str):
"""Elimina un nodo."""
node_name_upper = node_name.upper()
db.execute("DELETE FROM nodes WHERE node_name = ?", (node_name_upper,))
@staticmethod
def get_db_for_node(node_name: str) -> Optional[str]:
"""
Obtiene el nombre de DB para un nodo (si está activo).
Args:
node_name: Nombre del nodo
Returns:
Nombre de la DB o None si no existe o está inactivo
"""
node = NodeRepository.get(node_name)
if node and node.active:
return node.db_name
return None

1
app/engine/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Motor de procesamiento."""

310
app/engine/engine.py Normal file
View File

@@ -0,0 +1,310 @@
"""Motor principal de la aplicación."""
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 ..db.job_repository import JobRepository
from ..db.event_repository import EventRepository
from ..db.config_repository import ConfigRepository
from ..constants import JobStatus, DEFAULT_CONFIG
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()
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")
# 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
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 _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)

211
app/engine/file_watcher.py Normal file
View File

@@ -0,0 +1,211 @@
"""File watcher para detectar nuevos archivos ZIP."""
import os
import time
import hashlib
from pathlib import Path
from typing import Optional, Dict, Callable
from threading import Thread, Event
from ..utils.logger import app_logger
class FileStabilityChecker:
"""Verifica que un archivo esté estable (no siendo copiado)."""
def __init__(
self,
check_interval: int = 5,
stable_duration: int = 10,
use_ready_marker: bool = False
):
"""
Inicializa el verificador de estabilidad.
Args:
check_interval: Intervalo entre verificaciones (segundos)
stable_duration: Tiempo que debe estar estable (segundos)
use_ready_marker: Si True, requiere archivo .ready
"""
self.check_interval = check_interval
self.stable_duration = stable_duration
self.use_ready_marker = use_ready_marker
def is_file_ready(self, file_path: str) -> bool:
"""
Verifica si un archivo está listo para procesarse.
Args:
file_path: Ruta al archivo
Returns:
True si está listo
"""
path = Path(file_path)
if not path.exists():
return False
# Verificar marcador .ready si está habilitado
if self.use_ready_marker:
ready_marker = path.parent / f"{path.name}.ready"
if not ready_marker.exists():
app_logger.debug(f"Esperando marcador .ready para {path.name}")
return False
# Verificar estabilidad de tamaño
try:
initial_size = path.stat().st_size
initial_mtime = path.stat().st_mtime
time.sleep(self.stable_duration)
if not path.exists():
return False
final_size = path.stat().st_size
final_mtime = path.stat().st_mtime
is_stable = (initial_size == final_size and initial_mtime == final_mtime)
if not is_stable:
app_logger.debug(
f"Archivo {path.name} aún está cambiando "
f"(size: {initial_size} -> {final_size})"
)
return is_stable
except Exception as e:
app_logger.error(f"Error verificando estabilidad de {file_path}: {e}")
return False
class FileWatcher:
"""Vigila una carpeta por nuevos archivos ZIP."""
def __init__(
self,
watch_folder: str,
on_file_ready: Callable[[str], None],
scan_interval: int = 30,
stability_checker: Optional[FileStabilityChecker] = None
):
"""
Inicializa el file watcher.
Args:
watch_folder: Carpeta a vigilar
on_file_ready: Callback cuando un archivo está listo
scan_interval: Intervalo de escaneo (segundos)
stability_checker: Verificador de estabilidad (opcional)
"""
self.watch_folder = watch_folder
self.on_file_ready = on_file_ready
self.scan_interval = scan_interval
self.stability_checker = stability_checker or FileStabilityChecker()
self._stop_event = Event()
self._thread: Optional[Thread] = None
self._known_files: Dict[str, float] = {} # path -> mtime
def start(self):
"""Inicia el watcher en un thread separado."""
if self._thread and self._thread.is_alive():
app_logger.warning("FileWatcher ya está corriendo")
return
self._stop_event.clear()
self._thread = Thread(target=self._run, daemon=True)
self._thread.start()
app_logger.info(f"FileWatcher iniciado en: {self.watch_folder}")
def stop(self):
"""Detiene el watcher."""
if self._thread:
self._stop_event.set()
self._thread.join(timeout=5)
app_logger.info("FileWatcher detenido")
def scan_now(self):
"""Fuerza un escaneo inmediato."""
app_logger.info("Escaneo manual solicitado")
self._scan_folder()
def _run(self):
"""Loop principal del watcher."""
while not self._stop_event.is_set():
try:
self._scan_folder()
except Exception as e:
app_logger.error(f"Error en FileWatcher: {e}", exc_info=True)
# Esperar con posibilidad de interrumpir
self._stop_event.wait(self.scan_interval)
def _scan_folder(self):
"""Escanea la carpeta por archivos nuevos."""
folder = Path(self.watch_folder)
if not folder.exists():
app_logger.warning(f"Carpeta de entrada no existe: {self.watch_folder}")
return
# Buscar archivos .zip y .zip.001
patterns = ["*.zip", "*.zip.001"]
found_files = []
for pattern in patterns:
found_files.extend(folder.glob(pattern))
for file_path in found_files:
file_str = str(file_path)
# Ignorar archivos ya procesados recientemente
if file_str in self._known_files:
continue
# Verificar si está listo
if not self.stability_checker.is_file_ready(file_str):
continue
# Marcar como conocido
self._known_files[file_str] = file_path.stat().st_mtime
# Notificar
app_logger.info(f"Archivo nuevo detectado y listo: {file_path.name}")
try:
self.on_file_ready(file_str)
except Exception as e:
app_logger.error(f"Error procesando archivo {file_path.name}: {e}")
# Limpiar archivos conocidos que ya no existen
self._clean_known_files()
def _clean_known_files(self):
"""Limpia archivos conocidos que ya no existen."""
to_remove = []
for file_path in list(self._known_files.keys()):
if not Path(file_path).exists():
to_remove.append(file_path)
for file_path in to_remove:
del self._known_files[file_path]
def calculate_file_hash(file_path: str) -> str:
"""
Calcula el SHA256 hash de un archivo.
Args:
file_path: Ruta al archivo
Returns:
Hash hexadecimal
"""
sha256 = hashlib.sha256()
with open(file_path, 'rb') as f:
while chunk := f.read(8192):
sha256.update(chunk)
return sha256.hexdigest()

View File

@@ -0,0 +1,324 @@
"""Worker para procesar jobs de restauración."""
import time
import shutil
from pathlib import Path
from typing import Optional
from datetime import datetime
from PySide6.QtCore import QObject, Signal, QRunnable
from ..constants import JobStatus, StepType
from ..db.job_repository import JobRepository
from ..db.job_step_repository import JobStepRepository
from ..db.event_repository import EventRepository
from ..db.node_repository import NodeRepository
from ..extract.seven_zip import SevenZipExtractor
from ..sql.sql_manager import SQLServerManager
from ..utils.logger import app_logger
class RestoreWorkerSignals(QObject):
"""Señales para comunicación con la UI."""
job_started = Signal(str) # job_id
job_progress = Signal(str, str) # job_id, status
job_completed = Signal(str, bool) # job_id, success
error_occurred = Signal(str, str) # job_id, error
class RestoreWorker(QRunnable):
"""Worker que procesa un job de restauración."""
def __init__(
self,
job_id: str,
config: dict,
dry_run: bool = False
):
"""
Inicializa el worker.
Args:
job_id: ID del job a procesar
config: Configuración de la aplicación
dry_run: Modo dry run (no ejecuta RESTORE)
"""
super().__init__()
self.job_id = job_id
self.config = config
self.dry_run = dry_run
self.signals = RestoreWorkerSignals()
self._extract_dir: Optional[str] = None
self._bak_path: Optional[str] = None
def run(self):
"""Ejecuta el procesamiento del job."""
start_time = time.time()
try:
app_logger.info(f"Iniciando procesamiento de job {self.job_id}")
self.signals.job_started.emit(self.job_id)
# Obtener job
job = JobRepository.get(self.job_id)
if not job:
raise ValueError(f"Job {self.job_id} no encontrado")
# Pipeline de procesamiento
self._process_node_mapping(job)
self._extract_backup(job)
self._restore_database(job)
self._cleanup(job)
# Actualizar tiempos
total_ms = int((time.time() - start_time) * 1000)
JobRepository.update_timing(self.job_id, total_ms=total_ms)
# Marcar como completado
JobRepository.update_status(self.job_id, JobStatus.COMPLETED)
app_logger.info(
f"Job {self.job_id} completado exitosamente en {total_ms}ms"
)
self.signals.job_completed.emit(self.job_id, True)
except Exception as e:
error_msg = str(e)
app_logger.error(f"Error procesando job {self.job_id}: {error_msg}", exc_info=True)
JobRepository.update_status(
self.job_id,
JobStatus.FAILED,
error=error_msg,
increment_attempts=True
)
EventRepository.create("ERROR", f"Job falló: {error_msg}", self.job_id)
self.signals.error_occurred.emit(self.job_id, error_msg)
self.signals.job_completed.emit(self.job_id, False)
def _process_node_mapping(self, job):
"""Procesa el mapeo de nodo a base de datos."""
step_id = JobStepRepository.create(self.job_id, StepType.NODE_MAPPING)
try:
# Obtener node name del archivo (con extensión, uppercase)
node_name = Path(job.source_name).name.upper()
# Buscar mapeo en la tabla nodes
db_name = NodeRepository.get_db_for_node(node_name)
if not db_name:
raise ValueError(
f"NODE_NOT_MAPPED: No existe mapeo activo para nodo '{node_name}'"
)
# Actualizar job con node_name y db_name
JobRepository.update_node_and_db(self.job_id, node_name, db_name)
app_logger.info(f"Node '{node_name}' mapeado a DB '{db_name}'")
JobStepRepository.complete(step_id, exit_code=0, stdout=f"DB: {db_name}")
except Exception as e:
JobStepRepository.complete(step_id, exit_code=1, error=str(e))
raise
def _extract_backup(self, job):
"""Extrae el backup del archivo ZIP."""
JobRepository.update_status(self.job_id, JobStatus.EXTRACTING)
self.signals.job_progress.emit(self.job_id, JobStatus.EXTRACTING)
step_id = JobStepRepository.create(self.job_id, StepType.EXTRACT)
extract_start = time.time()
try:
# Configuración
seven_zip_path = self.config["paths"]["seven_zip_exe"]
extract_base = self.config["paths"]["extract_folder"]
timeout_minutes = self.config["timeouts"]["extract_minutes"]
# Crear carpeta de extracción para este job
self._extract_dir = str(Path(extract_base) / self.job_id)
Path(self._extract_dir).mkdir(parents=True, exist_ok=True)
# Extraer
extractor = SevenZipExtractor(seven_zip_path)
# Si es multipart, asegurar que usamos el primer archivo
source_path = job.source_path
if extractor.is_multipart(source_path):
source_path = extractor.get_first_part(source_path)
app_logger.info(f"Archivo multipart detectado, usando: {source_path}")
exit_code, stdout, stderr = extractor.extract(
source_path,
self._extract_dir,
timeout_minutes
)
extract_ms = int((time.time() - extract_start) * 1000)
JobRepository.update_timing(self.job_id, extract_ms=extract_ms)
if exit_code != 0:
raise RuntimeError(f"7-Zip falló con código {exit_code}: {stderr}")
JobStepRepository.complete(
step_id,
exit_code=exit_code,
stdout=stdout[:1000] if stdout else None,
stderr=stderr[:1000] if stderr else None
)
# Buscar archivo .bak
locate_step_id = JobStepRepository.create(self.job_id, StepType.LOCATE_BAK)
self._bak_path = SevenZipExtractor.find_bak_file(self._extract_dir)
if not self._bak_path:
raise FileNotFoundError("No se encontró archivo .bak en el archivo extraído")
JobStepRepository.complete(
locate_step_id,
exit_code=0,
stdout=f"BAK: {Path(self._bak_path).name}"
)
except Exception as e:
JobStepRepository.complete(step_id, exit_code=1, error=str(e))
raise
def _restore_database(self, job):
"""Restaura la base de datos desde el backup."""
JobRepository.update_status(self.job_id, JobStatus.RESTORING)
self.signals.job_progress.emit(self.job_id, JobStatus.RESTORING)
# Recargar job para obtener db_name actualizado
job = JobRepository.get(self.job_id)
if not job.db_name:
raise ValueError("DB name no está configurado en el job")
# Conectar a SQL
connect_step_id = JobStepRepository.create(self.job_id, StepType.SQL_CONNECT)
try:
sql_config = self.config["sql"]
sql_manager = SQLServerManager(
server=sql_config["server"],
use_windows_auth=sql_config["use_windows_auth"],
username=sql_config.get("username"),
password=sql_config.get("password")
)
# Test connection
success, error = sql_manager.test_connection()
if not success:
raise RuntimeError(f"Conexión SQL falló: {error}")
JobStepRepository.complete(connect_step_id, exit_code=0)
except Exception as e:
JobStepRepository.complete(connect_step_id, exit_code=1, error=str(e))
raise
# Obtener FILELISTONLY
filelist_step_id = JobStepRepository.create(self.job_id, StepType.FILELIST)
filelist_start = time.time()
try:
logical_files, error = sql_manager.get_filelist_from_backup(
self._bak_path,
timeout_minutes=self.config["timeouts"]["restore_minutes"]
)
filelist_ms = int((time.time() - filelist_start) * 1000)
JobRepository.update_timing(self.job_id, filelist_ms=filelist_ms)
if error:
raise RuntimeError(error)
files_info = ", ".join([f"{lf.logical_name}({lf.type})" for lf in logical_files])
JobStepRepository.complete(
filelist_step_id,
exit_code=0,
stdout=files_info[:1000]
)
except Exception as e:
JobStepRepository.complete(filelist_step_id, exit_code=1, error=str(e))
raise
# RESTORE DATABASE
restore_step_id = JobStepRepository.create(self.job_id, StepType.RESTORE)
restore_start = time.time()
try:
data_folder = self.config["paths"]["data_sql_folder"]
success, stdout, error = sql_manager.restore_database(
db_name=job.db_name,
backup_path=self._bak_path,
data_folder=data_folder,
logical_files=logical_files,
timeout_minutes=self.config["timeouts"]["restore_minutes"],
dry_run=self.dry_run
)
restore_ms = int((time.time() - restore_start) * 1000)
JobRepository.update_timing(self.job_id, restore_ms=restore_ms)
if not success:
raise RuntimeError(error or "RESTORE falló sin mensaje de error")
JobStepRepository.complete(
restore_step_id,
exit_code=0,
stdout=stdout[:1000] if stdout else None
)
except Exception as e:
JobStepRepository.complete(restore_step_id, exit_code=1, error=str(e))
raise
def _cleanup(self, job):
"""Limpia archivos temporales y mueve el ZIP."""
JobRepository.update_status(self.job_id, JobStatus.CLEANING)
self.signals.job_progress.emit(self.job_id, JobStatus.CLEANING)
step_id = JobStepRepository.create(self.job_id, StepType.CLEANUP)
try:
# Eliminar carpeta de extracción
if self._extract_dir and Path(self._extract_dir).exists():
shutil.rmtree(self._extract_dir)
app_logger.info(f"Carpeta de extracción eliminada: {self._extract_dir}")
# Mover ZIP a Processed
processed_folder = Path(self.config["paths"]["processed_folder"])
date_folder = processed_folder / datetime.now().strftime("%Y-%m-%d")
date_folder.mkdir(parents=True, exist_ok=True)
source_path = Path(job.source_path)
dest_path = date_folder / source_path.name
# Si es multipart, mover todas las partes
if SevenZipExtractor.is_multipart(str(source_path)):
# Buscar todas las partes
base_name = source_path.stem.split('.zip')[0]
parts = list(source_path.parent.glob(f"{base_name}.zip.*"))
for part in parts:
part_dest = date_folder / part.name
shutil.move(str(part), str(part_dest))
app_logger.info(f"Movido: {part.name} -> {part_dest}")
else:
shutil.move(str(source_path), str(dest_path))
app_logger.info(f"Movido: {source_path.name} -> {dest_path}")
JobStepRepository.complete(step_id, exit_code=0)
except Exception as e:
# No fallar el job por errores de limpieza
app_logger.warning(f"Error en limpieza (no crítico): {e}")
JobStepRepository.complete(step_id, exit_code=1, error=str(e))

1
app/extract/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Módulo de extracción."""

218
app/extract/seven_zip.py Normal file
View File

@@ -0,0 +1,218 @@
"""Gestión de extracción de archivos con 7-Zip."""
import subprocess
from pathlib import Path
from typing import Optional, Tuple
import time
from ..utils.logger import app_logger
class SevenZipExtractor:
"""Gestor de extracción con 7-Zip."""
def __init__(self, seven_zip_path: Optional[str] = None):
"""
Inicializa el extractor.
Args:
seven_zip_path: Ruta a 7z.exe (se auto-detecta si es None)
"""
self.seven_zip_path = seven_zip_path or self._auto_detect_7zip()
if not self.seven_zip_path or not Path(self.seven_zip_path).exists():
raise FileNotFoundError(
"No se encontró 7-Zip. Instálalo o especifica la ruta en la configuración."
)
@staticmethod
def _auto_detect_7zip() -> Optional[str]:
"""Auto-detecta la ubicación de 7z.exe."""
possible_paths = [
r"C:\Program Files\7-Zip\7z.exe",
r"D:\Program Files\7-Zip\7z.exe",
r"C:\Program Files (x86)\7-Zip\7z.exe",
r"D:\Program Files (x86)\7-Zip\7z.exe"
]
for path in possible_paths:
if Path(path).exists():
app_logger.info(f"7-Zip auto-detectado en: {path}")
return path
return None
def extract(
self,
zip_path: str,
output_dir: str,
timeout_minutes: int = 30
) -> Tuple[int, str, str]:
"""
Extrae un archivo ZIP (incluyendo multipart) usando 7-Zip.
Para archivos multipart (.zip.001), 7-Zip detecta automáticamente
las demás partes si están en la misma carpeta.
Args:
zip_path: Ruta al archivo ZIP (o .zip.001 para multipart)
output_dir: Directorio de salida
timeout_minutes: Timeout en minutos
Returns:
Tupla (exit_code, stdout, stderr)
Raises:
subprocess.TimeoutExpired: Si se excede el timeout
FileNotFoundError: Si no existe el archivo ZIP
"""
zip_path_obj = Path(zip_path)
if not zip_path_obj.exists():
raise FileNotFoundError(f"Archivo ZIP no encontrado: {zip_path}")
# Crear directorio de salida
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# Comando 7-Zip: extraer con rutas completas, sobrescribir sin preguntar
cmd = [
str(self.seven_zip_path),
"x", # Extract with full paths
f"-o{output_dir}", # Output directory
"-y", # Answer yes to all prompts
str(zip_path)
]
app_logger.info(f"Ejecutando extracción: {' '.join(cmd)}")
start_time = time.time()
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout_minutes * 60,
encoding='utf-8',
errors='replace'
)
elapsed = time.time() - start_time
app_logger.info(
f"Extracción completada en {elapsed:.2f}s - Exit code: {result.returncode}"
)
return result.returncode, result.stdout, result.stderr
except subprocess.TimeoutExpired as e:
elapsed = time.time() - start_time
app_logger.error(f"Timeout de extracción después de {elapsed:.2f}s")
raise
def list_contents(self, zip_path: str) -> Tuple[int, str, str]:
"""
Lista el contenido de un archivo ZIP.
Args:
zip_path: Ruta al archivo ZIP
Returns:
Tupla (exit_code, stdout, stderr)
"""
cmd = [
str(self.seven_zip_path),
"l", # List contents
str(zip_path)
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding='utf-8',
errors='replace'
)
return result.returncode, result.stdout, result.stderr
@staticmethod
def find_bak_file(extract_dir: str) -> Optional[str]:
"""
Busca el archivo .bak extraído en el directorio.
Args:
extract_dir: Directorio donde se extrajo
Returns:
Ruta al archivo .bak o None si no se encuentra
Raises:
ValueError: Si hay múltiples archivos .bak
"""
extract_path = Path(extract_dir)
bak_files = list(extract_path.rglob("*.bak"))
if not bak_files:
app_logger.error(f"No se encontró archivo .bak en {extract_dir}")
return None
if len(bak_files) > 1:
# Si hay múltiples, tomar el más reciente
app_logger.warning(
f"Múltiples archivos .bak encontrados ({len(bak_files)}), "
"seleccionando el más reciente"
)
bak_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
bak_path = str(bak_files[0])
app_logger.info(f"Archivo .bak encontrado: {bak_path}")
return bak_path
@staticmethod
def is_multipart(zip_path: str) -> bool:
"""
Verifica si un archivo es parte de un ZIP multipart.
Args:
zip_path: Ruta al archivo
Returns:
True si es multipart (.zip.001, .zip.002, etc.)
"""
path = Path(zip_path)
# Buscar patrón .zip.NNN
if path.suffix.lower() in ['.zip']:
# Verificar si hay un .001 al final del stem
if path.stem.endswith('.001') or path.stem.endswith('.002'):
return True
# Verificar extensiones como .zip.001
name_lower = path.name.lower()
return '.zip.' in name_lower and any(
name_lower.endswith(f'.zip.{i:03d}') for i in range(1, 100)
)
@staticmethod
def get_first_part(zip_path: str) -> str:
"""
Si es un archivo multipart, retorna la ruta al primer archivo (.001).
Args:
zip_path: Ruta a cualquier parte del multipart
Returns:
Ruta al primer archivo (.001)
"""
path = Path(zip_path)
name_lower = path.name.lower()
# Si ya es .001, retornar tal cual
if name_lower.endswith('.zip.001'):
return str(path)
# Si es otra parte, buscar el .001
if '.zip.' in name_lower:
base_name = name_lower.split('.zip.')[0]
first_part = path.parent / f"{base_name}.zip.001"
if first_part.exists():
return str(first_part)
return str(path)

1
app/sql/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Módulo SQL."""

277
app/sql/sql_manager.py Normal file
View File

@@ -0,0 +1,277 @@
"""Gestión de conexiones y operaciones SQL Server."""
import pyodbc
from typing import Optional, Tuple, List
import time
from dataclasses import dataclass
from ..utils.logger import app_logger
@dataclass
class LogicalFile:
"""Representa un archivo lógico de una base de datos."""
logical_name: str
physical_name: str
type: str # 'D' (data) o 'L' (log)
class SQLServerManager:
"""Gestor de operaciones con SQL Server."""
def __init__(
self,
server: str,
use_windows_auth: bool = True,
username: Optional[str] = None,
password: Optional[str] = None
):
"""
Inicializa el gestor de SQL Server.
Args:
server: Nombre o IP del servidor SQL Server
use_windows_auth: Usar autenticación de Windows
username: Usuario SQL (si no usa Windows Auth)
password: Contraseña SQL (si no usa Windows Auth)
"""
self.server = server
self.use_windows_auth = use_windows_auth
self.username = username
self.password = password
def get_connection_string(self, database: Optional[str] = None) -> str:
"""
Construye la cadena de conexión.
Args:
database: Base de datos específica (opcional)
Returns:
Cadena de conexión ODBC
"""
parts = [
f"DRIVER={{ODBC Driver 17 for SQL Server}}",
f"SERVER={self.server}"
]
if database:
parts.append(f"DATABASE={database}")
if self.use_windows_auth:
parts.append("Trusted_Connection=yes")
else:
parts.append(f"UID={self.username}")
parts.append(f"PWD={self.password}")
return ";".join(parts)
def test_connection(self) -> Tuple[bool, Optional[str]]:
"""
Prueba la conexión a SQL Server.
Returns:
Tupla (éxito, mensaje_error)
"""
try:
conn_str = self.get_connection_string()
conn = pyodbc.connect(conn_str, timeout=10)
cursor = conn.cursor()
cursor.execute("SELECT @@VERSION")
version = cursor.fetchone()[0]
conn.close()
app_logger.info(f"Conexión SQL Server exitosa: {version[:50]}...")
return True, None
except Exception as e:
error_msg = f"Error conectando a SQL Server: {str(e)}"
app_logger.error(error_msg)
return False, error_msg
def get_filelist_from_backup(
self,
backup_path: str,
timeout_minutes: int = 5
) -> Tuple[List[LogicalFile], Optional[str]]:
"""
Obtiene la lista de archivos lógicos de un backup usando RESTORE FILELISTONLY.
Args:
backup_path: Ruta al archivo .bak
timeout_minutes: Timeout en minutos
Returns:
Tupla (lista de LogicalFile, mensaje_error)
"""
try:
conn_str = self.get_connection_string()
conn = pyodbc.connect(conn_str, timeout=timeout_minutes * 60)
cursor = conn.cursor()
start_time = time.time()
app_logger.info(f"Ejecutando RESTORE FILELISTONLY FROM DISK = '{backup_path}'")
query = f"RESTORE FILELISTONLY FROM DISK = N'{backup_path}'"
cursor.execute(query)
elapsed = time.time() - start_time
app_logger.info(f"FILELISTONLY completado en {elapsed:.2f}s")
logical_files = []
for row in cursor.fetchall():
# Las columnas típicas son: LogicalName, PhysicalName, Type, ...
logical_name = row.LogicalName
physical_name = row.PhysicalName
file_type = row.Type
logical_files.append(LogicalFile(
logical_name=logical_name,
physical_name=physical_name,
type=file_type
))
app_logger.debug(
f"Archivo lógico: {logical_name} ({file_type}) -> {physical_name}"
)
conn.close()
if not logical_files:
return [], "No se encontraron archivos lógicos en el backup"
return logical_files, None
except Exception as e:
error_msg = f"Error obteniendo FILELISTONLY: {str(e)}"
app_logger.error(error_msg)
return [], error_msg
def restore_database(
self,
db_name: str,
backup_path: str,
data_folder: str,
logical_files: List[LogicalFile],
timeout_minutes: int = 60,
dry_run: bool = False
) -> Tuple[bool, Optional[str], Optional[str]]:
"""
Restaura una base de datos desde un backup.
Args:
db_name: Nombre de la base de datos
backup_path: Ruta al archivo .bak
data_folder: Carpeta donde se crearán los archivos de datos
logical_files: Lista de archivos lógicos del backup
timeout_minutes: Timeout en minutos
dry_run: Si True, solo simula (no ejecuta RESTORE)
Returns:
Tupla (éxito, stdout, error)
"""
try:
# Construir las cláusulas MOVE
move_clauses = []
for lf in logical_files:
if lf.type == 'D': # Data file
new_path = f"{data_folder}\\{db_name}.mdf"
elif lf.type == 'L': # Log file
new_path = f"{data_folder}\\{db_name}_log.ldf"
else:
# Archivos adicionales (filestream, etc.)
continue
move_clauses.append(f"MOVE N'{lf.logical_name}' TO N'{new_path}'")
if not move_clauses:
return False, None, "No se pudieron determinar los archivos de datos y log"
# Construir el comando RESTORE
restore_cmd = f"""
-- Poner la base de datos en modo single user
ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
-- Restaurar
RESTORE DATABASE [{db_name}]
FROM DISK = N'{backup_path}'
WITH {', '.join(move_clauses)}, REPLACE;
-- Volver a modo multi user
ALTER DATABASE [{db_name}] SET MULTI_USER;
"""
app_logger.info(f"Comando RESTORE generado:\n{restore_cmd}")
if dry_run:
app_logger.info("Modo DRY RUN: No se ejecutará el RESTORE")
return True, restore_cmd, None
# Ejecutar RESTORE
conn_str = self.get_connection_string()
conn = pyodbc.connect(conn_str, timeout=timeout_minutes * 60)
conn.autocommit = True # Necesario para ALTER DATABASE
cursor = conn.cursor()
start_time = time.time()
app_logger.info(f"Ejecutando RESTORE DATABASE [{db_name}]...")
# Ejecutar en múltiples pasos
output_lines = []
# 1. Single user
try:
cursor.execute(f"ALTER DATABASE [{db_name}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE")
output_lines.append("Base de datos configurada en modo SINGLE_USER")
except Exception as e:
app_logger.warning(f"Error configurando SINGLE_USER (puede no existir la DB): {e}")
# 2. RESTORE
restore_query = f"RESTORE DATABASE [{db_name}] FROM DISK = N'{backup_path}' WITH {', '.join(move_clauses)}, REPLACE"
cursor.execute(restore_query)
output_lines.append("RESTORE DATABASE completado")
# 3. Multi user
cursor.execute(f"ALTER DATABASE [{db_name}] SET MULTI_USER")
output_lines.append("Base de datos configurada en modo MULTI_USER")
elapsed = time.time() - start_time
output_lines.append(f"Restauración completada en {elapsed:.2f}s")
conn.close()
output = "\n".join(output_lines)
app_logger.info(output)
return True, output, None
except Exception as e:
error_msg = f"Error restaurando base de datos: {str(e)}"
app_logger.error(error_msg)
return False, None, error_msg
def database_exists(self, db_name: str) -> bool:
"""
Verifica si una base de datos existe.
Args:
db_name: Nombre de la base de datos
Returns:
True si existe
"""
try:
conn_str = self.get_connection_string()
conn = pyodbc.connect(conn_str, timeout=10)
cursor = conn.cursor()
cursor.execute(
"SELECT COUNT(*) FROM sys.databases WHERE name = ?",
(db_name,)
)
exists = cursor.fetchone()[0] > 0
conn.close()
return exists
except Exception as e:
app_logger.error(f"Error verificando existencia de DB: {e}")
return False

1
app/ui/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Interfaz gráfica de usuario."""

399
app/ui/config_tab.py Normal file
View File

@@ -0,0 +1,399 @@
"""Tab de configuración."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
QFormLayout, QLineEdit, QPushButton, QSpinBox,
QCheckBox, QFileDialog, QMessageBox, QScrollArea
)
from pathlib import Path
from ..utils.crypto import encrypt_password, decrypt_password
from ..extract.seven_zip import SevenZipExtractor
from ..sql.sql_manager import SQLServerManager
class ConfigTab(QWidget):
"""Tab de configuración de la aplicación."""
def __init__(self, engine):
"""Inicializa el tab."""
super().__init__()
self.engine = engine
self.config = {}
self._setup_ui()
def _setup_ui(self):
"""Configura la interfaz."""
# Scroll area para la configuración
scroll = QScrollArea()
scroll.setWidgetResizable(True)
container = QWidget()
layout = QVBoxLayout(container)
layout.setSpacing(15)
# Grupo: Carpetas
paths_group = QGroupBox("Carpetas")
paths_layout = QFormLayout()
self.input_folder_input = QLineEdit()
input_browse_btn = QPushButton("📁")
input_browse_btn.clicked.connect(lambda: self._browse_folder(self.input_folder_input))
input_layout = QHBoxLayout()
input_layout.addWidget(self.input_folder_input)
input_layout.addWidget(input_browse_btn)
paths_layout.addRow("Carpeta de Entrada:", input_layout)
self.processed_folder_input = QLineEdit()
processed_browse_btn = QPushButton("📁")
processed_browse_btn.clicked.connect(lambda: self._browse_folder(self.processed_folder_input))
processed_layout = QHBoxLayout()
processed_layout.addWidget(self.processed_folder_input)
processed_layout.addWidget(processed_browse_btn)
paths_layout.addRow("Carpeta Procesados:", processed_layout)
self.failed_folder_input = QLineEdit()
failed_browse_btn = QPushButton("📁")
failed_browse_btn.clicked.connect(lambda: self._browse_folder(self.failed_folder_input))
failed_layout = QHBoxLayout()
failed_layout.addWidget(self.failed_folder_input)
failed_layout.addWidget(failed_browse_btn)
paths_layout.addRow("Carpeta Fallados:", failed_layout)
self.extract_folder_input = QLineEdit()
extract_browse_btn = QPushButton("📁")
extract_browse_btn.clicked.connect(lambda: self._browse_folder(self.extract_folder_input))
extract_layout = QHBoxLayout()
extract_layout.addWidget(self.extract_folder_input)
extract_layout.addWidget(extract_browse_btn)
paths_layout.addRow("Carpeta Extracción:", extract_layout)
self.data_sql_folder_input = QLineEdit()
data_browse_btn = QPushButton("📁")
data_browse_btn.clicked.connect(lambda: self._browse_folder(self.data_sql_folder_input))
data_layout = QHBoxLayout()
data_layout.addWidget(self.data_sql_folder_input)
data_layout.addWidget(data_browse_btn)
paths_layout.addRow("Carpeta Data SQL:", data_layout)
self.seven_zip_input = QLineEdit()
seven_zip_browse_btn = QPushButton("📁")
seven_zip_browse_btn.clicked.connect(self._browse_seven_zip)
seven_zip_detect_btn = QPushButton("🔍 Detectar")
seven_zip_detect_btn.clicked.connect(self._detect_seven_zip)
seven_zip_layout = QHBoxLayout()
seven_zip_layout.addWidget(self.seven_zip_input)
seven_zip_layout.addWidget(seven_zip_browse_btn)
seven_zip_layout.addWidget(seven_zip_detect_btn)
paths_layout.addRow("7-Zip (7z.exe):", seven_zip_layout)
paths_group.setLayout(paths_layout)
layout.addWidget(paths_group)
# Grupo: SQL Server
sql_group = QGroupBox("SQL Server")
sql_layout = QFormLayout()
self.sql_server_input = QLineEdit()
self.sql_server_input.setPlaceholderText("localhost o IP")
sql_layout.addRow("Servidor:", self.sql_server_input)
self.sql_windows_auth_checkbox = QCheckBox()
self.sql_windows_auth_checkbox.setChecked(True)
self.sql_windows_auth_checkbox.toggled.connect(self._on_auth_changed)
sql_layout.addRow("Usar Windows Auth:", self.sql_windows_auth_checkbox)
self.sql_username_input = QLineEdit()
self.sql_username_input.setEnabled(False)
sql_layout.addRow("Usuario SQL:", self.sql_username_input)
self.sql_password_input = QLineEdit()
self.sql_password_input.setEchoMode(QLineEdit.EchoMode.Password)
self.sql_password_input.setEnabled(False)
sql_layout.addRow("Contraseña SQL:", self.sql_password_input)
test_sql_btn = QPushButton("🔌 Probar Conexión")
test_sql_btn.clicked.connect(self._test_sql_connection)
sql_layout.addRow("", test_sql_btn)
sql_group.setLayout(sql_layout)
layout.addWidget(sql_group)
# Grupo: Concurrencia
concurrency_group = QGroupBox("Concurrencia")
concurrency_layout = QFormLayout()
self.extract_workers_spinbox = QSpinBox()
self.extract_workers_spinbox.setMinimum(1)
self.extract_workers_spinbox.setMaximum(10)
concurrency_layout.addRow("Workers de Extracción:", self.extract_workers_spinbox)
self.restore_workers_spinbox = QSpinBox()
self.restore_workers_spinbox.setMinimum(1)
self.restore_workers_spinbox.setMaximum(5)
concurrency_layout.addRow("Workers de Restore:", self.restore_workers_spinbox)
concurrency_group.setLayout(concurrency_layout)
layout.addWidget(concurrency_group)
# Grupo: Estabilidad de Archivos
stability_group = QGroupBox("Estabilidad de Archivos")
stability_layout = QFormLayout()
self.stability_enabled_checkbox = QCheckBox()
self.stability_enabled_checkbox.setChecked(True)
stability_layout.addRow("Verificar Estabilidad:", self.stability_enabled_checkbox)
self.stability_interval_spinbox = QSpinBox()
self.stability_interval_spinbox.setMinimum(1)
self.stability_interval_spinbox.setMaximum(60)
self.stability_interval_spinbox.setSuffix(" s")
stability_layout.addRow("Intervalo de Verificación:", self.stability_interval_spinbox)
self.stable_duration_spinbox = QSpinBox()
self.stable_duration_spinbox.setMinimum(1)
self.stable_duration_spinbox.setMaximum(300)
self.stable_duration_spinbox.setSuffix(" s")
stability_layout.addRow("Duración Estable:", self.stable_duration_spinbox)
self.ready_marker_checkbox = QCheckBox()
stability_layout.addRow("Usar Marcador .ready:", self.ready_marker_checkbox)
stability_group.setLayout(stability_layout)
layout.addWidget(stability_group)
# Grupo: Timeouts
timeouts_group = QGroupBox("Timeouts")
timeouts_layout = QFormLayout()
self.extract_timeout_spinbox = QSpinBox()
self.extract_timeout_spinbox.setMinimum(1)
self.extract_timeout_spinbox.setMaximum(240)
self.extract_timeout_spinbox.setSuffix(" min")
timeouts_layout.addRow("Extracción:", self.extract_timeout_spinbox)
self.restore_timeout_spinbox = QSpinBox()
self.restore_timeout_spinbox.setMinimum(1)
self.restore_timeout_spinbox.setMaximum(480)
self.restore_timeout_spinbox.setSuffix(" min")
timeouts_layout.addRow("Restauración:", self.restore_timeout_spinbox)
timeouts_group.setLayout(timeouts_layout)
layout.addWidget(timeouts_group)
# Grupo: Características
features_group = QGroupBox("Características")
features_layout = QFormLayout()
self.dry_run_checkbox = QCheckBox()
features_layout.addRow("Modo Dry Run:", self.dry_run_checkbox)
self.auto_scan_checkbox = QCheckBox()
self.auto_scan_checkbox.setChecked(True)
features_layout.addRow("Escaneo Automático:", self.auto_scan_checkbox)
self.scan_interval_spinbox = QSpinBox()
self.scan_interval_spinbox.setMinimum(10)
self.scan_interval_spinbox.setMaximum(600)
self.scan_interval_spinbox.setSuffix(" s")
features_layout.addRow("Intervalo de Escaneo:", self.scan_interval_spinbox)
features_group.setLayout(features_layout)
layout.addWidget(features_group)
# Botones de acción
actions_layout = QHBoxLayout()
save_btn = QPushButton("💾 Guardar Configuración")
save_btn.clicked.connect(self._save_config)
actions_layout.addWidget(save_btn)
actions_layout.addStretch()
layout.addLayout(actions_layout)
layout.addStretch()
scroll.setWidget(container)
# Layout principal
main_layout = QVBoxLayout(self)
main_layout.addWidget(scroll)
def load_config(self, config: dict):
"""Carga la configuración en la UI."""
self.config = config
# Carpetas
paths = config.get("paths", {})
self.input_folder_input.setText(paths.get("input_folder", ""))
self.processed_folder_input.setText(paths.get("processed_folder", ""))
self.failed_folder_input.setText(paths.get("failed_folder", ""))
self.extract_folder_input.setText(paths.get("extract_folder", ""))
self.data_sql_folder_input.setText(paths.get("data_sql_folder", ""))
self.seven_zip_input.setText(paths.get("seven_zip_exe", ""))
# SQL
sql = config.get("sql", {})
self.sql_server_input.setText(sql.get("server", "localhost"))
self.sql_windows_auth_checkbox.setChecked(sql.get("use_windows_auth", True))
self.sql_username_input.setText(sql.get("username", ""))
# Descifrar password si existe
encrypted_pass = sql.get("password_encrypted", "")
if encrypted_pass:
try:
decrypted = decrypt_password(encrypted_pass)
self.sql_password_input.setText(decrypted)
except:
pass
# Concurrencia
concurrency = config.get("concurrency", {})
self.extract_workers_spinbox.setValue(concurrency.get("extract_workers", 1))
self.restore_workers_spinbox.setValue(concurrency.get("restore_workers", 1))
# Estabilidad
stability = config.get("stability", {})
self.stability_enabled_checkbox.setChecked(stability.get("check_enabled", True))
self.stability_interval_spinbox.setValue(stability.get("check_interval_seconds", 5))
self.stable_duration_spinbox.setValue(stability.get("stable_duration_seconds", 10))
self.ready_marker_checkbox.setChecked(stability.get("use_ready_marker", False))
# Timeouts
timeouts = config.get("timeouts", {})
self.extract_timeout_spinbox.setValue(timeouts.get("extract_minutes", 30))
self.restore_timeout_spinbox.setValue(timeouts.get("restore_minutes", 60))
# Características
features = config.get("features", {})
self.dry_run_checkbox.setChecked(features.get("dry_run_mode", False))
self.auto_scan_checkbox.setChecked(features.get("auto_scan_enabled", True))
self.scan_interval_spinbox.setValue(features.get("scan_interval_seconds", 30))
def _save_config(self):
"""Guarda la configuración."""
# Construir config
config = {
"paths": {
"input_folder": self.input_folder_input.text(),
"processed_folder": self.processed_folder_input.text(),
"failed_folder": self.failed_folder_input.text(),
"extract_folder": self.extract_folder_input.text(),
"data_sql_folder": self.data_sql_folder_input.text(),
"seven_zip_exe": self.seven_zip_input.text()
},
"sql": {
"server": self.sql_server_input.text(),
"use_windows_auth": self.sql_windows_auth_checkbox.isChecked(),
"username": self.sql_username_input.text(),
"password_encrypted": ""
},
"concurrency": {
"extract_workers": self.extract_workers_spinbox.value(),
"restore_workers": self.restore_workers_spinbox.value()
},
"stability": {
"check_enabled": self.stability_enabled_checkbox.isChecked(),
"check_interval_seconds": self.stability_interval_spinbox.value(),
"stable_duration_seconds": self.stable_duration_spinbox.value(),
"use_ready_marker": self.ready_marker_checkbox.isChecked()
},
"timeouts": {
"extract_minutes": self.extract_timeout_spinbox.value(),
"restore_minutes": self.restore_timeout_spinbox.value()
},
"retries": self.config.get("retries", {"max_attempts": 3, "retry_delay_seconds": 60}),
"features": {
"dry_run_mode": self.dry_run_checkbox.isChecked(),
"auto_scan_enabled": self.auto_scan_checkbox.isChecked(),
"scan_interval_seconds": self.scan_interval_spinbox.value()
}
}
# Cifrar password si no usa Windows Auth
if not self.sql_windows_auth_checkbox.isChecked():
password = self.sql_password_input.text()
if password:
try:
config["sql"]["password_encrypted"] = encrypt_password(password)
except Exception as e:
QMessageBox.warning(self, "Advertencia", f"Error cifrando contraseña: {e}")
# Guardar
try:
self.engine.save_config(config)
QMessageBox.information(self, "Éxito", "Configuración guardada exitosamente.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Error guardando configuración: {e}")
def _browse_folder(self, line_edit: QLineEdit):
"""Abre el diálogo para seleccionar carpeta."""
folder = QFileDialog.getExistingDirectory(self, "Seleccionar Carpeta")
if folder:
line_edit.setText(folder)
def _browse_seven_zip(self):
"""Abre el diálogo para seleccionar 7z.exe."""
file_path, _ = QFileDialog.getOpenFileName(
self,
"Seleccionar 7z.exe",
"C:\\Program Files\\7-Zip",
"Ejecutables (*.exe)"
)
if file_path:
self.seven_zip_input.setText(file_path)
def _detect_seven_zip(self):
"""Auto-detecta 7-Zip."""
path = SevenZipExtractor._auto_detect_7zip()
if path:
self.seven_zip_input.setText(path)
QMessageBox.information(self, "Éxito", f"7-Zip detectado en:\n{path}")
else:
QMessageBox.warning(
self,
"No Encontrado",
"No se pudo auto-detectar 7-Zip. Por favor, selecciona manualmente."
)
def _on_auth_changed(self, checked: bool):
"""Maneja el cambio en el tipo de autenticación."""
self.sql_username_input.setEnabled(not checked)
self.sql_password_input.setEnabled(not checked)
def _test_sql_connection(self):
"""Prueba la conexión a SQL Server."""
server = self.sql_server_input.text()
use_windows_auth = self.sql_windows_auth_checkbox.isChecked()
username = self.sql_username_input.text()
password = self.sql_password_input.text()
if not server:
QMessageBox.warning(self, "Error", "Debe especificar un servidor.")
return
try:
manager = SQLServerManager(
server=server,
use_windows_auth=use_windows_auth,
username=username if not use_windows_auth else None,
password=password if not use_windows_auth else None
)
success, error = manager.test_connection()
if success:
QMessageBox.information(
self,
"Éxito",
"Conexión a SQL Server exitosa."
)
else:
QMessageBox.critical(
self,
"Error",
f"Error conectando a SQL Server:\n{error}"
)
except Exception as e:
QMessageBox.critical(self, "Error", f"Error: {e}")

195
app/ui/dashboard_tab.py Normal file
View File

@@ -0,0 +1,195 @@
"""Tab del Dashboard."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QGroupBox,
QTableWidget, QTableWidgetItem, QPushButton, QGridLayout
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from ..db.job_repository import JobRepository
class DashboardTab(QWidget):
"""Tab del Dashboard con estadísticas y estado."""
def __init__(self, engine):
"""Inicializa el tab."""
super().__init__()
self.engine = engine
self._setup_ui()
self._load_initial_stats()
def _setup_ui(self):
"""Configura la interfaz."""
layout = QVBoxLayout(self)
layout.setSpacing(15)
# Título
title = QLabel("Dashboard - Estado del Sistema")
title_font = QFont()
title_font.setPointSize(16)
title_font.setBold(True)
title.setFont(title_font)
layout.addWidget(title)
# Grid de estadísticas
stats_group = QGroupBox("Estadísticas de Jobs")
stats_layout = QGridLayout()
self.stats_labels = {}
stats_items = [
("total", "Total de Jobs", 0, 0),
("queued", "En Cola", 0, 1),
("running", "Ejecutando", 0, 2),
("completed", "Completados", 1, 0),
("failed", "Fallados", 1, 1),
("active_threads", "Threads Activos", 1, 2)
]
for key, label_text, row, col in stats_items:
label = QLabel(label_text + ":")
value = QLabel("0")
value_font = QFont()
value_font.setPointSize(18)
value_font.setBold(True)
value.setFont(value_font)
value.setAlignment(Qt.AlignmentFlag.AlignCenter)
stats_layout.addWidget(label, row * 2, col)
stats_layout.addWidget(value, row * 2 + 1, col)
self.stats_labels[key] = value
stats_group.setLayout(stats_layout)
layout.addWidget(stats_group)
# Tiempos promedio
times_group = QGroupBox("Tiempos Promedio (completados)")
times_layout = QHBoxLayout()
self.time_labels = {}
time_items = [
("total_ms", "Total"),
("extract_ms", "Extracción"),
("restore_ms", "Restauración")
]
for key, label_text in time_items:
vbox = QVBoxLayout()
label = QLabel(label_text + ":")
value = QLabel("0 s")
value_font = QFont()
value_font.setPointSize(14)
value_font.setBold(True)
value.setFont(value_font)
value.setAlignment(Qt.AlignmentFlag.AlignCenter)
vbox.addWidget(label)
vbox.addWidget(value)
times_layout.addLayout(vbox)
self.time_labels[key] = value
times_group.setLayout(times_layout)
layout.addWidget(times_group)
# Estado del motor
motor_group = QGroupBox("Estado del Motor")
motor_layout = QHBoxLayout()
self.motor_status_label = QLabel("Detenido")
motor_status_font = QFont()
motor_status_font.setPointSize(14)
motor_status_font.setBold(True)
self.motor_status_label.setFont(motor_status_font)
self.motor_status_label.setStyleSheet("color: red;")
motor_layout.addWidget(QLabel("Estado:"))
motor_layout.addWidget(self.motor_status_label)
motor_layout.addStretch()
motor_group.setLayout(motor_layout)
layout.addWidget(motor_group)
# Últimos jobs
recent_group = QGroupBox("Últimos 10 Jobs")
recent_layout = QVBoxLayout()
self.recent_table = QTableWidget()
self.recent_table.setColumnCount(5)
self.recent_table.setHorizontalHeaderLabels([
"Job ID", "Archivo", "Nodo", "DB", "Estado"
])
self.recent_table.horizontalHeader().setStretchLastSection(True)
recent_layout.addWidget(self.recent_table)
refresh_btn = QPushButton("🔄 Actualizar")
refresh_btn.clicked.connect(self._refresh_recent_jobs)
recent_layout.addWidget(refresh_btn)
recent_group.setLayout(recent_layout)
layout.addWidget(recent_group)
layout.addStretch()
def _load_initial_stats(self):
"""Carga las estadísticas iniciales."""
stats = self.engine.get_stats()
self.update_stats(stats)
self._refresh_recent_jobs()
def update_stats(self, stats: dict):
"""Actualiza las estadísticas en la UI."""
# Contadores
for key in ["total", "queued", "running", "completed", "failed", "active_threads"]:
if key in stats:
self.stats_labels[key].setText(str(stats[key]))
# Tiempos
for key in ["total_ms", "extract_ms", "restore_ms"]:
if key in stats:
ms = stats[key]
seconds = ms / 1000
if seconds >= 60:
minutes = seconds / 60
text = f"{minutes:.1f} min"
else:
text = f"{seconds:.1f} s"
self.time_labels[key].setText(text)
# Estado del motor
if stats.get("running"):
if stats.get("paused"):
self.motor_status_label.setText("Pausado")
self.motor_status_label.setStyleSheet("color: orange;")
else:
self.motor_status_label.setText("Corriendo")
self.motor_status_label.setStyleSheet("color: green;")
else:
self.motor_status_label.setText("Detenido")
self.motor_status_label.setStyleSheet("color: red;")
def _refresh_recent_jobs(self):
"""Refresca la tabla de jobs recientes."""
jobs = JobRepository.get_all(limit=10)
self.recent_table.setRowCount(len(jobs))
for i, job in enumerate(jobs):
self.recent_table.setItem(i, 0, QTableWidgetItem(job.job_id[:8]))
self.recent_table.setItem(i, 1, QTableWidgetItem(job.source_name))
self.recent_table.setItem(i, 2, QTableWidgetItem(job.node_name or "N/A"))
self.recent_table.setItem(i, 3, QTableWidgetItem(job.db_name or "N/A"))
status_item = QTableWidgetItem(job.status)
# Colorear según estado
if job.status == "completed":
status_item.setForeground(Qt.GlobalColor.darkGreen)
elif "failed" in job.status:
status_item.setForeground(Qt.GlobalColor.red)
self.recent_table.setItem(i, 4, status_item)
self.recent_table.resizeColumnsToContents()

228
app/ui/jobs_tab.py Normal file
View File

@@ -0,0 +1,228 @@
"""Tab de Jobs."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget,
QTableWidgetItem, QPushButton, QLineEdit, QLabel,
QComboBox, QSplitter, QTextEdit, QGroupBox
)
from PySide6.QtCore import Qt
from ..db.job_repository import JobRepository
from ..db.job_step_repository import JobStepRepository
from ..constants import JobStatus
class JobsTab(QWidget):
"""Tab de gestión y visualización de jobs."""
def __init__(self, engine):
"""Inicializa el tab."""
super().__init__()
self.engine = engine
self.selected_job_id = None
self._setup_ui()
self.refresh_jobs()
def _setup_ui(self):
"""Configura la interfaz."""
layout = QVBoxLayout(self)
# Filtros
filters_layout = QHBoxLayout()
filters_layout.addWidget(QLabel("Buscar:"))
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Nombre de archivo o Job ID...")
self.search_input.textChanged.connect(self.refresh_jobs)
filters_layout.addWidget(self.search_input)
filters_layout.addWidget(QLabel("Estado:"))
self.status_filter = QComboBox()
self.status_filter.addItems([
"Todos",
JobStatus.QUEUED,
JobStatus.EXTRACTING,
JobStatus.RESTORING,
JobStatus.COMPLETED,
JobStatus.FAILED
])
self.status_filter.currentTextChanged.connect(self.refresh_jobs)
filters_layout.addWidget(self.status_filter)
refresh_btn = QPushButton("🔄 Actualizar")
refresh_btn.clicked.connect(self.refresh_jobs)
filters_layout.addWidget(refresh_btn)
filters_layout.addStretch()
layout.addLayout(filters_layout)
# Splitter para tabla y detalles
splitter = QSplitter(Qt.Orientation.Vertical)
# Tabla de jobs
self.jobs_table = QTableWidget()
self.jobs_table.setColumnCount(8)
self.jobs_table.setHorizontalHeaderLabels([
"Job ID", "Fecha", "Archivo", "Nodo", "DB", "Estado", "Intentos", "Tiempo (s)"
])
self.jobs_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.jobs_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self.jobs_table.itemSelectionChanged.connect(self._on_selection_changed)
splitter.addWidget(self.jobs_table)
# Detalles del job seleccionado
details_widget = QWidget()
details_layout = QVBoxLayout(details_widget)
details_group = QGroupBox("Detalles del Job")
details_group_layout = QVBoxLayout()
self.job_details_text = QTextEdit()
self.job_details_text.setReadOnly(True)
self.job_details_text.setMaximumHeight(200)
details_group_layout.addWidget(self.job_details_text)
details_group.setLayout(details_group_layout)
details_layout.addWidget(details_group)
steps_group = QGroupBox("Pasos del Job")
steps_group_layout = QVBoxLayout()
self.steps_table = QTableWidget()
self.steps_table.setColumnCount(5)
self.steps_table.setHorizontalHeaderLabels([
"Paso", "Inicio", "Duración (ms)", "Exit Code", "Error"
])
steps_group_layout.addWidget(self.steps_table)
steps_group.setLayout(steps_group_layout)
details_layout.addWidget(steps_group)
splitter.addWidget(details_widget)
splitter.setStretchFactor(0, 3)
splitter.setStretchFactor(1, 2)
layout.addWidget(splitter)
def refresh_jobs(self):
"""Refresca la lista de jobs."""
# Obtener filtros
search_text = self.search_input.text().lower()
status_filter = self.status_filter.currentText()
# Obtener jobs
if status_filter == "Todos":
jobs = JobRepository.get_all(limit=100)
else:
jobs = JobRepository.get_all(status=status_filter, limit=100)
# Filtrar por búsqueda
if search_text:
jobs = [
j for j in jobs
if search_text in j.job_id.lower() or
search_text in j.source_name.lower()
]
# Llenar tabla
self.jobs_table.setRowCount(len(jobs))
for i, job in enumerate(jobs):
self.jobs_table.setItem(i, 0, QTableWidgetItem(job.job_id[:8]))
# Fecha
created = job.created_at.split("T")[0] if "T" in job.created_at else job.created_at
self.jobs_table.setItem(i, 1, QTableWidgetItem(created))
self.jobs_table.setItem(i, 2, QTableWidgetItem(job.source_name))
self.jobs_table.setItem(i, 3, QTableWidgetItem(job.node_name or "N/A"))
self.jobs_table.setItem(i, 4, QTableWidgetItem(job.db_name or "N/A"))
status_item = QTableWidgetItem(job.status)
if job.status == JobStatus.COMPLETED:
status_item.setForeground(Qt.GlobalColor.darkGreen)
elif "failed" in job.status:
status_item.setForeground(Qt.GlobalColor.red)
self.jobs_table.setItem(i, 5, status_item)
self.jobs_table.setItem(i, 6, QTableWidgetItem(str(job.attempts)))
# Tiempo
if job.total_ms:
time_s = job.total_ms / 1000
self.jobs_table.setItem(i, 7, QTableWidgetItem(f"{time_s:.1f}"))
else:
self.jobs_table.setItem(i, 7, QTableWidgetItem("N/A"))
# Guardar job_id en la fila
self.jobs_table.item(i, 0).setData(Qt.ItemDataRole.UserRole, job.job_id)
self.jobs_table.resizeColumnsToContents()
def _on_selection_changed(self):
"""Maneja el cambio de selección en la tabla."""
selected_items = self.jobs_table.selectedItems()
if not selected_items:
return
# Obtener job_id de la fila seleccionada
row = selected_items[0].row()
job_id = self.jobs_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
if job_id != self.selected_job_id:
self.selected_job_id = job_id
self._load_job_details()
def _load_job_details(self):
"""Carga los detalles del job seleccionado."""
if not self.selected_job_id:
return
job = JobRepository.get(self.selected_job_id)
if not job:
return
# Mostrar detalles
details = f"""
Job ID: {job.job_id}
Archivo: {job.source_name}
Ruta: {job.source_path}
Nodo: {job.node_name or 'N/A'}
Base de Datos: {job.db_name or 'N/A'}
Estado: {job.status}
Intentos: {job.attempts}
Creado: {job.created_at}
Iniciado: {job.started_at or 'N/A'}
Finalizado: {job.finished_at or 'N/A'}
Tiempos:
- Total: {job.total_ms or 0} ms
- Extracción: {job.extract_ms or 0} ms
- Filelist: {job.filelist_ms or 0} ms
- Restauración: {job.restore_ms or 0} ms
Último Error: {job.last_error or 'N/A'}
""".strip()
self.job_details_text.setPlainText(details)
# Cargar steps
steps = JobStepRepository.get_by_job(self.selected_job_id)
self.steps_table.setRowCount(len(steps))
for i, step in enumerate(steps):
self.steps_table.setItem(i, 0, QTableWidgetItem(step.step))
started = step.started_at.split("T")[1].split(".")[0] if "T" in step.started_at else step.started_at
self.steps_table.setItem(i, 1, QTableWidgetItem(started))
self.steps_table.setItem(i, 2, QTableWidgetItem(str(step.duration_ms or 0)))
self.steps_table.setItem(i, 3, QTableWidgetItem(str(step.exit_code or "")))
self.steps_table.setItem(i, 4, QTableWidgetItem(step.error or ""))
self.steps_table.resizeColumnsToContents()

105
app/ui/logs_tab.py Normal file
View File

@@ -0,0 +1,105 @@
"""Tab de logs y soporte."""
import subprocess
from pathlib import Path
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QTextEdit, QComboBox, QLabel, QMessageBox
)
from ..db.event_repository import EventRepository
from ..constants import LOGS_DIR, LogLevel
class LogsTab(QWidget):
"""Tab de visualización de logs y soporte."""
def __init__(self, engine):
"""Inicializa el tab."""
super().__init__()
self.engine = engine
self._setup_ui()
self.refresh_logs()
def _setup_ui(self):
"""Configura la interfaz."""
layout = QVBoxLayout(self)
# Controles
controls_layout = QHBoxLayout()
controls_layout.addWidget(QLabel("Nivel:"))
self.level_filter = QComboBox()
self.level_filter.addItems([
"Todos",
LogLevel.DEBUG,
LogLevel.INFO,
LogLevel.WARNING,
LogLevel.ERROR,
LogLevel.CRITICAL
])
self.level_filter.setCurrentText(LogLevel.INFO)
self.level_filter.currentTextChanged.connect(self.refresh_logs)
controls_layout.addWidget(self.level_filter)
refresh_btn = QPushButton("🔄 Actualizar")
refresh_btn.clicked.connect(self.refresh_logs)
controls_layout.addWidget(refresh_btn)
controls_layout.addStretch()
open_folder_btn = QPushButton("📁 Abrir Carpeta Logs")
open_folder_btn.clicked.connect(self._open_logs_folder)
controls_layout.addWidget(open_folder_btn)
layout.addLayout(controls_layout)
# Área de logs
self.logs_text = QTextEdit()
self.logs_text.setReadOnly(True)
self.logs_text.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
layout.addWidget(self.logs_text)
# Info
info_layout = QHBoxLayout()
info_label = QLabel("💡 Los logs se rotan automáticamente cada 10MB.")
info_layout.addWidget(info_label)
info_layout.addStretch()
layout.addLayout(info_layout)
def refresh_logs(self):
"""Refresca los logs desde la base de datos."""
level = self.level_filter.currentText()
if level == "Todos":
events = EventRepository.get_all(limit=200)
else:
events = EventRepository.get_all(level=level, limit=200)
# Formatear logs
log_lines = []
for event in reversed(events): # Mostrar más recientes primero
timestamp = event.created_at.replace("T", " ").split(".")[0]
job_info = f" [Job: {event.job_id[:8]}]" if event.job_id else ""
log_lines.append(
f"{timestamp} | {event.level:8s}{job_info} | {event.message}"
)
self.logs_text.setPlainText("\n".join(log_lines))
def _open_logs_folder(self):
"""Abre la carpeta de logs en el explorador."""
try:
if LOGS_DIR.exists():
subprocess.run(["explorer", str(LOGS_DIR)])
else:
QMessageBox.warning(
self,
"Carpeta No Existe",
f"La carpeta de logs no existe:\n{LOGS_DIR}"
)
except Exception as e:
QMessageBox.critical(self, "Error", f"Error abriendo carpeta: {e}")

267
app/ui/main_window.py Normal file
View File

@@ -0,0 +1,267 @@
"""Ventana principal de la aplicación."""
from PySide6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QTabWidget,
QSystemTrayIcon, QMenu
)
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QIcon, QCloseEvent, QAction
from .dashboard_tab import DashboardTab
from .jobs_tab import JobsTab
from .nodes_tab import NodesTab
from .config_tab import ConfigTab
from .logs_tab import LogsTab
from ..engine.engine import RestoreEngine
from ..utils.logger import app_logger
class MainWindow(QMainWindow):
"""Ventana principal de la aplicación."""
# Señal para minimizar a tray
minimize_to_tray = Signal()
def __init__(self):
"""Inicializa la ventana principal."""
super().__init__()
# Motor de restauración
self.engine = RestoreEngine()
# Configurar ventana
self.setWindowTitle("CloudRestoreAS - Restauración Automática SQL Server")
self.setMinimumSize(1200, 800)
# System tray
self.tray_icon: QSystemTrayIcon = None
self._setup_tray()
# UI
self._setup_ui()
# Conectar señales del motor
self._connect_engine_signals()
# Timer para actualizar stats
self.stats_timer = QTimer()
self.stats_timer.timeout.connect(self._update_stats)
self.stats_timer.start(5000) # Cada 5 segundos
app_logger.info("Ventana principal inicializada")
def _setup_ui(self):
"""Configura la interfaz de usuario."""
# Widget central
central_widget = QWidget()
self.setCentralWidget(central_widget)
# Layout principal
layout = QVBoxLayout(central_widget)
layout.setContentsMargins(10, 10, 10, 10)
# Tabs
self.tabs = QTabWidget()
# Crear tabs
self.dashboard_tab = DashboardTab(self.engine)
self.jobs_tab = JobsTab(self.engine)
self.nodes_tab = NodesTab(self.engine)
self.config_tab = ConfigTab(self.engine)
self.logs_tab = LogsTab(self.engine)
# Agregar tabs
self.tabs.addTab(self.dashboard_tab, "📊 Dashboard")
self.tabs.addTab(self.jobs_tab, "📋 Jobs")
self.tabs.addTab(self.nodes_tab, "🔗 Nodos")
self.tabs.addTab(self.config_tab, "⚙️ Configuración")
self.tabs.addTab(self.logs_tab, "📝 Logs")
layout.addWidget(self.tabs)
# Menu bar
self._setup_menu()
def _setup_menu(self):
"""Configura el menú."""
menubar = self.menuBar()
# Menú Motor
motor_menu = menubar.addMenu("Motor")
self.start_action = QAction("▶️ Iniciar", self)
self.start_action.triggered.connect(self._start_engine)
motor_menu.addAction(self.start_action)
self.pause_action = QAction("⏸️ Pausar", self)
self.pause_action.triggered.connect(self._pause_engine)
self.pause_action.setEnabled(False)
motor_menu.addAction(self.pause_action)
self.resume_action = QAction("▶️ Continuar", self)
self.resume_action.triggered.connect(self._resume_engine)
self.resume_action.setEnabled(False)
motor_menu.addAction(self.resume_action)
self.stop_action = QAction("⏹️ Detener", self)
self.stop_action.triggered.connect(self._stop_engine)
self.stop_action.setEnabled(False)
motor_menu.addAction(self.stop_action)
motor_menu.addSeparator()
self.scan_action = QAction("🔍 Escanear Ahora", self)
self.scan_action.triggered.connect(self._scan_now)
motor_menu.addAction(self.scan_action)
# Menú Ayuda
help_menu = menubar.addMenu("Ayuda")
about_action = QAction("Acerca de", self)
about_action.triggered.connect(self._show_about)
help_menu.addAction(about_action)
def _setup_tray(self):
"""Configura el icono de bandeja del sistema."""
# Crear icono (usar un icono por defecto o crear uno simple)
self.tray_icon = QSystemTrayIcon(self)
self.tray_icon.setToolTip("CloudRestoreAS")
# Menú del tray
tray_menu = QMenu()
show_action = QAction("Mostrar", self)
show_action.triggered.connect(self._show_window)
tray_menu.addAction(show_action)
hide_action = QAction("Ocultar", self)
hide_action.triggered.connect(self.hide)
tray_menu.addAction(hide_action)
tray_menu.addSeparator()
pause_tray_action = QAction("Pausar", self)
pause_tray_action.triggered.connect(self._pause_engine)
tray_menu.addAction(pause_tray_action)
resume_tray_action = QAction("Continuar", self)
resume_tray_action.triggered.connect(self._resume_engine)
tray_menu.addAction(resume_tray_action)
tray_menu.addSeparator()
quit_action = QAction("Salir", self)
quit_action.triggered.connect(self._quit_application)
tray_menu.addAction(quit_action)
self.tray_icon.setContextMenu(tray_menu)
# Doble clic en tray para mostrar/ocultar
self.tray_icon.activated.connect(self._on_tray_activated)
# Mostrar tray
self.tray_icon.show()
def _connect_engine_signals(self):
"""Conecta las señales del motor."""
self.engine.signals.job_created.connect(self._on_job_created)
self.engine.signals.stats_updated.connect(self._on_stats_updated)
self.engine.signals.config_loaded.connect(self._on_config_loaded)
def closeEvent(self, event: QCloseEvent):
"""Maneja el evento de cierre de ventana (minimizar a tray)."""
if self.tray_icon.isVisible():
self.hide()
event.ignore()
app_logger.info("Ventana minimizada a bandeja del sistema")
else:
event.accept()
def _on_tray_activated(self, reason):
"""Maneja la activación del icono de tray."""
if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
if self.isVisible():
self.hide()
else:
self._show_window()
def _show_window(self):
"""Muestra la ventana."""
self.show()
self.activateWindow()
self.raise_()
def _start_engine(self):
"""Inicia el motor."""
self.engine.start()
self.start_action.setEnabled(False)
self.pause_action.setEnabled(True)
self.stop_action.setEnabled(True)
app_logger.info("Motor iniciado desde UI")
def _pause_engine(self):
"""Pausa el motor."""
self.engine.pause()
self.pause_action.setEnabled(False)
self.resume_action.setEnabled(True)
app_logger.info("Motor pausado desde UI")
def _resume_engine(self):
"""Reanuda el motor."""
self.engine.resume()
self.resume_action.setEnabled(False)
self.pause_action.setEnabled(True)
app_logger.info("Motor reanudado desde UI")
def _stop_engine(self):
"""Detiene el motor."""
self.engine.stop()
self.stop_action.setEnabled(False)
self.pause_action.setEnabled(False)
self.resume_action.setEnabled(False)
self.start_action.setEnabled(True)
app_logger.info("Motor detenido desde UI")
def _scan_now(self):
"""Fuerza un escaneo manual."""
self.engine.scan_now()
app_logger.info("Escaneo manual solicitado desde UI")
def _update_stats(self):
"""Actualiza las estadísticas."""
if self.engine.is_running():
stats = self.engine.get_stats()
self.dashboard_tab.update_stats(stats)
def _on_job_created(self, job_id: str):
"""Maneja la creación de un nuevo job."""
self.jobs_tab.refresh_jobs()
self._update_stats()
def _on_stats_updated(self, stats: dict):
"""Maneja la actualización de estadísticas."""
self.dashboard_tab.update_stats(stats)
def _on_config_loaded(self, config: dict):
"""Maneja la carga de configuración."""
self.config_tab.load_config(config)
def _show_about(self):
"""Muestra el diálogo Acerca de."""
from PySide6.QtWidgets import QMessageBox
QMessageBox.about(
self,
"Acerca de CloudRestoreAS",
"CloudRestoreAS v1.0.0\n\n"
"Aplicación de restauración automática de bases de datos SQL Server.\n\n"
"© 2026 Aduanasoft"
)
def _quit_application(self):
"""Sale de la aplicación completamente."""
app_logger.info("Saliendo de la aplicación")
self.engine.stop()
self.tray_icon.hide()
self.close()
from PySide6.QtWidgets import QApplication
QApplication.quit()

257
app/ui/nodes_tab.py Normal file
View File

@@ -0,0 +1,257 @@
"""Tab de gestión de nodos."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget,
QTableWidgetItem, QPushButton, QDialog, QFormLayout,
QLineEdit, QTextEdit, QCheckBox, QDialogButtonBox,
QMessageBox
)
from PySide6.QtCore import Qt
from ..db.node_repository import NodeRepository
class NodeDialog(QDialog):
"""Diálogo para crear/editar nodos."""
def __init__(self, parent=None, node=None):
"""Inicializa el diálogo."""
super().__init__(parent)
self.node = node
self.setWindowTitle("Editar Nodo" if node else "Nuevo Nodo")
self.setMinimumWidth(400)
self._setup_ui()
if node:
self._load_node_data()
def _setup_ui(self):
"""Configura la interfaz."""
layout = QFormLayout(self)
self.node_name_input = QLineEdit()
self.node_name_input.setPlaceholderText("Ej: BACKUP_SISTEMA.ZIP")
layout.addRow("Nombre del Nodo:", self.node_name_input)
self.db_name_input = QLineEdit()
self.db_name_input.setPlaceholderText("Ej: Sistema_DB")
layout.addRow("Base de Datos:", self.db_name_input)
self.active_checkbox = QCheckBox()
self.active_checkbox.setChecked(True)
layout.addRow("Activo:", self.active_checkbox)
self.notes_input = QTextEdit()
self.notes_input.setPlaceholderText("Notas opcionales...")
self.notes_input.setMaximumHeight(100)
layout.addRow("Notas:", self.notes_input)
# Botones
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addRow(buttons)
def _load_node_data(self):
"""Carga los datos del nodo a editar."""
self.node_name_input.setText(self.node.node_name)
self.node_name_input.setEnabled(False) # No permitir cambiar el nombre
self.db_name_input.setText(self.node.db_name)
self.active_checkbox.setChecked(self.node.active)
self.notes_input.setPlainText(self.node.notes or "")
def get_data(self):
"""Obtiene los datos del formulario."""
return {
"node_name": self.node_name_input.text().strip(),
"db_name": self.db_name_input.text().strip(),
"active": self.active_checkbox.isChecked(),
"notes": self.notes_input.toPlainText().strip()
}
class NodesTab(QWidget):
"""Tab de gestión de nodos."""
def __init__(self, engine):
"""Inicializa el tab."""
super().__init__()
self.engine = engine
self._setup_ui()
self.refresh_nodes()
def _setup_ui(self):
"""Configura la interfaz."""
layout = QVBoxLayout(self)
# Botones de acción
actions_layout = QHBoxLayout()
self.add_btn = QPushButton(" Nuevo Nodo")
self.add_btn.clicked.connect(self._add_node)
actions_layout.addWidget(self.add_btn)
self.edit_btn = QPushButton("✏️ Editar")
self.edit_btn.clicked.connect(self._edit_node)
self.edit_btn.setEnabled(False)
actions_layout.addWidget(self.edit_btn)
self.delete_btn = QPushButton("🗑️ Eliminar")
self.delete_btn.clicked.connect(self._delete_node)
self.delete_btn.setEnabled(False)
actions_layout.addWidget(self.delete_btn)
actions_layout.addStretch()
refresh_btn = QPushButton("🔄 Actualizar")
refresh_btn.clicked.connect(self.refresh_nodes)
actions_layout.addWidget(refresh_btn)
layout.addLayout(actions_layout)
# Tabla de nodos
self.nodes_table = QTableWidget()
self.nodes_table.setColumnCount(4)
self.nodes_table.setHorizontalHeaderLabels([
"Nombre del Nodo", "Base de Datos", "Activo", "Notas"
])
self.nodes_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.nodes_table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self.nodes_table.itemSelectionChanged.connect(self._on_selection_changed)
self.nodes_table.itemDoubleClicked.connect(self._edit_node)
layout.addWidget(self.nodes_table)
# Info
info_label = QLabel(
"💡 El nombre del nodo debe coincidir con el nombre del archivo ZIP (con extensión). "
"Se compara en MAYÚSCULAS y case-insensitive."
)
info_label.setWordWrap(True)
info_label.setStyleSheet("color: #666; font-size: 10pt;")
layout.addWidget(info_label)
def refresh_nodes(self):
"""Refresca la lista de nodos."""
nodes = NodeRepository.get_all()
self.nodes_table.setRowCount(len(nodes))
for i, node in enumerate(nodes):
self.nodes_table.setItem(i, 0, QTableWidgetItem(node.node_name))
self.nodes_table.setItem(i, 1, QTableWidgetItem(node.db_name))
active_item = QTableWidgetItem("" if node.active else "")
active_item.setForeground(
Qt.GlobalColor.darkGreen if node.active else Qt.GlobalColor.red
)
self.nodes_table.setItem(i, 2, active_item)
self.nodes_table.setItem(i, 3, QTableWidgetItem(node.notes or ""))
# Guardar node_name en la fila
self.nodes_table.item(i, 0).setData(Qt.ItemDataRole.UserRole, node.node_name)
self.nodes_table.resizeColumnsToContents()
def _on_selection_changed(self):
"""Maneja el cambio de selección."""
has_selection = len(self.nodes_table.selectedItems()) > 0
self.edit_btn.setEnabled(has_selection)
self.delete_btn.setEnabled(has_selection)
def _add_node(self):
"""Abre el diálogo para agregar un nodo."""
dialog = NodeDialog(self)
if dialog.exec() == QDialog.DialogCode.Accepted:
data = dialog.get_data()
if not data["node_name"] or not data["db_name"]:
QMessageBox.warning(
self,
"Error",
"El nombre del nodo y la base de datos son obligatorios."
)
return
try:
NodeRepository.create(
node_name=data["node_name"],
db_name=data["db_name"],
notes=data["notes"]
)
if not data["active"]:
NodeRepository.update(data["node_name"], active=False)
self.refresh_nodes()
QMessageBox.information(self, "Éxito", "Nodo creado exitosamente.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Error creando nodo: {e}")
def _edit_node(self):
"""Abre el diálogo para editar un nodo."""
selected_items = self.nodes_table.selectedItems()
if not selected_items:
return
row = selected_items[0].row()
node_name = self.nodes_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
node = NodeRepository.get(node_name)
if not node:
return
dialog = NodeDialog(self, node)
if dialog.exec() == QDialog.DialogCode.Accepted:
data = dialog.get_data()
if not data["db_name"]:
QMessageBox.warning(self, "Error", "La base de datos es obligatoria.")
return
try:
NodeRepository.update(
node_name=node_name,
db_name=data["db_name"],
active=data["active"],
notes=data["notes"]
)
self.refresh_nodes()
QMessageBox.information(self, "Éxito", "Nodo actualizado exitosamente.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Error actualizando nodo: {e}")
def _delete_node(self):
"""Elimina el nodo seleccionado."""
selected_items = self.nodes_table.selectedItems()
if not selected_items:
return
row = selected_items[0].row()
node_name = self.nodes_table.item(row, 0).data(Qt.ItemDataRole.UserRole)
reply = QMessageBox.question(
self,
"Confirmar Eliminación",
f"¿Está seguro de eliminar el nodo '{node_name}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
try:
NodeRepository.delete(node_name)
self.refresh_nodes()
QMessageBox.information(self, "Éxito", "Nodo eliminado exitosamente.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Error eliminando nodo: {e}")

1
app/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Utilidades comunes."""

59
app/utils/crypto.py Normal file
View File

@@ -0,0 +1,59 @@
"""Utilidades de cifrado para passwords usando DPAPI en Windows."""
import base64
import win32crypt
from typing import Optional
def encrypt_password(password: str) -> str:
"""
Cifra una contraseña usando DPAPI de Windows.
Args:
password: Contraseña en texto plano
Returns:
Contraseña cifrada en base64
"""
if not password:
return ""
try:
encrypted_bytes = win32crypt.CryptProtectData(
password.encode('utf-8'),
None,
None,
None,
None,
0
)
return base64.b64encode(encrypted_bytes).decode('ascii')
except Exception as e:
raise RuntimeError(f"Error cifrando contraseña: {e}")
def decrypt_password(encrypted_password: str) -> str:
"""
Descifra una contraseña usando DPAPI de Windows.
Args:
encrypted_password: Contraseña cifrada en base64
Returns:
Contraseña en texto plano
"""
if not encrypted_password:
return ""
try:
encrypted_bytes = base64.b64decode(encrypted_password)
decrypted_bytes = win32crypt.CryptUnprotectData(
encrypted_bytes,
None,
None,
None,
0
)[1]
return decrypted_bytes.decode('utf-8')
except Exception as e:
raise RuntimeError(f"Error descifrando contraseña: {e}")

66
app/utils/logger.py Normal file
View File

@@ -0,0 +1,66 @@
"""Configuración de logging con rotación."""
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Optional
from ..constants import LOGS_DIR
def setup_logger(
name: str,
log_file: Optional[str] = None,
level: int = logging.INFO,
max_bytes: int = 10 * 1024 * 1024, # 10MB
backup_count: int = 10
) -> logging.Logger:
"""
Configura un logger con rotación de archivos.
Args:
name: Nombre del logger
log_file: Nombre del archivo de log (opcional)
level: Nivel de logging
max_bytes: Tamaño máximo del archivo antes de rotar
backup_count: Número de archivos de backup a mantener
Returns:
Logger configurado
"""
logger = logging.getLogger(name)
logger.setLevel(level)
# Evitar duplicar handlers
if logger.handlers:
return logger
# Formato
formatter = logging.Formatter(
'%(asctime)s | %(name)s | %(levelname)s | %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Handler de consola
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# Handler de archivo (si se especifica)
if log_file:
LOGS_DIR.mkdir(parents=True, exist_ok=True)
log_path = LOGS_DIR / log_file
file_handler = RotatingFileHandler(
log_path,
maxBytes=max_bytes,
backupCount=backup_count,
encoding='utf-8'
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
# Logger principal de la aplicación
app_logger = setup_logger("CloudRestoreAS", "app.log")