diff --git a/app.py b/app.py index 9ada6f6..08ac91c 100644 --- a/app.py +++ b/app.py @@ -314,6 +314,31 @@ def api_regenerar(): except subprocess.TimeoutExpired: return jsonify({"ok": False, "mensaje": "Timeout al ejecutar el pipeline"}), 500 +# ----------------------------------------------------------------------- +# API: importar tickets desde correo +# ----------------------------------------------------------------------- +@app.route("/api/importar-email", methods=["POST"]) +@login_required +def api_importar_email(): + usuario = session["usuario"] + try: + r = subprocess.run( + [sys.executable, str(BASE_DIR / "importar_tickets_email.py"), "--usuario", usuario], + cwd=str(BASE_DIR), capture_output=True, text=True, timeout=60 + ) + output = r.stdout + r.stderr + if r.returncode != 0: + return jsonify({"ok": False, "mensaje": "Error al importar email", + "detalle": output}), 500 + # Extraer cuántos tickets se descargaron del output + import re as _re + m = _re.search(r"Total descargados: (\d+)", output) + n = int(m.group(1)) if m else 0 + return jsonify({"ok": True, "nuevos": n, + "mensaje": f"{n} ticket(s) nuevos importados del correo"}) + except subprocess.TimeoutExpired: + return jsonify({"ok": False, "mensaje": "Timeout al conectar con el correo"}), 500 + # ----------------------------------------------------------------------- # Archivos estaticos de tickets (solo autenticados) # ----------------------------------------------------------------------- diff --git a/importar_tickets_email.py b/importar_tickets_email.py index f5d6e89..03137c4 100644 --- a/importar_tickets_email.py +++ b/importar_tickets_email.py @@ -2,21 +2,21 @@ importar_tickets_email.py ------------------------- Descarga los tickets PDF de Mercadona desde el correo (IMAP) -y los guarda en la carpeta tickets/ para ser procesados. +y los guarda en tickets/{usuario}/ para ser procesados. Uso: - python importar_tickets_email.py + python importar_tickets_email.py --usuario tatiana -Configuracion en config.ini (se crea automaticamente la primera vez). - -Para Gmail necesitas una "contrasena de aplicacion": - Cuenta Google -> Seguridad -> Verificacion en dos pasos -> - Contrasenas de aplicacion -> Otra -> copiar los 16 caracteres. +Configuracion en config.ini (montado como volumen en Docker). +Soporta SSL sin verificacion de certificado (util con servidores locales). """ +import argparse import imaplib import email +import email.utils import os +import ssl import configparser import sys import subprocess @@ -24,26 +24,31 @@ from pathlib import Path from datetime import datetime from email.header import decode_header +# ----------------------------------------------------------------------- +# Argumentos +# ----------------------------------------------------------------------- +_parser = argparse.ArgumentParser() +_parser.add_argument("--usuario", default="default", help="Usuario al que asignar los tickets") +_args = _parser.parse_args() + # ----------------------------------------------------------------------- # Rutas # ----------------------------------------------------------------------- -BASE_DIR = Path(__file__).parent -TICKETS_DIR = BASE_DIR / "tickets" +BASE_DIR = Path(__file__).parent CONFIG_FILE = BASE_DIR / "config.ini" -TICKETS_DIR.mkdir(exist_ok=True) - # ----------------------------------------------------------------------- # Configuracion # ----------------------------------------------------------------------- DEFAULTS = { - "imap_host": "imap.gmail.com", - "imap_port": "993", - "correo": "", - "password": "", - "remitente": "noreply@mercadona.es", - "solo_nuevos": "true", - "ejecutar_pipeline": "false", + "imap_host": "localhost", + "imap_port": "993", + "correo": "", + "password": "", + "remitente": "noreply@mercadona.es", + "solo_nuevos": "true", + "ssl_verify": "true", + "ejecutar_pipeline": "true", } def leer_config(): @@ -56,55 +61,6 @@ def leer_config(): cfg["email"].setdefault(k, v) return cfg -def guardar_config(cfg): - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - cfg.write(f) - -def pedir_config(): - """Solicita los datos de acceso si no estan configurados. - En Docker, usa variables de entorno EMAIL_CORREO / EMAIL_PASSWORD / EMAIL_IMAP_HOST. - """ - cfg = leer_config() - sec = cfg["email"] - - # Leer desde variables de entorno (prioritarias sobre config.ini) - env_correo = os.environ.get("EMAIL_CORREO", "").strip() - env_pwd = os.environ.get("EMAIL_PASSWORD", "").strip() - env_host = os.environ.get("EMAIL_IMAP_HOST", "").strip() - - if env_correo: - sec["correo"] = env_correo - if env_pwd: - sec["password"] = env_pwd - if env_host: - sec["imap_host"] = env_host - - if not sec["correo"] or not sec["password"]: - # Modo interactivo: solo funciona fuera de Docker - if not sys.stdin.isatty(): - print("ERROR: config.ini vacio y no hay variables de entorno EMAIL_CORREO / EMAIL_PASSWORD.") - print("Opciones:") - print(" 1. Crea config.ini en el host y montalo como volumen.") - print(" 2. Añade EMAIL_CORREO y EMAIL_PASSWORD al docker-compose.yml.") - sys.exit(1) - - print("=== Configuracion inicial ===") - print("Necesito los datos de acceso al correo.") - print("Para Gmail usa una contrasena de aplicacion (no tu contrasena normal).") - print("https://myaccount.google.com/apppasswords\n") - - correo = input("Correo electronico: ").strip() - pwd = input("Contrasena (o contrasena de aplicacion): ").strip() - host = input(f"Servidor IMAP [{sec['imap_host']}]: ").strip() or sec["imap_host"] - - sec["correo"] = correo - sec["password"] = pwd - sec["imap_host"] = host - guardar_config(cfg) - print(f"\nConfiguracion guardada en {CONFIG_FILE}\n") - - return cfg - # ----------------------------------------------------------------------- # Decodificacion de nombres de archivo # ----------------------------------------------------------------------- @@ -123,26 +79,43 @@ def decodificar_nombre(nombre_raw): # ----------------------------------------------------------------------- # Descarga de tickets # ----------------------------------------------------------------------- -def descargar_tickets(): - cfg = pedir_config() +def descargar_tickets(usuario: str) -> int: + cfg = leer_config() sec = cfg["email"] - host = sec["imap_host"] - port = int(sec["imap_port"]) - correo = sec["correo"] - password = sec["password"] + # Variables de entorno sobreescriben config.ini + host = os.environ.get("EMAIL_IMAP_HOST", sec["imap_host"]).strip() + port = int(os.environ.get("EMAIL_IMAP_PORT", sec["imap_port"])) + correo = os.environ.get("EMAIL_CORREO", sec["correo"]).strip() + password = os.environ.get("EMAIL_PASSWORD", sec["password"]).strip() remitente = sec["remitente"] - solo_nuevos = sec["solo_nuevos"].lower() == "true" + solo_nuevos = sec["solo_nuevos"].lower() == "true" + ssl_verify = sec["ssl_verify"].lower() == "true" ejecutar_pipeline = sec["ejecutar_pipeline"].lower() == "true" - print(f"Conectando a {host}:{port} como {correo}...") + if not correo or not password: + print("ERROR: correo o password no configurados en config.ini / variables de entorno.") + sys.exit(1) + + tickets_dir = BASE_DIR / "tickets" / usuario + tickets_dir.mkdir(parents=True, exist_ok=True) + + print(f"[email] Conectando a {host}:{port} como {correo} (ssl_verify={ssl_verify})...") try: - conn = imaplib.IMAP4_SSL(host, port) + if ssl_verify: + conn = imaplib.IMAP4_SSL(host, port) + else: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx) conn.login(correo, password) except imaplib.IMAP4.error as e: - print(f"Error de autenticacion: {e}") - print("Comprueba el correo y la contrasena en config.ini") + print(f"[email] Error de autenticacion: {e}") + sys.exit(1) + except Exception as e: + print(f"[email] Error de conexion: {e}") sys.exit(1) conn.select("INBOX") @@ -155,21 +128,20 @@ def descargar_tickets(): ids = ids[0].split() if not ids: - print("No hay tickets nuevos de Mercadona.") + print("[email] No hay tickets nuevos de Mercadona.") conn.logout() return 0 - print(f"Encontrados {len(ids)} correo(s) de Mercadona.") + print(f"[email] Encontrados {len(ids)} correo(s) de Mercadona.") descargados = 0 for uid in ids: _, data = conn.fetch(uid, "(RFC822)") msg = email.message_from_bytes(data[0][1]) - # Fecha del correo para nombre de archivo fecha_str = msg.get("Date", "") try: - fecha_dt = email.utils.parsedate_to_datetime(fecha_str) + fecha_dt = email.utils.parsedate_to_datetime(fecha_str) fecha_fmt = fecha_dt.strftime("%Y%m%d") except Exception: fecha_fmt = datetime.now().strftime("%Y%m%d") @@ -181,31 +153,34 @@ def descargar_tickets(): if not nombre_orig: nombre_orig = f"ticket_{fecha_fmt}_{uid.decode()}.pdf" - # Evitar colisiones - destino = TICKETS_DIR / nombre_orig + destino = tickets_dir / nombre_orig if destino.exists(): - destino = TICKETS_DIR / f"{fecha_fmt}_{nombre_orig}" + destino = tickets_dir / f"{fecha_fmt}_{nombre_orig}" destino.write_bytes(part.get_payload(decode=True)) - print(f" Guardado: {destino.name}") + print(f"[email] Guardado: {destino.name}") adjuntos_guardados += 1 descargados += 1 if adjuntos_guardados == 0: - print(f" Correo {uid.decode()}: sin PDF adjunto, ignorado.") + print(f"[email] Correo {uid.decode()}: sin PDF adjunto, ignorado.") - # Marcar como leido conn.store(uid, "+FLAGS", "\\Seen") conn.logout() - print(f"\nTotal descargados: {descargados} archivo(s) en {TICKETS_DIR}") + print(f"[email] Total descargados: {descargados} archivo(s) en {tickets_dir}") - # Ejecutar pipeline si esta configurado if ejecutar_pipeline and descargados > 0: - print("\nEjecutando pipeline de procesado...") - subprocess.run([sys.executable, str(BASE_DIR / "autocompra7.py")], check=False) - subprocess.run([sys.executable, str(BASE_DIR / "generar_lista.py")], check=False) - print("Pipeline completado.") + print("[email] Ejecutando pipeline...") + subprocess.run( + [sys.executable, str(BASE_DIR / "autocompra7.py"), "--usuario", usuario], + cwd=str(BASE_DIR), check=False + ) + subprocess.run( + [sys.executable, str(BASE_DIR / "generar_lista.py"), "--usuario", usuario], + cwd=str(BASE_DIR), check=False + ) + print("[email] Pipeline completado.") return descargados @@ -213,6 +188,5 @@ def descargar_tickets(): # Entry point # ----------------------------------------------------------------------- if __name__ == "__main__": - descargados = descargar_tickets() - if descargados == 0: - sys.exit(0) \ No newline at end of file + n = descargar_tickets(_args.usuario) + sys.exit(0 if n >= 0 else 1) \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 3e89e4a..4cfa639 100644 --- a/templates/index.html +++ b/templates/index.html @@ -74,6 +74,13 @@
+ +
+
+ +
@@ -355,6 +362,39 @@ estado.textContent = '❌ Error de conexion'; } } + // ----------------------------------------------------------------------- + // Importar tickets desde correo + // ----------------------------------------------------------------------- + async function importarEmail() { + const btn = document.getElementById('btnImportarEmail'); + const estado = document.getElementById('importarEmailEstado'); + btn.disabled = true; + estado.style.color = 'var(--text-muted)'; + estado.textContent = '⏳ Conectando con el correo...'; + try { + const res = await fetch('/api/importar-email', { method: 'POST' }); + const data = await res.json(); + if (data.ok) { + if (data.nuevos > 0) { + estado.textContent = '✅ ' + data.mensaje + ' — regenerando predicciones...'; + await fetch('/api/regenerar', { method: 'POST' }); + setTimeout(() => location.reload(), 1200); + } else { + estado.textContent = '✅ No hay tickets nuevos en el correo.'; + } + } else { + estado.style.color = '#f97316'; + estado.textContent = '❌ ' + data.mensaje; + if (data.detalle) { + estado.innerHTML += '
' + data.detalle + '
'; + } + } + } catch(e) { + estado.textContent = '❌ Error de conexion'; + } finally { + btn.disabled = false; + } + } \ No newline at end of file