From 119eee5f70bce394a720e85b0f8658921865339a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leopoldo=20Rodr=C3=ADguez?= Date: Thu, 22 Oct 2026 16:45:40 -0600 Subject: [PATCH] Tablero: Adriana y Gibran pueden borrar pendientes; lo borrado va a la papelera Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016X4CAujMuib2tDqt9JRg6X --- CLAUDE.md | 2 +- DESPLIEGUE.md | 34 ++++++++++++++++++++ backend/README.md | 4 ++- backend/actas-hbox.service | 2 ++ backend/app.py | 66 ++++++++++++++++++++++++++++++-------- backend/tests-1013.sh | 7 +++- docs/tableros/actividad.js | 5 ++- docs/tableros/index.md | 2 +- docs/tableros/kanban.js | 25 +++++++++++---- 9 files changed, 122 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 21c2adb..7dc5395 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ Adriana, Michelle, Jesús and the field technicians work from it; Claude closes | On the site | Source | Who edits | How | |---|---|---|---| -| **Pendientes** (kanban, «Por persona», Ingeniería/Producción views) | server `/srv/projects/drogheda/tareas` | everyone in the UI; **Claude only through lotes** | `backend/lotes/AAAA-MM-DD_tema.json` → Leo runs `./scripts/tablero.sh revisar` then `aplicar`. Format and rules: `backend/lotes/README.md` | +| **Pendientes** (kanban, «Por persona», Ingeniería/Producción views) | server `/srv/projects/drogheda/tareas` | everyone in the UI (deleting: admins + `TAREAS_BORRAN`, by default Adriana and Gibran; soft delete to `tareas/_borrados/`); **Claude only through lotes** | `backend/lotes/AAAA-MM-DD_tema.json` → Leo runs `./scripts/tablero.sh revisar` then `aplicar`. Format and rules: `backend/lotes/README.md` | | **Actividad** (home, Bitácora) | automatic: task history + actas | — | nothing to write; lotes show up as «Claude terminó / creó / comentó en …» | | **Semáforo** (home, 22-oct) and per-site color | `docs/datos/proyecto.json` → `semaforo` (color rojo/amarillo/verde, titulo, texto, causas with **bold**/[links](/…), destraba, actualizado) + `estado`/`nota`/`meta` on each `sitios[]` item and on `oficina` | Leo decides the color; Claude or Leo edit | update it in every crisis or junta hand-off; map pins and the site list link to `/tableros/pendientes/?sitio=` | | **Sitio of each task** (22-oct) | task field `sitios` = list of icave · timsa · eit · lzc · qro (no field = qro, Querétaro: fabricación e ingeniería) | everyone in the UI (site checkboxes); Claude through lotes (`"sitios": ["eit"]`) | filter `?sitio=eit,timsa`, view «Por sitio» (`?vista=sitio`); `GET /api/tareas` advertises the list; a lote with `sitios` needs the restarted backend | diff --git a/DESPLIEGUE.md b/DESPLIEGUE.md index 53b546d..f3b89e9 100644 --- a/DESPLIEGUE.md +++ b/DESPLIEGUE.md @@ -385,6 +385,40 @@ curl -s -u leo:PASS 'https://drogheda.helgen.online/api/instalaciones?sitio=icav --- +## A8 · 22-oct: Adriana y Gibran pueden borrar pendientes + +Qué cambia: **borrar un pendiente** ya no es solo de los administradores (`ACTAS_ADMINS`): también pueden los usuarios de +`TAREAS_BORRAN`, que por omisión son `adriana` y `gibranlp` (no hace falta tocar el servicio). No cambia ningún otro +permiso: actas, usuarios, sitios y plan siguen siendo solo de administradores. + +Borrar ya no destruye: el pendiente pasa a `/srv/projects/drogheda/tareas/_borrados/__.json` con su +historial, sigue en la Actividad («Adriana borró …», sin liga) y quien lo esperaba («Espera a que terminen») deja de +esperarlo, con una nota en su historial. + +**Cambió `backend/app.py`: reinicia el servicio.** + +```bash +./scripts/deploy.sh +ssh leo@git.helgen.online 'sudo systemctl restart actas-hbox' +``` + +Para dar o quitar el permiso: en `/etc/systemd/system/actas-hbox.service`, la línea +`Environment=TAREAS_BORRAN=adriana,gibranlp` con los usuarios que sí (los administradores siempre pueden); después +`sudo systemctl daemon-reload && sudo systemctl restart actas-hbox`. + +Recuperar un pendiente borrado (en el servidor): + +```bash +cd /srv/projects/drogheda/tareas +ls _borrados/ # __AAAAMMDDhhmmss.json +mv _borrados/__.json .json # vuelve al tablero como estaba; las dependencias que se quitaron no regresan solas +``` + +| Síntoma | Causa | Arreglo | +|---|---|---| +| Adriana o Gibran no ven «Borrar» en el detalle | servicio sin reiniciar | el `restart` de arriba | +| «Solo … pueden borrar pendientes» | ese usuario no es administrador ni está en `TAREAS_BORRAN` | así debe ser; si debe poder, agrégalo a `TAREAS_BORRAN` | + ## B · Verificación después de desplegar ```bash diff --git a/backend/README.md b/backend/README.md index 74fd410..8a8eb0c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -75,10 +75,12 @@ cada cambio. Carpetas (variables de entorno, con su valor por omisión junto a ` | `PLAN_FILE` | `/srv/projects/drogheda/plan-produccion.json` | el plan del pedido (570 → 113/327/130) | | `EQUIPO_DIR` | `/srv/projects/drogheda/equipo` | miembros del equipo (14-oct) + `_accesos.csv` | | `HTPASSWD_LOCK` | `/srv/projects/drogheda/.htpasswd.lock` | candado para escribir el htpasswd desde Equipo | +| `TAREAS_BORRAN` | `adriana,gibranlp` | quién borra pendientes además de `ACTAS_ADMINS` (22-oct); lo borrado va a `tareas/_borrados/` | Reglas comunes: toda escritura exige el usuario autenticado (`X-Remote-User`; sin él, 403); cada registro guarda `creado_por/creado_en/modificado_por/modificado_en` y un `historial` -de `{quien, cuando, cambio}`; **solo un administrador borra** (`ACTAS_ADMINS`). Un `POST` +de `{quien, cuando, cambio}`; **solo un administrador borra** (`ACTAS_ADMINS`), salvo los pendientes, que también +borran los de `TAREAS_BORRAN` y pasan a `tareas/_borrados/` en vez de desaparecer (22-oct). Un `POST` puede traer su propio `id` (lo usa `seed_1013.py` para ser idempotente): si ya existe → 409. Siembra inicial (plan, 4 sitios, ítems de inventario, pendientes del 13-oct), idempotente: diff --git a/backend/actas-hbox.service b/backend/actas-hbox.service index ad66220..b16c23d 100644 --- a/backend/actas-hbox.service +++ b/backend/actas-hbox.service @@ -23,6 +23,8 @@ Environment=ACTAS_ADMINS=leo # Página de inicio (21-oct): quién escribe el enfoque de la semana y dónde se guarda. #Environment=HOY_EDITORES=leo,adriana #Environment=HOY_FILE=/srv/projects/drogheda/enfoque.json +# Quién borra pendientes además de ACTAS_ADMINS (22-oct; por omisión adriana y gibranlp). Lo borrado va a tareas/_borrados/. +#Environment=TAREAS_BORRAN=adriana,gibranlp Environment=HTPASSWD_FILE=/etc/nginx/hbox-docs.htpasswd Environment=PASS_MIN=8 Environment=PORT=8081 diff --git a/backend/app.py b/backend/app.py index de9f774..ea3fcd0 100644 --- a/backend/app.py +++ b/backend/app.py @@ -30,6 +30,7 @@ Colecciones agregadas el 13-oct-2026 (tableros, inventario, sitios) — un JSON campos de auditoría creado_por/creado_en/modificado_por/modificado_en/historial; cualquier usuario autenticado crea y modifica, solo un administrador borra: GET/POST /api/tareas (?sitio=eit,timsa filtra por sitio; 22-oct) · PATCH/DELETE /api/tarea?id= · GET /api/tareas/resumen · POST /api/tareas/orden + (DELETE /api/tarea: administradores y TAREAS_BORRAN, por omisión adriana y gibranlp; el pendiente pasa a tareas/_borrados/) GET /api/plan · PUT /api/plan (admin) GET/POST /api/inventario · PATCH /api/inventario/item?id= · POST /api/inventario/movimiento GET /api/inventario/movimientos?item_id= · GET /api/inventario/export.csv @@ -1351,7 +1352,8 @@ def tareas_listar(): equipo = {k: {"nombre": m.get("nombre"), "activo": m.get("activo", True), "area": m.get("area")} for k, m in miembros.items()} return jsonify(ok=True, total=len(out), tareas=out, pistas=PISTAS, estados=ESTADOS, equipo=equipo, versiones=VERSIONES_TARJETA, sitios=[{"id": i, "nombre": n, "corto": c} for i, n, c in SITIOS_TAREA], - admin=is_admin(), user=current_user()) + admin=is_admin(), user=current_user(), puede_borrar=_puede_borrar_tarea(), + borran=sorted(ADMINS | TAREAS_BORRAN)) @app.post("/api/tareas") @@ -1462,20 +1464,54 @@ def tareas_orden(): return jsonify(ok=True, actualizadas=n) +# Quién borra pendientes (22-oct, Leo): los administradores (ACTAS_ADMINS) y TAREAS_BORRAN, por omisión +# adriana y gibranlp. Borrar no destruye: el pendiente pasa a tareas/_borrados/ con su historial (sigue en la +# Actividad) y se recupera moviendo el archivo de vuelta. Quien lo esperaba («bloqueada_por») deja de esperarlo. +TAREAS_BORRAN = {u.strip().lower() for u in os.environ.get("TAREAS_BORRAN", "adriana,gibranlp").split(",") if u.strip()} +TAREAS_BORRADOS = os.path.join(TAREAS_DIR, "_borrados") + + +def _puede_borrar_tarea(): + u = current_user().lower() + return bool(u) and (u in ADMINS or u in TAREAS_BORRAN) + + +def _y_lista(xs): + xs = [str(x) for x in xs if x] + return " y ".join([", ".join(xs[:-1]), xs[-1]]) if len(xs) > 1 else (xs[0] if xs else "") + + @app.delete("/api/tarea") def tarea_borrar(): - if not is_admin(): - return jsonify(ok=False, error="Solo un administrador puede borrar pendientes. " - "Si ya no aplica, muévelo a Hecho."), 403 + u = quien() + if not u: + return _sin_usuario() + if not _puede_borrar_tarea(): + nombres = {k: {"Leopoldo": "Leo"}.get((m.get("nombre") or k).split(" ")[0], (m.get("nombre") or k).split(" ")[0]) + for k, m in _miembros().items()} + return jsonify(ok=False, error="Solo " + _y_lista([nombres.get(x, x) for x in sorted(ADMINS | TAREAS_BORRAN)]) + + " pueden borrar pendientes. Si ya no aplica, muévelo a Hecho."), 403 rid = slug(request.args.get("id"), 60) if not rid: return jsonify(ok=False, error="falta id"), 400 ruta = _ruta_registro(TAREAS_DIR, rid) - if not os.path.isfile(ruta): + t = _leer_json(ruta) if os.path.isfile(ruta) else None + if not isinstance(t, dict): return jsonify(ok=False, error=f"No existe el pendiente {rid}."), 404 + _hist(t, u, "borró el pendiente") + t["borrado_por"], t["borrado_en"] = u, t["modificado_en"] + destino = os.path.join(TAREAS_BORRADOS, rid + "__" + re.sub(r"\D", "", t["borrado_en"]) + ".json") + _escribir_json(destino, t) # primero la copia en la papelera; después se quita del tablero os.remove(ruta) + sueltos = [] + for o in _listar_json(TAREAS_DIR): + if rid in (o.get("bloqueada_por") or []): + o["bloqueada_por"] = [x for x in o["bloqueada_por"] if x != rid] + _hist(o, u, f"Ya no espera a «{_corto(t.get('titulo'))}»: se borró ese pendiente.") + _escribir_json(_ruta_registro(TAREAS_DIR, o["id"]), o) + sueltos.append(o["id"]) _reescribir_indice(TAREAS_DIR, _listar_json(TAREAS_DIR), COLS_TAREAS) - return jsonify(ok=True, borrado=rid) + return jsonify(ok=True, borrado=rid, papelera=os.path.basename(destino), dependencias=sueltos) @app.get("/api/tareas/resumen") @@ -2051,13 +2087,17 @@ def actividad(): limite = max(1, min(_entero(request.args.get("limite"), 30), 200)) desde = (datetime.datetime.now() - datetime.timedelta(days=dias)).replace(microsecond=0).isoformat() eventos = [] - for t in _listar_json(TAREAS_DIR): - for h in (t.get("historial") or []): - cuando = str(h.get("cuando") or "") - if cuando >= desde: - eventos.append({"tipo": "pendiente", "cuando": cuando, "quien": h.get("quien") or "", - "cambio": _texto(h.get("cambio"), 300), "id": t.get("id"), - "titulo": t.get("titulo"), "pista": t.get("pista"), "estado": t.get("estado")}) + for borrados, lista in ((False, _listar_json(TAREAS_DIR)), (True, _listar_json(TAREAS_BORRADOS))): + for t in lista: + for h in (t.get("historial") or []): + cuando = str(h.get("cuando") or "") + if cuando >= desde: + ev = {"tipo": "pendiente", "cuando": cuando, "quien": h.get("quien") or "", + "cambio": _texto(h.get("cambio"), 300), "id": t.get("id"), + "titulo": t.get("titulo"), "pista": t.get("pista"), "estado": t.get("estado")} + if borrados: + ev["borrado"] = True # ya no está en el tablero: la Actividad no lo liga + eventos.append(ev) if os.path.isdir(BASE): for tipo in sorted(os.listdir(BASE)): carpeta = os.path.join(BASE, tipo) diff --git a/backend/tests-1013.sh b/backend/tests-1013.sh index 185a6fd..0b4f5c8 100755 --- a/backend/tests-1013.sh +++ b/backend/tests-1013.sh @@ -36,8 +36,13 @@ check "PATCH inexistente → 404" "No existe" "$(curl "${ALAN[@]}" -X PATCH "$U/ check "POST orden (renumera columna: t-b cambia de columna, t-a de posición)" '"actualizadas":2' "$(curl "${ALAN[@]}" -X POST $U/api/tareas/orden -d '{"estado":"en-curso","ids":["t-b","t-a"]}')" check "resumen" '"en-curso": 1' "$(curl -s $U/api/tareas/resumen | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)["por_pista"]["ingenieria"]))')" check "resumen vencidas" '"id":"t-a"' "$(curl -s $U/api/tareas/resumen)" -check "DELETE alan → 403" "Solo un administrador" "$(curl "${ALAN[@]}" -X DELETE "$U/api/tarea?id=t-b")" +check "DELETE alan → 403" "pueden borrar pendientes" "$(curl "${ALAN[@]}" -X DELETE "$U/api/tarea?id=t-b")" check "DELETE leo → ok" '"borrado":"t-b"' "$(curl "${LEO[@]}" -X DELETE "$U/api/tarea?id=t-b")" +# 22-oct: lo borrado va a la papelera y sigue en la Actividad; adriana y gibranlp también borran (TAREAS_BORRAN) +check "papelera" "t-b__" "$(ls $T/tareas/_borrados/ 2>/dev/null)" +check "Actividad: borró, sin liga" '"borrado":true' "$(curl "${LEO[@]}" "$U/api/actividad?dias=1&limite=200")" +check "adriana puede borrar" '"puede_borrar":true' "$(curl -s -H "X-Remote-User: adriana" $U/api/tareas)" +check "alan no" '"puede_borrar":false' "$(curl "${ALAN[@]}" $U/api/tareas)" check "índice CSV escrito" "t-a" "$(cat $T/tareas/_indice.csv)" echo "## plan" diff --git a/docs/tableros/actividad.js b/docs/tableros/actividad.js index 8d06947..b42a22a 100644 --- a/docs/tableros/actividad.js +++ b/docs/tableros/actividad.js @@ -52,6 +52,7 @@ // Lo que dice el historial («estado: por-hacer → en-curso; fecha_limite: … → …») en una frase. function frasePendiente(e) { const c = String(e.cambio || ''); + if (/^borró el pendiente/i.test(c)) return { verbo: 'borró', fin: '' }; const m = c.match(/^(?:creado en|creó el pendiente)\s*([a-z-]*)/i); if (m) return { verbo: 'creó', fin: m[1] && m[1] !== 'por-hacer' && ESTADOS[m[1]] ? ' en ' + ESTADOS[m[1]] : '' }; const partes = c.split(/;\s*/).map(p => p.match(/^([a-z_]+):\s*(.*?)\s*→\s*(.*)$/)).filter(Boolean); @@ -93,7 +94,9 @@ const quien = el('b', { text: q.nombre }); if (e.tipo === 'pendiente') { const f = frasePendiente(e); - const a = el('a', { href: '/tableros/pendientes/?id=' + encodeURIComponent(e.id || ''), text: '«' + corto(e.titulo, opts.corto || 70) + '»' }); + // un pendiente borrado ya no está en el tablero: su título va sin liga + const a = e.borrado ? el('span', { class: 'act-borrado', title: 'Pendiente borrado', text: '«' + corto(e.titulo, opts.corto || 70) + '»' }) + : el('a', { href: '/tableros/pendientes/?id=' + encodeURIComponent(e.id || ''), text: '«' + corto(e.titulo, opts.corto || 70) + '»' }); t.append(quien, ' ' + f.verbo + ' ', a); if (f.fin) t.append(f.fin); if (f.nota) t.append(el('span', { class: 'act-nota', text: corto(f.nota, 160) })); diff --git a/docs/tableros/index.md b/docs/tableros/index.md index fb2c8e7..7709694 100644 --- a/docs/tableros/index.md +++ b/docs/tableros/index.md @@ -24,7 +24,7 @@ Las páginas que abres para saber **dónde está el proyecto hoy**: qué pendien ## Cómo se usan -1. Los pendientes se mueven arrastrando entre columnas (en el teléfono, con «Mover a…»). Cualquier usuario autenticado crea y mueve; **borrar y reabrir** es de administradores (`ACTAS_ADMINS`). +1. Los pendientes se mueven arrastrando entre columnas (en el teléfono, con «Mover a…»). Cualquier usuario autenticado crea y mueve. **Borrar** un pendiente lo pueden hacer Leo, Adriana y Gibran: sale del tablero, queda en la Actividad y Leo lo puede recuperar; si solo ya no aplica, mejor muévelo a Hecho. Borrar o reabrir **actas** sigue siendo de administradores (`ACTAS_ADMINS`). 2. Cada pendiente lleva **pista**, **responsable** (uno o varios del [Equipo](equipo.md)), **fecha límite** y ligas. Si depende de otro, márcalo en «Espera a que terminen» y, si no puedes avanzar, déjalo en *Bloqueado*. 3. Los tableros de Ingeniería y Producción son **vistas** de los mismos pendientes filtrados por pista, más sus bloques fijos (versiones, cantidades). 4. Las cantidades del plan de producción (versión → objetivo → fecha) las edita un administrador en el propio tablero; los conteos reales salen de las actas. diff --git a/docs/tableros/kanban.js b/docs/tableros/kanban.js index 6efbc59..57d8aea 100644 --- a/docs/tableros/kanban.js +++ b/docs/tableros/kanban.js @@ -21,7 +21,8 @@ filtros: true = barra completa · 'min' = solo vistas, «Solo los míos» y «Nuevo» · false = sin barra. Servidor: GET /api/tareas · POST /api/tareas · PATCH /api/tarea?id= · POST /api/tareas/orden · - DELETE /api/tarea?id= (solo admin). Cada cambio guarda quién y cuándo en el historial. + DELETE /api/tarea?id= (administradores y TAREAS_BORRAN: «puede_borrar» y «borran» en GET /api/tareas; + el pendiente pasa a la papelera del servidor y sigue en la Actividad). Cada cambio guarda quién y cuándo. Equipo (14-oct): los responsables son miembros de /api/equipo (GET /api/tareas ya trae el mapa id → nombre). El pendiente exige responsable; se puede escribir texto libre si la persona aún no está dada de alta (el servidor avisa). Kanban.nombresResp(t, equipo) sirve a otras páginas. @@ -164,7 +165,7 @@ function montar(root, opts) { opts = opts || {}; - let tareas = [], admin = false, user = '', porId = {}; + let tareas = [], admin = false, user = '', porId = {}, puedeBorrar = false, borran = []; const q0 = new URLSearchParams(location.search); const filtro = { pista: opts.pista || '', responsable: '', version: '', buscar: '', hechas: 'recientes', mios: q0.get('mios') === '1', sitios: new Set((q0.get('sitio') || '').split(',').map(x => x.trim().toLowerCase()).filter(Boolean).map(x => ALIAS_SITIO[x] || x)) }; @@ -495,6 +496,8 @@ try { const j = await api('/api/tareas'); tareas = j.tareas || []; admin = !!j.admin; user = j.user || ''; + puedeBorrar = j.puede_borrar === undefined ? admin : !!j.puede_borrar; // servidor de antes del 22-oct: solo admin + borran = Array.isArray(j.borran) ? j.borran : []; if (Array.isArray(j.sitios) && j.sitios.length) SITIOS = j.sitios.map(x => [x.id, x.corto || x.id, x.id === 'qro' ? 'Querétaro' : (x.corto || x.id), x.nombre || x.id]); usarEquipo(j.equipo); pintar(); @@ -598,15 +601,23 @@ const m = el('div', { class: 'msg' }); cuerpo.append(m); const pie = el('div', { class: 'dpie' }); dlg.append(pie); - if (!nuevo && admin) { + if (!nuevo && puedeBorrar) { const del = el('button', { type: 'button', class: 'peligro izq', text: 'Borrar' }); del.addEventListener('click', async () => { - if (!confirm('¿Borrar el pendiente «' + t.titulo + '»? No se puede deshacer. Si ya no aplica, mejor muévelo a Hecho.')) return; - try { await api('/api/tarea?id=' + encodeURIComponent(t.id), { method: 'DELETE' }); dlg.close(); mensaje('Pendiente borrado.', true); recargar(); } - catch (e) { m.className = 'msg err'; m.textContent = e.message; } + if (!confirm('¿Borrar el pendiente «' + t.titulo + '»? Sale del tablero; queda en la Actividad y Leo lo puede recuperar. Si ya no aplica, mejor muévelo a Hecho.')) return; + try { + const r = await api('/api/tarea?id=' + encodeURIComponent(t.id), { method: 'DELETE' }); + const n = (r.dependencias || []).length; + dlg.close(); + mensaje('Pendiente borrado.' + (n === 1 ? ' Un pendiente ya no lo espera.' : n > 1 ? ' ' + n + ' pendientes ya no lo esperan.' : ''), true); + recargar(); + } catch (e) { m.className = 'msg err'; m.textContent = e.message; } }); pie.append(del); - } else if (!nuevo) pie.append(el('span', { class: 'izq vacio', text: 'Solo el administrador borra; si ya no aplica, muévelo a Hecho.' })); + } else if (!nuevo) { + const nombres = borran.map(nombreCorto), quienes = nombres.length > 1 ? nombres.slice(0, -1).join(', ') + ' y ' + nombres[nombres.length - 1] : (nombres[0] || 'un administrador'); + pie.append(el('span', { class: 'izq vacio', text: 'Solo ' + quienes + ' borran pendientes; si ya no aplica, muévelo a Hecho.' })); + } const cancelar = el('button', { type: 'button', class: 'sec', text: 'Cancelar' }); cancelar.addEventListener('click', () => dlg.close()); const guardar = el('button', { type: 'button', text: nuevo ? 'Crear pendiente' : 'Guardar cambios' }); guardar.addEventListener('click', async () => {