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