723 lines
27 KiB
Python
723 lines
27 KiB
Python
# backend/main.py
|
|
"""API REST del Dashboard de LEADS (FastAPI)."""
|
|
from fastapi import FastAPI, Query, HTTPException, Body
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from datetime import datetime
|
|
import uvicorn
|
|
|
|
import services
|
|
from cache_manager import start_background_refresh, cache_stats
|
|
|
|
app = FastAPI(title="Dashboard Leads API", version="1.0")
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware, allow_origins=["*"], allow_credentials=True,
|
|
allow_methods=["*"], allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
def _startup():
|
|
# Precarga en SEGUNDO PLANO (hilo aparte) para no bloquear el arranque del server.
|
|
import threading
|
|
def _precarga_bg():
|
|
print("[startup] Precargando datos de Leads (2do plano)...")
|
|
try:
|
|
services.precargar_todo()
|
|
print("[startup] Precarga completa.")
|
|
except Exception as e:
|
|
print(f"[startup] Precarga falló: {e}")
|
|
threading.Thread(target=_precarga_bg, daemon=True).start()
|
|
# Otros General en un hilo aparte (sus consultas son mas pesadas)
|
|
def _precarga_otros_bg():
|
|
try:
|
|
services.precargar_otros_general()
|
|
except Exception as e:
|
|
print(f"[startup] Precarga Otros falló: {e}")
|
|
threading.Thread(target=_precarga_otros_bg, daemon=True).start()
|
|
start_background_refresh(services.refrescar_todo, interval=1800) # 30 min (con horario)
|
|
|
|
|
|
@app.get("/api/health")
|
|
def health():
|
|
return {"status": "ok", "hora": datetime.now().isoformat()}
|
|
|
|
|
|
@app.get("/api/cache/stats")
|
|
def stats():
|
|
return cache_stats()
|
|
|
|
|
|
@app.post("/api/cache/refresh")
|
|
def refresh():
|
|
services.refrescar_todo()
|
|
return {"status": "refrescado"}
|
|
|
|
|
|
@app.get("/api/leads/filtros")
|
|
def get_filtros():
|
|
try:
|
|
return services.opciones_filtros()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/leads")
|
|
def get_leads(
|
|
ano: str = Query("TODOS"),
|
|
mes: str = Query("TODOS"),
|
|
dia: str = Query("TODOS"),
|
|
programa: str = Query("TODOS"),
|
|
sede: str = Query("TODOS"),
|
|
):
|
|
try:
|
|
return services.leads_dashboard(ano, mes, dia, programa, sede)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/vendedores")
|
|
def get_vendedores(
|
|
ano: str = Query("TODOS"),
|
|
mes: str = Query("TODOS"),
|
|
dia: str = Query("TODOS"),
|
|
):
|
|
try:
|
|
return services.vendedores_dashboard(ano, mes, dia)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/tiempo-respuesta/detalle")
|
|
def get_tiempo_respuesta_detalle(
|
|
dia: int = Query(...),
|
|
modo: str = Query("asignacion"),
|
|
vendedor: str = Query("TODOS"),
|
|
ano: str = Query("TODOS"),
|
|
mes: str = Query("TODOS"),
|
|
):
|
|
try:
|
|
return services.tiempo_respuesta_detalle(dia, modo, vendedor, ano, mes)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/programa/opciones")
|
|
def get_programa_opciones(ano: str = Query("TODOS"), mes: str = Query("TODOS")):
|
|
try:
|
|
return {"programas": services.programa_opciones(ano, mes)}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/programa")
|
|
def get_programa(
|
|
ano: str = Query("TODOS"),
|
|
mes: str = Query("TODOS"),
|
|
programa: str = Query("TODOS"),
|
|
):
|
|
try:
|
|
return services.programa_dashboard(ano, mes, programa)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/alertas")
|
|
def get_alertas_dash():
|
|
"""Cursos proximos a iniciar (<=30d) clasificados critico/advertencia/buen rumbo."""
|
|
try:
|
|
return services.alertas_dashboard()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/plantillas")
|
|
def get_plantillas():
|
|
"""Lista las plantillas de WhatsApp del inbox de Chatwoot (nombre + categoría)."""
|
|
import os, requests
|
|
CHATWOOT_URL = os.getenv("CHATWOOT_URL", "https://gestor.escueladerefrigeracion.edu.pe")
|
|
TOKEN = os.getenv("CHATWOOT_TOKEN", "4anazHvZnvKLtup8biu5Zuoh")
|
|
ACCOUNT_ID = os.getenv("CHATWOOT_ACCOUNT_ID", "1")
|
|
INBOX_ID = os.getenv("CHATWOOT_INBOX_ID", "8")
|
|
headers = {"api_access_token": TOKEN, "Content-Type": "application/json"}
|
|
try:
|
|
r = requests.get(f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/inboxes/{INBOX_ID}",
|
|
headers=headers, timeout=15)
|
|
r.raise_for_status()
|
|
inbox = r.json()
|
|
tpls = inbox.get("message_templates") or []
|
|
out = []
|
|
for t in tpls:
|
|
# El texto está en el componente tipo BODY (components[].text).
|
|
texto = ""
|
|
for comp in (t.get("components") or []):
|
|
if str(comp.get("type", "")).upper() == "BODY":
|
|
texto = comp.get("text", "") or ""
|
|
break
|
|
out.append({
|
|
"nombre": t.get("name", ""),
|
|
"categoria": t.get("category", ""),
|
|
"texto": texto,
|
|
"idioma": t.get("language", "") or "",
|
|
})
|
|
out.sort(key=lambda x: x["nombre"])
|
|
return {"plantillas": out}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/plantillas/enviar")
|
|
def post_plantillas_enviar(payload: dict = Body(...)):
|
|
"""Envía una plantilla de WhatsApp (Chatwoot) a una lista de números.
|
|
payload: {nombre, categoria, idioma, texto, telefonos:[...]}.
|
|
Solo se envía a números peruanos con código 51 (formato 51XXXXXXXXX)."""
|
|
import os, requests, time
|
|
CHATWOOT_URL = os.getenv("CHATWOOT_URL", "https://gestor.escueladerefrigeracion.edu.pe")
|
|
TOKEN = os.getenv("CHATWOOT_TOKEN", "4anazHvZnvKLtup8biu5Zuoh")
|
|
ACCOUNT_ID = os.getenv("CHATWOOT_ACCOUNT_ID", "1")
|
|
INBOX_ID = int(os.getenv("CHATWOOT_INBOX_ID", "8"))
|
|
headers = {"api_access_token": TOKEN, "Content-Type": "application/json"}
|
|
|
|
nombre = payload.get("nombre", "")
|
|
categoria = payload.get("categoria", "MARKETING")
|
|
idioma = payload.get("idioma", "es_PE")
|
|
texto = payload.get("texto", "")
|
|
telefonos_raw = payload.get("telefonos", []) or []
|
|
|
|
def ok(s): return s in (200, 201)
|
|
|
|
def normaliza(v):
|
|
s = str(v or "").strip()
|
|
if s.endswith(".0"): s = s[:-2]
|
|
for x in ("+", " ", "-", "(", ")"): s = s.replace(x, "")
|
|
return s
|
|
|
|
# Se envía a CUALQUIER país. Solo se exige que sea un número (dígitos).
|
|
# NO se crean contactos nuevos: si no existe en Chatwoot, se salta y se reporta.
|
|
validos, invalidos = [], []
|
|
for t in telefonos_raw:
|
|
s = normaliza(t)
|
|
if s.isdigit() and len(s) >= 8:
|
|
validos.append(s)
|
|
else:
|
|
invalidos.append(str(t))
|
|
|
|
# Números que NO existen como contacto en Chatwoot (no se crean, solo se reportan)
|
|
no_encontrados = []
|
|
|
|
def chat_id(tel):
|
|
tel_p = "+" + tel
|
|
try:
|
|
r = requests.get(f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/contacts/search",
|
|
headers=headers, params={"q": tel_p}, timeout=20)
|
|
data = r.json()
|
|
except Exception:
|
|
return None
|
|
# Si NO existe el contacto → no crear, marcar como no encontrado
|
|
if not (ok(r.status_code) and data.get("payload")):
|
|
no_encontrados.append(tel)
|
|
return None
|
|
cid = data["payload"][0]["id"]
|
|
rconv = requests.get(f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/contacts/{cid}/conversations",
|
|
headers=headers, timeout=20)
|
|
for conv in rconv.json().get("payload", []):
|
|
if conv.get("inbox_id") == INBOX_ID:
|
|
return conv["id"]
|
|
rn = requests.post(f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/conversations",
|
|
headers=headers, json={"source_id": str(cid), "inbox_id": INBOX_ID, "contact_id": cid}, timeout=20)
|
|
return rn.json().get("id") if ok(rn.status_code) else None
|
|
|
|
enviados, fallidos = 0, []
|
|
for tel in validos:
|
|
cid = chat_id(tel)
|
|
if not cid:
|
|
fallidos.append(tel); continue
|
|
body = {"content": texto, "private": False,
|
|
"template_params": {"name": nombre, "category": categoria,
|
|
"language": idioma, "processed_params": {}}}
|
|
try:
|
|
r = requests.post(f"{CHATWOOT_URL}/api/v1/accounts/{ACCOUNT_ID}/conversations/{cid}/messages",
|
|
headers=headers, json=body, timeout=30)
|
|
if ok(r.status_code):
|
|
enviados += 1
|
|
else:
|
|
fallidos.append(tel)
|
|
except Exception:
|
|
fallidos.append(tel)
|
|
time.sleep(1)
|
|
|
|
# "fallidos" incluye los que no existen (no_encontrados) + los que sí existían
|
|
# pero falló el envío. Se separan para poder avisar cuáles no están en la base.
|
|
fallo_envio = [t for t in fallidos if t not in set(no_encontrados)]
|
|
return {"status": "ok", "enviados": enviados,
|
|
"fallidos": fallidos, "invalidos": invalidos,
|
|
"no_encontrados": no_encontrados, "fallo_envio": fallo_envio,
|
|
"total": len(telefonos_raw)}
|
|
|
|
|
|
@app.get("/api/plantillas/campanias")
|
|
def get_plantillas_campanias():
|
|
"""Lista las campañas (código + frase) para el checklist."""
|
|
try:
|
|
return services.plantillas_campanias()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/plantillas/asesores")
|
|
def get_plantillas_asesores():
|
|
"""Lista los asesores (para el checklist de filtro)."""
|
|
try:
|
|
return services.plantillas_asesores()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/plantillas/estados")
|
|
def get_plantillas_estados():
|
|
"""Lista los estados/objeción (para el checklist de filtro)."""
|
|
try:
|
|
return services.plantillas_estados()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/plantillas/base")
|
|
def post_plantillas_base(payload: dict = Body(...)):
|
|
"""Devuelve la base de números (teléfono + 2 fechas + asesor + estado) de los
|
|
códigos elegidos, filtrando por asesores ('__VACIO__' = sin asesor) y estados."""
|
|
try:
|
|
return services.plantillas_base_por_codigos(
|
|
payload.get("codigos", []), payload.get("asesores"), payload.get("estados"))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# ── SUBIR BASE: etiquetas, asesores y envío masivo con verificación ────────
|
|
def _cw_cfg():
|
|
import os
|
|
return {
|
|
"URL": os.getenv("CHATWOOT_URL", "https://gestor.escueladerefrigeracion.edu.pe"),
|
|
"TOKEN": os.getenv("CHATWOOT_TOKEN", "4anazHvZnvKLtup8biu5Zuoh"),
|
|
"ACCOUNT_ID": os.getenv("CHATWOOT_ACCOUNT_ID", "1"),
|
|
"INBOX_ID": int(os.getenv("CHATWOOT_INBOX_ID", "8")),
|
|
}
|
|
|
|
|
|
@app.get("/api/subir-base/etiquetas")
|
|
def get_subir_base_etiquetas():
|
|
"""Lista las etiquetas (labels) de Chatwoot, EN VIVO (sin caché)."""
|
|
import requests
|
|
c = _cw_cfg()
|
|
h = {"api_access_token": c["TOKEN"], "Content-Type": "application/json"}
|
|
try:
|
|
r = requests.get(f"{c['URL']}/api/v1/accounts/{c['ACCOUNT_ID']}/labels", headers=h, timeout=15)
|
|
r.raise_for_status()
|
|
data = r.json().get("payload", r.json())
|
|
etiquetas = [x.get("title") for x in (data or []) if x.get("title")]
|
|
return {"etiquetas": sorted(etiquetas)}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/subir-base/asesores")
|
|
def get_subir_base_asesores():
|
|
"""Lista los asesores (agents) de Chatwoot con su id y nombre, EN VIVO."""
|
|
import requests
|
|
c = _cw_cfg()
|
|
h = {"api_access_token": c["TOKEN"], "Content-Type": "application/json"}
|
|
try:
|
|
r = requests.get(f"{c['URL']}/api/v1/accounts/{c['ACCOUNT_ID']}/agents", headers=h, timeout=15)
|
|
r.raise_for_status()
|
|
ags = [{"id": a.get("id"), "nombre": a.get("name") or a.get("email")}
|
|
for a in (r.json() or []) if a.get("id")]
|
|
ags.sort(key=lambda x: str(x["nombre"]).upper())
|
|
return {"asesores": ags}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/subir-base/enviar")
|
|
def post_subir_base_enviar(payload: dict = Body(...)):
|
|
"""Sube una base de números: crea contacto (o usa el existente), le asigna
|
|
asesor y etiquetas, le envía la plantilla, y tras unos segundos verifica el
|
|
estado. Si WhatsApp rechazó el número (error 131009 → 'Error al enviar'), BORRA
|
|
la conversación y el contacto. Los que se enviaron bien se quedan con todo.
|
|
payload: {nombre, categoria, idioma, texto, telefonos:[], etiquetas:[],
|
|
asesor_id (int o null para SIN ASIGNACIÓN)}."""
|
|
import requests, time
|
|
c = _cw_cfg()
|
|
URL, ACC, INBOX = c["URL"], c["ACCOUNT_ID"], c["INBOX_ID"]
|
|
h = {"api_access_token": c["TOKEN"], "Content-Type": "application/json"}
|
|
def ok(s): return s in (200, 201)
|
|
|
|
nombre = payload.get("nombre", "")
|
|
categoria = payload.get("categoria", "MARKETING")
|
|
idioma = payload.get("idioma", "es_PE")
|
|
texto = payload.get("texto", "")
|
|
etiquetas = [str(x).strip() for x in (payload.get("etiquetas") or []) if str(x).strip()]
|
|
asesor_id = payload.get("asesor_id") # None/"" = SIN ASIGNACIÓN
|
|
tels_raw = payload.get("telefonos", []) or []
|
|
|
|
def norm(v):
|
|
s = str(v or "").strip()
|
|
if s.endswith(".0"): s = s[:-2]
|
|
for x in ("+", " ", "-", "(", ")"): s = s.replace(x, "")
|
|
return s
|
|
|
|
validos, invalidos = [], []
|
|
for t in tels_raw:
|
|
s = norm(t)
|
|
if s.isdigit() and len(s) >= 8:
|
|
validos.append(s)
|
|
else:
|
|
invalidos.append(str(t))
|
|
|
|
enviados, borrados, fallo = [], [], []
|
|
seguimiento = [] # [{tel, contact_id, conv_id, msg_id, creado_contacto}]
|
|
|
|
for tel in validos:
|
|
tel_p = "+" + tel
|
|
# 1) Buscar contacto; si no existe, crearlo
|
|
creado = False
|
|
contact_id = None
|
|
try:
|
|
rb = requests.get(f"{URL}/api/v1/accounts/{ACC}/contacts/search",
|
|
headers=h, params={"q": tel_p}, timeout=20)
|
|
pay = rb.json().get("payload", []) if ok(rb.status_code) else []
|
|
if pay:
|
|
contact_id = pay[0]["id"]
|
|
except Exception:
|
|
pass
|
|
if not contact_id:
|
|
try:
|
|
# 1) Crear el contacto (sin inbox todavía).
|
|
rc = requests.post(f"{URL}/api/v1/accounts/{ACC}/contacts", headers=h,
|
|
json={"name": "Contacto " + tel, "phone_number": tel_p}, timeout=20)
|
|
jc = rc.json()
|
|
contact_id = (jc.get("payload", {}).get("contact", {}) or {}).get("id") or jc.get("id")
|
|
creado = True
|
|
print(f"[subir-base] {tel}: POST contacto status={rc.status_code} contact_id={contact_id}")
|
|
except Exception as e:
|
|
print(f"[subir-base] {tel}: error creando contacto: {e}")
|
|
fallo.append(tel); continue
|
|
if not contact_id:
|
|
fallo.append(tel); continue
|
|
|
|
# Vincular el contacto al inbox con source_id = número (igual que el SQL que
|
|
# inserta en contact_inboxes). Así WhatsApp reconoce el destino. Idempotente.
|
|
try:
|
|
requests.post(f"{URL}/api/v1/accounts/{ACC}/contacts/{contact_id}/contact_inboxes",
|
|
headers=h, json={"inbox_id": INBOX, "source_id": tel}, timeout=20)
|
|
except Exception as e:
|
|
print(f"[subir-base] {tel}: error vinculando inbox: {e}")
|
|
|
|
# 2) Conversación (usar la del inbox o crear)
|
|
conv_id = None
|
|
try:
|
|
rconv = requests.get(f"{URL}/api/v1/accounts/{ACC}/contacts/{contact_id}/conversations",
|
|
headers=h, timeout=20)
|
|
for cv in rconv.json().get("payload", []):
|
|
if cv.get("inbox_id") == INBOX:
|
|
conv_id = cv["id"]; break
|
|
except Exception:
|
|
pass
|
|
if not conv_id:
|
|
try:
|
|
# source_id = número de teléfono (así WhatsApp reconoce el destino).
|
|
# Usar el contact_id como source_id rompe el envío (131009).
|
|
rn = requests.post(f"{URL}/api/v1/accounts/{ACC}/conversations", headers=h,
|
|
json={"source_id": tel, "inbox_id": INBOX,
|
|
"contact_id": contact_id}, timeout=20)
|
|
conv_id = rn.json().get("id") if ok(rn.status_code) else None
|
|
print(f"[subir-base] {tel}: POST conv status={rn.status_code} conv_id={conv_id}")
|
|
except Exception as e:
|
|
print(f"[subir-base] {tel}: error creando conversacion: {e}")
|
|
if not conv_id:
|
|
if creado:
|
|
try: requests.delete(f"{URL}/api/v1/accounts/{ACC}/contacts/{contact_id}", headers=h, timeout=15)
|
|
except Exception: pass
|
|
fallo.append(tel); continue
|
|
|
|
# 3) Asignar asesor (si no es SIN ASIGNACIÓN) — DIRECTO en Postgres, para NO
|
|
# generar el evento "Asignado a ..." (que dispararía el panel de alertas).
|
|
if asesor_id:
|
|
try:
|
|
import services as _svc
|
|
_conn = _svc.get_dm().pg_conn() if hasattr(_svc, "get_dm") else None
|
|
except Exception:
|
|
_conn = None
|
|
if _conn is None:
|
|
# fallback: usar psycopg2 directo con los mismos env que el data_manager
|
|
try:
|
|
import psycopg2, os as _os
|
|
_conn = psycopg2.connect(
|
|
host=_os.getenv("PG_HOST", "191.98.134.81"),
|
|
dbname=_os.getenv("PG_DB", _os.getenv("PG_DATABASE", "chatwoot_production")),
|
|
user=_os.getenv("PG_USER", "postgres"),
|
|
password=_os.getenv("PG_PASS", _os.getenv("PG_PASSWORD", "")),
|
|
port=_os.getenv("PG_PORT", "5432"))
|
|
except Exception as e:
|
|
_conn = None
|
|
print(f"[subir-base] {tel}: sin conexion Postgres para asignar: {e}")
|
|
if _conn is not None:
|
|
try:
|
|
_cur = _conn.cursor()
|
|
_cur.execute("UPDATE conversations SET assignee_id=%s, updated_at=NOW() WHERE id=%s",
|
|
(int(asesor_id), int(conv_id)))
|
|
_conn.commit()
|
|
_cur.close(); _conn.close()
|
|
except Exception as e:
|
|
print(f"[subir-base] {tel}: error asignando en Postgres: {e}")
|
|
try: _conn.close()
|
|
except Exception: pass
|
|
# 4) Etiquetas (todas las elegidas)
|
|
if etiquetas:
|
|
try:
|
|
requests.post(f"{URL}/api/v1/accounts/{ACC}/conversations/{conv_id}/labels",
|
|
headers=h, json={"labels": etiquetas}, timeout=15)
|
|
except Exception:
|
|
pass
|
|
|
|
# 5) Enviar plantilla
|
|
msg_id = None
|
|
try:
|
|
body = {"content": texto, "private": False,
|
|
"template_params": {"name": nombre, "category": categoria,
|
|
"language": idioma, "processed_params": {}}}
|
|
rm = requests.post(f"{URL}/api/v1/accounts/{ACC}/conversations/{conv_id}/messages",
|
|
headers=h, json=body, timeout=30)
|
|
if ok(rm.status_code):
|
|
msg_id = rm.json().get("id")
|
|
print(f"[subir-base] {tel}: POST msg status={rm.status_code} msg_id={msg_id}")
|
|
except Exception as e:
|
|
print(f"[subir-base] {tel}: error enviando plantilla: {e}")
|
|
|
|
seguimiento.append({"tel": tel, "contact_id": contact_id, "conv_id": conv_id,
|
|
"msg_id": msg_id, "creado": creado})
|
|
time.sleep(0.4)
|
|
|
|
# 6) Esperar a que WhatsApp marque los estados (131009 tarda unos segundos)
|
|
time.sleep(22) # dar tiempo a WhatsApp a marcar el estado del mensaje
|
|
|
|
# 7) Revisar cada mensaje: SOLO borrar si está explícitamente "failed".
|
|
# Si no se encuentra el mensaje o el estado no es failed → NO se borra (se queda).
|
|
for s in seguimiento:
|
|
fallo_wa = False
|
|
encontrado = False
|
|
try:
|
|
rmsgs = requests.get(f"{URL}/api/v1/accounts/{ACC}/conversations/{s['conv_id']}/messages",
|
|
headers=h, timeout=20)
|
|
for m in rmsgs.json().get("payload", []):
|
|
if s["msg_id"] and m.get("id") == s["msg_id"]:
|
|
encontrado = True
|
|
st = str(m.get("status", "")).lower()
|
|
print(f"[subir-base] {s['tel']}: estado mensaje = '{st}'")
|
|
if st == "failed":
|
|
fallo_wa = True
|
|
break
|
|
if not encontrado:
|
|
print(f"[subir-base] {s['tel']}: mensaje NO encontrado (msg_id={s['msg_id']})")
|
|
except Exception as e:
|
|
print(f"[subir-base] {s['tel']}: error leyendo estado: {e}")
|
|
# Solo se borra si WhatsApp lo marcó como failed de verdad
|
|
if fallo_wa:
|
|
# Borrar conversación y contacto (número no existe)
|
|
try: requests.delete(f"{URL}/api/v1/accounts/{ACC}/conversations/{s['conv_id']}", headers=h, timeout=15)
|
|
except Exception: pass
|
|
try: requests.delete(f"{URL}/api/v1/accounts/{ACC}/contacts/{s['contact_id']}", headers=h, timeout=15)
|
|
except Exception: pass
|
|
borrados.append(s["tel"])
|
|
else:
|
|
enviados.append(s["tel"])
|
|
|
|
return {"status": "ok", "enviados": enviados, "borrados": borrados,
|
|
"fallo": fallo, "invalidos": invalidos, "total": len(tels_raw)}
|
|
|
|
|
|
@app.get("/api/roas")
|
|
def get_roas(
|
|
ano: str = Query("TODOS"),
|
|
mes: str = Query("TODOS"),
|
|
dia: str = Query("TODOS"),
|
|
):
|
|
try:
|
|
return services.roas_dashboard(ano, mes, dia)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/conjuntos-sin-sede")
|
|
def get_conjuntos_sin_sede():
|
|
try:
|
|
return services.conjuntos_sin_sede()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/conjuntos-sin-sede/guardar")
|
|
def post_conjunto_sede(body: dict = Body(...)):
|
|
try:
|
|
cambios = body.get("cambios") or []
|
|
return services.guardar_conjunto_sede(cambios)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/conjuntos-sin-sede/borrar")
|
|
def post_borrar_conjunto_sede(body: dict = Body(...)):
|
|
try:
|
|
return services.borrar_conjunto_sede(body.get("conjunto") or "")
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/otros-general")
|
|
def get_otros_general(
|
|
ano: str = Query("TODOS"),
|
|
mes: str = Query("TODOS"),
|
|
dia: str = Query("TODOS"),
|
|
):
|
|
try:
|
|
return services.otros_general(ano, mes, dia)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/conjuntos-sin-pauta")
|
|
def get_conjuntos_sin_pauta():
|
|
try:
|
|
return {"conjuntos": services.conjuntos_sin_pauta()}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/programas-ocultos")
|
|
def get_programas_ocultos():
|
|
try:
|
|
return services.programas_ocultos()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/programas-ocultos/encender")
|
|
def post_encender_programas(body: dict = Body(...)):
|
|
try:
|
|
nis = body.get("num_indices") or []
|
|
return services.encender_programas(nis)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/programas-disponibles")
|
|
def get_programas_disponibles():
|
|
try:
|
|
return services.programas_disponibles()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/leyenda-anuncios")
|
|
def get_leyenda():
|
|
try:
|
|
return services.leyenda_anuncios()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/leyenda-anuncios/guardar")
|
|
def post_leyenda(body: dict = Body(...)):
|
|
try:
|
|
cambios = body.get("cambios") or []
|
|
return services.guardar_leyenda(cambios)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# ── LEYENDA (campanias de Supabase): listar / agregar / borrar ──
|
|
@app.get("/api/campanias")
|
|
def get_campanias():
|
|
try:
|
|
return {"filas": services.campanias_listar()}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/campanias/agregar")
|
|
def post_campania(body: dict = Body(...)):
|
|
try:
|
|
return services.campania_agregar(body or {})
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/campanias/editar")
|
|
def post_editar_campania(body: dict = Body(...)):
|
|
try:
|
|
cid = body.get("id")
|
|
return services.campania_editar(cid, body or {})
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/campanias/borrar")
|
|
def post_borrar_campania(body: dict = Body(...)):
|
|
try:
|
|
return services.campania_borrar(body.get("id"))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/pauta/uso")
|
|
def get_uso_pauta(pauta: str = Query(...), excluir: str = Query("")):
|
|
try:
|
|
return services.uso_de_pauta(pauta, excluir)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post("/api/curso/guardar-pauta")
|
|
def post_guardar_pauta(body: dict = Body(...)):
|
|
try:
|
|
ni = str(body.get("num_indice", "")).strip()
|
|
pa = str(body.get("pauta", "")).strip()
|
|
cj = body.get("conjunto") or None
|
|
contar = body.get("contar") # "SI" / "NO" / None
|
|
if not ni:
|
|
raise HTTPException(status_code=400, detail="num_indice es obligatorio")
|
|
return services.guardar_edicion_curso(ni, pa, cj, contar)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/ultima-actualizacion")
|
|
def get_ultima_actualizacion():
|
|
try:
|
|
return services.ultima_actualizacion()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/leads/alertas")
|
|
def get_alertas():
|
|
"""Valores de programa/sede en cartera_junta que NO están en el diccionario."""
|
|
try:
|
|
return services.alertas()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get("/api/debug/cursos")
|
|
def debug_cursos(ano: str = "2026", mes: str = "1"):
|
|
"""Diagnóstico: lista cursos del periodo con inscritos, tipo y meta."""
|
|
try:
|
|
return services.debug_cursos(ano, mes)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=False)
|