Problemas
This commit is contained in:
parent
be61956a54
commit
581aa551c4
|
@ -14,7 +14,7 @@ AutoBackups is a Python application designed to automate the backup of Simatic S
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
```
|
```
|
||||||
|
autobackups
|
||||||
├── .logs/ # Application logs with startup timestamps
|
├── .logs/ # Application logs with startup timestamps
|
||||||
├── src
|
├── src
|
||||||
│ ├── app.py # Main entry point of the Flask application
|
│ ├── app.py # Main entry point of the Flask application
|
|
@ -0,0 +1,258 @@
|
||||||
|
# AutoBackups - Especificación Técnica Detallada
|
||||||
|
|
||||||
|
## Resumen Ejecutivo
|
||||||
|
AutoBackups es una aplicación Python que automatiza el backup de proyectos Simatic S7 y directorios definidos por el usuario, utilizando la API de Everything para búsqueda eficiente de archivos y un sistema de hash inteligente para evitar backups innecesarios.
|
||||||
|
|
||||||
|
## Arquitectura del Sistema
|
||||||
|
|
||||||
|
### 1. Componentes Principales
|
||||||
|
|
||||||
|
#### 1.1 Motor de Búsqueda (Everything API)
|
||||||
|
- **Librería**: PyEverything wrapper
|
||||||
|
- **DLL Local**: `Everything-SDK\dll\Everything64.dll`
|
||||||
|
- **Función**: Localizar archivos *.s7p en directorios de observación
|
||||||
|
- **Optimización**: Evitar último nivel del árbol para archivos .s7p
|
||||||
|
- **Ventaja**: Aprovecha el índice existente de Everything para búsquedas instantáneas
|
||||||
|
|
||||||
|
#### 1.2 Sistema de Hash en Dos Etapas
|
||||||
|
**Etapa 1**: Hash rápido de archivos *.s7p
|
||||||
|
- Solo verifica MD5 de (timestamp + tamaño) de archivos .s7p
|
||||||
|
- Si no hay cambios → no se procede con backup
|
||||||
|
- Si hay cambios → procede a Etapa 2
|
||||||
|
|
||||||
|
**Etapa 2**: Hash completo del proyecto
|
||||||
|
- Calcula MD5 de (timestamp + tamaño) de todos los archivos del directorio
|
||||||
|
- Compara con último hash almacenado
|
||||||
|
- Solo hace backup si hay diferencias
|
||||||
|
|
||||||
|
#### 1.3 Scheduler de Background
|
||||||
|
- **daily**: Una vez al día a hora configurada
|
||||||
|
- **hourly**: Cada hora
|
||||||
|
- **3-hour**: Cada 3 horas
|
||||||
|
- **7-hour**: Cada 7 horas
|
||||||
|
- **startup**: Al iniciar la aplicación
|
||||||
|
- **manual**: Solo bajo demanda del usuario
|
||||||
|
- **Escaneos**: Automáticos cada 1 hora
|
||||||
|
- **Backups**: Mínimo cada 10 minutos
|
||||||
|
- **Prioridad**: Baja prioridad de sistema
|
||||||
|
|
||||||
|
### 2. Estructura de Datos
|
||||||
|
|
||||||
|
#### 2.1 config.json (Configuración Global)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"observation_directories": [
|
||||||
|
{
|
||||||
|
"path": "C:\\Projects\\Siemens",
|
||||||
|
"type": "siemens_s7",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "D:\\Engineering\\Projects",
|
||||||
|
"type": "siemens_s7",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "C:\\Important\\Docs",
|
||||||
|
"type": "manual",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"backup_destination": "D:\\Backups\\AutoBackups",
|
||||||
|
"default_schedule": "daily",
|
||||||
|
"default_schedule_time": "02:00",
|
||||||
|
"retry_delay_hours": 1,
|
||||||
|
"web_interface": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 5000
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO",
|
||||||
|
"max_log_files": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 projects.json (Estado de Proyectos)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"id": "project_001",
|
||||||
|
"name": "PLC_MainLine",
|
||||||
|
"path": "C:\\Projects\\Siemens\\PLC_MainLine",
|
||||||
|
"type": "siemens_s7",
|
||||||
|
"s7p_file": "C:\\Projects\\Siemens\\PLC_MainLine\\PLC_MainLine.s7p",
|
||||||
|
"schedule": "daily",
|
||||||
|
"schedule_time": "02:00",
|
||||||
|
"enabled": true,
|
||||||
|
"last_backup": "2025-09-01T02:15:30",
|
||||||
|
"last_s7p_hash": "abc123def456",
|
||||||
|
"last_full_hash": "def789ghi012",
|
||||||
|
"last_s7p_timestamp": "2025-08-31T14:30:00",
|
||||||
|
"status": "ready",
|
||||||
|
"retry_count": 0,
|
||||||
|
"next_retry": null,
|
||||||
|
"error_message": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Flujo de Operación
|
||||||
|
|
||||||
|
#### 3.1 Inicio de Aplicación
|
||||||
|
1. Cargar configuración desde `config.json`
|
||||||
|
2. Cargar estado de proyectos desde `projects.json`
|
||||||
|
3. Inicializar logger con timestamp de inicio
|
||||||
|
4. Escanear directorios de observación usando Everything API
|
||||||
|
5. Actualizar lista de proyectos detectados
|
||||||
|
6. Iniciar scheduler
|
||||||
|
7. Iniciar interfaz web Flask
|
||||||
|
|
||||||
|
#### 4.2 Proceso de Backup
|
||||||
|
1. **Verificación de Espacio**: Verificar mínimo 100MB libres
|
||||||
|
2. **Verificación de Acceso .s7p**: Intentar comprimir solo archivo .s7p
|
||||||
|
3. **Hash Etapa 1**: Verificar MD5 de (timestamp + tamaño) del archivo .s7p
|
||||||
|
4. **Hash Etapa 2**: Si Etapa 1 detecta cambios, verificar hash completo
|
||||||
|
5. **Compresión**: Crear archivo ZIP con estructura específica preservada
|
||||||
|
6. **Almacenamiento**: Guardar en `backup_destination/ProjectPath/YYYY-MM-DD/HH-MM-SS_projects.zip`
|
||||||
|
7. **Actualización**: Actualizar hashes y timestamps en `projects.json`
|
||||||
|
|
||||||
|
#### 3.3 Gestión de Errores
|
||||||
|
- **Archivos en uso**: Marcar proyecto para reintento en 1 hora
|
||||||
|
- **Errores de permisos**: Log de error y notificación en interfaz
|
||||||
|
- **Errores de espacio**: Verificar espacio disponible antes de backup
|
||||||
|
|
||||||
|
### 4. Interfaz Web
|
||||||
|
|
||||||
|
#### 4.1 Dashboard Principal
|
||||||
|
- **Lista de Proyectos**: Tabla con estado, último backup, próximo backup
|
||||||
|
- **Controles Globales**: Backup manual global, pausar/reanudar scheduler
|
||||||
|
- **Estadísticas**: Total de proyectos, backups exitosos, errores
|
||||||
|
|
||||||
|
#### 4.2 Configuración de Proyectos
|
||||||
|
- **Habilitación/Deshabilitación**: Toggle por proyecto
|
||||||
|
- **Configuración de Schedule**: Dropdown con opciones de frecuencia
|
||||||
|
- **Backup Manual**: Botón de backup inmediato por proyecto
|
||||||
|
|
||||||
|
#### 4.3 Logs y Monitoreo
|
||||||
|
- **Log Viewer**: Mostrar logs recientes con filtros por nivel
|
||||||
|
- **Estado del Sistema**: Información de Everything API, espacio en disco
|
||||||
|
|
||||||
|
### 5. Estructura de Backup
|
||||||
|
|
||||||
|
#### 5.1 Nomenclatura de Archivos (Estructura Específica)
|
||||||
|
```
|
||||||
|
backup_destination/
|
||||||
|
├── LineA/
|
||||||
|
│ └── Project1/
|
||||||
|
│ ├── 2025-09-01/
|
||||||
|
│ │ ├── 02-15-30_projects.zip
|
||||||
|
│ │ └── 14-30-15_projects.zip
|
||||||
|
│ └── 2025-09-02/
|
||||||
|
│ └── 02-15-45_projects.zip
|
||||||
|
└── LineB/
|
||||||
|
└── Project2/
|
||||||
|
└── 2025-09-01/
|
||||||
|
└── 08-20-10_projects.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 Contenido del ZIP
|
||||||
|
```
|
||||||
|
02-15-30_projects.zip
|
||||||
|
└── LineA/
|
||||||
|
└── Project1/
|
||||||
|
├── project.s7p
|
||||||
|
├── subfolder1/
|
||||||
|
└── subfolder2/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Tecnologías y Librerías
|
||||||
|
|
||||||
|
#### 6.1 Python Core
|
||||||
|
- **Python**: 3.12
|
||||||
|
- **Framework Web**: Flask
|
||||||
|
- **Scheduler**: APScheduler
|
||||||
|
- **Compresión**: zipfile (biblioteca estándar)
|
||||||
|
|
||||||
|
#### 6.2 Librerías Específicas
|
||||||
|
- **Everything API**: PyEverything wrapper con DLL local
|
||||||
|
- **Hashing**: hashlib MD5 para (timestamp + tamaño)
|
||||||
|
- **Config**: json (biblioteca estándar)
|
||||||
|
- **Logging**: logging (biblioteca estándar)
|
||||||
|
- **File Access Check**: Intento de compresión + verificación .s7p
|
||||||
|
- **Process Priority**: pywin32 para baja prioridad
|
||||||
|
- **Disk Space**: psutil para monitoreo de espacio libre
|
||||||
|
|
||||||
|
#### 6.3 Frontend
|
||||||
|
- **HTML/CSS/JavaScript**: Vanilla (sin React por simplicidad)
|
||||||
|
- **AJAX**: Para comunicación con Flask API
|
||||||
|
- **Bootstrap**: Para estilizado responsivo
|
||||||
|
|
||||||
|
### 7. Consideraciones de Seguridad
|
||||||
|
|
||||||
|
#### 7.1 Acceso a Archivos
|
||||||
|
- Verificación de permisos antes de backup
|
||||||
|
- Manejo seguro de rutas para evitar path traversal
|
||||||
|
- Logs de todos los accesos a archivos
|
||||||
|
|
||||||
|
#### 7.2 Interfaz Web
|
||||||
|
- Solo acceso local (127.0.0.1)
|
||||||
|
- No autenticación requerida (uso local únicamente)
|
||||||
|
- Validación de inputs en formularios
|
||||||
|
|
||||||
|
### 8. Logging y Monitoreo
|
||||||
|
|
||||||
|
#### 8.1 Estructura de Logs
|
||||||
|
```
|
||||||
|
.logs/
|
||||||
|
├── autobackups_2025-09-01_08-30-15.log
|
||||||
|
├── autobackups_2025-09-02_08-30-22.log
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8.2 Niveles de Log
|
||||||
|
- **INFO**: Inicio/fin de backups, descubrimiento de proyectos
|
||||||
|
- **WARNING**: Archivos en uso, reintentos
|
||||||
|
- **ERROR**: Fallos de backup, errores de configuración
|
||||||
|
- **DEBUG**: Detalles de hashes, operaciones de archivos
|
||||||
|
|
||||||
|
### 9. Instalación y Despliegue
|
||||||
|
|
||||||
|
#### 9.1 Entorno Miniconda
|
||||||
|
```bash
|
||||||
|
conda create --name autobackups python=3.12
|
||||||
|
conda activate autobackups
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9.2 Ejecución
|
||||||
|
- **Desarrollo**: `python src/app.py`
|
||||||
|
- **Producción**: `pythonw.exe src/app.py` (sin consola)
|
||||||
|
- **Como servicio**: Futuro enhancement con `python-windows-service`
|
||||||
|
|
||||||
|
### 10. Roadmap de Desarrollo
|
||||||
|
|
||||||
|
#### Fase 1: Core Backend
|
||||||
|
- [ ] Integración con Everything API
|
||||||
|
- [ ] Sistema de hash en dos etapas
|
||||||
|
- [ ] Motor de backup básico
|
||||||
|
- [ ] Scheduler con APScheduler
|
||||||
|
|
||||||
|
#### Fase 2: Interfaz Web
|
||||||
|
- [ ] Dashboard básico con Flask
|
||||||
|
- [ ] API REST para operaciones CRUD
|
||||||
|
- [ ] Frontend con HTML/JS/Bootstrap
|
||||||
|
|
||||||
|
#### Fase 3: Características Avanzadas
|
||||||
|
- [ ] Logs web viewer
|
||||||
|
- [ ] Configuración avanzada de directorios
|
||||||
|
- [ ] Estadísticas y reportes
|
||||||
|
|
||||||
|
#### Fase 4: Optimizaciones
|
||||||
|
- [ ] Ejecución como servicio Windows
|
||||||
|
- [ ] Notificaciones por email
|
||||||
|
- [ ] Backup incremental
|
||||||
|
|
||||||
|
Este documento será la base para el desarrollo del proyecto AutoBackups.
|
|
@ -0,0 +1,124 @@
|
||||||
|
# AutoBackups - Preguntas Técnicas Pendientes
|
||||||
|
|
||||||
|
## Preguntas Críticas para el Desarrollo
|
||||||
|
|
||||||
|
### 1. Everything API - Integración
|
||||||
|
**Pregunta**: ¿Everything está instalado y funcionando en el sistema objetivo?
|
||||||
|
**Impacto**: Si no está disponible, necesitaremos un método alternativo de búsqueda de archivos.
|
||||||
|
**Opciones**:
|
||||||
|
- Usar `python-everything` si Everything está disponible
|
||||||
|
- Implementar búsqueda recursiva con `os.walk()` como fallback
|
||||||
|
- Usar `pathlib` con `glob` patterns
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Podemos asumir que Everything estará disponible o necesitamos fallback?
|
||||||
|
|
||||||
|
### 2. Estructura de Backup - Clarificación de Rutas
|
||||||
|
**Pregunta**: Si tenemos un archivo en `C:\Projects\Siemens\LineA\Project1\project.s7p`, ¿el backup debe ser?
|
||||||
|
**Opción A**:
|
||||||
|
```
|
||||||
|
backup_destination/2025-09-01_14-30-15/Projects_Siemens_LineA_Project1.zip
|
||||||
|
└── Projects/Siemens/LineA/Project1/...
|
||||||
|
```
|
||||||
|
**Opción B**:
|
||||||
|
```
|
||||||
|
backup_destination/2025-09-01_14-30-15/LineA_Project1.zip
|
||||||
|
└── LineA/Project1/...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué estructura prefieres?
|
||||||
|
|
||||||
|
### 3. Configuración de Schedules - Granularidad
|
||||||
|
**Pregunta**: ¿Los schedules deben ser configurables a nivel de:
|
||||||
|
- Por directorio de observación (todos los proyectos de un directorio)
|
||||||
|
- Por proyecto individual
|
||||||
|
- Ambos (global con override por proyecto)
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué nivel de granularidad necesitas?
|
||||||
|
|
||||||
|
### 4. Hash de Archivos - Algoritmo y Almacenamiento
|
||||||
|
**Pregunta**: Para el sistema de hash en dos etapas:
|
||||||
|
- ¿Usamos MD5 (rápido) o SHA256 (más seguro) para los hashes?
|
||||||
|
- ¿El hash incluye solo timestamp o también tamaño de archivo?
|
||||||
|
- ¿Cómo manejamos archivos que se mueven dentro del proyecto?
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué balance entre velocidad y precisión prefieres?
|
||||||
|
|
||||||
|
### 5. Detección de Archivos en Uso - Método
|
||||||
|
**Pregunta**: Para detectar archivos bloqueados, ¿prefieres:
|
||||||
|
**Opción A**: Intentar abrir cada archivo en modo escritura exclusiva
|
||||||
|
**Opción B**: Usar `lsof` (Linux) / `handle.exe` (Windows) para listar archivos abiertos
|
||||||
|
**Opción C**: Solo verificar el archivo .s7p principal
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué método consideras más apropiado?
|
||||||
|
|
||||||
|
### 6. Manejo de Proyectos Grandes - Performance
|
||||||
|
**Pregunta**: ¿Hay límites que debamos considerar?
|
||||||
|
- Tamaño máximo de proyecto para backup
|
||||||
|
- Tiempo máximo de backup
|
||||||
|
- Número máximo de archivos por proyecto
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Necesitamos algún tipo de throttling o límites?
|
||||||
|
|
||||||
|
### 7. Configuración de Directorios - Recursividad
|
||||||
|
**Pregunta**: Cuando especificamos un directorio de observación como `C:\Projects`, ¿debemos:
|
||||||
|
- Buscar solo en subdirectorios inmediatos
|
||||||
|
- Buscar recursivamente en toda la jerarquía
|
||||||
|
- Permitir configurar la profundidad de búsqueda
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué comportamiento prefieres por defecto?
|
||||||
|
|
||||||
|
### 8. Backup Incremental vs Completo
|
||||||
|
**Pregunta**: ¿Todos los backups deben ser completos o consideras backup incremental?
|
||||||
|
- Backup completo: Todo el proyecto cada vez
|
||||||
|
- Backup incremental: Solo archivos modificados
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Preferencia por simplicidad o eficiencia de espacio?
|
||||||
|
|
||||||
|
### 9. Web Interface - Características Específicas
|
||||||
|
**Pregunta**: ¿Qué funcionalidades son prioritarias en la interfaz web?
|
||||||
|
**Must-have**:
|
||||||
|
- Lista de proyectos con estado
|
||||||
|
- Trigger manual de backup
|
||||||
|
- Ver logs básicos
|
||||||
|
|
||||||
|
**Nice-to-have**:
|
||||||
|
- Configurar schedules por proyecto
|
||||||
|
- Ver progreso de backup en tiempo real
|
||||||
|
- Estadísticas históricas
|
||||||
|
- Configurar nuevos directorios
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Cuáles son must-have vs nice-to-have?
|
||||||
|
|
||||||
|
### 10. Error Handling - Estrategias de Recuperación
|
||||||
|
**Pregunta**: ¿Cómo manejar errores específicos?
|
||||||
|
- Espacio insuficiente en destino de backup
|
||||||
|
- Pérdida de conexión con Everything
|
||||||
|
- Corrupción de archivos de configuración
|
||||||
|
- Falla durante compresión
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué nivel de robustez necesitas?
|
||||||
|
|
||||||
|
### 11. Startup Behavior - Inicialización
|
||||||
|
**Pregunta**: Al iniciar la aplicación:
|
||||||
|
- ¿Debe hacer un escaneo completo inmediatamente?
|
||||||
|
- ¿Debe esperar al primer schedule programado?
|
||||||
|
- ¿Debe permitir configurar el comportamiento de startup?
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué comportamiento prefieres al iniciar?
|
||||||
|
|
||||||
|
### 12. Multi-threading - Concurrencia
|
||||||
|
**Pregunta**: ¿Los backups deben ejecutarse:
|
||||||
|
- Secuencialmente (uno por vez)
|
||||||
|
- En paralelo (múltiples simultáneos)
|
||||||
|
- Con límite configurable de concurrencia
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué balance entre velocidad y recursos del sistema?
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
Una vez que tengas respuestas a estas preguntas, podremos:
|
||||||
|
1. Finalizar el diseño técnico
|
||||||
|
2. Crear el `requirements.txt` definitivo
|
||||||
|
3. Comenzar con la implementación por fases
|
||||||
|
4. Definir el plan de testing
|
||||||
|
|
||||||
|
¿Podrías revisar estas preguntas y darme tus preferencias para cada una?
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"observation_directories": [
|
||||||
|
{
|
||||||
|
"path": "C:\\Projects\\Siemens",
|
||||||
|
"type": "siemens_s7",
|
||||||
|
"enabled": true,
|
||||||
|
"description": "Directorio principal de proyectos Siemens"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "D:\\Engineering\\Projects",
|
||||||
|
"type": "siemens_s7",
|
||||||
|
"enabled": true,
|
||||||
|
"description": "Proyectos de ingeniería adicionales"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "C:\\Important\\Documentation",
|
||||||
|
"type": "manual",
|
||||||
|
"enabled": true,
|
||||||
|
"description": "Documentación importante para backup manual"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"backup_destination": "D:\\Backups\\AutoBackups",
|
||||||
|
"global_settings": {
|
||||||
|
"default_schedule": "daily",
|
||||||
|
"default_schedule_time": "02:00",
|
||||||
|
"retry_delay_hours": 1,
|
||||||
|
"backup_timeout_minutes": 0,
|
||||||
|
"min_free_space_mb": 100,
|
||||||
|
"scan_interval_minutes": 60,
|
||||||
|
"min_backup_interval_minutes": 10
|
||||||
|
},
|
||||||
|
"everything_api": {
|
||||||
|
"dll_path": "Everything-SDK\\dll\\Everything64.dll",
|
||||||
|
"enabled": true,
|
||||||
|
"search_depth": -1,
|
||||||
|
"skip_last_level_for_s7p": true
|
||||||
|
},
|
||||||
|
"web_interface": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 5000,
|
||||||
|
"debug": false
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO",
|
||||||
|
"max_log_files": 30,
|
||||||
|
"log_rotation_days": 30
|
||||||
|
},
|
||||||
|
"backup_options": {
|
||||||
|
"compression_level": 6,
|
||||||
|
"include_subdirectories": true,
|
||||||
|
"preserve_directory_structure": true,
|
||||||
|
"hash_algorithm": "md5",
|
||||||
|
"hash_includes": ["timestamp", "size"],
|
||||||
|
"backup_type": "complete",
|
||||||
|
"process_priority": "low",
|
||||||
|
"sequential_execution": true,
|
||||||
|
"filename_format": "HH-MM-SS_projects.zip",
|
||||||
|
"date_format": "YYYY-MM-DD"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"version": "1.0",
|
||||||
|
"last_updated": "2025-09-01T08:30:15Z",
|
||||||
|
"total_projects": 0
|
||||||
|
},
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"id": "example_project_001",
|
||||||
|
"name": "PLC_MainLine_Example",
|
||||||
|
"path": "C:\\Projects\\Siemens\\LineA\\Project1",
|
||||||
|
"type": "siemens_s7",
|
||||||
|
"s7p_file": "C:\\Projects\\Siemens\\LineA\\Project1\\project.s7p",
|
||||||
|
"observation_directory": "C:\\Projects\\Siemens",
|
||||||
|
"relative_path": "LineA\\Project1",
|
||||||
|
"backup_path": "LineA\\Project1",
|
||||||
|
"schedule_config": {
|
||||||
|
"schedule": "daily",
|
||||||
|
"schedule_time": "02:00",
|
||||||
|
"enabled": true,
|
||||||
|
"next_scheduled_backup": "2025-09-02T02:00:00Z"
|
||||||
|
},
|
||||||
|
"backup_history": {
|
||||||
|
"last_backup_date": "2025-09-01T02:15:30Z",
|
||||||
|
"last_backup_file": "D:\\Backups\\AutoBackups\\LineA\\Project1\\2025-09-01\\02-15-30_projects.zip",
|
||||||
|
"backup_count": 5,
|
||||||
|
"last_successful_backup": "2025-09-01T02:15:30Z"
|
||||||
|
},
|
||||||
|
"hash_info": {
|
||||||
|
"last_s7p_hash": "abc123def456789",
|
||||||
|
"last_full_hash": "def789ghi012345",
|
||||||
|
"last_s7p_timestamp": "2025-08-31T14:30:00Z",
|
||||||
|
"last_s7p_size": 2048576,
|
||||||
|
"last_scan_timestamp": "2025-09-01T02:10:00Z",
|
||||||
|
"file_count": 1247,
|
||||||
|
"total_size_bytes": 125847296
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"current_status": "ready",
|
||||||
|
"last_error": null,
|
||||||
|
"retry_count": 0,
|
||||||
|
"next_retry": null,
|
||||||
|
"files_in_use": false,
|
||||||
|
"exclusivity_check_passed": true,
|
||||||
|
"last_status_update": "2025-09-01T02:15:35Z"
|
||||||
|
},
|
||||||
|
"discovery_info": {
|
||||||
|
"discovered_date": "2025-09-01T08:30:15Z",
|
||||||
|
"discovery_method": "everything_api",
|
||||||
|
"auto_discovered": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"statistics": {
|
||||||
|
"total_backups_created": 15,
|
||||||
|
"total_backup_size_mb": 2450.5,
|
||||||
|
"average_backup_time_seconds": 45.2,
|
||||||
|
"last_global_scan": "2025-09-01T08:30:15Z",
|
||||||
|
"projects_with_errors": 0,
|
||||||
|
"projects_pending_retry": 0
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Core Framework
|
||||||
|
Flask==2.3.3
|
||||||
|
Werkzeug==2.3.7
|
||||||
|
|
||||||
|
# Scheduling
|
||||||
|
APScheduler==3.10.4
|
||||||
|
|
||||||
|
# Everything API Integration
|
||||||
|
PyEverything==1.0.1 # Wrapper para Everything SDK
|
||||||
|
|
||||||
|
# File Operations and Utilities
|
||||||
|
pathlib2==2.3.7.post1 # Enhanced pathlib for Python 3.12
|
||||||
|
psutil==5.9.5 # System utilities (disk space monitoring)
|
||||||
|
filelock==3.12.4 # File locking for concurrent access
|
||||||
|
|
||||||
|
# Web Interface
|
||||||
|
Jinja2==3.1.2 # Template engine for Flask
|
||||||
|
|
||||||
|
# Configuration and Data
|
||||||
|
jsonschema==4.19.1 # JSON schema validation
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
colorlog==6.7.0 # Colored logging output
|
||||||
|
|
||||||
|
# Windows-specific dependencies
|
||||||
|
pywin32==306; sys_platform == "win32" # Windows API access for low priority
|
||||||
|
|
||||||
|
# Development and Testing (opcional, remove for production)
|
||||||
|
pytest==7.4.2
|
||||||
|
pytest-flask==1.2.0
|
||||||
|
black==23.9.1 # Code formatting
|
||||||
|
flake8==6.1.0 # Code linting
|
|
@ -0,0 +1,216 @@
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[codz]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py.cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
#poetry.toml
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||||
|
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||||
|
#pdm.lock
|
||||||
|
#pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# pixi
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||||
|
#pixi.lock
|
||||||
|
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||||
|
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||||
|
.pixi
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
*.rdb
|
||||||
|
*.aof
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# RabbitMQ
|
||||||
|
mnesia/
|
||||||
|
rabbitmq/
|
||||||
|
rabbitmq-data/
|
||||||
|
|
||||||
|
# ActiveMQ
|
||||||
|
activemq-data/
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.envrc
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Abstra
|
||||||
|
# Abstra is an AI-powered process automation framework.
|
||||||
|
# Ignore directories containing user credentials, local state, and settings.
|
||||||
|
# Learn more at https://abstra.io/docs
|
||||||
|
.abstra/
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||||
|
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||||
|
# you could uncomment the following to ignore the entire vscode folder
|
||||||
|
# .vscode/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# Marimo
|
||||||
|
marimo/_static/
|
||||||
|
marimo/_lsp/
|
||||||
|
__marimo__/
|
||||||
|
|
||||||
|
# Streamlit
|
||||||
|
.streamlit/secrets.toml
|
|
@ -0,0 +1,72 @@
|
||||||
|
# AutoBackups Project
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
AutoBackups is a Python application designed to automate the backup of Simatic S7 projects and other user-defined directories. The application leverages the Everything API to efficiently locate files and manage backups based on user-defined configurations.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Monitors specified directories for Simatic S7 files (*.s7p) and other user-defined directories.
|
||||||
|
- Automatically compresses and backs up projects on a configurable schedule (daily, hourly, 3-hour, 7-hour intervals, startup, or manual).
|
||||||
|
- Supports manual backup triggers.
|
||||||
|
- Skips projects with files in use and retries after one hour.
|
||||||
|
- Maintains a two-stage hash system: first checking *.s7p files, then all files to avoid unnecessary backups.
|
||||||
|
- User-friendly web interface built with Flask for configuration and status monitoring.
|
||||||
|
- Comprehensive logging system with timestamped log files.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
autobackups
|
||||||
|
├── .logs/ # Application logs with startup timestamps
|
||||||
|
├── src
|
||||||
|
│ ├── app.py # Main entry point of the Flask application
|
||||||
|
│ ├── models
|
||||||
|
│ │ └── __init__.py # Data models for configuration and directories
|
||||||
|
│ ├── routes
|
||||||
|
│ │ └── __init__.py # API routes for backup operations
|
||||||
|
│ ├── services
|
||||||
|
│ │ └── __init__.py # Business logic for backups
|
||||||
|
│ └── utils
|
||||||
|
│ └── __init__.py # Utility functions for hashing and file access
|
||||||
|
├── static
|
||||||
|
│ ├── css # CSS files for styling
|
||||||
|
│ └── js # JavaScript files for client-side functionality
|
||||||
|
├── templates
|
||||||
|
│ └── index.html # Main HTML template for the web interface
|
||||||
|
├── config.json # Global configuration settings for the application
|
||||||
|
├── projects.json # Per-project configuration, schedules, and hash storage
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
└── README.md # Project documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
1. Clone the repository:
|
||||||
|
```
|
||||||
|
git clone <repository-url>
|
||||||
|
cd autobackups
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create a Miniconda environment:
|
||||||
|
```
|
||||||
|
conda create --name autobackups python=3.12
|
||||||
|
conda activate autobackups
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install the required packages:
|
||||||
|
```
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Configure the application by editing `config.json` to specify directories to monitor and backup settings.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
- Start the Flask application:
|
||||||
|
```
|
||||||
|
python src/app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- Access the web interface at `http://localhost:5000` to configure settings and monitor backup statuses.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
Contributions are welcome! Please submit a pull request or open an issue for any enhancements or bug fixes.
|
||||||
|
|
||||||
|
## License
|
||||||
|
This project is licensed under the MIT License. See the LICENSE file for details.
|
|
@ -0,0 +1,124 @@
|
||||||
|
# AutoBackups - Preguntas Técnicas Pendientes
|
||||||
|
|
||||||
|
## Preguntas Críticas para el Desarrollo
|
||||||
|
|
||||||
|
### 1. Everything API - Integración
|
||||||
|
**Pregunta**: ¿Everything está instalado y funcionando en el sistema objetivo?
|
||||||
|
**Impacto**: Si no está disponible, necesitaremos un método alternativo de búsqueda de archivos.
|
||||||
|
**Opciones**:
|
||||||
|
- Usar `python-everything` si Everything está disponible
|
||||||
|
- Implementar búsqueda recursiva con `os.walk()` como fallback
|
||||||
|
- Usar `pathlib` con `glob` patterns
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Podemos asumir que Everything estará disponible o necesitamos fallback?
|
||||||
|
|
||||||
|
### 2. Estructura de Backup - Clarificación de Rutas
|
||||||
|
**Pregunta**: Si tenemos un archivo en `C:\Projects\Siemens\LineA\Project1\project.s7p`, ¿el backup debe ser?
|
||||||
|
**Opción A**:
|
||||||
|
```
|
||||||
|
backup_destination/2025-09-01_14-30-15/Projects_Siemens_LineA_Project1.zip
|
||||||
|
└── Projects/Siemens/LineA/Project1/...
|
||||||
|
```
|
||||||
|
**Opción B**:
|
||||||
|
```
|
||||||
|
backup_destination/2025-09-01_14-30-15/LineA_Project1.zip
|
||||||
|
└── LineA/Project1/...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué estructura prefieres?
|
||||||
|
|
||||||
|
### 3. Configuración de Schedules - Granularidad
|
||||||
|
**Pregunta**: ¿Los schedules deben ser configurables a nivel de:
|
||||||
|
- Por directorio de observación (todos los proyectos de un directorio)
|
||||||
|
- Por proyecto individual
|
||||||
|
- Ambos (global con override por proyecto)
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué nivel de granularidad necesitas?
|
||||||
|
|
||||||
|
### 4. Hash de Archivos - Algoritmo y Almacenamiento
|
||||||
|
**Pregunta**: Para el sistema de hash en dos etapas:
|
||||||
|
- ¿Usamos MD5 (rápido) o SHA256 (más seguro) para los hashes?
|
||||||
|
- ¿El hash incluye solo timestamp o también tamaño de archivo?
|
||||||
|
- ¿Cómo manejamos archivos que se mueven dentro del proyecto?
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué balance entre velocidad y precisión prefieres?
|
||||||
|
|
||||||
|
### 5. Detección de Archivos en Uso - Método
|
||||||
|
**Pregunta**: Para detectar archivos bloqueados, ¿prefieres:
|
||||||
|
**Opción A**: Intentar abrir cada archivo en modo escritura exclusiva
|
||||||
|
**Opción B**: Usar `lsof` (Linux) / `handle.exe` (Windows) para listar archivos abiertos
|
||||||
|
**Opción C**: Solo verificar el archivo .s7p principal
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué método consideras más apropiado?
|
||||||
|
|
||||||
|
### 6. Manejo de Proyectos Grandes - Performance
|
||||||
|
**Pregunta**: ¿Hay límites que debamos considerar?
|
||||||
|
- Tamaño máximo de proyecto para backup
|
||||||
|
- Tiempo máximo de backup
|
||||||
|
- Número máximo de archivos por proyecto
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Necesitamos algún tipo de throttling o límites?
|
||||||
|
|
||||||
|
### 7. Configuración de Directorios - Recursividad
|
||||||
|
**Pregunta**: Cuando especificamos un directorio de observación como `C:\Projects`, ¿debemos:
|
||||||
|
- Buscar solo en subdirectorios inmediatos
|
||||||
|
- Buscar recursivamente en toda la jerarquía
|
||||||
|
- Permitir configurar la profundidad de búsqueda
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué comportamiento prefieres por defecto?
|
||||||
|
|
||||||
|
### 8. Backup Incremental vs Completo
|
||||||
|
**Pregunta**: ¿Todos los backups deben ser completos o consideras backup incremental?
|
||||||
|
- Backup completo: Todo el proyecto cada vez
|
||||||
|
- Backup incremental: Solo archivos modificados
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Preferencia por simplicidad o eficiencia de espacio?
|
||||||
|
|
||||||
|
### 9. Web Interface - Características Específicas
|
||||||
|
**Pregunta**: ¿Qué funcionalidades son prioritarias en la interfaz web?
|
||||||
|
**Must-have**:
|
||||||
|
- Lista de proyectos con estado
|
||||||
|
- Trigger manual de backup
|
||||||
|
- Ver logs básicos
|
||||||
|
|
||||||
|
**Nice-to-have**:
|
||||||
|
- Configurar schedules por proyecto
|
||||||
|
- Ver progreso de backup en tiempo real
|
||||||
|
- Estadísticas históricas
|
||||||
|
- Configurar nuevos directorios
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Cuáles son must-have vs nice-to-have?
|
||||||
|
|
||||||
|
### 10. Error Handling - Estrategias de Recuperación
|
||||||
|
**Pregunta**: ¿Cómo manejar errores específicos?
|
||||||
|
- Espacio insuficiente en destino de backup
|
||||||
|
- Pérdida de conexión con Everything
|
||||||
|
- Corrupción de archivos de configuración
|
||||||
|
- Falla durante compresión
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué nivel de robustez necesitas?
|
||||||
|
|
||||||
|
### 11. Startup Behavior - Inicialización
|
||||||
|
**Pregunta**: Al iniciar la aplicación:
|
||||||
|
- ¿Debe hacer un escaneo completo inmediatamente?
|
||||||
|
- ¿Debe esperar al primer schedule programado?
|
||||||
|
- ¿Debe permitir configurar el comportamiento de startup?
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué comportamiento prefieres al iniciar?
|
||||||
|
|
||||||
|
### 12. Multi-threading - Concurrencia
|
||||||
|
**Pregunta**: ¿Los backups deben ejecutarse:
|
||||||
|
- Secuencialmente (uno por vez)
|
||||||
|
- En paralelo (múltiples simultáneos)
|
||||||
|
- Con límite configurable de concurrencia
|
||||||
|
|
||||||
|
**Decisión necesaria**: ¿Qué balance entre velocidad y recursos del sistema?
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
Una vez que tengas respuestas a estas preguntas, podremos:
|
||||||
|
1. Finalizar el diseño técnico
|
||||||
|
2. Crear el `requirements.txt` definitivo
|
||||||
|
3. Comenzar con la implementación por fases
|
||||||
|
4. Definir el plan de testing
|
||||||
|
|
||||||
|
¿Podrías revisar estas preguntas y darme tus preferencias para cada una?
|
|
@ -0,0 +1,275 @@
|
||||||
|
"""
|
||||||
|
AutoBackups - Aplicación Principal
|
||||||
|
Sistema automatizado de backup para proyectos Simatic S7
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Agregar el directorio src al path para imports
|
||||||
|
current_dir = Path(__file__).parent
|
||||||
|
src_dir = current_dir
|
||||||
|
sys.path.insert(0, str(src_dir))
|
||||||
|
|
||||||
|
# Imports de módulos propios
|
||||||
|
from models.config_model import Config
|
||||||
|
from models.project_model import ProjectManager
|
||||||
|
from utils.file_utils import DiskSpaceChecker
|
||||||
|
from services.basic_backup_service import BasicBackupService
|
||||||
|
|
||||||
|
|
||||||
|
class AutoBackupsApp:
|
||||||
|
"""Aplicación principal de AutoBackups"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.config = None
|
||||||
|
self.project_manager = None
|
||||||
|
self.discovery_service = None
|
||||||
|
self.disk_checker = None
|
||||||
|
self.logger = None
|
||||||
|
|
||||||
|
# Inicializar aplicación
|
||||||
|
self._setup_logging()
|
||||||
|
self._load_configuration()
|
||||||
|
self._initialize_services()
|
||||||
|
|
||||||
|
def _setup_logging(self):
|
||||||
|
"""Configurar sistema de logging"""
|
||||||
|
try:
|
||||||
|
# Crear directorio de logs si no existe
|
||||||
|
logs_dir = Path(__file__).parent / ".logs"
|
||||||
|
logs_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Nombre del archivo de log con timestamp
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
|
log_filename = logs_dir / f"autobackups_{timestamp}.log"
|
||||||
|
|
||||||
|
# Configurar logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(log_filename, encoding='utf-8'),
|
||||||
|
logging.StreamHandler(sys.stdout)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.logger.info(f"AutoBackups iniciado - Log: {log_filename}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error configurando logging: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def _load_configuration(self):
|
||||||
|
"""Cargar configuración del sistema"""
|
||||||
|
try:
|
||||||
|
self.logger.info("Cargando configuración...")
|
||||||
|
self.config = Config()
|
||||||
|
self.logger.info("Configuración cargada exitosamente")
|
||||||
|
|
||||||
|
# Mostrar información básica de configuración
|
||||||
|
self.logger.info(f"Directorios de observación: {len(self.config.observation_directories)}")
|
||||||
|
self.logger.info(f"Destino de backups: {self.config.backup_destination}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error cargando configuración: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def _initialize_services(self):
|
||||||
|
"""Inicializar servicios principales"""
|
||||||
|
try:
|
||||||
|
self.logger.info("Inicializando servicios...")
|
||||||
|
|
||||||
|
# Project Manager
|
||||||
|
self.project_manager = ProjectManager()
|
||||||
|
self.logger.info("Project Manager inicializado")
|
||||||
|
|
||||||
|
# Disk Space Checker
|
||||||
|
self.disk_checker = DiskSpaceChecker()
|
||||||
|
self.logger.info("Disk Space Checker inicializado")
|
||||||
|
|
||||||
|
# Basic Backup Service (Fase 1)
|
||||||
|
self.backup_service = BasicBackupService(self.config)
|
||||||
|
self.logger.info("Basic Backup Service inicializado")
|
||||||
|
|
||||||
|
# Project Discovery Service (temporalmente comentado)
|
||||||
|
# self.discovery_service = ProjectDiscoveryService(self.config, self.project_manager)
|
||||||
|
# self.logger.info("Project Discovery Service inicializado")
|
||||||
|
|
||||||
|
self.logger.info("Todos los servicios inicializados correctamente")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error inicializando servicios: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def check_system_requirements(self) -> bool:
|
||||||
|
"""Verificar requerimientos del sistema"""
|
||||||
|
try:
|
||||||
|
self.logger.info("Verificando requerimientos del sistema...")
|
||||||
|
|
||||||
|
# Verificar espacio en disco
|
||||||
|
backup_destination = self.config.backup_destination
|
||||||
|
min_space_mb = self.config.get_min_free_space_mb()
|
||||||
|
|
||||||
|
free_space_mb = self.disk_checker.get_free_space_mb(backup_destination)
|
||||||
|
|
||||||
|
if free_space_mb < min_space_mb:
|
||||||
|
self.logger.error(
|
||||||
|
f"Espacio insuficiente en destino de backup. "
|
||||||
|
f"Disponible: {free_space_mb:.1f}MB, "
|
||||||
|
f"Requerido: {min_space_mb}MB"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.logger.info(f"Espacio en disco OK: {free_space_mb:.1f}MB disponibles")
|
||||||
|
|
||||||
|
# Verificar Everything API (opcional en Fase 1)
|
||||||
|
if hasattr(self, 'backup_service'):
|
||||||
|
if self.backup_service.check_system_requirements():
|
||||||
|
self.logger.info("Requerimientos del sistema verificados")
|
||||||
|
else:
|
||||||
|
self.logger.warning("Algunos requerimientos fallaron")
|
||||||
|
else:
|
||||||
|
self.logger.warning("Backup service no disponible")
|
||||||
|
|
||||||
|
# Verificar directorios de observación
|
||||||
|
missing_dirs = []
|
||||||
|
for obs_dir in self.config.observation_directories:
|
||||||
|
if not Path(obs_dir["path"]).exists():
|
||||||
|
missing_dirs.append(obs_dir["path"])
|
||||||
|
|
||||||
|
if missing_dirs:
|
||||||
|
self.logger.warning(f"Directorios de observación no encontrados: {missing_dirs}")
|
||||||
|
|
||||||
|
self.logger.info("Verificación de requerimientos completada")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error verificando requerimientos del sistema: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def discover_projects(self) -> int:
|
||||||
|
"""Descubrir proyectos en directorios de observación"""
|
||||||
|
try:
|
||||||
|
self.logger.info("Iniciando descubrimiento de proyectos...")
|
||||||
|
|
||||||
|
# Usar el servicio básico de backup
|
||||||
|
if hasattr(self, 'backup_service'):
|
||||||
|
projects = self.backup_service.discover_projects_basic()
|
||||||
|
else:
|
||||||
|
projects = []
|
||||||
|
|
||||||
|
# Agregar proyectos al manager
|
||||||
|
for project_info in projects:
|
||||||
|
self.project_manager.add_or_update_project(project_info)
|
||||||
|
|
||||||
|
msg = f"Descubrimiento completado: {len(projects)} proyectos"
|
||||||
|
self.logger.info(msg)
|
||||||
|
|
||||||
|
return len(projects)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error en descubrimiento de proyectos: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def show_system_status(self):
|
||||||
|
"""Mostrar estado actual del sistema"""
|
||||||
|
try:
|
||||||
|
self.logger.info("=== ESTADO DEL SISTEMA ===")
|
||||||
|
|
||||||
|
# Información de configuración
|
||||||
|
self.logger.info(f"Directorios de observación configurados: {len(self.config.observation_directories)}")
|
||||||
|
self.logger.info(f"Destino de backups: {self.config.backup_destination}")
|
||||||
|
|
||||||
|
# Información de proyectos
|
||||||
|
all_projects = self.project_manager.get_all_projects()
|
||||||
|
enabled_projects = self.project_manager.get_enabled_projects()
|
||||||
|
|
||||||
|
self.logger.info(f"Total de proyectos: {len(all_projects)}")
|
||||||
|
self.logger.info(f"Proyectos habilitados: {len(enabled_projects)}")
|
||||||
|
|
||||||
|
# Información de espacio en disco
|
||||||
|
backup_dest = self.config.backup_destination
|
||||||
|
total_mb, used_mb, free_mb = self.disk_checker.get_disk_usage_info(backup_dest)
|
||||||
|
|
||||||
|
self.logger.info(f"Espacio en disco (destino backup):")
|
||||||
|
self.logger.info(f" Total: {total_mb:.1f}MB")
|
||||||
|
self.logger.info(f" Usado: {used_mb:.1f}MB")
|
||||||
|
self.logger.info(f" Libre: {free_mb:.1f}MB")
|
||||||
|
|
||||||
|
# Información de Everything API
|
||||||
|
if self.discovery_service.everything_searcher:
|
||||||
|
self.logger.info("Everything API: Disponible")
|
||||||
|
else:
|
||||||
|
self.logger.info("Everything API: No disponible")
|
||||||
|
|
||||||
|
self.logger.info("=== FIN ESTADO DEL SISTEMA ===")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error mostrando estado del sistema: {e}")
|
||||||
|
|
||||||
|
def run_initial_setup(self):
|
||||||
|
"""Ejecutar configuración inicial de la aplicación"""
|
||||||
|
try:
|
||||||
|
self.logger.info("=== CONFIGURACIÓN INICIAL DE AUTOBACKUPS ===")
|
||||||
|
|
||||||
|
# Verificar requerimientos del sistema
|
||||||
|
if not self.check_system_requirements():
|
||||||
|
self.logger.error("Los requerimientos del sistema no se cumplen")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Descubrir proyectos
|
||||||
|
projects_found = self.discover_projects()
|
||||||
|
|
||||||
|
if projects_found == 0:
|
||||||
|
self.logger.warning("No se encontraron proyectos para backup")
|
||||||
|
|
||||||
|
# Mostrar estado del sistema
|
||||||
|
self.show_system_status()
|
||||||
|
|
||||||
|
self.logger.info("=== CONFIGURACIÓN INICIAL COMPLETADA ===")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error en configuración inicial: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Función principal"""
|
||||||
|
try:
|
||||||
|
print("AutoBackups - Sistema de Backup Automatizado")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Crear y configurar aplicación
|
||||||
|
app = AutoBackupsApp()
|
||||||
|
|
||||||
|
# Ejecutar configuración inicial
|
||||||
|
if app.run_initial_setup():
|
||||||
|
app.logger.info("AutoBackups configurado correctamente")
|
||||||
|
|
||||||
|
# TODO: En las siguientes fases:
|
||||||
|
# - Iniciar scheduler de backups
|
||||||
|
# - Iniciar interfaz web Flask
|
||||||
|
# - Configurar tareas en background
|
||||||
|
|
||||||
|
print("\nConfiguración inicial completada.")
|
||||||
|
print("Revisa el archivo de log para más detalles.")
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Error en la configuración inicial. Revisa los logs.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nAplicación interrumpida por el usuario")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error inesperado: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""
|
||||||
|
AutoBackups - Data Models
|
||||||
|
Modelos de datos para configuración y manejo de proyectos
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .config_model import Config
|
||||||
|
from .project_model import Project, ProjectStatus
|
||||||
|
|
||||||
|
__all__ = ['Config', 'Project', 'ProjectStatus']
|
|
@ -0,0 +1,167 @@
|
||||||
|
"""
|
||||||
|
Configuration Model
|
||||||
|
Manejo de la configuración global del sistema
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Clase para manejar la configuración global del sistema"""
|
||||||
|
|
||||||
|
def __init__(self, config_path: str = None):
|
||||||
|
if config_path is None:
|
||||||
|
# Buscar config.json en el directorio del proyecto
|
||||||
|
current_dir = Path(__file__).parent.parent.parent
|
||||||
|
config_path = current_dir / "config.json"
|
||||||
|
|
||||||
|
self.config_path = Path(config_path)
|
||||||
|
self._config = {}
|
||||||
|
self.load_config()
|
||||||
|
|
||||||
|
def load_config(self) -> None:
|
||||||
|
"""Cargar configuración desde archivo JSON"""
|
||||||
|
try:
|
||||||
|
if self.config_path.exists():
|
||||||
|
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||||
|
self._config = json.load(f)
|
||||||
|
else:
|
||||||
|
# Crear configuración por defecto si no existe
|
||||||
|
self._create_default_config()
|
||||||
|
self.save_config()
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error cargando configuración: {e}")
|
||||||
|
|
||||||
|
def save_config(self) -> None:
|
||||||
|
"""Guardar configuración actual al archivo JSON"""
|
||||||
|
try:
|
||||||
|
with open(self.config_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(self._config, f, indent=2, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error guardando configuración: {e}")
|
||||||
|
|
||||||
|
def _create_default_config(self) -> None:
|
||||||
|
"""Crear configuración por defecto"""
|
||||||
|
self._config = {
|
||||||
|
"observation_directories": [],
|
||||||
|
"backup_destination": "D:\\Backups\\AutoBackups",
|
||||||
|
"global_settings": {
|
||||||
|
"default_schedule": "daily",
|
||||||
|
"default_schedule_time": "02:00",
|
||||||
|
"retry_delay_hours": 1,
|
||||||
|
"backup_timeout_minutes": 0,
|
||||||
|
"min_free_space_mb": 100,
|
||||||
|
"scan_interval_minutes": 60,
|
||||||
|
"min_backup_interval_minutes": 10
|
||||||
|
},
|
||||||
|
"everything_api": {
|
||||||
|
"dll_path": "Everything-SDK\\dll\\Everything64.dll",
|
||||||
|
"enabled": True,
|
||||||
|
"search_depth": -1,
|
||||||
|
"skip_last_level_for_s7p": True
|
||||||
|
},
|
||||||
|
"web_interface": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 5000,
|
||||||
|
"debug": False
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO",
|
||||||
|
"max_log_files": 30,
|
||||||
|
"log_rotation_days": 30
|
||||||
|
},
|
||||||
|
"backup_options": {
|
||||||
|
"compression_level": 6,
|
||||||
|
"include_subdirectories": True,
|
||||||
|
"preserve_directory_structure": True,
|
||||||
|
"hash_algorithm": "md5",
|
||||||
|
"hash_includes": ["timestamp", "size"],
|
||||||
|
"backup_type": "complete",
|
||||||
|
"process_priority": "low",
|
||||||
|
"sequential_execution": True,
|
||||||
|
"filename_format": "HH-MM-SS_projects.zip",
|
||||||
|
"date_format": "YYYY-MM-DD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Getters para las secciones principales
|
||||||
|
@property
|
||||||
|
def observation_directories(self) -> List[Dict[str, Any]]:
|
||||||
|
return self._config.get("observation_directories", [])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backup_destination(self) -> str:
|
||||||
|
return self._config.get("backup_destination", "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def global_settings(self) -> Dict[str, Any]:
|
||||||
|
return self._config.get("global_settings", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def everything_api(self) -> Dict[str, Any]:
|
||||||
|
return self._config.get("everything_api", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def web_interface(self) -> Dict[str, Any]:
|
||||||
|
return self._config.get("web_interface", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logging_config(self) -> Dict[str, Any]:
|
||||||
|
return self._config.get("logging", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backup_options(self) -> Dict[str, Any]:
|
||||||
|
return self._config.get("backup_options", {})
|
||||||
|
|
||||||
|
# Métodos de utilidad
|
||||||
|
def get_dll_path(self) -> str:
|
||||||
|
"""Obtener la ruta completa de la DLL de Everything"""
|
||||||
|
dll_relative_path = self.everything_api.get("dll_path", "")
|
||||||
|
if dll_relative_path:
|
||||||
|
# Convertir ruta relativa a absoluta
|
||||||
|
current_dir = Path(__file__).parent.parent.parent.parent
|
||||||
|
return str(current_dir / dll_relative_path)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_min_free_space_mb(self) -> int:
|
||||||
|
"""Obtener el espacio mínimo libre requerido en MB"""
|
||||||
|
return self.global_settings.get("min_free_space_mb", 100)
|
||||||
|
|
||||||
|
def get_scan_interval_minutes(self) -> int:
|
||||||
|
"""Obtener el intervalo de escaneo en minutos"""
|
||||||
|
return self.global_settings.get("scan_interval_minutes", 60)
|
||||||
|
|
||||||
|
def get_min_backup_interval_minutes(self) -> int:
|
||||||
|
"""Obtener el intervalo mínimo de backup en minutos"""
|
||||||
|
return self.global_settings.get("min_backup_interval_minutes", 10)
|
||||||
|
|
||||||
|
def add_observation_directory(self, path: str, dir_type: str, description: str = "") -> None:
|
||||||
|
"""Agregar un nuevo directorio de observación"""
|
||||||
|
new_dir = {
|
||||||
|
"path": path,
|
||||||
|
"type": dir_type,
|
||||||
|
"enabled": True,
|
||||||
|
"description": description
|
||||||
|
}
|
||||||
|
if "observation_directories" not in self._config:
|
||||||
|
self._config["observation_directories"] = []
|
||||||
|
self._config["observation_directories"].append(new_dir)
|
||||||
|
self.save_config()
|
||||||
|
|
||||||
|
def update_config_value(self, key_path: str, value: Any) -> None:
|
||||||
|
"""Actualizar un valor específico en la configuración usando dot notation"""
|
||||||
|
keys = key_path.split('.')
|
||||||
|
current = self._config
|
||||||
|
|
||||||
|
# Navegar hasta el penúltimo nivel
|
||||||
|
for key in keys[:-1]:
|
||||||
|
if key not in current:
|
||||||
|
current[key] = {}
|
||||||
|
current = current[key]
|
||||||
|
|
||||||
|
# Establecer el valor final
|
||||||
|
current[keys[-1]] = value
|
||||||
|
self.save_config()
|
|
@ -0,0 +1,265 @@
|
||||||
|
"""
|
||||||
|
Project Model
|
||||||
|
Modelo de datos para manejo de proyectos y su estado
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectStatus(Enum):
|
||||||
|
"""Estados posibles de un proyecto"""
|
||||||
|
READY = "ready"
|
||||||
|
BACKING_UP = "backing_up"
|
||||||
|
ERROR = "error"
|
||||||
|
FILES_IN_USE = "files_in_use"
|
||||||
|
RETRY_PENDING = "retry_pending"
|
||||||
|
DISABLED = "disabled"
|
||||||
|
|
||||||
|
|
||||||
|
class Project:
|
||||||
|
"""Clase para representar un proyecto individual"""
|
||||||
|
|
||||||
|
def __init__(self, project_data: Dict[str, Any]):
|
||||||
|
self.id = project_data.get("id", "")
|
||||||
|
self.name = project_data.get("name", "")
|
||||||
|
self.path = project_data.get("path", "")
|
||||||
|
self.type = project_data.get("type", "")
|
||||||
|
self.s7p_file = project_data.get("s7p_file", "")
|
||||||
|
self.observation_directory = project_data.get("observation_directory", "")
|
||||||
|
self.relative_path = project_data.get("relative_path", "")
|
||||||
|
self.backup_path = project_data.get("backup_path", "")
|
||||||
|
|
||||||
|
# Configuración de schedule
|
||||||
|
schedule_config = project_data.get("schedule_config", {})
|
||||||
|
self.schedule = schedule_config.get("schedule", "daily")
|
||||||
|
self.schedule_time = schedule_config.get("schedule_time", "02:00")
|
||||||
|
self.enabled = schedule_config.get("enabled", True)
|
||||||
|
self.next_scheduled_backup = schedule_config.get("next_scheduled_backup", "")
|
||||||
|
|
||||||
|
# Historia de backups
|
||||||
|
backup_history = project_data.get("backup_history", {})
|
||||||
|
self.last_backup_date = backup_history.get("last_backup_date", "")
|
||||||
|
self.last_backup_file = backup_history.get("last_backup_file", "")
|
||||||
|
self.backup_count = backup_history.get("backup_count", 0)
|
||||||
|
self.last_successful_backup = backup_history.get("last_successful_backup", "")
|
||||||
|
|
||||||
|
# Información de hash
|
||||||
|
hash_info = project_data.get("hash_info", {})
|
||||||
|
self.last_s7p_hash = hash_info.get("last_s7p_hash", "")
|
||||||
|
self.last_full_hash = hash_info.get("last_full_hash", "")
|
||||||
|
self.last_s7p_timestamp = hash_info.get("last_s7p_timestamp", "")
|
||||||
|
self.last_s7p_size = hash_info.get("last_s7p_size", 0)
|
||||||
|
self.last_scan_timestamp = hash_info.get("last_scan_timestamp", "")
|
||||||
|
self.file_count = hash_info.get("file_count", 0)
|
||||||
|
self.total_size_bytes = hash_info.get("total_size_bytes", 0)
|
||||||
|
|
||||||
|
# Estado actual
|
||||||
|
status = project_data.get("status", {})
|
||||||
|
self.current_status = ProjectStatus(status.get("current_status", "ready"))
|
||||||
|
self.last_error = status.get("last_error", None)
|
||||||
|
self.retry_count = status.get("retry_count", 0)
|
||||||
|
self.next_retry = status.get("next_retry", None)
|
||||||
|
self.files_in_use = status.get("files_in_use", False)
|
||||||
|
self.exclusivity_check_passed = status.get("exclusivity_check_passed", True)
|
||||||
|
self.last_status_update = status.get("last_status_update", "")
|
||||||
|
|
||||||
|
# Información de descubrimiento
|
||||||
|
discovery_info = project_data.get("discovery_info", {})
|
||||||
|
self.discovered_date = discovery_info.get("discovered_date", "")
|
||||||
|
self.discovery_method = discovery_info.get("discovery_method", "")
|
||||||
|
self.auto_discovered = discovery_info.get("auto_discovered", True)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convertir el proyecto a diccionario para serialización JSON"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"path": self.path,
|
||||||
|
"type": self.type,
|
||||||
|
"s7p_file": self.s7p_file,
|
||||||
|
"observation_directory": self.observation_directory,
|
||||||
|
"relative_path": self.relative_path,
|
||||||
|
"backup_path": self.backup_path,
|
||||||
|
"schedule_config": {
|
||||||
|
"schedule": self.schedule,
|
||||||
|
"schedule_time": self.schedule_time,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"next_scheduled_backup": self.next_scheduled_backup
|
||||||
|
},
|
||||||
|
"backup_history": {
|
||||||
|
"last_backup_date": self.last_backup_date,
|
||||||
|
"last_backup_file": self.last_backup_file,
|
||||||
|
"backup_count": self.backup_count,
|
||||||
|
"last_successful_backup": self.last_successful_backup
|
||||||
|
},
|
||||||
|
"hash_info": {
|
||||||
|
"last_s7p_hash": self.last_s7p_hash,
|
||||||
|
"last_full_hash": self.last_full_hash,
|
||||||
|
"last_s7p_timestamp": self.last_s7p_timestamp,
|
||||||
|
"last_s7p_size": self.last_s7p_size,
|
||||||
|
"last_scan_timestamp": self.last_scan_timestamp,
|
||||||
|
"file_count": self.file_count,
|
||||||
|
"total_size_bytes": self.total_size_bytes
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"current_status": self.current_status.value,
|
||||||
|
"last_error": self.last_error,
|
||||||
|
"retry_count": self.retry_count,
|
||||||
|
"next_retry": self.next_retry,
|
||||||
|
"files_in_use": self.files_in_use,
|
||||||
|
"exclusivity_check_passed": self.exclusivity_check_passed,
|
||||||
|
"last_status_update": self.last_status_update
|
||||||
|
},
|
||||||
|
"discovery_info": {
|
||||||
|
"discovered_date": self.discovered_date,
|
||||||
|
"discovery_method": self.discovery_method,
|
||||||
|
"auto_discovered": self.auto_discovered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_status(self, status: ProjectStatus, error_message: str = None) -> None:
|
||||||
|
"""Actualizar el estado del proyecto"""
|
||||||
|
self.current_status = status
|
||||||
|
self.last_error = error_message
|
||||||
|
self.last_status_update = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
if status == ProjectStatus.ERROR:
|
||||||
|
self.retry_count += 1
|
||||||
|
elif status == ProjectStatus.READY:
|
||||||
|
self.retry_count = 0
|
||||||
|
self.last_error = None
|
||||||
|
|
||||||
|
def update_hash_info(self, s7p_hash: str = None, full_hash: str = None,
|
||||||
|
s7p_timestamp: str = None, s7p_size: int = None,
|
||||||
|
file_count: int = None, total_size: int = None) -> None:
|
||||||
|
"""Actualizar información de hash"""
|
||||||
|
if s7p_hash is not None:
|
||||||
|
self.last_s7p_hash = s7p_hash
|
||||||
|
if full_hash is not None:
|
||||||
|
self.last_full_hash = full_hash
|
||||||
|
if s7p_timestamp is not None:
|
||||||
|
self.last_s7p_timestamp = s7p_timestamp
|
||||||
|
if s7p_size is not None:
|
||||||
|
self.last_s7p_size = s7p_size
|
||||||
|
if file_count is not None:
|
||||||
|
self.file_count = file_count
|
||||||
|
if total_size is not None:
|
||||||
|
self.total_size_bytes = total_size
|
||||||
|
|
||||||
|
self.last_scan_timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
def update_backup_info(self, backup_file_path: str) -> None:
|
||||||
|
"""Actualizar información después de un backup exitoso"""
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
self.last_backup_date = now
|
||||||
|
self.last_successful_backup = now
|
||||||
|
self.last_backup_file = backup_file_path
|
||||||
|
self.backup_count += 1
|
||||||
|
self.update_status(ProjectStatus.READY)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectManager:
|
||||||
|
"""Clase para manejar la colección de proyectos"""
|
||||||
|
|
||||||
|
def __init__(self, projects_file_path: str = None):
|
||||||
|
if projects_file_path is None:
|
||||||
|
# Buscar projects.json en el directorio del proyecto
|
||||||
|
current_dir = Path(__file__).parent.parent.parent
|
||||||
|
projects_file_path = current_dir / "projects.json"
|
||||||
|
|
||||||
|
self.projects_file_path = Path(projects_file_path)
|
||||||
|
self.projects: Dict[str, Project] = {}
|
||||||
|
self.metadata = {}
|
||||||
|
self.statistics = {}
|
||||||
|
self.load_projects()
|
||||||
|
|
||||||
|
def load_projects(self) -> None:
|
||||||
|
"""Cargar proyectos desde archivo JSON"""
|
||||||
|
try:
|
||||||
|
if self.projects_file_path.exists():
|
||||||
|
with open(self.projects_file_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
self.metadata = data.get("metadata", {})
|
||||||
|
self.statistics = data.get("statistics", {})
|
||||||
|
|
||||||
|
# Cargar proyectos
|
||||||
|
for project_data in data.get("projects", []):
|
||||||
|
project = Project(project_data)
|
||||||
|
self.projects[project.id] = project
|
||||||
|
else:
|
||||||
|
# Crear archivo por defecto si no existe
|
||||||
|
self._create_default_projects_file()
|
||||||
|
self.save_projects()
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error cargando proyectos: {e}")
|
||||||
|
|
||||||
|
def save_projects(self) -> None:
|
||||||
|
"""Guardar proyectos al archivo JSON"""
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
"metadata": self.metadata,
|
||||||
|
"projects": [project.to_dict() for project in self.projects.values()],
|
||||||
|
"statistics": self.statistics
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(self.projects_file_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Error guardando proyectos: {e}")
|
||||||
|
|
||||||
|
def _create_default_projects_file(self) -> None:
|
||||||
|
"""Crear archivo de proyectos por defecto"""
|
||||||
|
self.metadata = {
|
||||||
|
"version": "1.0",
|
||||||
|
"last_updated": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"total_projects": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
self.statistics = {
|
||||||
|
"total_backups_created": 0,
|
||||||
|
"total_backup_size_mb": 0.0,
|
||||||
|
"average_backup_time_seconds": 0.0,
|
||||||
|
"last_global_scan": "",
|
||||||
|
"projects_with_errors": 0,
|
||||||
|
"projects_pending_retry": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_project(self, project_data: Dict[str, Any]) -> Project:
|
||||||
|
"""Agregar un nuevo proyecto"""
|
||||||
|
project = Project(project_data)
|
||||||
|
self.projects[project.id] = project
|
||||||
|
self.metadata["total_projects"] = len(self.projects)
|
||||||
|
self.metadata["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
self.save_projects()
|
||||||
|
return project
|
||||||
|
|
||||||
|
def get_project(self, project_id: str) -> Optional[Project]:
|
||||||
|
"""Obtener un proyecto por ID"""
|
||||||
|
return self.projects.get(project_id)
|
||||||
|
|
||||||
|
def get_all_projects(self) -> List[Project]:
|
||||||
|
"""Obtener todos los proyectos"""
|
||||||
|
return list(self.projects.values())
|
||||||
|
|
||||||
|
def get_projects_by_status(self, status: ProjectStatus) -> List[Project]:
|
||||||
|
"""Obtener proyectos por estado"""
|
||||||
|
return [p for p in self.projects.values() if p.current_status == status]
|
||||||
|
|
||||||
|
def get_enabled_projects(self) -> List[Project]:
|
||||||
|
"""Obtener proyectos habilitados"""
|
||||||
|
return [p for p in self.projects.values() if p.enabled]
|
||||||
|
|
||||||
|
def update_statistics(self) -> None:
|
||||||
|
"""Actualizar estadísticas generales"""
|
||||||
|
self.statistics["projects_with_errors"] = len(
|
||||||
|
self.get_projects_by_status(ProjectStatus.ERROR)
|
||||||
|
)
|
||||||
|
self.statistics["projects_pending_retry"] = len(
|
||||||
|
self.get_projects_by_status(ProjectStatus.RETRY_PENDING)
|
||||||
|
)
|
||||||
|
self.save_projects()
|
|
@ -0,0 +1,12 @@
|
||||||
|
"""
|
||||||
|
AutoBackups - Services
|
||||||
|
Servicios principales para backup y descubrimiento de proyectos
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Imports comentados temporalmente para evitar problemas de dependencias circulares
|
||||||
|
# from .project_discovery_service import ProjectDiscoveryService
|
||||||
|
# from .backup_service import BackupService
|
||||||
|
# from .scheduler_service import SchedulerService
|
||||||
|
from .basic_backup_service import BasicBackupService
|
||||||
|
|
||||||
|
__all__ = ['BasicBackupService']
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""
|
||||||
|
Servicio de Backup Básico
|
||||||
|
Versión simplificada para Fase 1 sin Everything API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class BasicBackupService:
|
||||||
|
"""Servicio básico de backup sin Everything API"""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def check_system_requirements(self) -> bool:
|
||||||
|
"""Verificar requerimientos básicos del sistema"""
|
||||||
|
try:
|
||||||
|
# Verificar directorio de backup
|
||||||
|
backup_dest = self.config.backup_destination
|
||||||
|
if not os.path.exists(backup_dest):
|
||||||
|
self.logger.error(f"Directorio de backup no existe: {backup_dest}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Verificar directorios de observación
|
||||||
|
obs_dirs = self.config.observation_directories
|
||||||
|
for obs_dir in obs_dirs:
|
||||||
|
if obs_dir.get("enabled", True):
|
||||||
|
path = obs_dir["path"]
|
||||||
|
if not os.path.exists(path):
|
||||||
|
self.logger.warning(f"Directorio de observación no existe: {path}")
|
||||||
|
|
||||||
|
self.logger.info("Requerimientos básicos del sistema verificados")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error verificando requerimientos: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def discover_projects_basic(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Descubrimiento básico de proyectos usando búsqueda de archivos"""
|
||||||
|
projects = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
obs_dirs = [
|
||||||
|
obs_dir for obs_dir in self.config.observation_directories
|
||||||
|
if obs_dir.get("enabled", True) and obs_dir.get("type") == "siemens_s7"
|
||||||
|
]
|
||||||
|
|
||||||
|
for obs_dir in obs_dirs:
|
||||||
|
self.logger.info(f"Buscando proyectos S7 en: {obs_dir['path']}")
|
||||||
|
|
||||||
|
# Buscar archivos .s7p recursivamente
|
||||||
|
base_path = Path(obs_dir["path"])
|
||||||
|
if base_path.exists():
|
||||||
|
s7p_files = list(base_path.rglob("*.s7p"))
|
||||||
|
|
||||||
|
for s7p_file in s7p_files:
|
||||||
|
project_info = self._create_project_info(s7p_file, obs_dir)
|
||||||
|
projects.append(project_info)
|
||||||
|
self.logger.info(f"Proyecto encontrado: {project_info['name']}")
|
||||||
|
|
||||||
|
self.logger.info(f"Descubrimiento completado. {len(projects)} proyectos encontrados")
|
||||||
|
return projects
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error en descubrimiento de proyectos: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _create_project_info(self, s7p_file: Path, obs_dir: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Crear información de proyecto desde archivo .s7p"""
|
||||||
|
project_path = s7p_file.parent
|
||||||
|
obs_path = Path(obs_dir["path"])
|
||||||
|
|
||||||
|
# Calcular ruta relativa
|
||||||
|
try:
|
||||||
|
relative_path = project_path.relative_to(obs_path)
|
||||||
|
except ValueError:
|
||||||
|
relative_path = project_path.name
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": project_path.name,
|
||||||
|
"path": str(project_path),
|
||||||
|
"s7p_file": str(s7p_file),
|
||||||
|
"observation_directory": obs_dir["path"],
|
||||||
|
"relative_path": str(relative_path),
|
||||||
|
"backup_path": str(relative_path),
|
||||||
|
"type": "siemens_s7",
|
||||||
|
"auto_discovered": True,
|
||||||
|
"discovery_method": "filesystem_search"
|
||||||
|
}
|
|
@ -0,0 +1,325 @@
|
||||||
|
"""
|
||||||
|
Project Discovery Service
|
||||||
|
Servicio para descubrir proyectos S7 usando Everything API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..models.project_model import Project, ProjectManager
|
||||||
|
from ..models.config_model import Config
|
||||||
|
from ..utils.everything_wrapper import EverythingSearcher, create_everything_searcher
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectDiscoveryService:
|
||||||
|
"""Servicio para descubrir y gestionar proyectos"""
|
||||||
|
|
||||||
|
def __init__(self, config: Config, project_manager: ProjectManager):
|
||||||
|
self.config = config
|
||||||
|
self.project_manager = project_manager
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Inicializar Everything searcher
|
||||||
|
dll_path = config.get_dll_path()
|
||||||
|
self.everything_searcher = create_everything_searcher(dll_path)
|
||||||
|
|
||||||
|
if not self.everything_searcher:
|
||||||
|
self.logger.warning("Everything API no disponible, funcionalidad limitada")
|
||||||
|
|
||||||
|
def discover_all_projects(self) -> List[Project]:
|
||||||
|
"""
|
||||||
|
Descubrir todos los proyectos en los directorios de observación
|
||||||
|
"""
|
||||||
|
self.logger.info("Iniciando descubrimiento de proyectos...")
|
||||||
|
|
||||||
|
discovered_projects = []
|
||||||
|
|
||||||
|
# Obtener directorios de observación habilitados
|
||||||
|
observation_dirs = [
|
||||||
|
obs_dir for obs_dir in self.config.observation_directories
|
||||||
|
if obs_dir.get("enabled", True)
|
||||||
|
]
|
||||||
|
|
||||||
|
for obs_dir in observation_dirs:
|
||||||
|
dir_path = obs_dir["path"]
|
||||||
|
dir_type = obs_dir["type"]
|
||||||
|
|
||||||
|
self.logger.info(f"Escaneando directorio: {dir_path} (tipo: {dir_type})")
|
||||||
|
|
||||||
|
if dir_type == "siemens_s7":
|
||||||
|
projects = self._discover_s7_projects(obs_dir)
|
||||||
|
else: # manual directories
|
||||||
|
projects = self._discover_manual_projects(obs_dir)
|
||||||
|
|
||||||
|
discovered_projects.extend(projects)
|
||||||
|
|
||||||
|
self.logger.info(f"Descubrimiento completado: {len(discovered_projects)} proyectos encontrados")
|
||||||
|
|
||||||
|
# Actualizar project manager con proyectos descubiertos
|
||||||
|
self._update_project_manager(discovered_projects)
|
||||||
|
|
||||||
|
return discovered_projects
|
||||||
|
|
||||||
|
def _discover_s7_projects(self, obs_dir: Dict[str, Any]) -> List[Project]:
|
||||||
|
"""Descubrir proyectos S7 en un directorio de observación"""
|
||||||
|
projects = []
|
||||||
|
dir_path = obs_dir["path"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.everything_searcher and self.everything_searcher.is_everything_available():
|
||||||
|
# Usar Everything API para búsqueda rápida
|
||||||
|
s7p_files = self.everything_searcher.search_s7p_files([dir_path])
|
||||||
|
else:
|
||||||
|
# Fallback a búsqueda manual
|
||||||
|
s7p_files = self._manual_search_s7p_files(dir_path)
|
||||||
|
|
||||||
|
self.logger.debug(f"Encontrados {len(s7p_files)} archivos .s7p en {dir_path}")
|
||||||
|
|
||||||
|
for s7p_file in s7p_files:
|
||||||
|
project = self._create_project_from_s7p(s7p_file, obs_dir)
|
||||||
|
if project:
|
||||||
|
projects.append(project)
|
||||||
|
self.logger.debug(f"Proyecto creado: {project.name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error descubriendo proyectos S7 en {dir_path}: {e}")
|
||||||
|
|
||||||
|
return projects
|
||||||
|
|
||||||
|
def _discover_manual_projects(self, obs_dir: Dict[str, Any]) -> List[Project]:
|
||||||
|
"""Descubrir proyectos de directorio manual"""
|
||||||
|
projects = []
|
||||||
|
dir_path = obs_dir["path"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Para directorios manuales, crear un proyecto por directorio
|
||||||
|
if Path(dir_path).exists() and Path(dir_path).is_dir():
|
||||||
|
project = self._create_manual_project(obs_dir)
|
||||||
|
if project:
|
||||||
|
projects.append(project)
|
||||||
|
self.logger.debug(f"Proyecto manual creado: {project.name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error creando proyecto manual para {dir_path}: {e}")
|
||||||
|
|
||||||
|
return projects
|
||||||
|
|
||||||
|
def _manual_search_s7p_files(self, directory: str) -> List[str]:
|
||||||
|
"""Búsqueda manual de archivos .s7p como fallback"""
|
||||||
|
s7p_files = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
path = Path(directory)
|
||||||
|
if not path.exists():
|
||||||
|
return s7p_files
|
||||||
|
|
||||||
|
# Búsqueda recursiva con optimización (evitar último nivel)
|
||||||
|
skip_last_level = self.config.everything_api.get("skip_last_level_for_s7p", True)
|
||||||
|
|
||||||
|
for file_path in path.rglob("*.s7p"):
|
||||||
|
if skip_last_level:
|
||||||
|
# Verificar que no esté en el último nivel (debe tener subdirectorios)
|
||||||
|
parent_dir = file_path.parent
|
||||||
|
has_subdirs = any(item.is_dir() for item in parent_dir.iterdir())
|
||||||
|
if not has_subdirs:
|
||||||
|
continue # Saltar archivos en directorios sin subdirectorios
|
||||||
|
|
||||||
|
s7p_files.append(str(file_path))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error en búsqueda manual de S7P en {directory}: {e}")
|
||||||
|
|
||||||
|
return s7p_files
|
||||||
|
|
||||||
|
def _create_project_from_s7p(self, s7p_file_path: str, obs_dir: Dict[str, Any]) -> Optional[Project]:
|
||||||
|
"""Crear un objeto Project desde un archivo .s7p"""
|
||||||
|
try:
|
||||||
|
s7p_path = Path(s7p_file_path)
|
||||||
|
project_dir = s7p_path.parent
|
||||||
|
obs_path = Path(obs_dir["path"])
|
||||||
|
|
||||||
|
# Generar ID único para el proyecto
|
||||||
|
project_id = self._generate_project_id(s7p_file_path)
|
||||||
|
|
||||||
|
# Determinar nombre del proyecto
|
||||||
|
project_name = s7p_path.stem
|
||||||
|
|
||||||
|
# Calcular ruta relativa
|
||||||
|
try:
|
||||||
|
relative_path = project_dir.relative_to(obs_path)
|
||||||
|
backup_path = str(relative_path)
|
||||||
|
except ValueError:
|
||||||
|
# Si no se puede calcular relativa, usar nombre del directorio
|
||||||
|
backup_path = project_dir.name
|
||||||
|
relative_path = Path(project_dir.name)
|
||||||
|
|
||||||
|
# Crear datos del proyecto
|
||||||
|
project_data = {
|
||||||
|
"id": project_id,
|
||||||
|
"name": project_name,
|
||||||
|
"path": str(project_dir),
|
||||||
|
"type": "siemens_s7",
|
||||||
|
"s7p_file": s7p_file_path,
|
||||||
|
"observation_directory": obs_dir["path"],
|
||||||
|
"relative_path": str(relative_path),
|
||||||
|
"backup_path": backup_path,
|
||||||
|
"schedule_config": {
|
||||||
|
"schedule": self.config.global_settings.get("default_schedule", "daily"),
|
||||||
|
"schedule_time": self.config.global_settings.get("default_schedule_time", "02:00"),
|
||||||
|
"enabled": True,
|
||||||
|
"next_scheduled_backup": ""
|
||||||
|
},
|
||||||
|
"backup_history": {
|
||||||
|
"last_backup_date": "",
|
||||||
|
"last_backup_file": "",
|
||||||
|
"backup_count": 0,
|
||||||
|
"last_successful_backup": ""
|
||||||
|
},
|
||||||
|
"hash_info": {
|
||||||
|
"last_s7p_hash": "",
|
||||||
|
"last_full_hash": "",
|
||||||
|
"last_s7p_timestamp": "",
|
||||||
|
"last_s7p_size": 0,
|
||||||
|
"last_scan_timestamp": "",
|
||||||
|
"file_count": 0,
|
||||||
|
"total_size_bytes": 0
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"current_status": "ready",
|
||||||
|
"last_error": None,
|
||||||
|
"retry_count": 0,
|
||||||
|
"next_retry": None,
|
||||||
|
"files_in_use": False,
|
||||||
|
"exclusivity_check_passed": True,
|
||||||
|
"last_status_update": datetime.now(timezone.utc).isoformat()
|
||||||
|
},
|
||||||
|
"discovery_info": {
|
||||||
|
"discovered_date": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"discovery_method": "everything_api" if self.everything_searcher else "manual_search",
|
||||||
|
"auto_discovered": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Project(project_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error creando proyecto desde {s7p_file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _create_manual_project(self, obs_dir: Dict[str, Any]) -> Optional[Project]:
|
||||||
|
"""Crear un proyecto para directorio manual"""
|
||||||
|
try:
|
||||||
|
dir_path = Path(obs_dir["path"])
|
||||||
|
|
||||||
|
# Generar ID único
|
||||||
|
project_id = self._generate_project_id(str(dir_path))
|
||||||
|
|
||||||
|
# Usar nombre del directorio como nombre del proyecto
|
||||||
|
project_name = dir_path.name
|
||||||
|
|
||||||
|
project_data = {
|
||||||
|
"id": project_id,
|
||||||
|
"name": project_name,
|
||||||
|
"path": str(dir_path),
|
||||||
|
"type": "manual",
|
||||||
|
"s7p_file": "",
|
||||||
|
"observation_directory": str(dir_path.parent),
|
||||||
|
"relative_path": dir_path.name,
|
||||||
|
"backup_path": dir_path.name,
|
||||||
|
"schedule_config": {
|
||||||
|
"schedule": self.config.global_settings.get("default_schedule", "daily"),
|
||||||
|
"schedule_time": self.config.global_settings.get("default_schedule_time", "02:00"),
|
||||||
|
"enabled": True,
|
||||||
|
"next_scheduled_backup": ""
|
||||||
|
},
|
||||||
|
"backup_history": {
|
||||||
|
"last_backup_date": "",
|
||||||
|
"last_backup_file": "",
|
||||||
|
"backup_count": 0,
|
||||||
|
"last_successful_backup": ""
|
||||||
|
},
|
||||||
|
"hash_info": {
|
||||||
|
"last_s7p_hash": "",
|
||||||
|
"last_full_hash": "",
|
||||||
|
"last_s7p_timestamp": "",
|
||||||
|
"last_s7p_size": 0,
|
||||||
|
"last_scan_timestamp": "",
|
||||||
|
"file_count": 0,
|
||||||
|
"total_size_bytes": 0
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"current_status": "ready",
|
||||||
|
"last_error": None,
|
||||||
|
"retry_count": 0,
|
||||||
|
"next_retry": None,
|
||||||
|
"files_in_use": False,
|
||||||
|
"exclusivity_check_passed": True,
|
||||||
|
"last_status_update": datetime.now(timezone.utc).isoformat()
|
||||||
|
},
|
||||||
|
"discovery_info": {
|
||||||
|
"discovered_date": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"discovery_method": "manual_directory",
|
||||||
|
"auto_discovered": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Project(project_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error creando proyecto manual para {obs_dir['path']}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _generate_project_id(self, path: str) -> str:
|
||||||
|
"""Generar ID único para un proyecto basado en su ruta"""
|
||||||
|
# Usar hash de la ruta + timestamp para garantizar unicidad
|
||||||
|
import hashlib
|
||||||
|
path_hash = hashlib.md5(path.encode()).hexdigest()[:8]
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
return f"project_{path_hash}_{timestamp}"
|
||||||
|
|
||||||
|
def _update_project_manager(self, discovered_projects: List[Project]) -> None:
|
||||||
|
"""Actualizar el project manager con proyectos descubiertos"""
|
||||||
|
try:
|
||||||
|
# Obtener proyectos existentes
|
||||||
|
existing_projects = {p.path: p for p in self.project_manager.get_all_projects()}
|
||||||
|
|
||||||
|
# Agregar nuevos proyectos
|
||||||
|
new_projects_count = 0
|
||||||
|
for project in discovered_projects:
|
||||||
|
if project.path not in existing_projects:
|
||||||
|
self.project_manager.add_project(project.to_dict())
|
||||||
|
new_projects_count += 1
|
||||||
|
self.logger.debug(f"Proyecto agregado: {project.name}")
|
||||||
|
|
||||||
|
if new_projects_count > 0:
|
||||||
|
self.logger.info(f"Se agregaron {new_projects_count} nuevos proyectos")
|
||||||
|
else:
|
||||||
|
self.logger.info("No se encontraron nuevos proyectos")
|
||||||
|
|
||||||
|
# Actualizar estadísticas
|
||||||
|
self.project_manager.update_statistics()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error actualizando project manager: {e}")
|
||||||
|
|
||||||
|
def rescan_observation_directories(self) -> int:
|
||||||
|
"""
|
||||||
|
Re-escanear directorios de observación para nuevos proyectos
|
||||||
|
Retorna el número de nuevos proyectos encontrados
|
||||||
|
"""
|
||||||
|
self.logger.info("Iniciando re-escaneo de directorios de observación...")
|
||||||
|
|
||||||
|
initial_count = len(self.project_manager.get_all_projects())
|
||||||
|
|
||||||
|
# Re-descubrir proyectos
|
||||||
|
self.discover_all_projects()
|
||||||
|
|
||||||
|
final_count = len(self.project_manager.get_all_projects())
|
||||||
|
new_projects = final_count - initial_count
|
||||||
|
|
||||||
|
self.logger.info(f"Re-escaneo completado: {new_projects} nuevos proyectos encontrados")
|
||||||
|
|
||||||
|
return new_projects
|
|
@ -0,0 +1,10 @@
|
||||||
|
"""
|
||||||
|
AutoBackups - Utilities
|
||||||
|
Funciones de utilidad para hashing, manejo de archivos y otras operaciones
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .hash_utils import HashCalculator
|
||||||
|
from .file_utils import FileChecker, DiskSpaceChecker
|
||||||
|
from .everything_wrapper import EverythingSearcher
|
||||||
|
|
||||||
|
__all__ = ['HashCalculator', 'FileChecker', 'DiskSpaceChecker', 'EverythingSearcher']
|
|
@ -0,0 +1,272 @@
|
||||||
|
"""
|
||||||
|
Everything API Wrapper
|
||||||
|
Integración con Everything para búsqueda rápida de archivos .s7p
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import ctypes
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
class EverythingSearcher:
|
||||||
|
"""Wrapper para la API de Everything"""
|
||||||
|
|
||||||
|
def __init__(self, dll_path: str = None):
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.dll = None
|
||||||
|
self.is_initialized = False
|
||||||
|
|
||||||
|
if dll_path:
|
||||||
|
self.load_dll(dll_path)
|
||||||
|
|
||||||
|
def load_dll(self, dll_path: str) -> bool:
|
||||||
|
"""Cargar la DLL de Everything"""
|
||||||
|
try:
|
||||||
|
if not os.path.exists(dll_path):
|
||||||
|
self.logger.error(f"DLL de Everything no encontrada: {dll_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.dll = ctypes.WinDLL(dll_path)
|
||||||
|
|
||||||
|
# Definir funciones de la API
|
||||||
|
self._setup_api_functions()
|
||||||
|
|
||||||
|
# Inicializar Everything
|
||||||
|
self.is_initialized = self._initialize_everything()
|
||||||
|
|
||||||
|
if self.is_initialized:
|
||||||
|
self.logger.info("Everything API inicializada correctamente")
|
||||||
|
else:
|
||||||
|
self.logger.error("Error inicializando Everything API")
|
||||||
|
|
||||||
|
return self.is_initialized
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error cargando DLL de Everything: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _setup_api_functions(self):
|
||||||
|
"""Configurar las funciones de la API"""
|
||||||
|
try:
|
||||||
|
# Everything_SetSearchW - Establecer consulta de búsqueda
|
||||||
|
self.dll.Everything_SetSearchW.argtypes = [ctypes.c_wchar_p]
|
||||||
|
self.dll.Everything_SetSearchW.restype = None
|
||||||
|
|
||||||
|
# Everything_SetMatchPath - Habilitar coincidencia de ruta completa
|
||||||
|
self.dll.Everything_SetMatchPath.argtypes = [ctypes.c_bool]
|
||||||
|
self.dll.Everything_SetMatchPath.restype = None
|
||||||
|
|
||||||
|
# Everything_SetMatchCase - Habilitar coincidencia de mayúsculas/minúsculas
|
||||||
|
self.dll.Everything_SetMatchCase.argtypes = [ctypes.c_bool]
|
||||||
|
self.dll.Everything_SetMatchCase.restype = None
|
||||||
|
|
||||||
|
# Everything_QueryW - Ejecutar consulta
|
||||||
|
self.dll.Everything_QueryW.argtypes = [ctypes.c_bool]
|
||||||
|
self.dll.Everything_QueryW.restype = ctypes.c_bool
|
||||||
|
|
||||||
|
# Everything_GetNumResults - Obtener número de resultados
|
||||||
|
self.dll.Everything_GetNumResults.restype = ctypes.c_uint
|
||||||
|
|
||||||
|
# Everything_GetResultFullPathNameW - Obtener ruta completa del resultado
|
||||||
|
self.dll.Everything_GetResultFullPathNameW.argtypes = [
|
||||||
|
ctypes.c_uint, ctypes.c_wchar_p, ctypes.c_uint
|
||||||
|
]
|
||||||
|
self.dll.Everything_GetResultFullPathNameW.restype = ctypes.c_uint
|
||||||
|
|
||||||
|
# Everything_GetLastError - Obtener último error
|
||||||
|
self.dll.Everything_GetLastError.restype = ctypes.c_uint
|
||||||
|
|
||||||
|
self.logger.debug("Funciones API configuradas correctamente")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error configurando funciones API: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _initialize_everything(self) -> bool:
|
||||||
|
"""Inicializar Everything con configuraciones por defecto"""
|
||||||
|
try:
|
||||||
|
# Configurar opciones de búsqueda
|
||||||
|
self.dll.Everything_SetMatchPath(True)
|
||||||
|
self.dll.Everything_SetMatchCase(False)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error inicializando Everything: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def search_s7p_files(self, search_directories: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Buscar archivos .s7p en los directorios especificados
|
||||||
|
Retorna lista de rutas completas a archivos .s7p encontrados
|
||||||
|
"""
|
||||||
|
if not self.is_initialized:
|
||||||
|
self.logger.error("Everything no está inicializado")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
all_s7p_files = []
|
||||||
|
|
||||||
|
for directory in search_directories:
|
||||||
|
s7p_files = self._search_s7p_in_directory(directory)
|
||||||
|
all_s7p_files.extend(s7p_files)
|
||||||
|
|
||||||
|
# Eliminar duplicados manteniendo el orden
|
||||||
|
unique_files = []
|
||||||
|
seen = set()
|
||||||
|
for file_path in all_s7p_files:
|
||||||
|
if file_path not in seen:
|
||||||
|
seen.add(file_path)
|
||||||
|
unique_files.append(file_path)
|
||||||
|
|
||||||
|
self.logger.info(f"Encontrados {len(unique_files)} archivos .s7p únicos")
|
||||||
|
return unique_files
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error buscando archivos S7P: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _search_s7p_in_directory(self, directory: str) -> List[str]:
|
||||||
|
"""Buscar archivos .s7p en un directorio específico"""
|
||||||
|
try:
|
||||||
|
# Normalizar la ruta del directorio
|
||||||
|
directory = os.path.normpath(directory)
|
||||||
|
|
||||||
|
# Construir consulta de búsqueda
|
||||||
|
# Buscar archivos .s7p en el directorio y subdirectorios
|
||||||
|
search_query = f'"{directory}" ext:s7p'
|
||||||
|
|
||||||
|
self.logger.debug(f"Búsqueda en Everything: {search_query}")
|
||||||
|
|
||||||
|
# Establecer consulta
|
||||||
|
self.dll.Everything_SetSearchW(search_query)
|
||||||
|
|
||||||
|
# Ejecutar búsqueda
|
||||||
|
if not self.dll.Everything_QueryW(True):
|
||||||
|
error_code = self.dll.Everything_GetLastError()
|
||||||
|
self.logger.error(f"Error en consulta Everything: código {error_code}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Obtener número de resultados
|
||||||
|
num_results = self.dll.Everything_GetNumResults()
|
||||||
|
self.logger.debug(f"Everything encontró {num_results} resultados para {directory}")
|
||||||
|
|
||||||
|
if num_results == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Extraer rutas de los resultados
|
||||||
|
results = []
|
||||||
|
buffer_size = 4096 # Tamaño del buffer para rutas
|
||||||
|
|
||||||
|
for i in range(num_results):
|
||||||
|
# Buffer para almacenar la ruta
|
||||||
|
path_buffer = ctypes.create_unicode_buffer(buffer_size)
|
||||||
|
|
||||||
|
# Obtener ruta completa
|
||||||
|
chars_copied = self.dll.Everything_GetResultFullPathNameW(
|
||||||
|
i, path_buffer, buffer_size
|
||||||
|
)
|
||||||
|
|
||||||
|
if chars_copied > 0:
|
||||||
|
file_path = path_buffer.value
|
||||||
|
if file_path and os.path.exists(file_path):
|
||||||
|
results.append(file_path)
|
||||||
|
self.logger.debug(f"Archivo S7P encontrado: {file_path}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error buscando en directorio {directory}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def search_files_by_pattern(self, pattern: str, max_results: int = 1000) -> List[str]:
|
||||||
|
"""
|
||||||
|
Buscar archivos usando un patrón específico
|
||||||
|
"""
|
||||||
|
if not self.is_initialized:
|
||||||
|
self.logger.error("Everything no está inicializado")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.debug(f"Búsqueda por patrón: {pattern}")
|
||||||
|
|
||||||
|
# Establecer consulta
|
||||||
|
self.dll.Everything_SetSearchW(pattern)
|
||||||
|
|
||||||
|
# Ejecutar búsqueda
|
||||||
|
if not self.dll.Everything_QueryW(True):
|
||||||
|
error_code = self.dll.Everything_GetLastError()
|
||||||
|
self.logger.error(f"Error en consulta Everything: código {error_code}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Obtener número de resultados
|
||||||
|
num_results = self.dll.Everything_GetNumResults()
|
||||||
|
actual_results = min(num_results, max_results)
|
||||||
|
|
||||||
|
self.logger.debug(f"Procesando {actual_results} de {num_results} resultados")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
buffer_size = 4096
|
||||||
|
|
||||||
|
for i in range(actual_results):
|
||||||
|
path_buffer = ctypes.create_unicode_buffer(buffer_size)
|
||||||
|
chars_copied = self.dll.Everything_GetResultFullPathNameW(
|
||||||
|
i, path_buffer, buffer_size
|
||||||
|
)
|
||||||
|
|
||||||
|
if chars_copied > 0:
|
||||||
|
file_path = path_buffer.value
|
||||||
|
if file_path and os.path.exists(file_path):
|
||||||
|
results.append(file_path)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error buscando patrón {pattern}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def is_everything_available(self) -> bool:
|
||||||
|
"""Verificar si Everything está disponible y funcionando"""
|
||||||
|
return self.is_initialized
|
||||||
|
|
||||||
|
def get_project_directory_from_s7p(self, s7p_file_path: str,
|
||||||
|
observation_directory: str) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Determinar el directorio del proyecto y la ruta relativa desde un archivo .s7p
|
||||||
|
Retorna: (directorio_proyecto, ruta_relativa)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
s7p_path = Path(s7p_file_path)
|
||||||
|
obs_path = Path(observation_directory)
|
||||||
|
|
||||||
|
# El directorio del proyecto es el padre del archivo .s7p
|
||||||
|
project_dir = s7p_path.parent
|
||||||
|
|
||||||
|
# Calcular ruta relativa desde el directorio de observación
|
||||||
|
try:
|
||||||
|
relative_path = project_dir.relative_to(obs_path)
|
||||||
|
return str(project_dir), str(relative_path)
|
||||||
|
except ValueError:
|
||||||
|
# Si no se puede calcular la ruta relativa, usar ruta absoluta
|
||||||
|
self.logger.warning(f"No se puede calcular ruta relativa para {s7p_file_path}")
|
||||||
|
return str(project_dir), str(project_dir)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error determinando directorio de proyecto para {s7p_file_path}: {e}")
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
|
# Funciones de utilidad para crear instancias
|
||||||
|
def create_everything_searcher(dll_path: str) -> Optional[EverythingSearcher]:
|
||||||
|
"""Crear una instancia de EverythingSearcher con manejo de errores"""
|
||||||
|
try:
|
||||||
|
searcher = EverythingSearcher(dll_path)
|
||||||
|
if searcher.is_everything_available():
|
||||||
|
return searcher
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).error(f"Error creando EverythingSearcher: {e}")
|
||||||
|
return None
|
|
@ -0,0 +1,247 @@
|
||||||
|
"""
|
||||||
|
File Utilities
|
||||||
|
Utilidades para verificación de archivos y espacio en disco
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import psutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple, List, Optional, Dict
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
class FileChecker:
|
||||||
|
"""Verificador de acceso a archivos y detección de archivos en uso"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def check_file_accessibility(self, file_path: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Verificar si un archivo es accesible para lectura
|
||||||
|
Retorna: (es_accesible, mensaje_error)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
return False, f"Archivo no existe: {file_path}"
|
||||||
|
|
||||||
|
# Intentar abrir el archivo en modo lectura
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
# Leer un byte para verificar acceso real
|
||||||
|
f.read(1)
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
return False, f"Sin permisos para acceder: {file_path}"
|
||||||
|
except OSError as e:
|
||||||
|
if "being used by another process" in str(e).lower():
|
||||||
|
return False, f"Archivo en uso: {file_path}"
|
||||||
|
return False, f"Error de sistema: {e}"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Error inesperado: {e}"
|
||||||
|
|
||||||
|
def check_directory_accessibility(self, directory_path: str,
|
||||||
|
check_all_files: bool = False) -> Tuple[bool, List[str]]:
|
||||||
|
"""
|
||||||
|
Verificar accesibilidad de un directorio
|
||||||
|
Si check_all_files es True, verifica todos los archivos
|
||||||
|
Retorna: (es_accesible, lista_errores)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = Path(directory_path)
|
||||||
|
if not path.exists():
|
||||||
|
return False, [f"Directorio no existe: {directory_path}"]
|
||||||
|
|
||||||
|
if not path.is_dir():
|
||||||
|
return False, [f"La ruta no es un directorio: {directory_path}"]
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if check_all_files:
|
||||||
|
# Verificar todos los archivos en el directorio
|
||||||
|
for file_path in path.rglob("*"):
|
||||||
|
if file_path.is_file():
|
||||||
|
accessible, error_msg = self.check_file_accessibility(str(file_path))
|
||||||
|
if not accessible:
|
||||||
|
errors.append(error_msg)
|
||||||
|
else:
|
||||||
|
# Solo verificar acceso básico al directorio
|
||||||
|
try:
|
||||||
|
list(path.iterdir())
|
||||||
|
except PermissionError:
|
||||||
|
errors.append(f"Sin permisos para acceder al directorio: {directory_path}")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Error accediendo al directorio: {e}")
|
||||||
|
|
||||||
|
return len(errors) == 0, errors
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, [f"Error verificando directorio {directory_path}: {e}"]
|
||||||
|
|
||||||
|
def check_s7p_file_exclusively(self, s7p_file_path: str) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Verificar específicamente el archivo .s7p para exclusividad
|
||||||
|
Método rápido antes de procesar todo el proyecto
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Primero verificar acceso básico
|
||||||
|
accessible, error_msg = self.check_file_accessibility(s7p_file_path)
|
||||||
|
if not accessible:
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
# Intentar crear un backup temporal para verificar exclusividad
|
||||||
|
import tempfile
|
||||||
|
temp_dir = tempfile.gettempdir()
|
||||||
|
temp_file = Path(temp_dir) / f"autobackup_test_{Path(s7p_file_path).name}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.copy2(s7p_file_path, temp_file)
|
||||||
|
# Si la copia fue exitosa, el archivo no está en uso exclusivo
|
||||||
|
temp_file.unlink() # Eliminar archivo temporal
|
||||||
|
return True, ""
|
||||||
|
except Exception as e:
|
||||||
|
if "being used by another process" in str(e).lower():
|
||||||
|
return False, f"Archivo S7P en uso exclusivo: {s7p_file_path}"
|
||||||
|
return False, f"Error verificando exclusividad S7P: {e}"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Error verificando archivo S7P {s7p_file_path}: {e}"
|
||||||
|
|
||||||
|
def is_file_locked(self, file_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verificar si un archivo específico está bloqueado
|
||||||
|
"""
|
||||||
|
accessible, _ = self.check_file_accessibility(file_path)
|
||||||
|
return not accessible
|
||||||
|
|
||||||
|
def get_locked_files_in_directory(self, directory_path: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Obtener lista de archivos bloqueados en un directorio
|
||||||
|
"""
|
||||||
|
locked_files = []
|
||||||
|
try:
|
||||||
|
path = Path(directory_path)
|
||||||
|
for file_path in path.rglob("*"):
|
||||||
|
if file_path.is_file():
|
||||||
|
if self.is_file_locked(str(file_path)):
|
||||||
|
locked_files.append(str(file_path))
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error buscando archivos bloqueados en {directory_path}: {e}")
|
||||||
|
|
||||||
|
return locked_files
|
||||||
|
|
||||||
|
|
||||||
|
class DiskSpaceChecker:
|
||||||
|
"""Verificador de espacio en disco"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def get_free_space_mb(self, path: str) -> float:
|
||||||
|
"""
|
||||||
|
Obtener espacio libre en MB para una ruta específica
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if os.name == 'nt': # Windows
|
||||||
|
free_bytes = shutil.disk_usage(path).free
|
||||||
|
else: # Linux/Mac
|
||||||
|
statvfs = os.statvfs(path)
|
||||||
|
free_bytes = statvfs.f_frsize * statvfs.f_bavail
|
||||||
|
|
||||||
|
return free_bytes / (1024 * 1024) # Convertir a MB
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error obteniendo espacio libre para {path}: {e}")
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def get_disk_usage_info(self, path: str) -> Tuple[float, float, float]:
|
||||||
|
"""
|
||||||
|
Obtener información completa de uso de disco
|
||||||
|
Retorna: (total_mb, usado_mb, libre_mb)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if os.name == 'nt': # Windows
|
||||||
|
usage = shutil.disk_usage(path)
|
||||||
|
total_mb = usage.total / (1024 * 1024)
|
||||||
|
free_mb = usage.free / (1024 * 1024)
|
||||||
|
used_mb = total_mb - free_mb
|
||||||
|
else: # Linux/Mac
|
||||||
|
statvfs = os.statvfs(path)
|
||||||
|
total_mb = (statvfs.f_frsize * statvfs.f_blocks) / (1024 * 1024)
|
||||||
|
free_mb = (statvfs.f_frsize * statvfs.f_bavail) / (1024 * 1024)
|
||||||
|
used_mb = total_mb - free_mb
|
||||||
|
|
||||||
|
return total_mb, used_mb, free_mb
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error obteniendo información de disco para {path}: {e}")
|
||||||
|
return 0.0, 0.0, 0.0
|
||||||
|
|
||||||
|
def has_sufficient_space(self, path: str, required_mb: float,
|
||||||
|
min_free_mb: float = 100.0) -> Tuple[bool, float]:
|
||||||
|
"""
|
||||||
|
Verificar si hay suficiente espacio para una operación
|
||||||
|
Retorna: (tiene_espacio_suficiente, espacio_libre_actual_mb)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
free_mb = self.get_free_space_mb(path)
|
||||||
|
total_required = required_mb + min_free_mb
|
||||||
|
|
||||||
|
return free_mb >= total_required, free_mb
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error verificando espacio suficiente para {path}: {e}")
|
||||||
|
return False, 0.0
|
||||||
|
|
||||||
|
def estimate_directory_size_mb(self, directory_path: str) -> float:
|
||||||
|
"""
|
||||||
|
Estimar el tamaño de un directorio en MB
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
total_size = 0
|
||||||
|
path = Path(directory_path)
|
||||||
|
|
||||||
|
for file_path in path.rglob("*"):
|
||||||
|
if file_path.is_file():
|
||||||
|
try:
|
||||||
|
total_size += file_path.stat().st_size
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
# Saltar archivos inaccesibles
|
||||||
|
continue
|
||||||
|
|
||||||
|
return total_size / (1024 * 1024) # Convertir a MB
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error estimando tamaño de {directory_path}: {e}")
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def get_system_disk_info(self) -> Dict[str, Tuple[float, float, float]]:
|
||||||
|
"""
|
||||||
|
Obtener información de todos los discos del sistema
|
||||||
|
Retorna: {drive: (total_mb, usado_mb, libre_mb)}
|
||||||
|
"""
|
||||||
|
disk_info = {}
|
||||||
|
try:
|
||||||
|
# Obtener todas las particiones
|
||||||
|
partitions = psutil.disk_partitions()
|
||||||
|
|
||||||
|
for partition in partitions:
|
||||||
|
try:
|
||||||
|
usage = psutil.disk_usage(partition.mountpoint)
|
||||||
|
total_mb = usage.total / (1024 * 1024)
|
||||||
|
used_mb = usage.used / (1024 * 1024)
|
||||||
|
free_mb = usage.free / (1024 * 1024)
|
||||||
|
|
||||||
|
disk_info[partition.device] = (total_mb, used_mb, free_mb)
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
# Saltar unidades sin acceso
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error obteniendo información del sistema: {e}")
|
||||||
|
|
||||||
|
return disk_info
|
|
@ -0,0 +1,197 @@
|
||||||
|
"""
|
||||||
|
Hash Utilities
|
||||||
|
Sistema de hash en dos etapas para optimizar detección de cambios
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Tuple, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
class HashCalculator:
|
||||||
|
"""Calculadora de hash optimizada para el sistema de dos etapas"""
|
||||||
|
|
||||||
|
def __init__(self, algorithm: str = "md5"):
|
||||||
|
self.algorithm = algorithm.lower()
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def calculate_file_hash(self, file_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Calcular hash de un archivo basado en timestamp + tamaño
|
||||||
|
No lee el contenido del archivo para mayor velocidad
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
stat = path.stat()
|
||||||
|
timestamp = str(stat.st_mtime)
|
||||||
|
size = str(stat.st_size)
|
||||||
|
|
||||||
|
# Crear hash combinando timestamp y tamaño
|
||||||
|
hash_input = f"{timestamp}:{size}".encode('utf-8')
|
||||||
|
|
||||||
|
if self.algorithm == "md5":
|
||||||
|
return hashlib.md5(hash_input).hexdigest()
|
||||||
|
elif self.algorithm == "sha256":
|
||||||
|
return hashlib.sha256(hash_input).hexdigest()
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Algoritmo de hash no soportado: {self.algorithm}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error calculando hash para {file_path}: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def calculate_s7p_hash(self, s7p_file_path: str) -> Tuple[str, str, int]:
|
||||||
|
"""
|
||||||
|
Calcular hash específico para archivo .s7p
|
||||||
|
Retorna: (hash, timestamp_iso, size_bytes)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = Path(s7p_file_path)
|
||||||
|
if not path.exists():
|
||||||
|
return "", "", 0
|
||||||
|
|
||||||
|
stat = path.stat()
|
||||||
|
timestamp = str(stat.st_mtime)
|
||||||
|
size = stat.st_size
|
||||||
|
|
||||||
|
# Crear hash
|
||||||
|
hash_input = f"{timestamp}:{size}".encode('utf-8')
|
||||||
|
if self.algorithm == "md5":
|
||||||
|
file_hash = hashlib.md5(hash_input).hexdigest()
|
||||||
|
else:
|
||||||
|
file_hash = hashlib.sha256(hash_input).hexdigest()
|
||||||
|
|
||||||
|
# Convertir timestamp a ISO format
|
||||||
|
from datetime import datetime
|
||||||
|
timestamp_iso = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||||
|
|
||||||
|
return file_hash, timestamp_iso, size
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error calculando hash S7P para {s7p_file_path}: {e}")
|
||||||
|
return "", "", 0
|
||||||
|
|
||||||
|
def calculate_directory_hash(self, directory_path: str, exclude_patterns: List[str] = None) -> Tuple[str, int, int]:
|
||||||
|
"""
|
||||||
|
Calcular hash de todos los archivos en un directorio
|
||||||
|
Retorna: (hash_completo, cantidad_archivos, tamaño_total)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = Path(directory_path)
|
||||||
|
if not path.exists():
|
||||||
|
return "", 0, 0
|
||||||
|
|
||||||
|
if exclude_patterns is None:
|
||||||
|
exclude_patterns = []
|
||||||
|
|
||||||
|
file_hashes = []
|
||||||
|
file_count = 0
|
||||||
|
total_size = 0
|
||||||
|
|
||||||
|
# Recorrer todos los archivos recursivamente
|
||||||
|
for file_path in path.rglob("*"):
|
||||||
|
if file_path.is_file():
|
||||||
|
# Verificar si el archivo debe ser excluido
|
||||||
|
should_exclude = False
|
||||||
|
for pattern in exclude_patterns:
|
||||||
|
if pattern in str(file_path):
|
||||||
|
should_exclude = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if should_exclude:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
stat = file_path.stat()
|
||||||
|
file_hash = self.calculate_file_hash(str(file_path))
|
||||||
|
if file_hash:
|
||||||
|
# Incluir la ruta relativa en el hash para detectar movimientos
|
||||||
|
relative_path = file_path.relative_to(path)
|
||||||
|
combined_hash_input = f"{relative_path}:{file_hash}".encode('utf-8')
|
||||||
|
|
||||||
|
if self.algorithm == "md5":
|
||||||
|
combined_hash = hashlib.md5(combined_hash_input).hexdigest()
|
||||||
|
else:
|
||||||
|
combined_hash = hashlib.sha256(combined_hash_input).hexdigest()
|
||||||
|
|
||||||
|
file_hashes.append(combined_hash)
|
||||||
|
file_count += 1
|
||||||
|
total_size += stat.st_size
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Error procesando archivo {file_path}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Crear hash final combinando todos los hashes de archivos
|
||||||
|
if file_hashes:
|
||||||
|
# Ordenar para asegurar consistencia
|
||||||
|
file_hashes.sort()
|
||||||
|
combined_input = ":".join(file_hashes).encode('utf-8')
|
||||||
|
|
||||||
|
if self.algorithm == "md5":
|
||||||
|
final_hash = hashlib.md5(combined_input).hexdigest()
|
||||||
|
else:
|
||||||
|
final_hash = hashlib.sha256(combined_input).hexdigest()
|
||||||
|
|
||||||
|
return final_hash, file_count, total_size
|
||||||
|
else:
|
||||||
|
return "", 0, 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error calculando hash de directorio {directory_path}: {e}")
|
||||||
|
return "", 0, 0
|
||||||
|
|
||||||
|
def compare_hashes(self, hash1: str, hash2: str) -> bool:
|
||||||
|
"""Comparar dos hashes de forma segura"""
|
||||||
|
if not hash1 or not hash2:
|
||||||
|
return False
|
||||||
|
return hash1 == hash2
|
||||||
|
|
||||||
|
def has_s7p_changed(self, s7p_file_path: str, last_known_hash: str,
|
||||||
|
last_known_timestamp: str, last_known_size: int) -> bool:
|
||||||
|
"""
|
||||||
|
Verificar si el archivo .s7p ha cambiado (Etapa 1 del sistema de hash)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
current_hash, current_timestamp, current_size = self.calculate_s7p_hash(s7p_file_path)
|
||||||
|
|
||||||
|
if not current_hash:
|
||||||
|
return True # Si no se puede calcular, asumir que cambió
|
||||||
|
|
||||||
|
# Comparar hash actual con el último conocido
|
||||||
|
if not last_known_hash:
|
||||||
|
return True # Si no hay hash previo, definitivamente cambió
|
||||||
|
|
||||||
|
return not self.compare_hashes(current_hash, last_known_hash)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error verificando cambios en S7P {s7p_file_path}: {e}")
|
||||||
|
return True # En caso de error, asumir que cambió
|
||||||
|
|
||||||
|
def has_directory_changed(self, directory_path: str, last_known_hash: str,
|
||||||
|
exclude_patterns: List[str] = None) -> Tuple[bool, str, int, int]:
|
||||||
|
"""
|
||||||
|
Verificar si el directorio ha cambiado (Etapa 2 del sistema de hash)
|
||||||
|
Retorna: (ha_cambiado, nuevo_hash, cantidad_archivos, tamaño_total)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
current_hash, file_count, total_size = self.calculate_directory_hash(
|
||||||
|
directory_path, exclude_patterns
|
||||||
|
)
|
||||||
|
|
||||||
|
if not current_hash:
|
||||||
|
return True, "", 0, 0
|
||||||
|
|
||||||
|
if not last_known_hash:
|
||||||
|
return True, current_hash, file_count, total_size
|
||||||
|
|
||||||
|
has_changed = not self.compare_hashes(current_hash, last_known_hash)
|
||||||
|
return has_changed, current_hash, file_count, total_size
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error verificando cambios en directorio {directory_path}: {e}")
|
||||||
|
return True, "", 0, 0
|
Loading…
Reference in New Issue