feat: importar tickets PDF desde correo IMAP (Dovecot local, ssl sin verificar)
This commit is contained in:
parent
3d31d308e5
commit
4de7a3d8e0
25
app.py
25
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)
|
||||
# -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
n = descargar_tickets(_args.usuario)
|
||||
sys.exit(0 if n >= 0 else 1)
|
||||
|
|
@ -74,6 +74,13 @@
|
|||
<div id="fotoProductos" style="margin-top:.5rem;"></div>
|
||||
|
||||
<div style="margin-top:1.25rem; border-top:1px solid var(--border); padding-top:1rem;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="importarEmail()" id="btnImportarEmail">
|
||||
📧 Importar tickets del correo
|
||||
</button>
|
||||
<div id="importarEmailEstado" style="font-size:.8rem; color:var(--text-muted); margin-top:.4rem;"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem; border-top:1px solid var(--border); padding-top:1rem;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="regenerar()" id="btnRegenerar">
|
||||
Regenerar predicciones
|
||||
</button>
|
||||
|
|
@ -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 += '<pre style="font-size:.7rem;white-space:pre-wrap;margin-top:.3rem;color:#f97316;">' + data.detalle + '</pre>';
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
estado.textContent = '❌ Error de conexion';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue