hbox-docs/backend/incidencias.py

405 lines
22 KiB
Python

"""Incidencias del sitio (27-oct-2026): lo que falla en drogheda.helgen.online, reportado desde cualquier página
(sección «Problemas del sitio» al pie), desde la página de entrada (sin sesión) o detectado solo (vigía).
Lo engancha app.py al final con registrar(app, globals()), como instalaciones.py.
Datos: DATOS_BASE/incidencias/INC-0001.json (un JSON por incidencia) + _indice.csv; folio con candado en _folio.
DATOS_BASE/incidencias/_errores/cliente-AAAA-MM-DD.jsonl errores de JavaScript y llamadas fallidas (navegador)
DATOS_BASE/incidencias/_errores/servidor-AAAA-MM-DD.jsonl excepciones y respuestas 5xx del backend
Nada se borra: una incidencia se resuelve o se descarta.
Rutas con sesión:
GET /api/incidencias?estado=abiertas|cerradas|todas&tipo=&origen=&q= lista + conteos
GET /api/incidencias/resumen lo que muestra el pie de cada página (abiertas, mías, últimas 3)
GET /api/incidencia?id=INC-0001
POST /api/incidencias {titulo?, descripcion, tipo, pagina, contexto, firma?}
(firma: quien detecta solo —vigia o claude— manda una huella; si ya hay una abierta con esa huella se suma
una ocurrencia en vez de abrir otra)
PATCH /api/incidencia?id= {comentario} cualquiera · {estado, gravedad, asignado, publica,
resumen_publico, duplicado_de, tarea_id, titulo, tipo} quien edita (INCIDENCIAS_EDITORES + administradores);
quien la reportó puede marcarla resuelta o reabrirla
POST /api/incidencias/error errores del navegador (los manda js/incidencias.js solo)
GET /api/incidencias/errores?dias=2 (editores) errores de cliente y servidor agrupados
Rutas sin sesión (nginx las deja pasar sin auth_request y SIN X-Remote-User):
POST /api/incidencias/publica {nombre, contacto, descripcion, tipo} desde /entrar.html
GET /api/incidencias/publicas avisos marcados «publica» (solo título público, estado y fecha)
"""
import datetime
import fcntl
import hashlib
import json
import os
import re
import traceback
from flask import got_request_exception, jsonify, request
TIPOS = ("acceso", "error", "datos", "lento", "sugerencia", "otro")
NOMBRE_TIPO = {"acceso": "No puedo entrar", "error": "Error o pantalla rota", "datos": "Datos incorrectos",
"lento": "Lento", "sugerencia": "Sugerencia", "otro": "Otro"}
ESTADOS = ("nueva", "en-revision", "en-curso", "esperando", "resuelta", "descartada")
ABIERTAS = ("nueva", "en-revision", "en-curso", "esperando")
GRAVEDADES = ("alta", "media", "baja")
ORIGENES = ("sitio", "entrada", "vigia", "agente")
SISTEMA = {"vigia", "claude"} # pueden abrir con origen vigia/agente y con firma
COLS = ["id", "titulo", "tipo", "estado", "gravedad", "origen", "reportado_por", "asignado", "pagina", "ocurrencias",
"publica", "tarea_id", "duplicado_de", "creado_en", "modificado_en", "resuelta_en"]
MAX_ERRORES_DIA = 3000
def registrar(app, g):
carpeta = os.environ.get("INCIDENCIAS_DIR", os.path.join(g["DATOS_BASE"], "incidencias"))
carpeta_err = os.path.join(carpeta, "_errores")
editores = {u.strip().lower() for u in os.environ.get("INCIDENCIAS_EDITORES", "leo,gibranlp,adriana,claude").split(",")
if u.strip()}
texto, ahora, hist = g["_texto"], g["ahora"], g["_hist"]
def yo():
return g["current_user"]()
def puede_editar(u):
return bool(u) and (u.lower() in editores or u.lower() in g["ADMINS"])
def ruta(iid):
return os.path.join(carpeta, iid + ".json")
def todas():
return [d for d in g["_listar_json"](carpeta) if str(d.get("id", "")).startswith("INC-")]
def guardar(d):
g["_escribir_json"](ruta(d["id"]), d)
g["_reescribir_indice"](carpeta, todas(), COLS)
def siguiente_folio():
os.makedirs(carpeta, exist_ok=True)
with open(os.path.join(carpeta, "_folio"), "a+", encoding="utf-8") as f:
fcntl.flock(f, fcntl.LOCK_EX)
f.seek(0)
try:
n = int((f.read() or "0").strip() or 0)
except ValueError:
n = 0
existentes = [int(m.group(1)) for x in os.listdir(carpeta) if (m := re.match(r"INC-(\d+)\.json$", x))]
n = max([n] + existentes) + 1
f.seek(0)
f.truncate()
f.write(str(n))
f.flush()
fcntl.flock(f, fcntl.LOCK_UN)
return f"INC-{n:04d}"
def contexto_limpio(c):
"""El contexto técnico que manda la página: se guarda acotado (nada de cookies ni contraseñas)."""
if not isinstance(c, dict):
return {}
out = {}
for k in ("pantalla", "navegador", "idioma", "referer", "hora_local"):
if c.get(k):
out[k] = texto(c.get(k), 200)
for k in ("errores", "llamadas"):
v = c.get(k)
if isinstance(v, list):
out[k] = [{kk: texto(x.get(kk), 300) for kk in list(x)[:8]} for x in v[-10:] if isinstance(x, dict)]
return out
def nueva(d, u, origen):
tipo = d.get("tipo") if d.get("tipo") in TIPOS else "otro"
desc = texto(d.get("descripcion"), 4000)
titulo = texto(d.get("titulo"), 140) or (desc.split("\n")[0][:120] if desc else NOMBRE_TIPO[tipo])
r = {"id": siguiente_folio(), "titulo": titulo, "descripcion": desc, "tipo": tipo, "estado": "nueva",
"gravedad": d.get("gravedad") if d.get("gravedad") in GRAVEDADES else ("alta" if tipo == "acceso" else "media"),
"origen": origen, "reportado_por": u or "", "nombre": texto(d.get("nombre"), 80),
"contacto": texto(d.get("contacto"), 120), "asignado": "", "pagina": texto(d.get("pagina"), 300),
"navegador": texto(request.headers.get("User-Agent"), 200), "contexto": contexto_limpio(d.get("contexto")),
"firma": texto(d.get("firma"), 80), "ocurrencias": 1, "ultima_vez": ahora(), "evidencia": [],
"publica": False, "resumen_publico": "", "tarea_id": "", "duplicado_de": "", "comentarios": [],
"creado_por": u or "(sin sesión)", "creado_en": ahora(), "resuelta_en": None}
if isinstance(d.get("evidencia"), list):
r["evidencia"] = [texto(x, 600) for x in d["evidencia"][:20]]
hist(r, u or "(sin sesión)", f"reportada desde {origen}")
guardar(r)
return r
def para_lista(r, completo=False):
if completo:
return r
keys = ("id", "titulo", "tipo", "estado", "gravedad", "origen", "reportado_por", "nombre", "asignado", "pagina",
"ocurrencias", "ultima_vez", "publica", "tarea_id", "duplicado_de", "creado_en", "modificado_en",
"resuelta_en")
out = {k: r.get(k) for k in keys}
out["comentarios"] = len(r.get("comentarios") or [])
out["ultimo_comentario"] = (r.get("comentarios") or [{}])[-1] if r.get("comentarios") else None
return out
# ------------------------------------------------------------------ con sesión
@app.get("/api/incidencias")
def incidencias_lista():
u = yo()
if not u:
return g["_sin_usuario"]()
est = (request.args.get("estado") or "abiertas").strip()
tipo = (request.args.get("tipo") or "").strip()
origen = (request.args.get("origen") or "").strip()
q = (request.args.get("q") or "").strip().lower()
regs = todas()
conteo = {"abiertas": sum(1 for r in regs if r.get("estado") in ABIERTAS),
"por_estado": {e: sum(1 for r in regs if r.get("estado") == e) for e in ESTADOS},
"por_tipo": {t: sum(1 for r in regs if r.get("tipo") == t and r.get("estado") in ABIERTAS) for t in TIPOS}}
if est == "abiertas":
regs = [r for r in regs if r.get("estado") in ABIERTAS]
elif est == "cerradas":
regs = [r for r in regs if r.get("estado") not in ABIERTAS]
elif est in ESTADOS:
regs = [r for r in regs if r.get("estado") == est]
if tipo in TIPOS:
regs = [r for r in regs if r.get("tipo") == tipo]
if origen in ORIGENES:
regs = [r for r in regs if r.get("origen") == origen]
if q:
regs = [r for r in regs if q in (r.get("id", "") + " " + r.get("titulo", "") + " " + r.get("descripcion", "")).lower()]
abiertas = sorted([r for r in regs if r.get("estado") in ABIERTAS],
key=lambda r: r.get("modificado_en") or r.get("creado_en") or "", reverse=True)
abiertas.sort(key=lambda r: GRAVEDADES.index(r["gravedad"]) if r.get("gravedad") in GRAVEDADES else 1) # estable
cerradas = sorted([r for r in regs if r.get("estado") not in ABIERTAS],
key=lambda r: r.get("modificado_en") or "", reverse=True)
return jsonify(ok=True, incidencias=[para_lista(r) for r in abiertas + cerradas][:300], conteo=conteo,
tipos=[{"id": t, "nombre": NOMBRE_TIPO[t]} for t in TIPOS], estados=list(ESTADOS),
gravedades=list(GRAVEDADES), puede_editar=puede_editar(u), yo=u)
@app.get("/api/incidencias/resumen")
def incidencias_resumen():
u = yo()
if not u:
return g["_sin_usuario"]()
regs = [r for r in todas() if r.get("estado") in ABIERTAS]
regs.sort(key=lambda r: r.get("modificado_en") or r.get("creado_en") or "", reverse=True)
mias = [r for r in regs if (r.get("reportado_por") or "").lower() == u.lower()]
return jsonify(ok=True, abiertas=len(regs), mias=len(mias),
ultimas=[{k: r.get(k) for k in ("id", "titulo", "estado", "tipo")} for r in regs[:3]])
@app.get("/api/incidencia")
def incidencia_una():
if not yo():
return g["_sin_usuario"]()
r = g["_leer_json"](ruta(g["slug"](request.args.get("id"), 12)))
if not r:
return jsonify(ok=False, error="No existe esa incidencia."), 404
return jsonify(ok=True, incidencia=r, puede_editar=puede_editar(yo()))
@app.post("/api/incidencias")
def incidencias_crear():
u = yo()
if not u:
return g["_sin_usuario"]()
d = g["_json_body"]()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
if not texto(d.get("descripcion")) and not texto(d.get("titulo")):
return jsonify(ok=False, error="Cuéntanos qué pasó (aunque sea una línea)."), 400
origen = d.get("origen") if d.get("origen") in ORIGENES else "sitio"
if origen in ("vigia", "agente") and u.lower() not in SISTEMA:
origen = "sitio"
firma = texto(d.get("firma"), 80)
if firma and u.lower() in SISTEMA:
for r in todas():
if r.get("firma") == firma and r.get("estado") in ABIERTAS:
r["ocurrencias"] = int(r.get("ocurrencias") or 1) + 1
r["ultima_vez"] = ahora()
ev = [texto(x, 600) for x in (d.get("evidencia") or [])][:5]
r["evidencia"] = (r.get("evidencia") or [])[-(20 - len(ev)):] + ev if ev else r.get("evidencia") or []
hist(r, u, f"se repitió (ocurrencia {r['ocurrencias']})")
guardar(r)
return jsonify(ok=True, id=r["id"], nueva=False, ocurrencias=r["ocurrencias"])
r = nueva(d, u, origen)
return jsonify(ok=True, id=r["id"], nueva=True, incidencia=r)
@app.patch("/api/incidencia")
def incidencia_cambiar():
u = yo()
if not u:
return g["_sin_usuario"]()
r = g["_leer_json"](ruta(g["slug"](request.args.get("id"), 12)))
if not r:
return jsonify(ok=False, error="No existe esa incidencia."), 404
d = g["_json_body"]()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
editor = puede_editar(u)
reporto = (r.get("reportado_por") or "").lower() == u.lower()
cambios = []
if texto(d.get("comentario")):
r.setdefault("comentarios", []).append({"quien": u, "cuando": ahora(), "texto": texto(d.get("comentario"), 4000)})
cambios.append("comentó")
if "estado" in d:
e = d.get("estado")
if e not in ESTADOS:
return jsonify(ok=False, error="Estado inválido."), 400
if not editor and not (reporto and e in ("resuelta", "nueva")):
return jsonify(ok=False, error="Solo quien atiende las incidencias cambia su estado "
"(tú puedes marcar la tuya como resuelta o reabrirla)."), 403
if e != r.get("estado"):
cambios.append(f"estado: {r.get('estado')} → {e}")
r["estado"] = e
r["resuelta_en"] = ahora() if e in ("resuelta", "descartada") else None
for campo, validos, largo in (("gravedad", GRAVEDADES, 0), ("tipo", TIPOS, 0), ("asignado", None, 40),
("tarea_id", None, 60), ("duplicado_de", None, 12), ("titulo", None, 140),
("resumen_publico", None, 200)):
if campo not in d:
continue
if not editor:
return jsonify(ok=False, error=f"Solo quien atiende las incidencias cambia «{campo}»."), 403
v = d.get(campo) if validos else texto(d.get(campo), largo)
if validos and v not in validos:
return jsonify(ok=False, error=f"Valor inválido para {campo}."), 400
if v != r.get(campo):
cambios.append(f"{campo}: {g['_corto'](r.get(campo))} → {g['_corto'](v)}")
r[campo] = v
if "publica" in d:
if not editor:
return jsonify(ok=False, error="Solo quien atiende las incidencias decide qué se ve en la entrada."), 403
v = g["_bool"](d.get("publica"), False)
if v and not texto(r.get("resumen_publico")): # el título puede traer nombres o teléfonos: nunca se publica
return jsonify(ok=False, error="Para mostrarla en la entrada escribe primero el texto corto público "
"(sin nombres, teléfonos ni datos internos)."), 400
if v != bool(r.get("publica")):
cambios.append("se muestra en la página de entrada" if v else "ya no se muestra en la entrada")
r["publica"] = v
if not cambios:
return jsonify(ok=True, incidencia=r, sin_cambios=True)
hist(r, u, "; ".join(cambios))
guardar(r)
return jsonify(ok=True, incidencia=r)
# ------------------------------------------------------------------ errores (navegador y servidor)
def anotar_error(lado, reg):
os.makedirs(carpeta_err, exist_ok=True)
fp = os.path.join(carpeta_err, f"{lado}-{datetime.date.today():%Y-%m-%d}.jsonl")
try:
if os.path.exists(fp) and os.path.getsize(fp) > MAX_ERRORES_DIA * 1200:
return False
with open(fp, "a", encoding="utf-8") as f:
f.write(json.dumps(reg, ensure_ascii=False) + "\n")
return True
except OSError:
return False
@app.post("/api/incidencias/error")
def incidencias_error():
u = yo()
if not u:
return g["_sin_usuario"]()
d = g["_json_body"]() or {}
reg = {"cuando": ahora(), "usuario": u, "tipo": texto(d.get("tipo"), 20) or "js",
"mensaje": texto(d.get("mensaje"), 400), "fuente": texto(d.get("fuente"), 200),
"linea": g["_entero"](d.get("linea"), 0), "pagina": texto(d.get("pagina"), 200),
"url_api": texto(d.get("url_api"), 200), "estado_http": g["_entero"](d.get("estado_http"), 0),
"pila": texto(d.get("pila"), 1200), "navegador": texto(request.headers.get("User-Agent"), 160)}
return jsonify(ok=anotar_error("cliente", reg))
def leer_errores(dias):
out = {"cliente": [], "servidor": []}
for i in range(dias):
dia = datetime.date.today() - datetime.timedelta(days=i)
for lado in out:
fp = os.path.join(carpeta_err, f"{lado}-{dia:%Y-%m-%d}.jsonl")
if os.path.isfile(fp):
with open(fp, encoding="utf-8", errors="ignore") as f:
for linea in f:
try:
out[lado].append(json.loads(linea))
except Exception: # noqa: BLE001
pass
return out
def agrupar(regs, claves):
grupos = {}
for r in regs:
k = hashlib.sha1("|".join(str(r.get(c, "")) for c in claves).encode()).hexdigest()[:12]
gr = grupos.setdefault(k, {"firma": k, "veces": 0, "usuarios": set(), "primero": r.get("cuando"),
"ultimo": r.get("cuando"), "ejemplo": r})
gr["veces"] += 1
gr["usuarios"].add(r.get("usuario") or "")
gr["ultimo"] = max(gr["ultimo"] or "", r.get("cuando") or "")
out = sorted(grupos.values(), key=lambda x: -x["veces"])
for x in out:
x["usuarios"] = sorted(x["usuarios"])
return out
@app.get("/api/incidencias/errores")
def incidencias_errores():
u = yo()
if not puede_editar(u):
return jsonify(ok=False, error="Solo quien atiende las incidencias ve los errores."), 403
dias = max(1, min(14, g["_entero"](request.args.get("dias"), 2)))
e = leer_errores(dias)
return jsonify(ok=True, dias=dias,
cliente=agrupar(e["cliente"], ("tipo", "mensaje", "fuente", "linea", "url_api", "estado_http")),
servidor=agrupar(e["servidor"], ("tipo", "ruta", "error")))
def _excepcion(sender, exception, **extra): # noqa: ARG001
try:
anotar_error("servidor", {"cuando": ahora(), "tipo": "excepcion", "ruta": request.path,
"metodo": request.method, "usuario": yo(),
"error": f"{type(exception).__name__}: {exception}"[:400],
"traza": "".join(traceback.format_exception(exception))[-3000:]})
except Exception: # noqa: BLE001
pass
got_request_exception.connect(_excepcion, app, weak=False)
@app.after_request
def _respuestas_5xx(resp):
try:
if resp.status_code >= 500 and request.path.startswith("/api/"):
cuerpo = resp.get_json(silent=True) if resp.is_json else None
anotar_error("servidor", {"cuando": ahora(), "tipo": f"respuesta-{resp.status_code}", "ruta": request.path,
"metodo": request.method, "usuario": yo(),
"error": texto((cuerpo or {}).get("error") if isinstance(cuerpo, dict) else "", 400)})
except Exception: # noqa: BLE001
pass
return resp
# ------------------------------------------------------------------ sin sesión (página de entrada)
@app.post("/api/incidencias/publica")
def incidencias_publica():
d = g["_json_body"]() or request.form or {}
if texto(d.get("sitio_web")): # campo trampa: solo lo llenan los robots
return jsonify(ok=True, id="INC-0000")
desc = texto(d.get("descripcion"), 2000)
contacto = texto(d.get("contacto"), 120)
nombre = texto(d.get("nombre"), 80)
if len(desc) < 5:
return jsonify(ok=False, error="Cuéntanos qué pasa (aunque sea una línea)."), 400
if not (nombre or contacto):
return jsonify(ok=False, error="Dinos quién eres (tu usuario o tu nombre) para poder ayudarte."), 400
# límite: 5 por IP por hora (además del limit_req de nginx)
ip = (request.headers.get("X-Real-IP") or request.remote_addr or "")[:60]
hace_1h = (datetime.datetime.now() - datetime.timedelta(hours=1)).replace(microsecond=0).isoformat()
recientes = [r for r in todas() if r.get("origen") == "entrada" and (r.get("creado_en") or "") >= hace_1h
and (r.get("contexto") or {}).get("ip") == ip]
if len(recientes) >= 5:
return jsonify(ok=False, error="Ya recibimos varios reportes desde aquí. Te contactamos pronto."), 429
tipo = d.get("tipo") if d.get("tipo") in ("acceso", "error", "otro") else "acceso"
r = nueva({"descripcion": desc, "tipo": tipo, "nombre": nombre, "contacto": contacto,
"titulo": (NOMBRE_TIPO[tipo] + " — " + (nombre or contacto))[:140],
"pagina": "/entrar.html", "contexto": {"pantalla": texto(d.get("pantalla"), 40)}}, "", "entrada")
r["contexto"]["ip"] = ip
r["reportado_por"] = ""
guardar(r)
return jsonify(ok=True, id=r["id"])
@app.get("/api/incidencias/publicas")
def incidencias_publicas():
hace = (datetime.datetime.now() - datetime.timedelta(days=14)).replace(microsecond=0).isoformat()
regs = [r for r in todas() if r.get("publica") and texto(r.get("resumen_publico"))
and (r.get("estado") in ABIERTAS or (r.get("resuelta_en") or "") >= hace)]
regs.sort(key=lambda r: r.get("modificado_en") or "", reverse=True)
return jsonify(ok=True, avisos=[{"id": r["id"], "titulo": r.get("resumen_publico"),
"estado": r.get("estado"),
"resuelta": r.get("estado") in ("resuelta", "descartada"),
"actualizado": (r.get("modificado_en") or r.get("creado_en") or "")[:10]}
for r in regs[:8]])
g["_incidencias"] = {"todas": todas, "nueva": nueva, "guardar": guardar, "leer_errores": leer_errores,
"ABIERTAS": ABIERTAS}