hbox-docs/backend/tablero.py

631 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""El tablero de pendientes desde la terminal: foto para leerlo y lotes para cambiarlo (21-oct-2026).
drogheda.helgen.online es el centro de mando del proyecto: cada tarea termina ahí, también las que
hace Claude. Claude no llega al servidor, así que deja sus cambios al tablero en un **lote**
(backend/lotes/AAAA-MM-DD_tema.json); Leo lo revisa y lo aplica con ./scripts/tablero.sh, y Claude lee
el tablero en la **foto** (_estado/tablero.json y _estado/tablero.md en la Mac de Leo, fuera de git).
En el servidor (lo llama ./scripts/tablero.sh por ssh):
python3 tablero.py foto pendientes, enfoque y actividad de 14 días, en JSON (stdout)
python3 tablero.py revisar lotes/ qué haría cada lote que falta; no escribe nada
python3 tablero.py aplicar lotes/ aplica lo que falta; lo ya aplicado se salta
python3 tablero.py pendientes lotes/ una línea: cuántos cambios faltan por aplicar
En la Mac:
python3 backend/tablero.py resumen _estado/tablero.json la foto en texto (tablero.md)
Habla con el backend por HTTP local (TABLERO_API, por omisión http://127.0.0.1:8081) y escribe con
X-Remote-User = «autor» del lote: pasa por las mismas validaciones que el sitio y cada cambio queda en
el historial del pendiente, con su autor y su nota. Lo aplicado se anota en
$DATOS_BASE/lotes-aplicados.json (la misma carpeta de datos que usa app.py). Un lote aplicado no se
vuelve a aplicar aunque alguien deshaga el cambio a mano, y no se edita después de aplicarse: se escribe
otro. Un cambio nunca toca un pendiente que ya está en Hecho (salvo que el lote lo pida con si_estado).
Formato de los lotes: backend/lotes/README.md. Solo usa la biblioteca estándar de Python.
"""
import argparse
import datetime
import glob
import hashlib
import json
import os
import re
import sys
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
API = os.environ.get("TABLERO_API", "http://127.0.0.1:8081").rstrip("/")
DATOS_BASE = os.environ.get("DATOS_BASE") or (
os.path.dirname(os.environ.get("ACTAS_DIR", "/srv/projects/drogheda/actas").rstrip("/")) or "/srv/projects/drogheda")
REGISTRO = os.environ.get("TABLERO_REGISTRO") or os.path.join(DATOS_BASE, "lotes-aplicados.json")
LECTOR = "claude" # X-Remote-User para las lecturas (no cambia nada; queda claro quién consulta)
ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho")
ABIERTOS = ("por-hacer", "en-curso", "bloqueado")
NOMBRE = {"por-hacer": "Por hacer", "en-curso": "En curso", "bloqueado": "Bloqueado", "hecho": "Hecho"}
CAMPOS = ("titulo", "descripcion", "pista", "estado", "responsable", "fecha_limite", "enlaces", "etiquetas",
"bloqueada_por", "version_tarjeta", "sitios")
LOTE_ID = re.compile(r"^\d{4}-\d{2}-\d{2}_[a-z0-9][a-z0-9-]{1,60}$")
TAREA_ID = re.compile(r"^[a-z0-9][a-z0-9-]{2,59}$")
MES = ("ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic")
class SinBackend(Exception):
pass
# ------------------------------------------------------------------ utilidades
def ahora():
return datetime.datetime.now().replace(microsecond=0).isoformat()
def norm(s):
"""'Pasada 6 — V5.6' → 'pasada 6 — v5.6': minúsculas, sin acentos, espacios simples."""
s = unicodedata.normalize("NFKD", str(s or "")).encode("ascii", "ignore").decode().lower()
return re.sub(r"\s+", " ", s).strip()
def clave(s):
"""Para buscar por título sin que estorben acentos, mayúsculas ni signos:
'Pasada 3 — parte A (V5.6)' → 'pasada 3 parte a v5.6'; 'D-21-2' → 'd 21 2'."""
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9.]+", " ", norm(s))).strip(" .")
def corto(v, n=48):
if isinstance(v, list):
v = ", ".join(str(x.get("titulo", x)) if isinstance(x, dict) else str(x) for x in v) or "—"
s = "—" if v in (None, "") else str(v)
return s if len(s) <= n else s[:n - 1] + "…"
def fecha_corta(iso):
"""'2026-10-22…' → '22-oct' (con hora si la trae: '22-oct 19:40')."""
s = str(iso or "")
if not re.match(r"^\d{4}-\d{2}-\d{2}", s):
return s or "—"
f = f"{int(s[8:10])}-{MES[int(s[5:7]) - 1]}"
return f + (" " + s[11:16] if len(s) >= 16 else "")
def huella(cambio):
return hashlib.sha256(json.dumps(cambio, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()[:12]
def llamar(metodo, ruta, usuario, cuerpo=None):
datos = None if cuerpo is None else json.dumps(cuerpo, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(API + ruta, data=datos, method=metodo, headers={
"X-Remote-User": usuario, "Content-Type": "application/json", "Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=20) as r:
return r.status, json.loads(r.read().decode("utf-8") or "{}")
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode("utf-8") or "{}")
except ValueError:
return e.code, {"ok": False, "error": f"HTTP {e.code}"}
except (urllib.error.URLError, OSError) as e:
raise SinBackend(f"No contesta el backend en {API} ({getattr(e, 'reason', e)}). "
"¿Está arriba el servicio actas-hbox?")
VALORES = {} # pista, version_tarjeta y sitios válidos, tal como los publica el backend en GET /api/tareas
SITIOS_INFO = [] # [{id, nombre, corto}] del backend (desde el 22-oct)
def leer_tareas():
st, j = llamar("GET", "/api/tareas", LECTOR)
if st != 200 or not j.get("ok"):
raise SinBackend(f"No pude leer los pendientes: {j.get('error') or st}")
VALORES.update(pista=list(j.get("pistas") or []), version_tarjeta=list(j.get("versiones") or []))
SITIOS_INFO[:] = [x for x in (j.get("sitios") or []) if isinstance(x, dict) and x.get("id")]
VALORES["sitios"] = [x["id"] for x in SITIOS_INFO]
return j.get("tareas") or [], j.get("equipo") or {}
def valores_malos(campos):
"""Lo que el backend rechazaría (o ignoraría): se ve en «revisar» en vez de fallar al aplicar."""
malos = []
for k, validos in VALORES.items():
if k == "sitios" or k not in campos:
continue
if validos and campos[k] not in validos:
malos.append(f"{k} «{campos[k]}» no existe (usa {', '.join(validos)})")
if "sitios" in campos:
validos = VALORES.get("sitios") or []
if not validos:
# un backend sin reiniciar guardaría el pendiente y tiraría «sitios» sin avisar
malos.append("el backend todavía no conoce «sitios»: reinicia actas-hbox")
else:
lista = campos["sitios"] if isinstance(campos["sitios"], list) else [campos["sitios"]]
fuera = [str(x) for x in lista if str(x) not in validos]
if not lista:
malos.append("«sitios» vacío: pon al menos uno")
elif fuera:
malos.append(f"sitios {', '.join(fuera)} no existen (usa {', '.join(validos)})")
return malos
def leer_registro():
try:
with open(REGISTRO, encoding="utf-8") as f:
r = json.load(f)
return r if isinstance(r, dict) and isinstance(r.get("lotes"), dict) else {"lotes": {}}
except FileNotFoundError:
return {"lotes": {}}
def escribir_registro(r):
os.makedirs(os.path.dirname(REGISTRO) or ".", exist_ok=True)
tmp = REGISTRO + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(r, f, ensure_ascii=False, indent=1)
os.replace(tmp, REGISTRO)
# ------------------------------------------------------------------ lotes: formato
def validar_cambio(c):
e = []
if not isinstance(c, dict):
return ["debe ser un objeto"]
if "crear" in c:
t = c["crear"]
sobra = set(c) - {"crear", "si_no_hay"}
if sobra:
e.append("llaves que no van junto a «crear»: " + ", ".join(sorted(sobra)))
if not isinstance(t, dict):
return e + ["«crear» debe ser un objeto"]
malas = set(t) - set(CAMPOS) - {"id"}
if malas:
e.append("campos que no existen: " + ", ".join(sorted(malas)))
if not TAREA_ID.match(str(t.get("id") or "")):
e.append("«crear.id» es obligatorio: minúsculas, números y guiones (3 a 60)")
for k in ("titulo", "pista", "responsable"):
if not str(t.get(k) or "").strip():
e.append(f"falta «crear.{k}»")
if t.get("estado") and t["estado"] not in ESTADOS:
e.append(f"estado inválido: {t['estado']}")
if t.get("fecha_limite") and not re.match(r"^\d{4}-\d{2}-\d{2}$", str(t["fecha_limite"])):
e.append("«fecha_limite» va como AAAA-MM-DD")
sn = c.get("si_no_hay")
if sn is not None and not (isinstance(sn, str) or (isinstance(sn, list) and all(isinstance(x, str) for x in sn))):
e.append("«si_no_hay» es un texto o una lista de textos")
return e
sobra = set(c) - {"id", "buscar", "todos", "si_estado", "poner", "nota"}
if sobra:
e.append("llaves que no existen: " + ", ".join(sorted(sobra)))
if bool(c.get("id")) == bool(c.get("buscar")):
e.append("lleva «id» o «buscar» (uno de los dos)")
if c.get("todos") and not c.get("buscar"):
e.append("«todos» solo va con «buscar»")
b = c.get("buscar")
if b is not None:
textos = b if isinstance(b, list) else [b]
if not textos or not all(isinstance(x, str) for x in textos):
e.append("«buscar» es un texto o una lista de textos")
elif sum(len(clave(x)) for x in textos if not x.startswith("-")) < 4:
e.append("«buscar» necesita 4 letras o más (sin contar los «-texto»)")
nota = str(c.get("nota") or "").strip()
if not nota:
e.append("falta «nota»: el porqué del cambio, queda en el historial del pendiente")
# más de 300 caracteres: el backend guarda 300; se recorta y «revisar» lo dice (no bloquea el lote)
p = c.get("poner", {})
if not isinstance(p, dict):
e.append("«poner» debe ser un objeto")
else:
malas = set(p) - set(CAMPOS)
if malas:
e.append("campos que no existen en «poner»: " + ", ".join(sorted(malas)))
if "estado" in p and p["estado"] not in ESTADOS:
e.append(f"estado inválido en «poner»: {p['estado']}")
if p.get("fecha_limite") and not re.match(r"^\d{4}-\d{2}-\d{2}$", str(p["fecha_limite"])):
e.append("«fecha_limite» va como AAAA-MM-DD (o null para quitarla)")
se = c.get("si_estado")
if se is not None and (not isinstance(se, list) or not se or any(x not in ESTADOS for x in se)):
e.append("«si_estado» es una lista de estados (por-hacer, en-curso, bloqueado, hecho)")
return e
def cargar_lote(ruta):
try:
with open(ruta, encoding="utf-8") as f:
lote = json.load(f)
except ValueError as ex:
raise ValueError(f"{ruta}: no es JSON válido ({ex})")
if not isinstance(lote, dict):
raise ValueError(f"{ruta}: el lote debe ser un objeto JSON")
err = []
lid = str(lote.get("lote") or "")
if not LOTE_ID.match(lid):
err.append("«lote» debe ser AAAA-MM-DD_tema (minúsculas y guiones)")
elif os.path.splitext(os.path.basename(ruta))[0] != lid:
err.append(f"el archivo debe llamarse {lid}.json")
if not TAREA_ID.match(norm(lote.get("autor")) or "-"):
err.append("falta «autor»: el usuario con el que se escribe (p. ej. claude)")
if not str(lote.get("motivo") or "").strip():
err.append("falta «motivo»: qué tarea cerró este lote")
cambios = lote.get("cambios")
if not isinstance(cambios, list) or not cambios:
err.append("«cambios» debe ser una lista con al menos un cambio")
else:
for i, c in enumerate(cambios, 1):
err += [f"cambio {i}: {x}" for x in validar_cambio(c)]
if err:
raise ValueError(f"{ruta}:\n - " + "\n - ".join(err))
lote["autor"] = norm(lote["autor"])
return lote
def archivos_de(rutas):
out = []
for r in rutas:
if os.path.isdir(r):
out += sorted(f for f in glob.glob(os.path.join(r, "*.json")))
elif os.path.isfile(r):
out.append(r)
else:
raise ValueError(f"No existe {r}")
return out
# ------------------------------------------------------------------ lotes: plan y aplicación
def _ids(responsable):
return sorted({norm(x).replace(" ", "") for x in re.split(r"[,;/]+", str(responsable or "")) if norm(x)})
def igual(campo, actual, nuevo):
if campo == "responsable":
return _ids(actual) == _ids(nuevo)
if campo in ("etiquetas", "bloqueada_por"):
return sorted(norm(x) for x in (actual or [])) == sorted(norm(x) for x in (nuevo or []))
if campo == "sitios":
lst = lambda v: v if isinstance(v, list) else ([v] if v else [])
return sorted({norm(x) for x in lst(actual)}) == sorted({norm(x) for x in lst(nuevo)})
if campo == "enlaces":
par = lambda lista: [(str(x.get("titulo", "")).strip(), str(x.get("url", "")).strip())
for x in (lista or []) if isinstance(x, dict)]
return par(actual) == par(nuevo)
if campo == "fecha_limite":
return (actual or None) == (nuevo or None)
return str("" if actual is None else actual).strip() == str("" if nuevo is None else nuevo).strip()
def _quien_es(t):
return f"{t['id']} «{corto(t.get('titulo'), 70)}»"
def contiene(titulo, texto):
q = clave(texto)
return bool(q) and f" {q} " in f" {clave(titulo)} "
def coincide(titulo, buscar):
"""«buscar» es un texto o una lista de textos que el título debe tener todos; «-texto» = que no lo tenga
(["piloto", "v5.6", "-banco"] encuentra «Piloto V5.6: 5 tarjetas» y no «Banco del piloto V5.6»)."""
for x in (buscar if isinstance(buscar, list) else [buscar]):
if x.startswith("-"):
if contiene(titulo, x[1:]):
return False
elif not contiene(titulo, x):
return False
return True
def _texto_busqueda(buscar):
return " + ".join(f"«{x}»" for x in (buscar if isinstance(buscar, list) else [buscar]))
def _cambio_a(t, c):
"""Un pendiente existente → (detalle, petición) o (motivo, None) si no aplica."""
si = c.get("si_estado") or list(ABIERTOS)
if t.get("estado") not in si:
return (f"{_quien_es(t)} está en {NOMBRE.get(t.get('estado'), t.get('estado'))}; "
f"este cambio solo aplica en {', '.join(NOMBRE[x] for x in si)}"), None
poner = {k: v for k, v in (c.get("poner") or {}).items() if not igual(k, t.get(k), v)}
if valores_malos(poner):
return "✗ " + f"{_quien_es(t)}: " + "; ".join(valores_malos(poner)), None
partes = [f"{k}: {corto(t.get(k), 30)} → {corto(v, 30)}" for k, v in poner.items()]
nota = c["nota"].strip()
aviso = ""
if len(nota) > 300:
nota, aviso = nota[:299].rstrip() + "…", f" (nota de {len(c['nota'].strip())} caracteres, recortada a 300)"
detalle = f"{_quien_es(t)}: " + ("; ".join(partes) + " · " if partes else "") + f"nota «{corto(nota, 80)}»{aviso}"
return detalle, ("PATCH", "/api/tarea?id=" + urllib.parse.quote(t["id"]), dict(poner, _nota=nota), t["id"])
def planear(c, tareas):
"""Qué haría un cambio contra el tablero de ahora → (acción, detalle, [peticiones]).
acción: crear · cambiar · saltar · invalido. Cada petición: (método, ruta, cuerpo, id del pendiente)."""
por_id = {t.get("id"): t for t in tareas}
if "crear" in c:
t = c["crear"]
if t["id"] in por_id:
return "saltar", f"ya existe {_quien_es(por_id[t['id']])}", []
evitar = c.get("si_no_hay")
for x in ([evitar] if isinstance(evitar, str) else (evitar or [])):
for o in tareas:
if o.get("estado") != "hecho" and contiene(o.get("titulo"), x):
return "saltar", f"ya hay uno abierto parecido (dice «{x}»): {_quien_es(o)}", []
cuerpo = dict(t)
cuerpo.setdefault("estado", "por-hacer")
if valores_malos(cuerpo):
return "invalido", f"nuevo {cuerpo['id']}: " + "; ".join(valores_malos(cuerpo)), []
datos = [cuerpo["pista"], NOMBRE.get(cuerpo["estado"], cuerpo["estado"]), cuerpo["responsable"]]
if cuerpo.get("fecha_limite"):
datos.append("vence " + fecha_corta(cuerpo["fecha_limite"]))
if cuerpo.get("sitios"): # 22-oct: el sitio también se revisa antes de aplicar
cortos = {x["id"]: x.get("corto") or x["id"] for x in SITIOS_INFO}
sit = cuerpo["sitios"] if isinstance(cuerpo["sitios"], list) else [cuerpo["sitios"]]
datos.append("sitio " + " · ".join(cortos.get(x, x) for x in sit))
return "crear", f"nuevo {cuerpo['id']} «{corto(cuerpo['titulo'], 70)}» ({', '.join(datos)})", \
[("POST", "/api/tareas", cuerpo, cuerpo["id"])]
if c.get("id"):
t = por_id.get(c["id"])
if not t:
return "saltar", f"no existe el pendiente {c['id']} (¿se borró?)", []
objetivo = [t]
else:
q = _texto_busqueda(c["buscar"])
abiertos = [o for o in tareas if o.get("estado") != "hecho" and coincide(o.get("titulo"), c["buscar"])]
if c.get("todos"):
if not abiertos:
return "saltar", f"ningún pendiente abierto dice {q} en el título", []
objetivo = abiertos
else:
cand = abiertos or [o for o in tareas if o.get("estado") == "hecho" and coincide(o.get("titulo"), c["buscar"])]
if len(cand) != 1:
if not cand:
return "saltar", f"ningún pendiente dice {q} en el título", []
return "saltar", (f"{q} coincide con {len(cand)} pendientes (usa «todos» o un texto más preciso): "
+ "; ".join(_quien_es(o) for o in cand[:4])), []
objetivo = cand
planes = [_cambio_a(t, c) for t in objetivo]
peticiones = [p for _, p in planes if p]
if len(objetivo) == 1:
detalle, p = planes[0]
if not p:
malo = detalle.startswith("✗ ")
return ("invalido" if malo else "saltar"), (detalle[2:] if malo else detalle), []
return "cambiar", detalle, peticiones
detalle = f"{_texto_busqueda(c['buscar'])}, {len(objetivo)} pendientes:\n" + "\n".join(
f" – {d}" if p else f" – se salta: {d}" for d, p in planes)
malos = [d for d, p in planes if not p and d.startswith("✗ ")]
if malos:
return "invalido", detalle, []
return ("cambiar" if peticiones else "saltar"), detalle, peticiones
def simular(tareas, peticion):
"""En «revisar», que los cambios siguientes del mismo lote vean los anteriores."""
metodo, ruta, cuerpo, rid = peticion
if metodo == "POST":
return tareas + [dict(cuerpo)]
return [dict(t, **{k: v for k, v in cuerpo.items() if not k.startswith("_")}) if t.get("id") == rid else t
for t in tareas]
def correr(rutas, aplicar, silencioso=False):
archivos = archivos_de(rutas)
# un lote con errores de formato se salta (y se dice por qué); los demás sí se revisan y aplican
lotes, malos = [], []
for a in archivos:
try:
lotes.append((a, cargar_lote(a)))
except ValueError as ex:
malos.append(str(ex))
ids = [lote["lote"] for _, lote in lotes]
if len(ids) != len(set(ids)):
raise ValueError("Hay dos lotes con el mismo «lote».")
reg = leer_registro()
tareas, _ = leer_tareas()
por_aplicar = errores = 0
di = (lambda *a: None) if silencioso else print
for m in malos:
di("✗ " + m + "\n → este lote no se aplica hasta corregirlo (un lote nuevo); los demás sí.")
errores += len(malos)
if not lotes:
di("No hay lotes en " + ", ".join(rutas) + ".")
for archivo, lote in lotes:
lid = lote["lote"]
r = reg["lotes"].get(lid)
previos = (r or {}).get("cambios", {})
cambiado = [i for i, c in enumerate(lote["cambios"], 1)
if str(i) in previos and previos[str(i)].get("huella") != huella(c)]
if r and r.get("completo"):
di(f"✓ {lid}: aplicado el {fecha_corta(r.get('fin'))} ({r.get('motivo', '')[:70]})"
+ (f"\n ⚠ el archivo cambió después de aplicarse (cambios {', '.join(map(str, cambiado))}); "
"no se vuelve a aplicar: lo nuevo va en otro lote" if cambiado else ""))
continue
if cambiado:
di(f"✗ {lid}: los cambios {', '.join(map(str, cambiado))} ya no son los que se aplicaron. "
"Un lote aplicado no se edita: escribe uno nuevo.")
errores += 1
continue
di(f"\n▶ {lid} — {lote['motivo']} (lo escribe: {lote['autor']})")
if aplicar and not r:
r = reg["lotes"][lid] = {"archivo": os.path.basename(archivo), "autor": lote["autor"],
"motivo": lote["motivo"], "inicio": ahora(), "fin": None, "completo": False,
"aplicado_por": os.environ.get("SUDO_USER") or os.environ.get("USER") or "?",
"cambios": {}}
for i, c in enumerate(lote["cambios"], 1):
previo = previos.get(str(i))
if previo and previo.get("resultado") in ("hecho", "saltado"):
di(f" {i}. ya {previo['resultado']}: {previo['detalle']}")
continue
accion, detalle, peticiones = planear(c, tareas)
signo = {"crear": "+", "cambiar": "~", "saltar": "·", "invalido": "✗"}[accion]
if not aplicar:
di(f" {i}. {signo} {'se salta: ' if accion == 'saltar' else ''}{detalle}")
por_aplicar += len(peticiones)
for p in peticiones:
tareas = simular(tareas, p)
errores += accion == "invalido"
continue
if accion == "invalido":
r["cambios"][str(i)] = {"resultado": "error", "detalle": detalle, "huella": huella(c), "cuando": ahora()}
di(f" {i}. ✗ no se manda: {detalle}")
errores += 1
elif accion == "saltar":
r["cambios"][str(i)] = {"resultado": "saltado", "detalle": detalle, "huella": huella(c), "cuando": ahora()}
di(f" {i}. · se salta: {detalle}")
else:
ya = set((previo or {}).get("hechos_en") or []) # en un reintento, lo que ya salió no se repite
fallas = []
for metodo, ruta, cuerpo, rid in peticiones:
if rid in ya:
continue
st, resp = llamar(metodo, ruta, lote["autor"], cuerpo)
if st in (200, 201) and resp.get("ok"):
t = resp.get("tarea") or {}
tareas = [x for x in tareas if x.get("id") != t.get("id")] + [t]
ya.add(rid)
if resp.get("aviso"):
di(f" aviso del backend ({rid}): {resp['aviso']}")
else:
fallas.append(f"{rid}: {resp.get('error') or st}")
r["cambios"][str(i)] = {"resultado": "error" if fallas else "hecho",
"detalle": detalle + (" → " + "; ".join(fallas) if fallas else ""),
"hechos_en": sorted(ya), "huella": huella(c), "cuando": ahora()}
if fallas:
di(f" {i}. ✗ {detalle}\n el backend dijo: " + "; ".join(fallas))
errores += 1
else:
di(f" {i}. {signo} {detalle}")
escribir_registro(reg) # después de cada cambio: si algo se cae, no se pierde lo aplicado
if aplicar:
r["completo"] = all(r["cambios"].get(str(i), {}).get("resultado") in ("hecho", "saltado")
for i in range(1, len(lote["cambios"]) + 1))
r["fin"] = ahora() if r["completo"] else None
escribir_registro(reg)
if not aplicar and not silencioso:
print(f"\n{por_aplicar} cambio(s) por aplicar." + (" Para aplicarlos: ./scripts/tablero.sh aplicar" if por_aplicar else ""))
return por_aplicar, errores
# ------------------------------------------------------------------ foto y resumen
def foto():
tareas, equipo = leer_tareas()
out = {"tomada": ahora(), "api": API, "tareas": tareas, "equipo": equipo, "sitios": list(SITIOS_INFO)}
st, h = llamar("GET", "/api/hoy", LECTOR)
out["enfoque"] = h.get("enfoque") if st == 200 else None
st, a = llamar("GET", "/api/actividad?dias=14&limite=150", LECTOR)
out["actividad"] = a.get("eventos", []) if st == 200 else []
out["lotes"] = {k: {"completo": v.get("completo"), "fin": v.get("fin"), "motivo": v.get("motivo")}
for k, v in leer_registro()["lotes"].items()}
json.dump(out, sys.stdout, ensure_ascii=False, indent=1)
sys.stdout.write("\n")
def resumen(ruta):
with open(ruta, encoding="utf-8") as f:
F = json.load(f)
tareas, eq = F.get("tareas") or [], F.get("equipo") or {}
hoy = str(F.get("tomada") or ahora())[:10]
apodo = {"Leopoldo": "Leo"} # como en el sitio (Kanban.nombreCorto)
nombre = lambda i: (lambda n: apodo.get(n, n))(((eq.get(i) or {}).get("nombre") or i or "—").split(" ")[0])
estado_de = {t.get("id"): t.get("estado") for t in tareas}
sitios_info = F.get("sitios") or []
sit_corto = {x["id"]: x.get("corto") or x["id"] for x in sitios_info}
abiertos = [t for t in tareas if t.get("estado") != "hecho"]
vencido = lambda t: bool(t.get("fecha_limite")) and t.get("estado") != "hecho" and t["fecha_limite"] < hoy
cuenta = {e: sum(1 for t in tareas if t.get("estado") == e) for e in ESTADOS}
o = [f"# Tablero de pendientes — foto del {fecha_corta(F.get('tomada'))}", "",
"Generado por `./scripts/tablero.sh foto` (no se edita: se vuelve a bajar). Fuente: " + str(F.get("api")), "",
f"**Abiertos: {len(abiertos)}** — por hacer {cuenta['por-hacer']} · en curso {cuenta['en-curso']} · "
f"bloqueados {cuenta['bloqueado']} · **vencidos {sum(map(vencido, abiertos))}** · hechos {cuenta['hecho']} en total", ""]
en = F.get("enfoque")
if en:
o += [f"## Enfoque de la semana ({nombre(en.get('editado_por'))}, {fecha_corta(en.get('editado_en'))})", "",
en.get("titulo") or "", ""] + [f"- {p}" for p in (en.get("puntos") or [])] + [""]
else:
o += ["## Enfoque de la semana", "", "Nadie lo ha escrito en el sitio (se ve el de docs/datos/proyecto.json).", ""]
def linea(t):
bits = [NOMBRE.get(t.get("estado"), t.get("estado")), t.get("pista") or "—"]
if t.get("version_tarjeta") not in (None, "", "—"):
bits.append(t["version_tarjeta"])
if t.get("fecha_limite"):
bits.append(("**VENCIDO** " if vencido(t) else "vence ") + fecha_corta(t["fecha_limite"]))
espera = [x for x in (t.get("bloqueada_por") or []) if estado_de.get(x) != "hecho"]
if espera:
bits.append("espera a " + ", ".join(espera))
if sit_corto and t.get("sitios"):
bits.append("sitio: " + ", ".join(sit_corto.get(x, x) for x in t["sitios"]))
return f"- {t.get('titulo')} — `{t.get('id')}` · " + " · ".join(bits)
orden = lambda t: (ESTADOS.index(t.get("estado")) if t.get("estado") in ESTADOS else 9, t.get("fecha_limite") or "9999")
personas = {}
for t in abiertos:
for p in (t.get("responsable_ids") or _ids(t.get("responsable")) or ["(sin responsable)"]):
personas.setdefault(p, []).append(t)
o += ["## Abiertos por persona", ""]
for p in sorted(personas, key=lambda x: (-len(personas[x]), x)):
o += [f"### {nombre(p)} ({len(personas[p])})", ""] + [linea(t) for t in sorted(personas[p], key=orden)] + [""]
if sitios_info:
o += ["## Abiertos por sitio", ""]
for x in sitios_info:
lst = [t for t in abiertos if x["id"] in (t.get("sitios") or [])]
bl = sum(1 for t in lst if t.get("estado") == "bloqueado")
o += [f"### {x.get('nombre') or x['id']} ({len(lst)}" + (f", {bl} bloqueado{'s' if bl > 1 else ''}" if bl else "") + ")", ""]
o += [linea(t) for t in sorted(lst, key=orden)] or ["- (nada abierto)"]
o += [""]
hace14 = (datetime.date.fromisoformat(hoy) - datetime.timedelta(days=14)).isoformat()
hechos = sorted((t for t in tareas if t.get("estado") == "hecho" and str(t.get("modificado_en") or "") >= hace14),
key=lambda t: str(t.get("modificado_en")), reverse=True)
o += ["## Hechos en los últimos 14 días", ""] + \
[f"- {fecha_corta(t.get('modificado_en'))} · {t.get('titulo')} — `{t.get('id')}`" for t in hechos] + \
([] if hechos else ["- (ninguno)"]) + [""]
o += ["## Actividad (14 días, lo más nuevo arriba)", ""]
for e in (F.get("actividad") or [])[:60]:
if e.get("tipo") == "pendiente":
o.append(f"- {fecha_corta(e.get('cuando'))} · {nombre(e.get('quien'))}: {corto(e.get('cambio'), 140)} "
f"— «{corto(e.get('titulo'), 60)}» `{e.get('id')}`")
else:
o.append(f"- {fecha_corta(e.get('cuando'))} · {e.get('tecnico') or nombre(e.get('quien'))}: acta "
f"{e.get('acta')} de {e.get('serie') or 'sin serie'} {e.get('resultado') or ''}".rstrip())
lotes = F.get("lotes") or {}
o += ["", "## Lotes de Claude", ""] + [
f"- `{k}` — {'aplicado el ' + fecha_corta(v.get('fin')) if v.get('completo') else 'a medias'}: {v.get('motivo') or ''}"
for k, v in sorted(lotes.items(), reverse=True)] + ([] if lotes else ["- (ninguno aplicado todavía)"])
print("\n".join(o))
# ------------------------------------------------------------------ main
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0],
formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
ap.add_argument("accion", choices=("foto", "revisar", "aplicar", "pendientes", "resumen"))
ap.add_argument("rutas", nargs="*", help="lotes (archivos o carpetas); para «resumen», la foto")
a = ap.parse_args()
aqui = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lotes")
try:
if a.accion == "foto":
foto()
return 0
if a.accion == "resumen":
resumen(a.rutas[0] if a.rutas else "_estado/tablero.json")
return 0
rutas = a.rutas or [aqui]
if a.accion == "pendientes":
n, err = correr(rutas, aplicar=False, silencioso=True)
print((f"Tablero: {n} cambio(s) de lotes por aplicar" if n else "Tablero: no hay lotes por aplicar")
+ (f"; {err} con errores" if err else "") + (" → ./scripts/tablero.sh revisar" if n or err else "."))
return 3 if err else 0
n, err = correr(rutas, aplicar=(a.accion == "aplicar"))
return 3 if err else 0
except ValueError as e:
print("✗ " + str(e), file=sys.stderr)
return 1
except SinBackend as e:
print("✗ " + str(e), file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())