From 372e6fe7f86bf58a3a18b89d7d1f73013ccb52f0 Mon Sep 17 00:00:00 2001 From: George Date: Wed, 5 Aug 2026 08:30:16 -0500 Subject: [PATCH] =?UTF-8?q?se=20agrega=20la=20rotaci=C3=B3n=20de=20log=20y?= =?UTF-8?q?=20se=20actualiza=20la=20documentaci=C3=B3n=20del=20proyecto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 15 ++-- .gitignore | 1 + Dockerfile | 9 +- database_utilities.py | 2 +- docker-compose.yml | 3 +- docs/README.md | 118 ++++++++++++++++++++++++++ docs/configuracion.md | 192 ++++++++++++++++++++++++++++++++++++++++++ docs/flujo.md | 76 +++++++++++++++++ docs/mermaid.md | 53 ------------ emails.db | Bin 278528 -> 0 bytes file_manager.py | 6 +- logger_utilities.py | 78 +++++++++++------ main.py | 3 +- 13 files changed, 459 insertions(+), 97 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/configuracion.md create mode 100644 docs/flujo.md delete mode 100644 docs/mermaid.md delete mode 100644 emails.db diff --git a/.dockerignore b/.dockerignore index 1128a78..a0e8237 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,12 +1,15 @@ -.git -tests/ -__pycache__ -*.pyc -*.log -.env +correos-venv/ +__pycache__/ output_json/ logs/ adjuntos/ resultado/ data/ +.git/ +.gitignore +*.pyc +*.log +.env .DS_Store +.pytest_cache/ + diff --git a/.gitignore b/.gitignore index 1009f82..7a7362a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ output_json/ resultado/ .DS_Store emails.db +.pytest_cache/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 40cd267..4aba70b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,12 @@ -FROM python:3.11-slim +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + TZ=America/Lima WORKDIR /app COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --upgrade pip --no-cache-dir && pip install --no-cache-dir -r requirements.txt RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -11,6 +14,6 @@ RUN apt-get update \ rsync \ && rm -rf /var/lib/apt/lists/* -COPY *.py ./ +COPY . . CMD ["python", "main.py"] diff --git a/database_utilities.py b/database_utilities.py index 5321117..b034d63 100644 --- a/database_utilities.py +++ b/database_utilities.py @@ -1,6 +1,6 @@ import sqlite3 -DB_NAME = "emails.db" +DB_NAME = "data/emails.db" # ======================================================================== def init_db(): diff --git a/docker-compose.yml b/docker-compose.yml index 0bf8e6d..f477433 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,6 @@ services: build: . container_name: descarga_adjuntos_cpe restart: unless-stopped - network_mode: host environment: - TZ=America/Lima volumes: @@ -14,4 +13,4 @@ services: - ./output_json:/app/output_json - ./adjuntos:/app/adjuntos - ./resultado:/app/resultado - - /mnt/FileSAP:/mnt/FileSAP + - /Users/george/FileSAP:/mnt/FileSAP diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2a23dde --- /dev/null +++ b/docs/README.md @@ -0,0 +1,118 @@ +# Descarga Adjuntos Correos CPE + +Daemon que descarga automáticamente adjuntos de correos electrónicos (facturas, boletas, notas de crédito) desde Microsoft Outlook, extrae metadatos de archivos XML/PDF, y los sincroniza a una carpeta compartida SMB. + +## Arquitectura + +![Diagrama de Flujo](flujo.md) + +## Requisitos + +- Docker y Docker Compose +- Python 3.12 (solo para ejecución local sin Docker) +- Acceso a Microsoft Graph API (credenciales de Azure AD) +- Servidor SMB/CIFS en la red local + +## Instalación + +### Con Docker (recomendado) + +```bash +# Clonar el repositorio +git clone +cd descarga_adjuntos_correos_cpe + +# Crear los directorios necesarios +mkdir -p logs data adjuntos resultado output_json + +# Configurar archivos (ver configuracion.md) +# Editar config.json y scheduler.json + +# Construir y ejecutar +docker compose build +docker compose up -d +``` + +### Sin Docker + +```bash +# Crear entorno virtual +python -m venv venv +source venv/bin/activate # Linux/Mac +# venv\Scripts\activate # Windows + +# Instalar dependencias +pip install -r requirements.txt + +# Ejecutar +python main.py +``` + +## Estructura del Proyecto + +``` +├── main.py # Punto de entrada - daemon principal +├── config.json # Configuracion de credenciales y rutas +├── config_loader.py # Carga de config.json en dataclasses +├── config_model.py # Definicion del esquema de configuracion +├── scheduler.json # Horarios y dias de operacion +├── outlook_utilities.py # Autenticacion MSAL y descarga de correos +├── email_services.py # Filtrado de correos y descompresion ZIP +├── document_processor.py # Extraccion de metadata de XML +├── pdf_utilities.py # Extraccion de texto y regex de PDFs +├── llm_processor.py # Fallback con LLM (Ollama) para PDFs +├── file_manager.py # Renombrado, movimiento y limpieza de archivos +├── database_utilities.py # SQLite para tracking de correos procesados +├── smb_manager.py # Montaje y sincronizacion SMB +├── logger_utilities.py # Sistema de logging con rotacion diaria +├── Dockerfile # Imagen Docker +├── docker-compose.yml # Orquestacion del servicio +├── logs/ # Archivos de log diarios +├── data/ # Base de datos SQLite +├── adjuntos/ # Descarga temporal de adjuntos +├── resultado/ # Archivos procesados y renombrados +├── output_json/ # Exportacion JSON de resultados +└── tests/ # Suite de pruebas +``` + +## Comandos Utiles + +```bash +# Ver logs en tiempo real +docker compose logs -f descarga-cpe + +# Ver ultimas lineas de log +docker compose logs --tail=50 descarga-cpe + +# Detener el servicio +docker compose stop descarga-cpe + +# Reiniciar el servicio +docker compose restart descarga-cpe + +# Reconstruir tras cambios en el codigo +docker compose build descarga-cpe && docker compose up -d descarga-cpe + +# Verificar estado del contenedor +docker compose ps descarga-cpe + +# Ejecutar pruebas +python -m pytest tests/ -v +``` + +## Scheduler + +El daemon opera segun los horarios definidos en `scheduler.json`: + +| Horario | Intervalo | Modo | +|---------|-----------|------| +| 06:30 - 13:15 | 5 minutos | rapido | +| 13:15 - 14:00 | 1 hora | full | +| 14:00 - 17:30 | 5 minutos | rapido | +| 17:30 - 21:30 | 1 hora | full | + +Dias activos: Lunes a Viernes (0-4) + +## Logs + +Los archivos de log se guardan en `logs/` con formato `YYYY-MM-DD.log`. La rotacion es automatica al cambiar de dia. Los logs con mas de 30 dias se eliminan automaticamente al iniciar el daemon. diff --git a/docs/configuracion.md b/docs/configuracion.md new file mode 100644 index 0000000..5a4d734 --- /dev/null +++ b/docs/configuracion.md @@ -0,0 +1,192 @@ +# Guia de Configuracion + +## config.json + +Archivo de configuracion principal con credenciales y rutas. **No commitear este archivo al repositorio.** + +### Estructura completa + +```json +{ + "hana": { + "server": "IP_DEL_SERVIDOR", + "port": 30015, + "user": "USUARIO_HANA", + "password": "CONTRASENA_HANA" + }, + "outlook": { + "client_id": "CLIENT_ID_AZURE_AD", + "client_secret": "CLIENT_SECRET_AZURE_AD", + "tenant_id": "TENANT_ID_AZURE_AD", + "download_folder": "./adjuntos", + "verify_ssl": false, + "ca_bundle": null + }, + "smb": { + "server": "IP_SERVIDOR_SMB", + "username": "USUARIO_SMB", + "password": "CONTRASENA_SMB", + "share": "NOMBRE_DEL_SHARE", + "mount_points": { + "windows": "S:", + "linux": "/mnt/FileSAP", + "mac": "/Users/usuario/FileSAP", + "docker": "/mnt/FileSAP" + } + }, + "mails": [ + { + "email": "correo@empresa.com", + "ruta_descarga": "./adjuntos/empresa", + "ruta_final": "./resultado/empresa", + "base_datos": "SBO_EMPRESA_XX", + "usuario": "manager", + "password": "CONTRASENA_SAP", + "smb_folder": "/COMPRAS/EMPRESA/ADJUNTOS_CORREOS/2025-03" + } + ] +} +``` + +### Secciones + +#### hana + Conexion a SAP HANA Database para consultas de datos de negocio. + +| Campo | Descripcion | +|-------|-------------| +| `server` | IP o hostname del servidor HANA | +| `port` | Puerto (por defecto 30015) | +| `user` | Usuario de conexion | +| `password` | Contrasena | + +#### outlook + Credenciales de la aplicacion registrada en Azure AD para Microsoft Graph API. + +| Campo | Descripcion | +|-------|-------------| +| `client_id` | ID de la aplicacion en Azure AD | +| `client_secret` | Secreto de la aplicacion | +| `tenant_id` | ID del tenant de Azure AD | +| `download_folder` | Carpeta temporal para descarga de adjuntos | +| `verify_ssl` | `true` para verificar certificados SSL, `false` para omitir | +| `ca_bundle` | Ruta a bundle CA personalizado, `null` para usar el del sistema | + +**Como obtener credenciales de Outlook:** + +1. Ir a [Azure Portal](https://portal.azure.com) > Azure Active Directory > App registrations +2. Crear una nueva aplicacion o usar una existente +3. Configurar permisos de Microsoft Graph: `Mail.Read` (delegated) +4. Copiar el Application (client) ID, Directory (tenant) ID, y crear un Client Secret + +#### smb + Configuracion del servidor de archivos compartidos SMB/CIFS. + +| Campo | Descripcion | +|-------|-------------| +| `server` | IP o hostname del servidor SMB | +| `username` | Usuario de autenticacion | +| `password` | Contrasena | +| `share` | Nombre del recurso compartido | +| `mount_points` | Puntos de montaje segun el sistema operativo | + +Los `mount_points` se usan automaticamente segun donde se ejecute: +- `docker` se usa cuando corre en contenedor +- `linux`, `mac`, `windows` para ejecucion local + +#### mails + Lista de buzones de correo a monitorear. Cada entrada es un buzón independiente. + +| Campo | Descripcion | +|-------|-------------| +| `email` | Direccion del buzón de correo | +| `ruta_descarga` | Carpeta temporal para descargar adjuntos de este buzón | +| `ruta_final` | Carpeta donde se mueven los archivos procesados | +| `base_datos` | Codigo de base de datos SAP Business One | +| `usuario` | Usuario de SAP Business One | +| `password` | Contrasena de SAP Business One | +| `smb_folder` | Ruta destino en el share SMB para sincronizar | + +Se pueden configurar multiples buzones en el array `mails`. + +--- + +## scheduler.json + +Define los horarios de operacion del daemon. + +### Estructura + +```json +{ + "HORARIOS": [ + [6, 30, 13, 15, 300, "rapido"], + [13, 15, 14, 0, 3600, "full"], + [14, 0, 17, 30, 300, "rapido"], + [17, 30, 21, 30, 3600, "full"] + ], + "DIAS_ACTIVOS": [0, 1, 2, 3, 4] +} +``` + +### HORARIOS + +Cada entrada es un array con 6 valores: + +``` +[hora_inicio, minuto_inicio, hora_fin, minuto_fin, intervalo_segundos, modo] +``` + +| Indice | Descripcion | +|--------|-------------| +| 0 | Hora de inicio (0-23) | +| 1 | Minuto de inicio (0-59) | +| 2 | Hora de fin (0-23) | +| 3 | Minuto de fin (0-59) | +| 4 | Intervalo entre ciclos en segundos | +| 5 | Modo de operacion (`rapido` o `full`) | + +**Ejemplo:** `[6, 30, 13, 15, 300, "rapido"]` significa: +- Activo desde las 06:30 hasta las 13:15 +- Cada 300 segundos (5 minutos) +- Modo rapido + +### DIAS_ACTIVOS + +Array con los dias de la semana en formato numerico: + +| Valor | Dia | +|-------|-----| +| 0 | Lunes | +| 1 | Martes | +| 2 | Miercoles | +| 3 | Jueves | +| 4 | Viernes | +| 5 | Sabado | +| 6 | Domingo | + +**Ejemplo:** `[0, 1, 2, 3, 4]` = Lunes a Viernes + +### Modos de operacion + +- **rapido**: Busca correos con frecuencia alta (cada 5 min). Indicado para horas pico. +- **full**: Busca correos con frecuencia baja (cada 1 hora). Indicado para horas de baja actividad. + +--- + +## Variables de Entorno + +| Variable | Valor | Descripcion | +|----------|-------|-------------| +| `PYTHONUNBUFFERED` | `1` | Desactiva buffering de Python para logs en tiempo real | +| `TZ` | `America/Lima` | Zona horaria del contenedor | + +Estas se configuran en el `Dockerfile` y `docker-compose.yml`. + +--- + +## Seguridad + +- **Nunca commitear** `config.json` al repositorio (ya esta en `.gitignore`) +- Usar volumenes Docker montados como solo lectura (`:ro`) para archivos sensibles +- En produccion, considerar usar Docker secrets o variables de entorno en vez de archivos JSON diff --git a/docs/flujo.md b/docs/flujo.md new file mode 100644 index 0000000..476afd9 --- /dev/null +++ b/docs/flujo.md @@ -0,0 +1,76 @@ +# Diagrama de Flujo - Descarga Adjuntos Correos CPE + +```mermaid +graph TD + Start((INICIO
main.py)) --> Init[setup_logger + limpiar_logs_antiguos
init_db SQLite] + Init --> Loop{while _running} + + Loop --> RotarLog[rotar_log - rotacion diaria] + RotarLog --> CheckDay{Dia activo?
weekdays 0-4} + CheckDay -->|No| SleepDay[Esperar hasta
proximo dia activo] + SleepDay --> Loop + + CheckDay -->|Si| CheckTime{Dentro de
horario?} + CheckTime -->|No| SleepTime[Esperar hasta
06:30] + SleepTime --> Loop + + CheckTime -->|Si| Token[obtener_token
MSAL - renueva cada 50min] + Token --> LoopAccounts + + subgraph LoopAccounts [Bucle por cada Cuenta de Correo - config.mails] + direction TB + Process[procesar_cuenta] + Process --> GetEmails[get_emails
Graph API] + GetEmails --> FilterNew[filtrar_correos_nuevos
SQLite - IDs ya procesados] + FilterNew --> HasNew{Hay correos
nuevos?} + HasNew -->|No| Skip[Sin correos nuevos] + HasNew -->|Si| EmailLoop + + subgraph EmailLoop [Bucle por cada Correo Nuevo con Adjuntos] + direction TB + Download[download_attachments
Graph API] + Download --> Unzip[procesar_adjuntos_email
Descomprimir ZIP + filtrar PDF/XML] + Unzip --> ExtractMeta[procesar_adjuntos_enriquecidos
obtener_datos_xml + extraer_texto_pdf] + + ExtractMeta --> Classify{Clasificar
metadatos} + Classify -->|Completos| Rename[renombrar_archivos_por_metadata
F001.xml -> RUC-01-F001.xml] + Classify -->|Parciales| Evaluar[mover_a_evaluar
revision manual] + Classify -->|Vacios| Delete[limpiar_carpeta_descarga] + + Rename --> MoveResult[mover_validos_a_resultado
a /resultado/empresa] + MoveResult --> Cleanup[limpiar_carpeta_descarga] + end + + Skip --> SMBSync + EmailLoop --> SMBSync[sincronizar_a_smb
rsync a carpeta compartida] + end + + LoopAccounts --> ExportJSON[guardar_json
/output_json/timestamp.json] + ExportJSON --> Sleep[Esperar intervalo
segundos - 5min o 1hr] + Sleep --> Loop + + Loop -->|senal SIGTERM/SIGINT| Stop((FIN)) + + style Start fill:#f9f,stroke:#333,stroke-width:2px + style Stop fill:#f9f,stroke:#333,stroke-width:2px + style Loop fill:#e3f2fd,stroke:#1565c0,stroke-width:2px + style LoopAccounts fill:#e1f5fe,stroke:#01579b,stroke-dasharray: 5 5 + style EmailLoop fill:#fff3e0,stroke:#e65100 + style SMBSync fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px + style Classify fill:#fce4ec,stroke:#c62828 +``` + +## Flujo resumido + +1. **Inicio**: Configura logger con rotacion diaria, limpia logs viejos (>30 dias), inicializa SQLite +2. **Loop principal**: Verifica dia activo y horario antes de procesar +3. **Token**: Obtiene/renueva token OAuth de Microsoft Graph (valido 60 min) +4. **Por cada cuenta de correo**: + - Consulta correos via Graph API + - Filtra los ya procesados (SQLite) + - Descarga adjuntos, descomprime ZIPs + - Extrae metadata de XML y PDF (con fallback a LLM/Ollama) + - Clasifica: completos (renombrar+mover), parciales (evaluar/), vacios (eliminar) +5. **SMB**: Sincroniza archivos procesados a carpeta compartida via rsync +6. **Exporta**: Guarda resultados en JSON con timestamp +7. **Duerme**: Espera el intervalo configurado (5min rapido / 1hr full) diff --git a/docs/mermaid.md b/docs/mermaid.md deleted file mode 100644 index 1b7ab5a..0000000 --- a/docs/mermaid.md +++ /dev/null @@ -1,53 +0,0 @@ -```mermaid -graph TD - Start((INICIO
main.py)) --> Init[Configurar Logger y
Base de Datos SQLITE] - Init --> Auth[Obtener Token de Acceso
Outlook API] - - subgraph MailConfigLoop [Bucle por cada Cuenta de Correo en config.json] - direction TB - GetEmails[Consultar correos del día
outlook_utilities.get_emails] - GetEmails --> FilterNew[Filtrar correos no procesados
email_services.filtrar_correos_nuevos] - - subgraph EmailLoop [Bucle por cada Correo Nuevo con Adjuntos] - direction TB - Download[Descargar adjuntos a /adjuntos/temp
outlook_utilities.download_attachments] - Download --> Unzip[Descomprimir ZIPs y filtrar archivos
email_services.procesar_adjuntos_email] - - subgraph DataExtraction [Extracción de Metadatos] - direction LR - ParseXML[Parsear XML
document_processor.obtener_datos_xml] - ParsePDF[Parsear PDF multilínea
pdf_utilities.obtener_datos_pdf] - end - - Unzip --> DataExtraction - - DataExtraction --> Grouping[Lógica de Agrupación y Renombrado
file_manager.procesar_renombrado_agrupado] - - Grouping --> Rename[Renombrado físico de archivos
F001.xml -> RUC-01-F001.xml] - - Rename --> MoveLocal[Mover archivos agrupados a /resultado/
file_manager.mover_archivos_a_resultado] - end - - FilterNew --> EmailLoop - - EmailLoop --> SMBSync[Sincronización SMB
smb_utils.sincronizar_a_smb] - - subgraph SMBSteps [Proceso SMB] - direction LR - Mount[Montar Unidad según SO
mount_smbfs / net use] --> Rsync[Ejecutar rsync -av
--remove-source-files] - end - - SMBSync --- SMBSteps - end - - Auth --> MailConfigLoop - - MailConfigLoop --> ExportJSON[Exportar resultados finales a JSON
/output_json/YYYY-MM-DD.json] - ExportJSON --> End((FIN)) - - style Start fill:#f9f,stroke:#333,stroke-width:2px - style End fill:#f9f,stroke:#333,stroke-width:2px - style MailConfigLoop fill:#e1f5fe,stroke:#01579b,stroke-dasharray: 5 5 - style EmailLoop fill:#fff3e0,stroke:#e65100 - style SMBSync fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px -``` diff --git a/emails.db b/emails.db deleted file mode 100644 index 242b42f0e5c3bdc1c0e452488a9e03f478d52101..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 278528 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 diff --git a/file_manager.py b/file_manager.py index c625460..a9a8a6e 100644 --- a/file_manager.py +++ b/file_manager.py @@ -40,7 +40,7 @@ def renombrar_archivos_por_metadata(metadatos, ruta_base): if os.path.exists(ruta_old): if os.path.exists(ruta_new) and ruta_old != ruta_new: os.remove(ruta_new) - os.rename(ruta_old, ruta_new) + shutil.move(ruta_old, ruta_new) m['archivo'] = nuevo_nombre except Exception as e: print(f" ⚠️ Error renombrando '{nombre_original}': {e}") @@ -69,7 +69,7 @@ def mover_validos_a_resultado(metadatos, ruta_origen, ruta_destino): if os.path.exists(ruta_old): if os.path.exists(ruta_new) and ruta_old != ruta_new: os.remove(ruta_new) - os.rename(ruta_old, ruta_new) + shutil.move(ruta_old, ruta_new) movidos += 1 except Exception as e: print(f" ⚠️ Error trasladando '{archivo}' a resultado: {e}") @@ -99,7 +99,7 @@ def mover_a_evaluar(metadatos, ruta_origen, ruta_evaluar): if os.path.exists(ruta_old): if os.path.exists(ruta_new) and ruta_old != ruta_new: os.remove(ruta_new) - os.rename(ruta_old, ruta_new) + shutil.move(ruta_old, ruta_new) movidos += 1 except Exception as e: print(f" ⚠️ Error moviendo '{archivo}' a evaluar: {e}") diff --git a/logger_utilities.py b/logger_utilities.py index c61e100..65f40cf 100644 --- a/logger_utilities.py +++ b/logger_utilities.py @@ -2,13 +2,15 @@ import sys import os from datetime import datetime, timedelta -# ======================================================================== +_stdout_original = sys.stdout +_stderr_original = sys.stderr +_file_stream = None +_log_dir = "logs" + +# ============================================================================ class MultiStream: - """ - Clase para redirigir la salida a múltiples flujos (consola y archivo). - """ def __init__(self, *streams): - self.streams = streams + self.streams = list(streams) def write(self, data): for stream in self.streams: @@ -19,31 +21,51 @@ class MultiStream: for stream in self.streams: stream.flush() -# ======================================================================== + def reemplazar_file(self, old_fh, new_fh): + for i, s in enumerate(self.streams): + if s is old_fh: + self.streams[i] = new_fh + return + +# ============================================================================ +def _ruta_log(hoy=None): + if hoy is None: + hoy = datetime.now().strftime("%Y-%m-%d") + if not os.path.exists(_log_dir): + os.makedirs(_log_dir) + return os.path.join(_log_dir, f"{hoy}.log") + +# ============================================================================ def setup_logger(): - """ - Configura la redirección de stdout y stderr a un archivo de log y a la consola. - Retorna la ruta del archivo de log creado. - """ - log_dir = "logs" - if not os.path.exists(log_dir): - os.makedirs(log_dir) - - # Nombre del archivo: YYYY-MM-DD_HH-MM-SS.log - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - log_filename = f"{timestamp}.log" - log_path = os.path.join(log_dir, log_filename) - - # Abrir archivo en modo append con encoding utf-8 para soportar iconos - log_file = open(log_path, "a", encoding="utf-8") - - # Redirigir stdout y stderr - sys.stdout = MultiStream(sys.stdout, log_file) - sys.stderr = MultiStream(sys.stderr, log_file) - + global _file_stream + log_path = _ruta_log() + _file_stream = open(log_path, "a", encoding="utf-8") + sys.stdout = MultiStream(_stdout_original, _file_stream) + sys.stderr = MultiStream(_stderr_original, _file_stream) return log_path -# ======================================================================== +# ============================================================================ +def rotar_log(): + global _file_stream + hoy = datetime.now().strftime("%Y-%m-%d") + log_path = _ruta_log(hoy) + + if _file_stream and os.path.abspath(_file_stream.name) == os.path.abspath(log_path): + return + + nuevo_fh = open(log_path, "a", encoding="utf-8") + if isinstance(sys.stdout, MultiStream): + sys.stdout.reemplazar_file(_file_stream, nuevo_fh) + if isinstance(sys.stderr, MultiStream): + sys.stderr.reemplazar_file(_file_stream, nuevo_fh) + try: + _file_stream.close() + except Exception: + pass + _file_stream = nuevo_fh + print(f"📄 Log rotado a: {log_path}") + +# ============================================================================ def limpiar_logs_antiguos(dias=7): """ Elimina archivos de log con más de 'n' días de antigüedad. @@ -68,4 +90,4 @@ def limpiar_logs_antiguos(dias=7): if count > 0: print(f"🧹 Se eliminaron {count} archivos de log antiguos (más de {dias} días).") -# ======================================================================== \ No newline at end of file +# ============================================================================ \ No newline at end of file diff --git a/main.py b/main.py index edf7c2f..4688d27 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,7 @@ import document_processor import file_manager import llm_processor from smb_manager import sincronizar_a_smb -from logger_utilities import setup_logger, limpiar_logs_antiguos +from logger_utilities import setup_logger, limpiar_logs_antiguos, rotar_log from config_loader import CONFIG @@ -227,6 +227,7 @@ def main(): fecha_obtencion_token = None while _running: + rotar_log() ahora = datetime.now() if ahora.weekday() not in DIAS_ACTIVOS: