#!/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())