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 242b42f..0000000 Binary files a/emails.db and /dev/null differ 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: