Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013SfRSvDd335NPYEbBrDFGE
405 lines
22 KiB
Python
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}
|