Files
dashboard-leads/backend/main.py

478 lines
16 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,
})
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:[...]}.
Se envía a CUALQUIER país. NO se crean contactos nuevos: si el número no
existe en Chatwoot, se salta y se reporta en 'no_encontrados'."""
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, min 8).
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)
# "fallo_envio" = existían pero falló el envío (excluye los no_encontrados)
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))
@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)