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:
|
except subprocess.TimeoutExpired:
|
||||||
return jsonify({"ok": False, "mensaje": "Timeout al ejecutar el pipeline"}), 500
|
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)
|
# Archivos estaticos de tickets (solo autenticados)
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,21 @@
|
||||||
importar_tickets_email.py
|
importar_tickets_email.py
|
||||||
-------------------------
|
-------------------------
|
||||||
Descarga los tickets PDF de Mercadona desde el correo (IMAP)
|
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:
|
Uso:
|
||||||
python importar_tickets_email.py
|
python importar_tickets_email.py --usuario tatiana
|
||||||
|
|
||||||
Configuracion en config.ini (se crea automaticamente la primera vez).
|
Configuracion en config.ini (montado como volumen en Docker).
|
||||||
|
Soporta SSL sin verificacion de certificado (util con servidores locales).
|
||||||
Para Gmail necesitas una "contrasena de aplicacion":
|
|
||||||
Cuenta Google -> Seguridad -> Verificacion en dos pasos ->
|
|
||||||
Contrasenas de aplicacion -> Otra -> copiar los 16 caracteres.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import imaplib
|
import imaplib
|
||||||
import email
|
import email
|
||||||
|
import email.utils
|
||||||
import os
|
import os
|
||||||
|
import ssl
|
||||||
import configparser
|
import configparser
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -24,26 +24,31 @@ from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from email.header import decode_header
|
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
|
# Rutas
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
TICKETS_DIR = BASE_DIR / "tickets"
|
|
||||||
CONFIG_FILE = BASE_DIR / "config.ini"
|
CONFIG_FILE = BASE_DIR / "config.ini"
|
||||||
|
|
||||||
TICKETS_DIR.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Configuracion
|
# Configuracion
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
"imap_host": "imap.gmail.com",
|
"imap_host": "localhost",
|
||||||
"imap_port": "993",
|
"imap_port": "993",
|
||||||
"correo": "",
|
"correo": "",
|
||||||
"password": "",
|
"password": "",
|
||||||
"remitente": "noreply@mercadona.es",
|
"remitente": "noreply@mercadona.es",
|
||||||
"solo_nuevos": "true",
|
"solo_nuevos": "true",
|
||||||
"ejecutar_pipeline": "false",
|
"ssl_verify": "true",
|
||||||
|
"ejecutar_pipeline": "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
def leer_config():
|
def leer_config():
|
||||||
|
|
@ -56,55 +61,6 @@ def leer_config():
|
||||||
cfg["email"].setdefault(k, v)
|
cfg["email"].setdefault(k, v)
|
||||||
return cfg
|
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
|
# Decodificacion de nombres de archivo
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
@ -123,26 +79,43 @@ def decodificar_nombre(nombre_raw):
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Descarga de tickets
|
# Descarga de tickets
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
def descargar_tickets():
|
def descargar_tickets(usuario: str) -> int:
|
||||||
cfg = pedir_config()
|
cfg = leer_config()
|
||||||
sec = cfg["email"]
|
sec = cfg["email"]
|
||||||
|
|
||||||
host = sec["imap_host"]
|
# Variables de entorno sobreescriben config.ini
|
||||||
port = int(sec["imap_port"])
|
host = os.environ.get("EMAIL_IMAP_HOST", sec["imap_host"]).strip()
|
||||||
correo = sec["correo"]
|
port = int(os.environ.get("EMAIL_IMAP_PORT", sec["imap_port"]))
|
||||||
password = sec["password"]
|
correo = os.environ.get("EMAIL_CORREO", sec["correo"]).strip()
|
||||||
|
password = os.environ.get("EMAIL_PASSWORD", sec["password"]).strip()
|
||||||
remitente = sec["remitente"]
|
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"
|
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:
|
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)
|
conn.login(correo, password)
|
||||||
except imaplib.IMAP4.error as e:
|
except imaplib.IMAP4.error as e:
|
||||||
print(f"Error de autenticacion: {e}")
|
print(f"[email] Error de autenticacion: {e}")
|
||||||
print("Comprueba el correo y la contrasena en config.ini")
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[email] Error de conexion: {e}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
conn.select("INBOX")
|
conn.select("INBOX")
|
||||||
|
|
@ -155,21 +128,20 @@ def descargar_tickets():
|
||||||
ids = ids[0].split()
|
ids = ids[0].split()
|
||||||
|
|
||||||
if not ids:
|
if not ids:
|
||||||
print("No hay tickets nuevos de Mercadona.")
|
print("[email] No hay tickets nuevos de Mercadona.")
|
||||||
conn.logout()
|
conn.logout()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print(f"Encontrados {len(ids)} correo(s) de Mercadona.")
|
print(f"[email] Encontrados {len(ids)} correo(s) de Mercadona.")
|
||||||
descargados = 0
|
descargados = 0
|
||||||
|
|
||||||
for uid in ids:
|
for uid in ids:
|
||||||
_, data = conn.fetch(uid, "(RFC822)")
|
_, data = conn.fetch(uid, "(RFC822)")
|
||||||
msg = email.message_from_bytes(data[0][1])
|
msg = email.message_from_bytes(data[0][1])
|
||||||
|
|
||||||
# Fecha del correo para nombre de archivo
|
|
||||||
fecha_str = msg.get("Date", "")
|
fecha_str = msg.get("Date", "")
|
||||||
try:
|
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")
|
fecha_fmt = fecha_dt.strftime("%Y%m%d")
|
||||||
except Exception:
|
except Exception:
|
||||||
fecha_fmt = datetime.now().strftime("%Y%m%d")
|
fecha_fmt = datetime.now().strftime("%Y%m%d")
|
||||||
|
|
@ -181,31 +153,34 @@ def descargar_tickets():
|
||||||
if not nombre_orig:
|
if not nombre_orig:
|
||||||
nombre_orig = f"ticket_{fecha_fmt}_{uid.decode()}.pdf"
|
nombre_orig = f"ticket_{fecha_fmt}_{uid.decode()}.pdf"
|
||||||
|
|
||||||
# Evitar colisiones
|
destino = tickets_dir / nombre_orig
|
||||||
destino = TICKETS_DIR / nombre_orig
|
|
||||||
if destino.exists():
|
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))
|
destino.write_bytes(part.get_payload(decode=True))
|
||||||
print(f" Guardado: {destino.name}")
|
print(f"[email] Guardado: {destino.name}")
|
||||||
adjuntos_guardados += 1
|
adjuntos_guardados += 1
|
||||||
descargados += 1
|
descargados += 1
|
||||||
|
|
||||||
if adjuntos_guardados == 0:
|
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.store(uid, "+FLAGS", "\\Seen")
|
||||||
|
|
||||||
conn.logout()
|
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:
|
if ejecutar_pipeline and descargados > 0:
|
||||||
print("\nEjecutando pipeline de procesado...")
|
print("[email] Ejecutando pipeline...")
|
||||||
subprocess.run([sys.executable, str(BASE_DIR / "autocompra7.py")], check=False)
|
subprocess.run(
|
||||||
subprocess.run([sys.executable, str(BASE_DIR / "generar_lista.py")], check=False)
|
[sys.executable, str(BASE_DIR / "autocompra7.py"), "--usuario", usuario],
|
||||||
print("Pipeline completado.")
|
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
|
return descargados
|
||||||
|
|
||||||
|
|
@ -213,6 +188,5 @@ def descargar_tickets():
|
||||||
# Entry point
|
# Entry point
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
descargados = descargar_tickets()
|
n = descargar_tickets(_args.usuario)
|
||||||
if descargados == 0:
|
sys.exit(0 if n >= 0 else 1)
|
||||||
sys.exit(0)
|
|
||||||
|
|
@ -74,6 +74,13 @@
|
||||||
<div id="fotoProductos" style="margin-top:.5rem;"></div>
|
<div id="fotoProductos" style="margin-top:.5rem;"></div>
|
||||||
|
|
||||||
<div style="margin-top:1.25rem; border-top:1px solid var(--border); padding-top:1rem;">
|
<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">
|
<button class="btn btn-secondary btn-sm" onclick="regenerar()" id="btnRegenerar">
|
||||||
Regenerar predicciones
|
Regenerar predicciones
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -355,6 +362,39 @@
|
||||||
estado.textContent = '❌ Error de conexion';
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Loading…
Reference in New Issue