Tablero: Adriana y Gibran pueden borrar pendientes; lo borrado va a la papelera

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X4CAujMuib2tDqt9JRg6X
This commit is contained in:
Leopoldo Rodríguez 2026-10-22 16:45:40 -06:00
parent 4ba4c712d5
commit 119eee5f70
9 changed files with 122 additions and 25 deletions

View File

@ -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=<id>` |
| **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 |

View File

@ -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/<id>__<AAAAMMDDhhmmss>.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/ # <id>__AAAAMMDDhhmmss.json
mv _borrados/<id>__<fecha>.json <id>.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

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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) }));

View File

@ -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.

View File

@ -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 () => {