primer commit
This commit is contained in:
commit
83f704c85d
12 files changed
+717
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.ide/
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"hana": {
|
||||||
|
"server": "192.168.1.175",
|
||||||
|
"port": 30015,
|
||||||
|
"database": "SBO_TAGUMEDICA_21",
|
||||||
|
"user": "SYSTEM",
|
||||||
|
"password": "SAPB1Admin.25"
|
||||||
|
},
|
||||||
|
"outlook": {
|
||||||
|
"client_id": "957c324b-3da3-43ed-b912-ca52fb5d3128",
|
||||||
|
"client_secret": "Guc8Q~u6FH3xBKpxJ-hQ7QSpRWbnvmX.LnWr-dw7",
|
||||||
|
"tenant_id": "76f77b2f-6fc3-42a0-ae83-df05508405d6",
|
||||||
|
"attachments_folder": "attachments",
|
||||||
|
"verify_ssl": false,
|
||||||
|
"ca_bundle": null,
|
||||||
|
"from_email": "business.intelligence@tagumedica.com",
|
||||||
|
"to_email": [
|
||||||
|
"jefes-area-comercial-tgm@tagumedica.com",
|
||||||
|
"luis.wu@tagumedica.com",
|
||||||
|
"ricardo.palacios@tagumedica.com",
|
||||||
|
"eduard.samson@tagumedica.com",
|
||||||
|
"business.intelligence@tagumedica.com",
|
||||||
|
"hugo.condori@tagumedica.com",
|
||||||
|
"marco.marinos@tagumedica.com",
|
||||||
|
"natalie.arambulo@tagumedica.com"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"crystal": {
|
||||||
|
"path": "C:/Windows/Microsoft.NET/assembly/GAC_MSIL",
|
||||||
|
"version": "v4.0_13.0.4000.0__692fbea5521e1304"
|
||||||
|
},
|
||||||
|
"powerbi": {
|
||||||
|
"url": "https://app.powerbi.com/view?r=eyJrIjoiMGNjOGVjYWUtYzcwNi00OTJiLWE5MjItNzJhMzRjYmFmNjA1IiwidCI6Ijc2Zjc3YjJmLTZmYzMtNDJhMC1hZTgzLWRmMDU1MDg0MDVkNiIsImMiOjR9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"hana": {
|
||||||
|
"server": "192.168.1.175",
|
||||||
|
"port": 30015,
|
||||||
|
"database": "SBO_TAGUMEDICA_21",
|
||||||
|
"user": "SYSTEM",
|
||||||
|
"password": "SAPB1Admin.25"
|
||||||
|
},
|
||||||
|
"outlook": {
|
||||||
|
"client_id": "957c324b-3da3-43ed-b912-ca52fb5d3128",
|
||||||
|
"client_secret": "Guc8Q~u6FH3xBKpxJ-hQ7QSpRWbnvmX.LnWr-dw7",
|
||||||
|
"tenant_id": "76f77b2f-6fc3-42a0-ae83-df05508405d6",
|
||||||
|
"attachments_folder": "attachments",
|
||||||
|
"verify_ssl": false,
|
||||||
|
"ca_bundle": null,
|
||||||
|
"from_email": "george.calle@tagumedica.com",
|
||||||
|
"to_email": [
|
||||||
|
"jefes-area-comercial-tgm@tagumedica.com",
|
||||||
|
"luis.wu@tagumedica.com",
|
||||||
|
"ricardo.palacios@tagumedica.com",
|
||||||
|
"eduard.samson@tagumedica.com",
|
||||||
|
"business.intelligence@tagumedica.com",
|
||||||
|
"hugo.condori@tagumedica.com",
|
||||||
|
"marco.marinos@tagumedica.com",
|
||||||
|
"natalie.arambulo@tagumedica.com"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"crystal": {
|
||||||
|
"path": "C:/Windows/Microsoft.NET/assembly/GAC_MSIL"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from config_model import Config
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Definir la ruta del archivo de configuración
|
||||||
|
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
"""Carga la configuración desde el archivo JSON."""
|
||||||
|
try:
|
||||||
|
return Config.load(CONFIG_PATH)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Al cargar la configuracion: {e}")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
# Variable global CONFIG disponible para otros módulos
|
||||||
|
CONFIG = load_config()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HanaConfig:
|
||||||
|
server: str
|
||||||
|
port: int
|
||||||
|
database: str
|
||||||
|
user: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OutlookConfig:
|
||||||
|
client_id: str
|
||||||
|
client_secret: str
|
||||||
|
tenant_id: str
|
||||||
|
from_email: str
|
||||||
|
to_email: list[str]
|
||||||
|
attachments_folder: str = "./attachments"
|
||||||
|
verify_ssl: bool = True
|
||||||
|
ca_bundle: Optional[str] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CrystalConfig:
|
||||||
|
path: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PowerbiConfig:
|
||||||
|
url: str
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
hana: HanaConfig
|
||||||
|
outlook: OutlookConfig
|
||||||
|
crystal: CrystalConfig
|
||||||
|
powerbi: PowerbiConfig
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load(path: str = "config.json") -> 'Config':
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise FileNotFoundError(f"No se encontró el archivo de configuración: {path}")
|
||||||
|
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
hana_data = data.get('hana', {})
|
||||||
|
outlook_data = data.get('outlook', {})
|
||||||
|
crystal_data = data.get('crystal', {})
|
||||||
|
|
||||||
|
# Validar campos requeridos para HANA
|
||||||
|
required_hana = ['server', 'port', 'database', 'user', 'password']
|
||||||
|
for field in required_hana:
|
||||||
|
if field not in hana_data:
|
||||||
|
raise KeyError(f"Falta el campo requerido '{field}' en la sección 'hana' de {path}")
|
||||||
|
|
||||||
|
# Validar campos requeridos para Outlook
|
||||||
|
required_outlook = ['client_id', 'client_secret', 'tenant_id', 'from_email', 'to_email']
|
||||||
|
for field in required_outlook:
|
||||||
|
if field not in outlook_data:
|
||||||
|
raise KeyError(f"Falta el campo requerido '{field}' en la sección 'outlook' de {path}")
|
||||||
|
|
||||||
|
# Validar campos requeridos para Crystal
|
||||||
|
required_crystal = ['path', 'version']
|
||||||
|
for field in required_crystal:
|
||||||
|
if field not in crystal_data:
|
||||||
|
raise KeyError(f"Falta el campo requerido '{field}' en la sección 'crystal' de {path}")
|
||||||
|
|
||||||
|
powerbi_data = data.get('powerbi', {})
|
||||||
|
required_powerbi = ['url']
|
||||||
|
for field in required_powerbi:
|
||||||
|
if field not in powerbi_data:
|
||||||
|
raise KeyError(f"Falta el campo requerido '{field}' en la sección 'powerbi' de {path}")
|
||||||
|
|
||||||
|
hana = HanaConfig(**hana_data)
|
||||||
|
outlook = OutlookConfig(**outlook_data)
|
||||||
|
crystal = CrystalConfig(**crystal_data)
|
||||||
|
powerbi=PowerbiConfig(**powerbi_data)
|
||||||
|
|
||||||
|
return Config(hana=hana, outlook=outlook, crystal=crystal,powerbi=powerbi)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Previsualización del Correo</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
||||||
|
<div style="max-width: 600px; margin: auto; border: 1px solid #ddd; padding: 20px; border-radius: 8px;">
|
||||||
|
<p style="color: gray; font-size: 0.8em; text-align: center; margin-top: 0;">--- PREVISUALIZACIÓN DEL CORREO ---
|
||||||
|
</p>
|
||||||
|
<hr style="border: 0; border-top: 1px solid #eee; margin-bottom: 20px;">
|
||||||
|
|
||||||
|
<h2 style="color: #2e6c80;">Informe Automático de Ventas</h2>
|
||||||
|
<p>Buenos días</p>
|
||||||
|
<p>Me complace compartir con ustedes el reporte de avance de ventas correspondiente al
|
||||||
|
<strong>13/05/2026</strong>.</p>
|
||||||
|
<p>La información se encuentra disponible en el siguiente dashboard:</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://app.powerbi.com/view?r=eyJrIjoiNWQwY2E4Y2MtNmUyYy00Njc4LWI2YjUtOWQ3Zjc5ZjllNTM0IiwidCI6Ijc2Zjc3YjJmLTZmYzMtNDJhMC1hZTgzLWRmMDU1MDg0MDVkNiIsImMiOjR9"
|
||||||
|
target="_blank" style="color: #0056b3; text-decoration: none; font-weight: bold;">
|
||||||
|
Ver reporte Power BI
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div style="height: 20px;"></div>
|
||||||
|
<p>Quedamos atentos a cualquier consulta o comentario.</p>
|
||||||
|
<br>
|
||||||
|
<p style="font-weight: bold; margin-bottom: 0;">Saludos cordiales,</p>
|
||||||
|
<p style="font-weight: bold; margin-top: 5px;">Equipo de Inteligencia de Negocios</p>
|
||||||
|
<p style="font-size: 0.9em; color: #666; margin-top: 30px;">Este es un mensaje generado automáticamente por el
|
||||||
|
sistema de reportes.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
class MultiStream:
|
||||||
|
"""
|
||||||
|
Clase para redirigir la salida a múltiples flujos (consola y archivo).
|
||||||
|
"""
|
||||||
|
def __init__(self, *streams):
|
||||||
|
self.streams = streams
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
for stream in self.streams:
|
||||||
|
stream.write(data)
|
||||||
|
stream.flush()
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
for stream in self.streams:
|
||||||
|
stream.flush()
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
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)
|
||||||
|
|
||||||
|
return log_path
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
def limpiar_logs_antiguos(dias=7):
|
||||||
|
"""
|
||||||
|
Elimina archivos de log con más de 'n' días de antigüedad.
|
||||||
|
"""
|
||||||
|
log_dir = "logs"
|
||||||
|
if not os.path.exists(log_dir):
|
||||||
|
return
|
||||||
|
|
||||||
|
limite = datetime.now() - timedelta(days=dias)
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for archivo in os.listdir(log_dir):
|
||||||
|
ruta_archivo = os.path.join(log_dir, archivo)
|
||||||
|
if os.path.isfile(ruta_archivo) and archivo.endswith(".log"):
|
||||||
|
fecha_archivo = datetime.fromtimestamp(os.path.getmtime(ruta_archivo))
|
||||||
|
if fecha_archivo < limite:
|
||||||
|
try:
|
||||||
|
os.remove(ruta_archivo)
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error al eliminar log {archivo}: {e}")
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
print(f"🧹 Se eliminaron {count} archivos de log antiguos (más de {dias} días).")
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import clr
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
import logger_utilities
|
||||||
|
|
||||||
|
# Importar configuración y utilidades personalizadas
|
||||||
|
from outlook import get_access_token, send_email
|
||||||
|
from config_loader import CONFIG
|
||||||
|
|
||||||
|
# --- CRYSTAL REPORTS RUNTIME INITIALIZATION ---
|
||||||
|
########################################################
|
||||||
|
def init_crystal_runtime():
|
||||||
|
"""
|
||||||
|
Inicializa el runtime de Crystal Reports cargando las DLLs necesarias desde el GAC.
|
||||||
|
Se basa en la ruta configurada en config.json para determinar la versión.
|
||||||
|
"""
|
||||||
|
# gac_path = r"C:\Windows\Microsoft.NET\assembly\GAC_MSIL"
|
||||||
|
gac_path = CONFIG.crystal.path
|
||||||
|
|
||||||
|
# La versión del runtime de Crystal Reports instalada en este sistema
|
||||||
|
version_folder = CONFIG.crystal.version
|
||||||
|
|
||||||
|
dlls = [
|
||||||
|
os.path.join(gac_path, "CrystalDecisions.CrystalReports.Engine", version_folder, "CrystalDecisions.CrystalReports.Engine.dll"),
|
||||||
|
os.path.join(gac_path, "CrystalDecisions.Shared", version_folder, "CrystalDecisions.Shared.dll"),
|
||||||
|
os.path.join(gac_path, "CrystalDecisions.ReportSource", version_folder, "CrystalDecisions.ReportSource.dll")
|
||||||
|
]
|
||||||
|
|
||||||
|
for dll in dlls:
|
||||||
|
if not os.path.exists(dll):
|
||||||
|
print(f"CRITICAL ERROR: No se encontró la DLL necesaria: {dll}")
|
||||||
|
print("Verifique que el SAP Crystal Reports Runtime esté instalado correctamente.")
|
||||||
|
sys.exit(1)
|
||||||
|
clr.AddReference(dll)
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
# Inicializar el runtime antes de importar los tipos de .NET
|
||||||
|
init_crystal_runtime()
|
||||||
|
|
||||||
|
# Importar tipos de .NET después de cargar las referencias
|
||||||
|
# pyrefly: ignore [missing-import]
|
||||||
|
from System import DateTime
|
||||||
|
# pyrefly: ignore [missing-import]
|
||||||
|
from CrystalDecisions.CrystalReports.Engine import ReportDocument
|
||||||
|
# pyrefly: ignore [missing-import]
|
||||||
|
from CrystalDecisions.Shared import ExportFormatType
|
||||||
|
|
||||||
|
# --- HELPERS ---
|
||||||
|
########################################################
|
||||||
|
def configure_database(report):
|
||||||
|
"""
|
||||||
|
Configura las credenciales de la base de datos HANA para el reporte y todas sus sub-tablas.
|
||||||
|
"""
|
||||||
|
hc = CONFIG.hana
|
||||||
|
# Login general para el reporte
|
||||||
|
report.SetDatabaseLogon(hc.user, hc.password, hc.server, hc.database)
|
||||||
|
|
||||||
|
# Asegurar conexión en cada tabla individual
|
||||||
|
for table in report.Database.Tables:
|
||||||
|
conn_info = table.LogOnInfo.ConnectionInfo
|
||||||
|
conn_info.ServerName = hc.server
|
||||||
|
conn_info.DatabaseName = hc.database
|
||||||
|
conn_info.UserID = hc.user
|
||||||
|
conn_info.Password = hc.password
|
||||||
|
table.ApplyLogOnInfo(table.LogOnInfo)
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
def ejecutar_reporte(rpt_filename: str, pdf_path: Path, params: dict) -> bool:
|
||||||
|
"""
|
||||||
|
Carga un reporte .rpt, aplica parámetros y lo exporta a PDF.
|
||||||
|
|
||||||
|
:param rpt_filename: Nombre o ruta del archivo .rpt
|
||||||
|
:param pdf_path: Ruta de salida del archivo PDF
|
||||||
|
:param params: Diccionario de parámetros {Nombre: Valor}
|
||||||
|
:return: True si tuvo éxito, False en caso contrario
|
||||||
|
"""
|
||||||
|
print(f"--- Generando: {pdf_path.name} ---")
|
||||||
|
|
||||||
|
report = ReportDocument()
|
||||||
|
try:
|
||||||
|
report.Load(rpt_filename)
|
||||||
|
configure_database(report)
|
||||||
|
|
||||||
|
# Asignar parámetros dinámicamente
|
||||||
|
for key, value in params.items():
|
||||||
|
report.SetParameterValue(key, value)
|
||||||
|
|
||||||
|
# Exportar a disco
|
||||||
|
report.ExportToDisk(ExportFormatType.PortableDocFormat, str(pdf_path))
|
||||||
|
print(f"SUCCESS: Reporte guardado en {pdf_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Falló la ejecución del reporte '{rpt_filename}': {e}")
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
# Siempre liberar recursos de Crystal Reports
|
||||||
|
report.Close()
|
||||||
|
report.Dispose()
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
def get_report_dates():
|
||||||
|
"""
|
||||||
|
Calcula las fechas de reporte: Ayer y el primer día del mes actual.
|
||||||
|
Retorna objetos System.DateTime compatibles con Crystal Reports.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
yesterday = now - timedelta(days=1)
|
||||||
|
|
||||||
|
# Objetos .NET DateTime
|
||||||
|
dt_yesterday = DateTime(yesterday.year, yesterday.month, yesterday.day)
|
||||||
|
dt_month_start = DateTime(yesterday.year, yesterday.month, 1)
|
||||||
|
|
||||||
|
return dt_month_start, dt_yesterday
|
||||||
|
|
||||||
|
# --- PROCESO PRINCIPAL ---
|
||||||
|
########################################################
|
||||||
|
def main():
|
||||||
|
print("====================================================")
|
||||||
|
print("INICIANDO AUTOMATIZACIÓN DE CRYSTAL REPORTS")
|
||||||
|
print(f"Fecha/Hora: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print("====================================================")
|
||||||
|
|
||||||
|
# 1. Autenticación con Microsoft Graph
|
||||||
|
access_token = get_access_token()
|
||||||
|
if not access_token:
|
||||||
|
print("ABORT: Error crítico de autenticación con Outlook.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Preparación del Entorno
|
||||||
|
base_path = Path(__file__).parent
|
||||||
|
output_dir = base_path / CONFIG.outlook.attachments_folder
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 3. Cálculo de Fechas
|
||||||
|
dt_month_start, dt_yesterday = get_report_dates()
|
||||||
|
str_ayer = dt_yesterday.ToString("dd/MM/yyyy")
|
||||||
|
str_inicio = dt_month_start.ToString("dd/MM/yyyy")
|
||||||
|
|
||||||
|
# 4. Generación de Reportes
|
||||||
|
rpt_file = "venta_presupuesto_linea.rpt"
|
||||||
|
adjuntos = []
|
||||||
|
|
||||||
|
# REPORTE 1: Venta del día de ayer
|
||||||
|
pdf_yesterday = output_dir / f"venta_presupuesto_linea_{dt_yesterday.ToString('dd_MM_yyyy')}.pdf"
|
||||||
|
if ejecutar_reporte(rpt_file, pdf_yesterday, {"FecIni": dt_yesterday, "FecFin": dt_yesterday}):
|
||||||
|
adjuntos.append(str(pdf_yesterday))
|
||||||
|
|
||||||
|
# REPORTE 2: Acumulado del mes actual (MTD)
|
||||||
|
pdf_mtd = output_dir / f"venta_presupuesto_linea_{dt_month_start.ToString('dd_MM_yyyy')}_al_{dt_yesterday.ToString('dd_MM_yyyy')}.pdf"
|
||||||
|
if ejecutar_reporte(rpt_file, pdf_mtd, {"FecIni": dt_month_start, "FecFin": dt_yesterday}):
|
||||||
|
adjuntos.append(str(pdf_mtd))
|
||||||
|
|
||||||
|
# 5. Envío de Correo Electrónico
|
||||||
|
if adjuntos:
|
||||||
|
subject = f"Informe de Venta de Presupuestos por Línea - {str_ayer}"
|
||||||
|
body = f"""
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif;">
|
||||||
|
<h2 style="color: #2e6c80;">Informe Automático de Ventas por Línea</h2>
|
||||||
|
<p>Buenos días</p>
|
||||||
|
<p>Me complace compartir con ustedes el reporte de avance de ventas por línea correspondiente al {str_ayer}.</p>
|
||||||
|
<p>La información se encuentra disponible en el siguiente dashboard:</p>
|
||||||
|
<p>
|
||||||
|
<a href="{CONFIG.powerbi.url}" target="_blank">
|
||||||
|
Ver reporte Power BI
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div style="height: 20px;"></div>
|
||||||
|
<p>Quedamos atentos a cualquier consulta o comentario.</p>
|
||||||
|
<br>
|
||||||
|
<p style="font-weight: bold;">Saludos cordiales,</p>
|
||||||
|
<p style="font-weight: bold;">Equipo de Inteligencia de Negocios</p>
|
||||||
|
<p style="font-size: 0.9em; color: #666;">Este es un mensaje generado automáticamente por el sistema de reportes.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
success = send_email(
|
||||||
|
access_token=access_token,
|
||||||
|
from_email=CONFIG.outlook.from_email,
|
||||||
|
to_recipients=CONFIG.outlook.to_email,
|
||||||
|
subject=subject,
|
||||||
|
html_body=body,
|
||||||
|
attachment_paths=adjuntos
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
print("PROCESO FINALIZADO: Correo enviado correctamente.")
|
||||||
|
else:
|
||||||
|
print("ERROR: No se generaron archivos PDF. No hay nada que enviar.")
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
# Configurar Logger (Redirige stdout a archivo y consola)
|
||||||
|
log_path = logger_utilities.setup_logger()
|
||||||
|
logger_utilities.limpiar_logs_antiguos(dias=30)
|
||||||
|
|
||||||
|
inicio_ejecucion = datetime.now()
|
||||||
|
print("="*60)
|
||||||
|
print(f"🚀 INICIO DE EJECUCIÓN: {inicio_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print(f"📄 Log guardado en: {log_path}")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
main()
|
||||||
|
|
||||||
|
fin_ejecucion = datetime.now()
|
||||||
|
duracion = fin_ejecucion - inicio_ejecucion
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(f"🏁 FIN DE EJECUCIÓN: {fin_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print(f"⏱️ Duración total: {str(duracion).split('.')[0]}") # Sin microsegundos
|
||||||
|
print("="*60)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FATAL MAIN ERROR: {e}")
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
import requests
|
||||||
|
import msal
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
import warnings
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from config_loader import CONFIG
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
########################################################
|
||||||
|
def get_access_token():
|
||||||
|
"""Obtiene el token de acceso usando MSAL (Client Credentials Flow)."""
|
||||||
|
outlook_config = CONFIG.outlook
|
||||||
|
client_id = outlook_config.client_id
|
||||||
|
client_secret = outlook_config.client_secret
|
||||||
|
tenant_id = outlook_config.tenant_id
|
||||||
|
authority = f"https://login.microsoftonline.com/{tenant_id}"
|
||||||
|
|
||||||
|
# Manejo de SSL
|
||||||
|
verify_ssl = outlook_config.verify_ssl
|
||||||
|
ca_bundle = outlook_config.ca_bundle
|
||||||
|
|
||||||
|
# MSAL usa requests internamente, podemos pasar proxies/verify
|
||||||
|
# a través de la configuración si fuera necesario, pero msal tiene su propia forma
|
||||||
|
# de configurar el http_client. Por simplicidad, si verify_ssl es False,
|
||||||
|
# podemos intentar configurar el ambiente o pasar un cliente custom.
|
||||||
|
|
||||||
|
app = msal.ConfidentialClientApplication(
|
||||||
|
client_id,
|
||||||
|
authority=authority,
|
||||||
|
client_credential=client_secret,
|
||||||
|
verify=verify_ssl if ca_bundle is None else ca_bundle
|
||||||
|
)
|
||||||
|
|
||||||
|
# El scope para Graph API en modo aplicación suele ser 'https://graph.microsoft.com/.default'
|
||||||
|
scopes = ["https://graph.microsoft.com/.default"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = app.acquire_token_silent(scopes, account=None)
|
||||||
|
if not result:
|
||||||
|
result = app.acquire_token_for_client(scopes=scopes)
|
||||||
|
|
||||||
|
if "access_token" in result:
|
||||||
|
return result["access_token"]
|
||||||
|
else:
|
||||||
|
raise Exception(f"Error obteniendo token: {result.get('error_description')}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error obteniendo token: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
def get_messages(access_token, user_email, date_filter=None, end_date_filter=None):
|
||||||
|
"""Obtiene todos los mensajes de un correo específico, manejando la paginación de Graph API."""
|
||||||
|
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages"
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {access_token}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
outlook_config = CONFIG.outlook
|
||||||
|
verify = outlook_config.verify_ssl if outlook_config.ca_bundle is None else outlook_config.ca_bundle
|
||||||
|
|
||||||
|
# Solo los mensajes que contengan adjuntos
|
||||||
|
filter_query = 'hasAttachments eq true'
|
||||||
|
|
||||||
|
if date_filter:
|
||||||
|
filter_query += f" and receivedDateTime ge {date_filter}"
|
||||||
|
|
||||||
|
if end_date_filter:
|
||||||
|
filter_query += f" and receivedDateTime le {end_date_filter}"
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'$filter': filter_query,
|
||||||
|
'$select': 'id,subject,receivedDateTime,hasAttachments,from',
|
||||||
|
'$top': 50 # Podemos pedir hasta 999, pero 50 es un buen default para probar paginación
|
||||||
|
}
|
||||||
|
|
||||||
|
all_messages = []
|
||||||
|
next_link = url
|
||||||
|
|
||||||
|
while next_link:
|
||||||
|
# En la primera iteración usamos url + params.
|
||||||
|
# En las siguientes, next_link ya trae los filtros y el token de skip en la URL.
|
||||||
|
if next_link == url:
|
||||||
|
response = requests.get(next_link, headers=headers, params=params, verify=verify)
|
||||||
|
else:
|
||||||
|
response = requests.get(next_link, headers=headers, verify=verify)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
messages = data.get('value', [])
|
||||||
|
all_messages.extend(messages)
|
||||||
|
next_link = data.get('@odata.nextLink')
|
||||||
|
else:
|
||||||
|
print(f"Error obteniendo mensajes para {user_email}: {response.text}")
|
||||||
|
break
|
||||||
|
|
||||||
|
return all_messages
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
def send_email(access_token, from_email, to_recipients, subject, html_body, attachment_paths=None):
|
||||||
|
"""
|
||||||
|
Envía un correo electrónico con cuerpo HTML y múltiples adjuntos PDF.
|
||||||
|
|
||||||
|
:param access_token: Token de acceso de Microsoft Graph API.
|
||||||
|
:param from_email: Correo electrónico del remitente (cuenta de Office 365).
|
||||||
|
:param to_recipients: Lista de correos electrónicos de los destinatarios.
|
||||||
|
:param subject: Asunto del correo.
|
||||||
|
:param html_body: Contenido del correo en formato HTML.
|
||||||
|
:param attachment_paths: Lista de rutas a los archivos PDF que se adjuntarán.
|
||||||
|
:return: True si se envió correctamente, False en caso contrario.
|
||||||
|
"""
|
||||||
|
url = f"https://graph.microsoft.com/v1.0/users/{from_email}/sendMail"
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {access_token}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configuración de SSL desde CONFIG
|
||||||
|
outlook_config = CONFIG.outlook
|
||||||
|
verify = outlook_config.verify_ssl if outlook_config.ca_bundle is None else outlook_config.ca_bundle
|
||||||
|
|
||||||
|
# Construir lista de destinatarios para Graph API
|
||||||
|
recipients_payload = []
|
||||||
|
for email in to_recipients:
|
||||||
|
recipients_payload.append({
|
||||||
|
"emailAddress": {
|
||||||
|
"address": email
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Construir lista de adjuntos
|
||||||
|
attachments_payload = []
|
||||||
|
if attachment_paths:
|
||||||
|
for path in attachment_paths:
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
content_bytes = f.read()
|
||||||
|
|
||||||
|
# Codificar el contenido en Base64
|
||||||
|
encoded_content = base64.b64encode(content_bytes).decode('utf-8')
|
||||||
|
filename = os.path.basename(path)
|
||||||
|
|
||||||
|
attachments_payload.append({
|
||||||
|
"@odata.type": "#microsoft.graph.fileAttachment",
|
||||||
|
"name": filename,
|
||||||
|
"contentType": "application/pdf",
|
||||||
|
"contentBytes": encoded_content
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
print(f"Advertencia: El archivo no existe y no se adjuntará: {path}")
|
||||||
|
|
||||||
|
# Estructura del mensaje para la API
|
||||||
|
email_data = {
|
||||||
|
"message": {
|
||||||
|
"subject": subject,
|
||||||
|
"body": {
|
||||||
|
"contentType": "HTML",
|
||||||
|
"content": html_body
|
||||||
|
},
|
||||||
|
"toRecipients": recipients_payload,
|
||||||
|
"attachments": attachments_payload
|
||||||
|
},
|
||||||
|
"saveToSentItems": "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, headers=headers, json=email_data, verify=verify)
|
||||||
|
if response.status_code == 202:
|
||||||
|
print(f"SUCCESS: Correo enviado exitosamente a: {', '.join(to_recipients)}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"ERROR: Al enviar correo: {response.status_code} - {response.text}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Excepcion al enviar correo: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
########################################################
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Requerimientos para la ejecución de la aplicación
|
||||||
|
|
||||||
|
# --- DEPENDENCIAS DE PYTHON (INSTALABLES VIA PIP) ---
|
||||||
|
pythonnet>=3.0.0
|
||||||
|
requests>=2.28.0
|
||||||
|
msal>=1.20.0
|
||||||
|
|
||||||
|
# --- DEPENDENCIAS DE SISTEMA OPERATIVO WINDOWS (NO INSTALABLES POR PIP) ---
|
||||||
|
# IMPORTANTE: Los siguientes componentes deben instalarse a nivel sistema.
|
||||||
|
#
|
||||||
|
# 1. SAP Crystal Reports, developer version for Microsoft Visual Studio o SAP Crystal Reports Runtime engine for .NET Framework
|
||||||
|
# (Instalar la versión de 32 o 64 bits de acuerdo a la versión de Python que estés ejecutando).
|
||||||
|
#
|
||||||
|
# 2. SAP HANA Client (Driver ODBC)
|
||||||
|
# (Requerido para la conexión a la base de datos).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import clr
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Try to find the DLL
|
||||||
|
gac_path = r"C:\Windows\Microsoft.NET\assembly\GAC_MSIL\CrystalDecisions.CrystalReports.Engine\v4.0_13.0.4000.0__692fbea5521e1304\CrystalDecisions.CrystalReports.Engine.dll"
|
||||||
|
print(f"Checking path: {gac_path}")
|
||||||
|
print(f"Exists: {Path(gac_path).exists()}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Attempting to add reference...")
|
||||||
|
# Try adding by full path first
|
||||||
|
clr.AddReference(gac_path)
|
||||||
|
print("Reference added successfully by path.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to add reference by path: {e}")
|
||||||
|
try:
|
||||||
|
print("Attempting to add reference by name...")
|
||||||
|
clr.AddReference("CrystalDecisions.CrystalReports.Engine")
|
||||||
|
print("Reference added successfully by name.")
|
||||||
|
except Exception as e2:
|
||||||
|
print(f"Failed to add reference by name: {e2}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Attempting import...")
|
||||||
|
# pyrefly: ignore [missing-import]
|
||||||
|
from CrystalDecisions.CrystalReports.Engine import ReportDocument
|
||||||
|
print("Import successful!")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Import failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
Binary file not shown.
Reference in new issue
Block a user