diff --git a/DESPLIEGUE.md b/DESPLIEGUE.md index a57e107..33737dd 100644 --- a/DESPLIEGUE.md +++ b/DESPLIEGUE.md @@ -14,6 +14,7 @@ Servidor: `git.helgen.online` · dominio público: `https://drogheda.helgen.onli | **Inventario (ítems y movimientos)** | `/srv/projects/drogheda/inventario` | las escribe el backend — **13-oct** | | **Sitios** | `/srv/projects/drogheda/sitios` | las escribe el backend — **13-oct** | | **Plan de producción** | `/srv/projects/drogheda/plan-produccion.json` | lo escribe el backend (admin) — **13-oct** | +| **Equipo (miembros, bitácora de accesos)** | `/srv/projects/drogheda/equipo` | las escribe el backend — **14-oct** | | Usuarios (HTTP Basic) | `/etc/nginx/hbox-docs.htpasswd` | `htpasswd` | **Lo importante:** `deploy.sh` publica **solo el sitio**. Si cambió `backend/app.py` —como en esta @@ -214,9 +215,44 @@ rsync -az leo@git.helgen.online:/srv/projects/drogheda/sitios/ ~/respaldo-si rsync -az leo@git.helgen.online:/srv/projects/drogheda/plan-produccion.json ~/respaldo-plan/ ``` +## A3 · 14-oct: equipo y usuarios + +Agrega la colección `equipo` (quién es quién; los responsables de los pendientes), la administración +de accesos desde el sitio (`/tableros/equipo.html`, solo admin) y hace obligatorio el responsable en +cada pendiente. Compatible con lo ya desplegado: los pendientes viejos siguen funcionando; la siembra +les calcula `responsable_ids` y pone `leo` solo donde el responsable estaba vacío. + +```bash +# Mac +cd ~/Documents/Claude/Projects/Drogheda/hbox-docs && git add -A && git commit -m "equipo, usuarios y responsables (14-oct)" && git push && ./scripts/deploy.sh + +# servidor +ssh leo@git.helgen.online +cd /srv/projects/drogheda/hbox-docs && git pull +sudo systemctl restart actas-hbox +./backend/venv/bin/python3 backend/seed_1013.py --usuario leo # crea equipo/ (9 miembros, sin accesos) + el pendiente de ferretería; no duplica +curl -s -H 'X-Remote-User: leo' http://127.0.0.1:8081/api/equipo | head -c 200 +``` + +La carpeta `equipo/` la crea la siembra (o el backend al primer alta) junto a `/actas`; no hay +variables nuevas que fijar (`EQUIPO_DIR`, `HTPASSWD_LOCK` vienen comentadas en el `.service`). + +**Para crear o quitar accesos desde la página, el servicio tiene que poder escribir el htpasswd** — +es lo mismo que ya pide «Mi cuenta» (§7): `sudo chown leo:www-data /etc/nginx/hbox-docs.htpasswd && +sudo chmod 640 /etc/nginx/hbox-docs.htpasswd`. Si no está hecho, la página de Equipo lo avisa y solo +deja editar miembros. A partir de aquí los usuarios se crean en **Equipo → Nuevo usuario / miembro** +(la contraseña inicial se muestra una sola vez), no con `htpasswd` en el servidor. Las cuentas que ya +existen y no son miembro (p. ej. `luz`) aparecen como «cuentas con acceso que no están en el equipo»: +dales de alta como miembro sin marcar «crear acceso». + +Verificar: `/tableros/equipo.html` como leo (botones) y como un técnico (solo lectura); crear un +pendiente sin responsable debe rechazarse; `backend/tests-1014.sh` reproduce todo con un htpasswd temporal. + | Síntoma | Causa | Arreglo | |---|---|---| | Los tableros dicen «No se pudo cargar (HTTP 404)» | backend viejo | paso 2 (`git pull` + restart) | +| «El servicio no tiene permiso de escribir el archivo de usuarios» al crear un acceso | falta el `chown`/`chmod` del htpasswd | §7 | +| «Falta el responsable del pendiente» al crear | regla del 14-oct | elige a alguien del equipo (o escríbelo en texto libre) | | «No puedo identificarte (falta X-Remote-User…)» al crear o mover | nginx no manda la cabecera | paso 4 del despliegue anterior | | El tablero sale vacío | falta la siembra | paso 3 | | «Solo un administrador…» al borrar o editar sitios/plan | ese usuario no está en `ACTAS_ADMINS` | así debe ser; pide a Leo | diff --git a/NUEVOS-1013.md b/NUEVOS-1013.md index 8cf05b5..b18b391 100644 --- a/NUEVOS-1013.md +++ b/NUEVOS-1013.md @@ -18,6 +18,8 @@ copian tal cual; los `.md` llevan frontmatter con `title` y `date`. | `sitios/sitio.html` | Página de un sitio (`?id=icave`) | 🚜 Campo | | `formularios/instalacion.html` | Acta de instalación en campo | 🚜 Campo → «Instalación en el puerto» (junto al checklist de puesta en marcha) | +| `tableros/equipo.html` | Equipo y accesos (quién es quién; usuarios del sitio, solo admin) — **14-oct** | 📋 Tableros (y enlazar desde ⚙ Cuenta → Administración) | + Archivos de apoyo (no van al nav): `tableros/tablero.css`, `tableros/kanban.js`. Sugerencia para `actas/index.md` (no lo edité, para no chocar con la reorganización): agregar la fila @@ -31,14 +33,19 @@ antenas, RTK FIX, fotos · [Sitios](../sitios/index.md)» en la tabla **Campo**. | `formularios/acta.js` | nuevo tipo de ítem `sitio` (select alimentado por `/api/sitios`; `guardaEn:'sitio'`); `fillFrom` respeta un select cuyas opciones aún no cargan | | `formularios/envio.html`, `recepcion.html`, `diagnostico.html`, `dano.html` | un ítem `sitio` (opcional) en la sección 0; en daño solo aparece si fue en campo / transporte / no se sabe | | `unidad.html` | bloque **Trazabilidad**: cadena de producción (tarjeta → módulos → integración → ensamble → configuración) y cadena de campo (envío → recepción → instalación → diagnósticos → daños) con sitio y fechas | +| `tableros/kanban.js`, `tablero.css` (14-oct) | responsables = miembros de `/api/equipo` (chips, varios por pendiente, texto libre opcional, obligatorio al crear); las tarjetas muestran el nombre; filtro por id; `Kanban.nombresResp()` para otras páginas | +| `tableros/kanban.html`, `ingenieria.html`, `produccion.html`, `index.md` (14-oct) | enlace «👥 Equipo»; producción muestra el nombre del responsable en bloqueos | +| `admin.html` (14-oct) | enlace «Usuarios y equipo →» (las funciones existentes no se movieron) | +| `cuenta.html` (14-oct) | una línea: si no tienes acceso, pídeselo a Leo (Administración → Usuarios y equipo) | ## Backend y despliegue | Ruta | Cambio | |---|---| -| `backend/app.py` | colecciones `tareas`, `plan`, `inventario` (+ `movimientos`), `sitios`; `/api/actas` y `/api/unidad` exponen `sitio`/`fecha`/`componentes` | -| `backend/seed_1013.py` | **nuevo** — siembra idempotente (plan, 4 sitios, 18 ítems, 12 pendientes; `--extra` para importar Notion) | +| `backend/app.py` | colecciones `tareas`, `plan`, `inventario` (+ `movimientos`), `sitios`; `/api/actas` y `/api/unidad` exponen `sitio`/`fecha`/`componentes`. **14-oct:** colección `equipo` (`GET/POST /api/equipo`, `PATCH /api/equipo/miembro`), accesos (`POST /api/usuarios`, `DELETE /api/usuario`), `responsable` obligatorio + `responsable_ids`, `resumen.por_responsable` | +| `backend/seed_1013.py` | **nuevo** — siembra idempotente (plan, 4 sitios, 18 ítems, 13 pendientes; `--extra` para importar Notion). **14-oct:** `sembrar_equipo` (9 miembros, sin accesos), pendiente `t-ferreteria-eit-timsa`, completa responsables vacíos y `responsable_ids` | | `backend/tests-1013.sh`, `backend/TESTS-1013.md` | **nuevos** — pruebas con `curl` (61) y resumen de las pruebas de navegador | +| `backend/tests-1014.sh`, `backend/TESTS-1014.md` | **nuevos (14-oct)** — equipo, accesos con htpasswd temporal, responsables (57) | | `backend/README.md` | sección 5: colecciones, variables, ejemplos `curl` por endpoint | | `backend/actas-hbox.service` | variables `TAREAS_DIR`, `INVENTARIO_DIR`, `SITIOS_DIR`, `PLAN_FILE` comentadas (valores por omisión junto a `/actas`) | | `DESPLIEGUE.md` | sección **A2 · Despliegue 13-oct** (git pull, mkdir, restart, siembra, verificación, respaldo, problemas frecuentes) y filas nuevas en «Qué vive dónde» | diff --git a/backend/README.md b/backend/README.md index 46c37d3..74fd410 100644 --- a/backend/README.md +++ b/backend/README.md @@ -73,6 +73,8 @@ cada cambio. Carpetas (variables de entorno, con su valor por omisión junto a ` | `INVENTARIO_DIR` | `/srv/projects/drogheda/inventario` | `items/` y `movimientos/` | | `SITIOS_DIR` | `/srv/projects/drogheda/sitios` | un JSON por sitio (`icave.json`…) | | `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 | 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` @@ -159,3 +161,43 @@ curl -s -u $U "$D/api/sitio/unidades?id=icave" campo `sitio` sea el slug del sitio (`coincidencia: "sitio"`), y también las que solo coinciden por texto (`terminal` de la flota, destino del envío) con el nombre o un alias (`coincidencia: "texto"`), agrupadas por unidad con su marca de daño vigente. + +## 6. Equipo y accesos (14-oct) + +Dos cosas distintas: un **miembro del equipo** (`EQUIPO_DIR`, omisión `/srv/projects/drogheda/equipo`, +un JSON por persona) es quien puede ser **responsable** de un pendiente; un **acceso** es una línea en +el htpasswd de nginx. `tiene_login` se deduce del htpasswd al leer; `es_admin` de `ACTAS_ADMINS`. +Los accesos se crean y quitan desde la página **Equipo** (admin), con la misma rutina `$apr1$` y la misma +escritura atómica que `/api/password`, bajo un `flock` (`HTPASSWD_LOCK`, omisión +`/srv/projects/drogheda/.htpasswd.lock`). Bitácora de altas/bajas en `equipo/_accesos.csv`. +Requisito en el servidor: el servicio debe poder **escribir** el htpasswd (`chown leo:www-data` + +`chmod 640`, §7 de DESPLIEGUE.md). + +Campos del miembro: `id` (= usuario del sitio si lo tiene), `nombre`, `rol`, `area` ∈ direccion·operaciones· +ingenieria·layout·produccion·pruebas·fabricacion·software·campo·asistente, `correo`, `telefono`, `activo`. + +```bash +curl -s -u $U "$D/api/equipo" # activos, con tiene_login, es_admin y pendientes {abiertos, vencidas, por estado} +curl -s -u $U "$D/api/equipo?todos=1" # incluye inactivos; para admin trae logins_sin_miembro +curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/equipo \ + -d '{"id":"adriana","nombre":"Adriana","rol":"Operaciones; lleva el PM","area":"operaciones"}' # admin; sin acceso al sitio +curl -s -u $U -H 'Content-Type: application/json' -X PATCH "$D/api/equipo/miembro?id=aldair" -d '{"rol":"Fabricación y ensamble"}' # admin +curl -s -u $U -H 'Content-Type: application/json' -X PATCH "$D/api/equipo/miembro?id=aldair" -d '{"activo":false}' # desactivar (nunca se borra) + +# acceso al sitio: crea la línea en el htpasswd (409 si existe; 400 si la contraseña es más corta que PASS_MIN) +# y crea/actualiza al miembro. La contraseña inicial se pasa en persona; la cambia en /cuenta.html. +curl -s -u $U -H 'Content-Type: application/json' -X POST $D/api/usuarios \ + -d '{"usuario":"adriana","password_inicial":"operaciones-2026","nombre":"Adriana","rol":"Operaciones","area":"operaciones","correo":"adriana@helgen.tech"}' +# quitar el acceso: borra la línea y deja al miembro activo=false. Se niega a quitarte a ti mismo (400) +# y a quitar al último administrador con acceso (409). +curl -s -u $U -X DELETE "$D/api/usuario?u=adriana" +curl -s -u $U "$D/api/usuarios" # sigue igual: lista de cuentas del htpasswd (admin) +``` + +**Pendientes (cambio del 14-oct):** `responsable` es obligatorio al crear (400 si falta). Acepta ids de +miembros separados por coma, espacio o `/` (`"leo, Jesús"` → `responsable_ids: ["leo","jesus"]`; los acentos +no importan) y también texto libre si la persona aún no está dada de alta: se guarda y la respuesta trae +`aviso`. `GET /api/tareas?responsable=jesus` filtra por id (o por texto, para los libres) y la respuesta +incluye `equipo` (id → nombre) para pintar nombres. `GET /api/tareas/resumen` agrega `por_responsable` +`{id: {por-hacer, en-curso, bloqueado, hecho, abiertos, vencidas, nombre}}`; los pendientes sin miembro +registrado cuentan bajo `_sin_equipo`. diff --git a/backend/TESTS-1014.md b/backend/TESTS-1014.md new file mode 100644 index 0000000..75eee18 --- /dev/null +++ b/backend/TESTS-1014.md @@ -0,0 +1,104 @@ +# Pruebas del 14-oct — equipo, accesos y responsables + +Complementa a `TESTS-1013.md`. Todo corrió en el sandbox contra `backend/app.py` con carpetas temporales, +un **htpasswd temporal** (`HTPASSWD_FILE`, permisos 640, hashes `$apr1$` generados con la misma rutina +del backend) y `ACTAS_ADMINS=leo,alan`. El usuario llega por `X-Remote-User`, como lo fija nginx. + +## 1 · Backend — `backend/tests-1014.sh` (57 comprobaciones) + +```bash +bash backend/tests-1014.sh /ruta/a/hbox-docs 8097 +``` + +``` +## siembra (incluye equipo) — con un pendiente viejo sin responsable y sin responsable_ids + ✓ equipo sembrado (9) + ✓ pendientes nuevos (12 de 13: t-caja-ethernet ya existía) + ✓ responsable vacío completado a leo + ✓ responsable_ids recalculado + ✓ t-caja-ethernet → leo con historial + ✓ segunda corrida: equipo no duplica + ✓ segunda corrida: nada que completar + ✓ t-ferreteria-eit-timsa sembrada para adriana +## equipo + ✓ GET equipo (cualquiera) + ✓ tiene_login derivado del htpasswd (leo sí) + ✓ adriana sin login + ✓ es_admin desde ACTAS_ADMINS + ✓ pendientes abiertos por miembro + ✓ logins sin miembro solo para admin (bob no admin → []) + ✓ logins sin miembro para admin (bob) + ✓ POST equipo no admin → 403 + ✓ POST equipo (admin) + ✓ POST equipo duplicado → 409 + ✓ POST equipo área inválida → 400 + ✓ PATCH miembro no admin → 403 + ✓ PATCH miembro (admin) → historial + ✓ PATCH activo=false (desactivar, nunca borrar) + ✓ GET equipo oculta inactivos + ✓ GET equipo?todos=1 los muestra + ✓ PATCH inexistente → 404 +## accesos (htpasswd temporal) + ✓ POST usuarios no admin → 403 + ✓ contraseña corta → 400 + ✓ usuario con espacios/mayúsculas → 400 + ✓ crea acceso a miembro existente (adriana) + ✓ línea en el htpasswd con apr1 + ✓ el hash valida con apr1 (misma rutina que /api/password) + ✓ permisos del archivo conservados (640) + ✓ duplicado → 409 + ✓ crea usuario Y miembro nuevos + ✓ GET /api/usuarios sigue igual (lista del htpasswd) + ✓ la cuenta nueva cambia su propia contraseña por /api/password + ✓ bitácora _accesos.csv + ✓ DELETE no admin → 403 + ✓ DELETE a sí mismo → 400 + ✓ DELETE inexistente → 404 + ✓ último admin con acceso (alan admin sin login quita a leo) → 409 + ✓ con dos admins con acceso, alan sí puede quitar a leo + ✓ leo ya no está en el htpasswd + ✓ miembro leo desactivado (no borrado) + ✓ DELETE nuevo1 (admin alan) +## responsable obligatorio y resumen + ✓ crear sin responsable → 400 + ✓ crear con ids (y 'Jesús' con acento → jesus) + ✓ texto libre → se acepta con aviso + ✓ lista como array también vale + ✓ PATCH responsable vacío → 400 + ✓ PATCH sin tocar responsable sigue funcionando + ✓ filtro por id + ✓ filtro con acento + ✓ GET tareas trae el mapa equipo + ✓ resumen por_responsable (jesus) + ✓ resumen por_responsable cuenta abiertos + ✓ resumen conserva por_pista + +RESULTADO: 57 correctas · 0 fallidas · datos en +``` + +| Área | Probado | +|---|---| +| siembra | con un pendiente viejo sin `responsable` ni `responsable_ids`: la primera corrida crea los 9 miembros y 12 pendientes (el viejo se respeta), completa el responsable vacío a `leo` con historial y recalcula `responsable_ids`; la segunda corrida no crea ni cambia nada | +| equipo | GET para cualquiera con `tiene_login` (derivado del htpasswd), `es_admin` (de `ACTAS_ADMINS`) y pendientes por miembro; `logins_sin_miembro` solo para admin; POST/PATCH 403 para no admin; 409 duplicado; 400 área inválida; PATCH con historial; `activo:false` oculta sin borrar (`?todos=1` lo muestra); 404 | +| accesos | POST 403 no admin; 400 contraseña corta y usuario inválido; alta sobre miembro existente (`nuevo_miembro:false`) y alta de usuario+miembro nuevos; línea `$apr1$` verificada con `apr1()`; permisos 640 conservados; 409 duplicado; `GET /api/usuarios` igual que antes; la cuenta nueva cambia su contraseña por `/api/password`; bitácora `_accesos.csv`; DELETE 403 no admin, 400 a sí mismo, 404 inexistente, **409 último administrador con acceso**, ok con dos admins; miembro queda `activo:false` | +| pendientes | crear sin responsable → 400; `"Michelle, Jesús"` → `["michelle","jesus"]`; texto libre aceptado con `aviso`; lista como array; PATCH con responsable vacío → 400; PATCH sin tocar responsable sigue igual; filtro por id y con acento; `GET /api/tareas` trae `equipo`; `resumen.por_responsable` con nombre y abiertos; `por_pista` se conserva | + +`backend/tests-1013.sh` se volvió a correr contra este backend (61/61) tras ajustar sus altas de pendientes +a la regla nueva (responsable obligatorio) y el conteo de la siembra (13 pendientes). + +## 2 · Páginas — Chromium headless (Playwright), teléfono y escritorio + +| Página | Probado | Resultado | +|---|---|---| +| `tableros/equipo.html` (alan) | ve la lista de miembros sin ningún botón ni «Nuevo usuario»; toca «Jesús» y ve sus 2 pendientes con enlace al tablero filtrado | ✓ | +| `tableros/kanban.html` (alan) | las tarjetas muestran el **nombre** («Michelle Márquez», no `michelle`); crear sin responsable → mensaje de validación y no se envía; con dos chips (Adriana, Alán) se crea y la tarjeta muestra ambos nombres; `?responsable=adriana` filtra por id | ✓ | +| `tableros/equipo.html` (leo) | «Nuevo usuario / miembro» con «crear acceso» marcado y contraseña generada → se muestra **una sola vez**; la fila nueva dice acceso «sí»; el hash del htpasswd valida con `apr1()`; «Quitar acceso» → acceso «no» e inactivo; «Editar» guarda el correo; leo no tiene botón para quitarse el acceso | ✓ | +| `tableros/produccion.html` / `ingenieria.html` | los bloqueos y las tarjetas muestran el nombre del responsable | ✓ | +| suite del 13-oct (`test_kanban.py`) | repetida con la regla nueva (elige un chip al crear): drag&drop, Mover a…, historial, borrar admin, plan | ✓ sin errores de consola | + +## 3 · Sitio estático + +`mkdocs build --strict` sobre una copia: las mismas 55 advertencias preexistentes (PDF/binarios ausentes +en la copia); ninguna sobre `tableros/equipo.html` ni los archivos tocados. Referencias `src`/`href` de +`equipo.html`, `kanban.html`, `ingenieria.html`, `produccion.html`, `admin.html`, `cuenta.html`: 0 rotas. +`node --check tableros/kanban.js`: sin errores. diff --git a/backend/actas-hbox.service b/backend/actas-hbox.service index a10a3f5..3a68294 100644 --- a/backend/actas-hbox.service +++ b/backend/actas-hbox.service @@ -18,6 +18,8 @@ Environment=ACTAS_ADMINS=leo #Environment=INVENTARIO_DIR=/srv/projects/drogheda/inventario #Environment=SITIOS_DIR=/srv/projects/drogheda/sitios #Environment=PLAN_FILE=/srv/projects/drogheda/plan-produccion.json +#Environment=EQUIPO_DIR=/srv/projects/drogheda/equipo +#Environment=HTPASSWD_LOCK=/srv/projects/drogheda/.htpasswd.lock 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 ae9c6db..b03c665 100644 --- a/backend/app.py +++ b/backend/app.py @@ -34,8 +34,11 @@ autenticado crea y modifica, solo un administrador borra: GET/POST /api/inventario · PATCH /api/inventario/item?id= · POST /api/inventario/movimiento GET /api/inventario/movimientos?item_id= · GET /api/inventario/export.csv GET /api/sitios · POST /api/sitios (admin) · PATCH /api/sitio?id= (admin) · GET /api/sitio/unidades?id= +Equipo y accesos (14-oct): los miembros del equipo NO son lo mismo que los usuarios con login. + GET /api/equipo · POST /api/equipo (admin) · PATCH /api/equipo/miembro?id= (admin) + POST /api/usuarios (admin: crea login en el htpasswd + miembro) · DELETE /api/usuario?u= (admin: quita el login) """ -import os, re, json, csv, datetime, uuid, hashlib +import os, re, json, csv, datetime, uuid, hashlib, unicodedata, fcntl from flask import Flask, request, jsonify, abort, send_file app = Flask(__name__) @@ -741,8 +744,14 @@ TAREAS_DIR = os.environ.get("TAREAS_DIR", os.path.join(DATOS_BASE, "tareas")) INVENTARIO_DIR = os.environ.get("INVENTARIO_DIR", os.path.join(DATOS_BASE, "inventario")) SITIOS_DIR = os.environ.get("SITIOS_DIR", os.path.join(DATOS_BASE, "sitios")) PLAN_FILE = os.environ.get("PLAN_FILE", os.path.join(DATOS_BASE, "plan-produccion.json")) +EQUIPO_DIR = os.environ.get("EQUIPO_DIR", os.path.join(DATOS_BASE, "equipo")) +# Candado para leer-modificar-escribir el htpasswd desde los endpoints de accesos (gunicorn corre +# 2 workers). Vive en la carpeta de datos porque /etc/nginx no es escribible por el servicio. +HTPASSWD_LOCK = os.environ.get("HTPASSWD_LOCK", os.path.join(DATOS_BASE, ".htpasswd.lock")) PISTAS = ("ingenieria", "produccion", "mecanica", "compras", "campo") +AREAS = ("direccion", "operaciones", "ingenieria", "layout", "produccion", "pruebas", "fabricacion", + "software", "campo", "asistente") ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho") VERSIONES_TARJETA = ("V5.5", "V5.6", "V5.7", "v6", "—") CATEGORIAS_INV = ("tarjeta", "modulo", "gabinete", "caja", "fuente", "bateria", "antena", "cable", @@ -882,12 +891,304 @@ def _ruta_registro(carpeta, rid): return os.path.join(carpeta, rid + ".json") +# ------------------------------------------------------------------ equipo (miembros) y accesos +# Un miembro del equipo es quien puede ser responsable de un pendiente. Tener login al sitio es +# otra cosa: tiene_login se deduce del htpasswd al leer, nunca se guarda. + +COLS_EQUIPO = ["id", "nombre", "rol", "area", "correo", "telefono", "activo", "creado_por", "creado_en", + "modificado_por", "modificado_en"] +USUARIO_RE = re.compile(r"^[a-z0-9][a-z0-9_\-.]{1,29}$") + + +def _norm_id(s): + """'Jesús' → 'jesus': minúsculas, sin acentos, solo [a-z0-9_-.].""" + s = unicodedata.normalize("NFKD", str(s or "")).encode("ascii", "ignore").decode().lower().strip() + return re.sub(r"[^a-z0-9_\-.]", "", s) + + +def _miembro_valido(d, base=None): + t = dict(base) if base else {} + alta = base is None + if alta or "nombre" in d: + n = _texto(d.get("nombre"), 80) + if not n: + return None, "Falta el nombre del miembro." + t["nombre"] = n + if alta or "rol" in d: + t["rol"] = _texto(d.get("rol"), 120) + if alta or "area" in d: + a = _norm_id(d.get("area")) or "produccion" + if a not in AREAS: + return None, f"Área inválida: {a}. Usa una de {', '.join(AREAS)}." + t["area"] = a + for k, n in (("correo", 120), ("telefono", 40), ("notas", 1000)): + if alta or k in d: + t[k] = _texto(d.get(k), n) + if alta or "activo" in d: + t["activo"] = _bool(d.get("activo"), True) + return t, None + + +def _guardar_miembro(t): + _escribir_json(_ruta_registro(EQUIPO_DIR, t["id"]), t) + _reescribir_indice(EQUIPO_DIR, _listar_json(EQUIPO_DIR), COLS_EQUIPO) + + +def _miembros(): + """{id: miembro} de todos (activos e inactivos).""" + return {m["id"]: m for m in _listar_json(EQUIPO_DIR) if m.get("id")} + + +def _logins(): + """Usuarios que existen en el htpasswd (en minúsculas), o set() si no hay archivo.""" + lineas = _leer_htpasswd() or [] + return {l.split(":", 1)[0].strip().lower() for l in lineas if ":" in l and not l.startswith("#")} + + +def _registrar_acceso(quien_lo_hizo, accion, usuario): + """Bitácora de altas y bajas de acceso: equipo/_accesos.csv.""" + os.makedirs(EQUIPO_DIR, exist_ok=True) + fp = os.path.join(EQUIPO_DIR, "_accesos.csv") + nuevo = not os.path.exists(fp) + with open(fp, "a", newline="", encoding="utf-8") as f: + w = csv.writer(f) + if nuevo: + w.writerow(["cuando", "quien", "accion", "usuario"]) + w.writerow([ahora(), quien_lo_hizo, accion, usuario]) + + +class _candado_htpasswd: + """flock sobre un archivo aparte, para que dos workers no pisen el htpasswd al mismo tiempo.""" + def __enter__(self): + os.makedirs(os.path.dirname(HTPASSWD_LOCK) or ".", exist_ok=True) + self.f = open(HTPASSWD_LOCK, "a+") + fcntl.flock(self.f, fcntl.LOCK_EX) + return self + + def __exit__(self, *a): + fcntl.flock(self.f, fcntl.LOCK_UN) + self.f.close() + + +def _cuenta_vacia(): + c = {e: 0 for e in ESTADOS} + c.update({"abiertos": 0, "vencidas": 0}) + return c + + +def _resumen_pendientes_por_miembro(): + """{id: {estado: n, 'abiertos': n, 'vencidas': n}} contando responsable_ids de cada pendiente.""" + hoy = datetime.date.today().isoformat() + out = {} + for t in _listar_json(TAREAS_DIR): + ids = t.get("responsable_ids") + if ids is None: + ids, _ = _ids_responsable(t.get("responsable")) + for rid in ids: + r = out.setdefault(rid, _cuenta_vacia()) + e = t.get("estado") + if e in ESTADOS: + r[e] += 1 + if e != "hecho": + r["abiertos"] += 1 + if _vencida(t, hoy): + r["vencidas"] += 1 + return out + + +@app.get("/api/equipo") +def equipo_listar(): + todos = request.args.get("todos") == "1" + logins = _logins() + pend = _resumen_pendientes_por_miembro() + out = [] + for m in _listar_json(EQUIPO_DIR): + if not todos and m.get("activo", True) is False: + continue + m["tiene_login"] = m["id"].lower() in logins + m["es_admin"] = m["id"].lower() in ADMINS + m["pendientes"] = pend.get(m["id"], _cuenta_vacia()) + out.append(m) + out.sort(key=lambda m: (not m.get("activo", True), AREAS.index(m["area"]) if m.get("area") in AREAS else 99, m.get("nombre", ""))) + # logins que no son miembros (cuentas viejas): se listan aparte para que el admin los vea + sueltos = sorted(u for u in logins if u not in {m["id"].lower() for m in _listar_json(EQUIPO_DIR)}) + return jsonify(ok=True, total=len(out), miembros=out, areas=AREAS, admins=sorted(ADMINS), + logins_sin_miembro=sueltos if is_admin() else [], htpasswd_ok=_leer_htpasswd() is not None, + pass_min=PASS_MIN, admin=is_admin(), user=current_user()) + + +@app.post("/api/equipo") +def equipo_crear(): + if not is_admin(): + return jsonify(ok=False, error="Solo un administrador da de alta miembros del equipo."), 403 + u = quien() + if not u: + return _sin_usuario() + d = _json_body() + if d is None: + return jsonify(ok=False, error="JSON inválido"), 400 + t, err = _miembro_valido(d) + if err: + return jsonify(ok=False, error=err), 400 + rid = _norm_id(d.get("id") or t["nombre"].split()[0]) + if not rid: + return jsonify(ok=False, error="El id debe tener letras o números (p. ej. adriana)."), 400 + if os.path.exists(_ruta_registro(EQUIPO_DIR, rid)): + return jsonify(ok=False, existe=True, error=f"Ya existe el miembro {rid}."), 409 + t["id"] = rid + t.update({"creado_por": u, "creado_en": ahora(), "modificado_por": u, "modificado_en": ahora()}) + t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "miembro creado"}] + _guardar_miembro(t) + t["tiene_login"] = rid in _logins() + t["es_admin"] = rid in ADMINS + return jsonify(ok=True, miembro=t), 201 + + +@app.patch("/api/equipo/miembro") +def equipo_modificar(): + if not is_admin(): + return jsonify(ok=False, error="Solo un administrador edita el equipo."), 403 + u = quien() + if not u: + return _sin_usuario() + rid = _norm_id(request.args.get("id")) + base = _leer_json(_ruta_registro(EQUIPO_DIR, rid)) if rid else None + if not base: + return jsonify(ok=False, error=f"No existe el miembro {rid}."), 404 + d = _json_body() + if d is None: + return jsonify(ok=False, error="JSON inválido"), 400 + for k in ("id", "tiene_login", "es_admin", "pendientes", "creado_por", "creado_en", "modificado_por", "modificado_en", "historial"): + d.pop(k, None) + t, err = _miembro_valido(d, base) + if err: + return jsonify(ok=False, error=err), 400 + cambios = [f"{k}: {_corto(base.get(k))} → {_corto(t.get(k))}" for k in d if k in t and t.get(k) != base.get(k)] + if not cambios: + return jsonify(ok=True, miembro=t, sin_cambios=True) + _hist(t, u, "; ".join(cambios)) + _guardar_miembro(t) + t["tiene_login"] = rid in _logins() + t["es_admin"] = rid in ADMINS + return jsonify(ok=True, miembro=t) + + +@app.post("/api/usuarios") +def usuarios_crear(): + """Alta de acceso al sitio (línea en el htpasswd, mismo hash $apr1$ que /api/password) + miembro del equipo.""" + if not is_admin(): + return jsonify(ok=False, error="Solo un administrador crea accesos."), 403 + yo = quien() + if not yo: + return _sin_usuario() + d = _json_body() + if d is None: + return jsonify(ok=False, error="JSON inválido"), 400 + usuario = _texto(d.get("usuario"), 30).lower() + if not USUARIO_RE.match(usuario): + return jsonify(ok=False, error="El usuario debe ser corto, en minúsculas y sin espacios ni acentos (p. ej. adriana)."), 400 + password = str(d.get("password_inicial") or "") + if len(password) < PASS_MIN: + return jsonify(ok=False, error=f"La contraseña inicial debe tener al menos {PASS_MIN} caracteres."), 400 + m_in = {"nombre": d.get("nombre") or usuario, "rol": d.get("rol", ""), "area": d.get("area", "produccion"), + "correo": d.get("correo", ""), "telefono": d.get("telefono", ""), "activo": True} + existente = _leer_json(_ruta_registro(EQUIPO_DIR, usuario)) + t, err = _miembro_valido({k: v for k, v in m_in.items() if existente is None or d.get(k) not in (None, "")}, existente) + if err: + return jsonify(ok=False, error=err), 400 + with _candado_htpasswd(): + lineas = _leer_htpasswd() + if lineas is None: + return jsonify(ok=False, error="No encuentro el archivo de usuarios en el servidor (HTPASSWD_FILE)."), 500 + if any(l.split(":", 1)[0].strip().lower() == usuario for l in lineas if ":" in l): + return jsonify(ok=False, existe=True, error=f"El usuario {usuario} ya tiene acceso. Si olvidó su contraseña, restablécela en Mi cuenta."), 409 + salt = "".join(ITOA64[b % 64] for b in os.urandom(8)) + lineas = [l for l in lineas if l.strip()] + [usuario + ":" + apr1(password, salt)] + try: + _escribir_htpasswd(lineas) + except PermissionError: + return jsonify(ok=False, error="El servicio no tiene permiso de escribir el archivo de usuarios. " + "Avisa a Leo (chown leo:www-data + chmod 640 de hbox-docs.htpasswd)."), 500 + except OSError as e: + return jsonify(ok=False, error="No se pudo guardar el acceso: " + str(e)), 500 + t["id"] = usuario + if existente is None: + t.update({"creado_por": yo, "creado_en": ahora(), "historial": []}) + t["activo"] = True + _hist(t, yo, "acceso al sitio creado") + _guardar_miembro(t) + _registrar_acceso(yo, "alta", usuario) + t["tiene_login"] = True + t["es_admin"] = usuario in ADMINS + return jsonify(ok=True, usuario=usuario, miembro=t, nuevo_miembro=existente is None), 201 + + +@app.delete("/api/usuario") +def usuario_borrar(): + """Quita el acceso (línea del htpasswd) y desactiva al miembro. Nunca borra el miembro ni sus pendientes.""" + if not is_admin(): + return jsonify(ok=False, error="Solo un administrador quita accesos."), 403 + yo = quien() + if not yo: + return _sin_usuario() + usuario = _texto(request.args.get("u"), 30).lower() + if not usuario: + return jsonify(ok=False, error="falta u"), 400 + if usuario == yo.lower(): + return jsonify(ok=False, error="No puedes quitarte el acceso a ti mismo."), 400 + with _candado_htpasswd(): + lineas = _leer_htpasswd() + if lineas is None: + return jsonify(ok=False, error="No encuentro el archivo de usuarios en el servidor (HTPASSWD_FILE)."), 500 + presentes = {l.split(":", 1)[0].strip().lower() for l in lineas if ":" in l} + if usuario not in presentes: + return jsonify(ok=False, error=f"El usuario {usuario} no tiene acceso."), 404 + if usuario in ADMINS and len(presentes & ADMINS) <= 1: + return jsonify(ok=False, error="Es el último administrador con acceso; no se puede quitar."), 409 + restantes = [l for l in lineas if not (":" in l and l.split(":", 1)[0].strip().lower() == usuario)] + try: + _escribir_htpasswd(restantes) + except PermissionError: + return jsonify(ok=False, error="El servicio no tiene permiso de escribir el archivo de usuarios. " + "Avisa a Leo (chown leo:www-data + chmod 640 de hbox-docs.htpasswd)."), 500 + except OSError as e: + return jsonify(ok=False, error="No se pudo guardar: " + str(e)), 500 + m = _leer_json(_ruta_registro(EQUIPO_DIR, usuario)) + if m: + m["activo"] = False + _hist(m, yo, "acceso al sitio retirado; miembro desactivado") + _guardar_miembro(m) + _registrar_acceso(yo, "baja", usuario) + return jsonify(ok=True, usuario=usuario, miembro_desactivado=bool(m)) + + # ------------------------------------------------------------------ tareas (tablero) -COLS_TAREAS = ["id", "titulo", "pista", "estado", "responsable", "fecha_limite", "version_tarjeta", +COLS_TAREAS = ["id", "titulo", "pista", "estado", "responsable", "responsable_ids", "fecha_limite", "version_tarjeta", "orden", "etiquetas", "bloqueada_por", "creado_por", "creado_en", "modificado_por", "modificado_en"] +def _ids_responsable(texto, miembros=None): + """'leo, Jesús / claude' → (['leo','jesus','claude'], []). Partes que no son miembros se devuelven + como texto libre (segunda lista) para avisar; no se rechazan.""" + miembros = _miembros() if miembros is None else miembros + ids, libres = [], [] + for parte in re.split(r"[,;/]+", str(texto or "")): + parte = parte.strip() + if not parte: + continue + tokens = [_norm_id(x) for x in parte.split()] + if tokens and all(x in miembros for x in tokens): + ids.extend(x for x in tokens if x not in ids) + elif _norm_id(parte.replace(" ", "")) in miembros: + rid = _norm_id(parte.replace(" ", "")) + if rid not in ids: + ids.append(rid) + else: + libres.append(parte) + return ids, libres + + def _tarea_valida(d, base=None): """Normaliza los campos de una tarjeta del tablero. Devuelve (tarea, error). Con base=None es un alta (todos los campos con su valor por defecto); con base es un PATCH @@ -916,7 +1217,20 @@ def _tarea_valida(d, base=None): return None, f"Estado inválido: {e}. Usa uno de {', '.join(ESTADOS)}." pon("estado", e) if alta or "responsable" in d: - pon("responsable", _texto(d.get("responsable"), 80)) + # Obligatorio: ids de miembros del equipo separados por coma/espacio (o texto libre si la + # persona aún no está dada de alta; se acepta y se avisa). 'Jesús' ≡ 'jesus'. + texto = _texto(d.get("responsable") if not isinstance(d.get("responsable"), list) + else ", ".join(str(x) for x in d.get("responsable")), 120) + if not texto: + return None, "Falta el responsable del pendiente. Elige a alguien del equipo." + ids, libres = _ids_responsable(texto) + pon("responsable", ", ".join(ids + libres)) + pon("responsable_ids", ids) + if libres: + t["_aviso"] = ("Responsable(s) sin registrar en el equipo: " + ", ".join(libres) + + ". Se guarda como texto; da de alta a la persona en Equipo para que cuente en su lista.") + elif base is not None and "responsable_ids" not in t: + t["responsable_ids"] = _ids_responsable(t.get("responsable"))[0] # pendientes de antes del 14-oct if alta or "fecha_limite" in d: f = d.get("fecha_limite") if f not in (None, "") and not _fecha_o_none(f): @@ -953,15 +1267,19 @@ def tareas_listar(): pista = _texto(request.args.get("pista"), 20).lower() estado = _texto(request.args.get("estado"), 20).lower() resp = _texto(request.args.get("responsable"), 80).lower() + resp_id = _norm_id(resp) version = _texto(request.args.get("version"), 10) hoy = datetime.date.today().isoformat() + miembros = _miembros() out = [] for t in _listar_json(TAREAS_DIR): + if "responsable_ids" not in t: # pendientes guardados antes del 14-oct + t["responsable_ids"] = _ids_responsable(t.get("responsable"), miembros)[0] if pista and t.get("pista") != pista: continue if estado and t.get("estado") != estado: continue - if resp and resp not in str(t.get("responsable", "")).lower(): + if resp and not (resp_id in t["responsable_ids"] or resp in str(t.get("responsable", "")).lower()): continue if version and t.get("version_tarjeta") != version: continue @@ -969,7 +1287,8 @@ def tareas_listar(): out.append(t) out.sort(key=lambda t: (ESTADOS.index(t.get("estado")) if t.get("estado") in ESTADOS else 9, t.get("orden", 0), t.get("creado_en", ""))) - return jsonify(ok=True, total=len(out), tareas=out, pistas=PISTAS, estados=ESTADOS, + 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, admin=is_admin(), user=current_user()) @@ -997,9 +1316,10 @@ def tareas_crear(): t["modificado_por"] = u t["modificado_en"] = t["creado_en"] t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "creado en " + t["estado"]}] + aviso = t.pop("_aviso", None) _guardar_tarea(t) t["vencida"] = _vencida(t) - return jsonify(ok=True, tarea=t), 201 + return jsonify(ok=True, tarea=t, aviso=aviso), 201 @app.patch("/api/tarea") @@ -1023,6 +1343,7 @@ def tarea_modificar(): t, err = _tarea_valida(d, base) if err: return jsonify(ok=False, error=err), 400 + aviso = t.pop("_aviso", None) cambios = [] for k in d: if k in t and t.get(k) != base.get(k): @@ -1031,19 +1352,19 @@ def tarea_modificar(): cambios.append(f"{k}: {_corto(base.get(k))} → {_corto(t.get(k))}") nota = _texto(d.get("_nota"), 300) if not cambios and not nota: - if t.get("orden") != base.get("orden"): - # solo cambió la posición: se guarda sin entrada en el historial + if t.get("orden") != base.get("orden") or t.get("responsable_ids") != base.get("responsable_ids"): + # solo cambió la posición (o se completaron los ids): se guarda sin entrada en el historial t["modificado_por"] = u t["modificado_en"] = ahora() _guardar_tarea(t) t["vencida"] = _vencida(t) - return jsonify(ok=True, tarea=t) + return jsonify(ok=True, tarea=t, aviso=aviso) t["vencida"] = _vencida(t) - return jsonify(ok=True, tarea=t, sin_cambios=True) + return jsonify(ok=True, tarea=t, sin_cambios=True, aviso=aviso) _hist(t, u, ("; ".join(cambios) + (" — " + nota if nota else "")) if cambios else nota) _guardar_tarea(t) t["vencida"] = _vencida(t) - return jsonify(ok=True, tarea=t) + return jsonify(ok=True, tarea=t, aviso=aviso) @app.post("/api/tareas/orden") @@ -1099,15 +1420,30 @@ def tareas_resumen(): conteo = {p: {e: 0 for e in ESTADOS} for p in PISTAS} vencidas = [] total = 0 + miembros = _miembros() + por_resp = {} for t in _listar_json(TAREAS_DIR): total += 1 p, e = t.get("pista"), t.get("estado") if p in conteo and e in conteo[p]: conteo[p][e] += 1 + ids = t.get("responsable_ids") + if ids is None: + ids = _ids_responsable(t.get("responsable"), miembros)[0] + for rid in (ids or ["_sin_equipo"]): + r = por_resp.setdefault(rid, _cuenta_vacia()) + if e in ESTADOS: + r[e] += 1 + if e != "hecho": + r["abiertos"] += 1 + if _vencida(t, hoy): + r["vencidas"] += 1 if _vencida(t, hoy): - vencidas.append({k: t.get(k) for k in ("id", "titulo", "pista", "estado", "responsable", "fecha_limite")}) + vencidas.append({k: t.get(k) for k in ("id", "titulo", "pista", "estado", "responsable", "responsable_ids", "fecha_limite")}) vencidas.sort(key=lambda x: x.get("fecha_limite") or "") - return jsonify(ok=True, total=total, hoy=hoy, por_pista=conteo, vencidas=vencidas) + for rid, r in por_resp.items(): + r["nombre"] = miembros.get(rid, {}).get("nombre") or ("(sin miembro del equipo)" if rid == "_sin_equipo" else rid) + return jsonify(ok=True, total=total, hoy=hoy, por_pista=conteo, por_responsable=por_resp, vencidas=vencidas) # ------------------------------------------------------------------ plan de producción diff --git a/backend/seed_1013.py b/backend/seed_1013.py index 83ee0f6..b3433f0 100644 --- a/backend/seed_1013.py +++ b/backend/seed_1013.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 -"""Siembra inicial del 13-oct-2026: plan de producción, los 4 sitios, los ítems de inventario -y los pendientes del tablero (brief §4). Idempotente: lo que ya existe (mismo id) se deja como está. +"""Siembra inicial del 13-oct-2026: plan de producción, los 4 sitios, los ítems de inventario, +el equipo (14-oct) y los pendientes del tablero (brief §4). Idempotente: lo que ya existe (mismo id) +se deja como está; en pendientes viejos solo se completa el responsable si estaba vacío y se calcula +responsable_ids. Uso (en el servidor, como el usuario del servicio, normalmente leo): cd /srv/projects/drogheda/hbox-docs @@ -31,6 +33,19 @@ def marca(u): # ------------------------------------------------------------------ datos +# Miembros del equipo (14-oct). NO crea accesos al sitio: eso se hace desde Equipo (admin) o /api/usuarios. +EQUIPO = [ + ("leo", "Leopoldo Rodríguez", "Dirección / ingeniería", "direccion", ""), + ("adriana", "Adriana", "Operaciones; lleva el PM del proyecto", "operaciones", ""), + ("michelle", "Michelle Márquez", "Layout de PCB", "layout", ""), + ("jesus", "Jesús", "Técnico de pruebas de tarjetas", "pruebas", ""), + ("alan", "Alán Basurto", "Técnico", "produccion", ""), + ("daniel", "Daniel Martínez", "Técnico", "produccion", ""), + ("aldair", "Aldair", "Fabricación / ensamble de tarjetas (rol por confirmar)", "fabricacion", ""), + ("gibran", "Gibran", "Software htracker", "software", ""), + ("claude", "Claude", "Asistente de ingeniería", "asistente", ""), +] + SITIOS = [ {"id": "icave", "nombre": "ICAVE", "cliente": "Hutchison Ports", "ubicacion": "Veracruz, Ver.", "alias": ["ICAVE (Veracruz)", "Oficina ICAVE"], "orden": 10, @@ -99,7 +114,7 @@ TAREAS = [ "enlaces": [{"titulo": "Próximos pasos v6", "url": "/hardware/v6-proximos-pasos/"}, {"titulo": "Plan de corrección", "url": "/hardware/v6-plan-correccion/"}], "descripcion": "Aplicar a P7/P8 lo decidido para P12 en V5.6; regla DFM U6; no habrá v6.1.", "etiquetas": ["fabricación"]}, - {"id": "t-caja-ethernet", "pista": "mecanica", "estado": "bloqueado", "responsable": "", "version_tarjeta": "—", + {"id": "t-caja-ethernet", "pista": "mecanica", "estado": "bloqueado", "responsable": "leo", "version_tarjeta": "—", "titulo": "Caja plástica HBOX — el cable Ethernet no cabe: definir y aplicar el cambio", "descripcion": "Falta definir la causa exacta (largo del boot RJ45, tamaño de la abertura o radio de curvatura) y quién lo corrige " "(pieza impresa en 3D o proveedor). Al definirlo: escribir el procedimiento y mover a En curso.", @@ -128,8 +143,16 @@ TAREAS = [ "titulo": "Acta de instalación en campo: formulario nuevo + instrucciones", "enlaces": [{"titulo": "Acta de instalación", "url": "/formularios/instalacion.html"}], "descripcion": "Formulario publicado el 13-oct; falta probarlo en una instalación real y ajustar el checklist."}, + {"id": "t-ferreteria-eit-timsa", "pista": "compras", "estado": "por-hacer", "responsable": "adriana", "version_tarjeta": "—", + "fecha_limite": "2026-10-14", + "titulo": "Cerrar compras de ferretería para EIT y TIMSA", + "descripcion": "Material de ferretería para la instalación de gabinetes en EIT (Ensenada) y TIMSA (Manzanillo). Cerrar pedidos mañana.", + "enlaces": [{"titulo": "Sitios", "url": "/sitios/"}], "etiquetas": ["instalación"]}, ] +# Responsable por omisión para pendientes ya sembrados que quedaron sin responsable (solo si está vacío). +RESPONSABLE_SI_VACIO = {"t-caja-ethernet": "leo"} + # ------------------------------------------------------------------ siembra @@ -180,7 +203,45 @@ def sembrar_inventario(u): return f"inventario: {n} ítems creados, {len(INVENTARIO) - n} ya existían (stock 0: falta el conteo físico)" -def sembrar_tareas(u, lista, origen="seed_1013"): +def sembrar_equipo(u): + n = 0 + for rid, nombre, rol, area, correo in EQUIPO: + if os.path.isfile(backend._ruta_registro(backend.EQUIPO_DIR, rid)): + continue + t, err = backend._miembro_valido({"nombre": nombre, "rol": rol, "area": area, "correo": correo, "activo": True}) + if err: + print(" miembro", rid, "->", err); continue + t["id"] = rid + t.update(marca(u)) + t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "miembro sembrado (seed_1013)"}] + backend._guardar_miembro(t) + n += 1 + return f"equipo: {n} miembros creados, {len(EQUIPO) - n} ya existían (sin crear accesos)" + + +def completar_responsables(u): + """Pendientes ya guardados: (a) responsable por omisión SOLO si está vacío; (b) responsable_ids + calculado si falta (pendientes de antes del 14-oct). No toca lo que la gente ya cambió.""" + n_resp, n_ids = 0, 0 + miembros = backend._miembros() + for t in backend._listar_json(backend.TAREAS_DIR): + cambio = False + if not str(t.get("responsable") or "").strip() and t.get("id") in RESPONSABLE_SI_VACIO: + t["responsable"] = RESPONSABLE_SI_VACIO[t["id"]] + backend._hist(t, u, "responsable: → " + t["responsable"] + " (seed_1013, estaba vacío)") + n_resp += 1; cambio = True + ids = backend._ids_responsable(t.get("responsable"), miembros)[0] + if t.get("responsable_ids") != ids: + t["responsable_ids"] = ids + n_ids += 1; cambio = True + if cambio: + backend._escribir_json(backend._ruta_registro(backend.TAREAS_DIR, t["id"]), t) + if n_resp or n_ids: + backend._reescribir_indice(backend.TAREAS_DIR, backend._listar_json(backend.TAREAS_DIR), backend.COLS_TAREAS) + return f"pendientes existentes: {n_resp} responsables completados, {n_ids} con responsable_ids recalculado" + + +def sembrar_tareas(u, lista, origen="seed_1013", responsable_omision="leo"): n, orden = 0, {} existentes = backend._listar_json(backend.TAREAS_DIR) for t in existentes: @@ -191,6 +252,9 @@ def sembrar_tareas(u, lista, origen="seed_1013"): print(" pendiente sin id, se omite:", d.get("titulo")); continue if os.path.isfile(backend._ruta_registro(backend.TAREAS_DIR, rid)): continue + if not str(d.get("responsable") or "").strip(): + d = dict(d, responsable=responsable_omision) # un pendiente siempre tiene responsable (14-oct) + print(f" pendiente {rid}: sin responsable en el origen → {responsable_omision}") t, err = backend._tarea_valida(d) if err: print(" pendiente", rid, "->", err); continue @@ -208,14 +272,15 @@ def sembrar_tareas(u, lista, origen="seed_1013"): def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--usuario", default="leo", help="quién firma la siembra en los campos de auditoría") - ap.add_argument("--solo", default="plan,sitios,inventario,tareas", help="partes a sembrar, separadas por coma") + ap.add_argument("--solo", default="plan,sitios,inventario,equipo,tareas", help="partes a sembrar, separadas por coma") ap.add_argument("--extra", help="JSON con una lista de pendientes adicionales (p. ej. importación de Notion)") ap.add_argument("--forzar-plan", action="store_true", help="reescribe el plan aunque ya exista") + ap.add_argument("--responsable-omision", default="leo", help="responsable para pendientes importados sin uno (--extra)") a = ap.parse_args() partes = {p.strip() for p in a.solo.split(",")} print("Carpetas:", {"tareas": backend.TAREAS_DIR, "inventario": backend.INVENTARIO_DIR, "sitios": backend.SITIOS_DIR, "plan": backend.PLAN_FILE}) - for d in (backend.TAREAS_DIR, backend.INV_ITEMS(), backend.INV_MOVS(), backend.SITIOS_DIR, + for d in (backend.TAREAS_DIR, backend.INV_ITEMS(), backend.INV_MOVS(), backend.SITIOS_DIR, backend.EQUIPO_DIR, os.path.dirname(backend.PLAN_FILE)): os.makedirs(d, exist_ok=True) if "plan" in partes: @@ -224,14 +289,17 @@ def main(): print(sembrar_sitios(a.usuario)) if "inventario" in partes: print(sembrar_inventario(a.usuario)) + if "equipo" in partes: + print(sembrar_equipo(a.usuario)) # antes que los pendientes: así responsable_ids sale completo if "tareas" in partes: print(sembrar_tareas(a.usuario, TAREAS)) + print(completar_responsables(a.usuario)) if a.extra: with open(a.extra, encoding="utf-8") as f: extra = json.load(f) if not isinstance(extra, list): sys.exit("--extra debe ser una lista JSON de pendientes") - print(sembrar_tareas(a.usuario, extra, origen=os.path.basename(a.extra))) + print(sembrar_tareas(a.usuario, extra, origen=os.path.basename(a.extra), responsable_omision=a.responsable_omision)) print("Listo.") diff --git a/backend/tests-1013.sh b/backend/tests-1013.sh index 3eaff86..185a6fd 100755 --- a/backend/tests-1013.sh +++ b/backend/tests-1013.sh @@ -22,10 +22,10 @@ check "actas vacías" '"total":0' "$(curl -s $U/api/actas)" echo "## tareas" check "sin usuario → 403" "No puedo identificarte" "$(curl -s -H 'Content-Type: application/json' -X POST $U/api/tareas -d '{"titulo":"x"}')" check "POST (alan)" '"id":"t-a"' "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"id":"t-a","titulo":"Pendiente A","pista":"ingenieria","responsable":"michelle","fecha_limite":"2026-10-01","version_tarjeta":"V5.6","enlaces":[{"titulo":"ECO","url":"/hardware/v56-eco/"}]}')" -check "POST id duplicado → 409" '"existe":true' "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"id":"t-a","titulo":"dup"}')" -check "POST pista inválida → 400" "Pista inv" "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"titulo":"x","pista":"ventas"}')" -check "POST fecha inválida → 400" "AAAA-MM-DD" "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"titulo":"x","fecha_limite":"13/10/2026"}')" -curl "${LEO[@]}" -X POST $U/api/tareas -d '{"id":"t-b","titulo":"Pendiente B","pista":"compras","bloqueada_por":["t-a"]}' > /dev/null +check "POST id duplicado → 409" '"existe":true' "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"id":"t-a","titulo":"dup","responsable":"leo"}')" +check "POST pista inválida → 400" "Pista inv" "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"titulo":"x","pista":"ventas","responsable":"leo"}')" +check "POST fecha inválida → 400" "AAAA-MM-DD" "$(curl "${ALAN[@]}" -X POST $U/api/tareas -d '{"titulo":"x","fecha_limite":"13/10/2026","responsable":"leo"}')" +curl "${LEO[@]}" -X POST $U/api/tareas -d '{"id":"t-b","titulo":"Pendiente B","pista":"compras","responsable":"leo","bloqueada_por":["t-a"]}' > /dev/null check "GET filtro pista" '"total":1' "$(curl -s "$U/api/tareas?pista=compras")" check "GET filtro responsable" '"total":1' "$(curl -s "$U/api/tareas?responsable=mich")" check "vencida calculada" '"vencida":true' "$(curl -s "$U/api/tareas?pista=ingenieria")" @@ -87,14 +87,14 @@ check "/api/actas trae sitio" '"sitio":"icave"' "$(curl -s "$U/api/actas")" echo "## siembra (idempotente)" S1=$(python3 $REPO/backend/seed_1013.py --usuario leo 2>&1) -check "primera corrida crea" "pendientes (seed_1013): 12 creados" "$S1" +check "primera corrida crea" "pendientes (seed_1013): 13 creados" "$S1" check "sitios: 3 nuevos (icave ya existía)" "sitios: 3 creados, 1 ya existían" "$S1" S2=$(python3 $REPO/backend/seed_1013.py --usuario leo 2>&1) -check "segunda corrida no duplica" "12 ya existían" "$S2" +check "segunda corrida no duplica" "13 ya existían" "$S2" check "inventario no duplica" "0 ítems creados" "$S2" -echo '[{"id":"t-notion-1","titulo":"Importado de Notion","pista":"ingenieria","estado":"hecho"}]' > $T/extra.json +echo '[{"id":"t-notion-1","titulo":"Importado de Notion","pista":"ingenieria","estado":"hecho"}]' > $T/extra.json # sin responsable: el seed pone el de omisión (leo) check "--extra importa" "extra.json): 1 creados" "$(python3 $REPO/backend/seed_1013.py --solo nada --extra $T/extra.json 2>&1)" -check "resumen tras siembra (1 + 12 sembrados + 1 extra)" '"total":14' "$(curl -s $U/api/tareas/resumen)" +check "resumen tras siembra (1 + 13 sembrados + 1 extra)" '"total":15' "$(curl -s $U/api/tareas/resumen)" echo; echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T" [ $fail -eq 0 ] diff --git a/backend/tests-1014.sh b/backend/tests-1014.sh new file mode 100644 index 0000000..6ba307c --- /dev/null +++ b/backend/tests-1014.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Pruebas del 14-oct: equipo, accesos (htpasswd temporal), responsable obligatorio, resumen por responsable, +# siembra idempotente con el equipo. Complementa a tests-1013.sh (que sigue valiendo tal cual). +# Uso: bash tests-1014.sh [puerto] +set -u +REPO=${1:?ruta a hbox-docs}; PORT=${2:-8097} +T=$(mktemp -d); export ACTAS_DIR=$T/actas PORT HTPASSWD_FILE=$T/htpasswd ACTAS_ADMINS=leo,alan +mkdir -p $ACTAS_DIR +# htpasswd de prueba con el mismo hash $apr1$ que escribe el backend (leo y bob; alan es admin SIN login todavía) +python3 - "$REPO" "$T/htpasswd" <<'EOF' +import sys; sys.path.insert(0, sys.argv[1] + "/backend"); import app +open(sys.argv[2], "w").write("leo:" + app.apr1("secreto123", "abcdefgh") + "\nbob:" + app.apr1("secreto123", "hgfedcba") + "\n") +EOF +chmod 640 $T/htpasswd +python3 $REPO/backend/app.py > $T/app.log 2>&1 & +APP=$!; trap "kill $APP 2>/dev/null" EXIT; sleep 1.5 +U="http://127.0.0.1:$PORT" +LEO=(-s -H "X-Remote-User: leo" -H "Content-Type: application/json") +ALAN=(-s -H "X-Remote-User: alan" -H "Content-Type: application/json") +BOB=(-s -H "X-Remote-User: bob" -H "Content-Type: application/json") +ok=0; fail=0 +check() { if [[ "$3" == *"$2"* ]]; then ok=$((ok+1)); echo " ✓ $1"; else fail=$((fail+1)); echo " ✕ $1 — esperaba «$2», obtuve: ${3:0:160}"; fi; } + +echo "## siembra (incluye equipo) — con un pendiente viejo sin responsable y sin responsable_ids" +mkdir -p $T/tareas +cat > $T/tareas/t-caja-ethernet.json <<'EOF' +{"id":"t-caja-ethernet","titulo":"Caja plástica HBOX — viejo","pista":"mecanica","estado":"bloqueado","responsable":"","fecha_limite":null, + "enlaces":[],"bloqueada_por":[],"orden":0,"etiquetas":[],"version_tarjeta":"—","creado_por":"leo","creado_en":"2026-10-13T20:00:00", + "modificado_por":"leo","modificado_en":"2026-10-13T20:00:00","historial":[{"quien":"leo","cuando":"2026-10-13T20:00:00","cambio":"creado en bloqueado"}]} +EOF +S1=$(python3 $REPO/backend/seed_1013.py --usuario leo 2>&1) +check "equipo sembrado (9)" "equipo: 9 miembros creados" "$S1" +check "pendientes nuevos (12 de 13: t-caja-ethernet ya existía)" "pendientes (seed_1013): 12 creados" "$S1" +check "responsable vacío completado a leo" "1 responsables completados" "$S1" +check "responsable_ids recalculado" "con responsable_ids recalculado" "$S1" +check "t-caja-ethernet → leo con historial" '"responsable": "leo"' "$(python3 -c "import json;print(json.dumps(json.load(open('$T/tareas/t-caja-ethernet.json')),indent=1))")" +S2=$(python3 $REPO/backend/seed_1013.py --usuario leo 2>&1) +check "segunda corrida: equipo no duplica" "equipo: 0 miembros creados, 9 ya existían" "$S2" +check "segunda corrida: nada que completar" "0 responsables completados, 0 con responsable_ids" "$S2" +check "t-ferreteria-eit-timsa sembrada para adriana" '"responsable_ids":["adriana"]' "$(curl -s "$U/api/tareas?responsable=adriana")" + +echo "## equipo" +R=$(curl "${BOB[@]}" $U/api/equipo) +check "GET equipo (cualquiera)" '"total":9' "$R" +check "tiene_login derivado del htpasswd (leo sí)" '"id": "leo"' "$(echo "$R" | python3 -c 'import sys,json; print(json.dumps([m for m in json.load(sys.stdin)["miembros"] if m["tiene_login"]]))')" +check "adriana sin login" '"tiene_login": false' "$(echo "$R" | python3 -c 'import sys,json; print(json.dumps([m for m in json.load(sys.stdin)["miembros"] if m["id"]=="adriana"]))')" +check "es_admin desde ACTAS_ADMINS" '"es_admin": true' "$(echo "$R" | python3 -c 'import sys,json; print(json.dumps([m for m in json.load(sys.stdin)["miembros"] if m["id"]=="alan"]))')" +check "pendientes abiertos por miembro" '"abiertos": 1' "$(echo "$R" | python3 -c 'import sys,json; print(json.dumps([m["pendientes"] for m in json.load(sys.stdin)["miembros"] if m["id"]=="adriana"]))')" +check "logins sin miembro solo para admin (bob no admin → [])" '"logins_sin_miembro":[]' "$R" +check "logins sin miembro para admin (bob)" '"logins_sin_miembro":["bob"]' "$(curl "${LEO[@]}" $U/api/equipo)" +check "POST equipo no admin → 403" "Solo un administrador" "$(curl "${BOB[@]}" -X POST $U/api/equipo -d '{"id":"x","nombre":"X"}')" +check "POST equipo (admin)" '"id":"carla"' "$(curl "${LEO[@]}" -X POST $U/api/equipo -d '{"id":"Carla","nombre":"Carla Ruiz","rol":"Compras","area":"operaciones"}')" +check "POST equipo duplicado → 409" '"existe":true' "$(curl "${LEO[@]}" -X POST $U/api/equipo -d '{"id":"carla","nombre":"dup"}')" +check "POST equipo área inválida → 400" "inv\\u00e1lida: ventas" "$(curl "${LEO[@]}" -X POST $U/api/equipo -d '{"id":"z","nombre":"Z","area":"ventas"}')" +check "PATCH miembro no admin → 403" "Solo un administrador" "$(curl "${BOB[@]}" -X PATCH "$U/api/equipo/miembro?id=carla" -d '{"rol":"x"}')" +check "PATCH miembro (admin) → historial" "rol: Compras" "$(curl "${LEO[@]}" -X PATCH "$U/api/equipo/miembro?id=carla" -d '{"rol":"Compras y logística"}')" +check "PATCH activo=false (desactivar, nunca borrar)" '"activo":false' "$(curl "${LEO[@]}" -X PATCH "$U/api/equipo/miembro?id=carla" -d '{"activo":false}')" +check "GET equipo oculta inactivos" '"total":9' "$(curl -s $U/api/equipo)" +check "GET equipo?todos=1 los muestra" '"total":10' "$(curl -s "$U/api/equipo?todos=1")" +check "PATCH inexistente → 404" "No existe el miembro" "$(curl "${LEO[@]}" -X PATCH "$U/api/equipo/miembro?id=nadie" -d '{"rol":"x"}')" + +echo "## accesos (htpasswd temporal)" +check "POST usuarios no admin → 403" "Solo un administrador" "$(curl "${BOB[@]}" -X POST $U/api/usuarios -d '{"usuario":"x","password_inicial":"12345678"}')" +check "contraseña corta → 400" "al menos 8" "$(curl "${LEO[@]}" -X POST $U/api/usuarios -d '{"usuario":"adriana","password_inicial":"corta"}')" +check "usuario con espacios/mayúsculas → 400" "sin espacios ni acentos" "$(curl "${LEO[@]}" -X POST $U/api/usuarios -d '{"usuario":"Adri ana","password_inicial":"12345678"}')" +check "crea acceso a miembro existente (adriana)" '"nuevo_miembro":false' "$(curl "${LEO[@]}" -X POST $U/api/usuarios -d '{"usuario":"adriana","password_inicial":"operaciones2026","correo":"adriana@helgen.tech"}')" +check "línea en el htpasswd con apr1" 'adriana:$apr1$' "$(cat $T/htpasswd)" +check "el hash valida con apr1 (misma rutina que /api/password)" "True" "$(python3 - "$REPO" "$T/htpasswd" <<'EOF' +import sys; sys.path.insert(0, sys.argv[1] + "/backend"); import app +l=[x for x in open(sys.argv[2]).read().splitlines() if x.startswith("adriana:")][0].split(":",1)[1] +print(app.apr1("operaciones2026", l.split("$")[2]) == l) +EOF +)" +check "permisos del archivo conservados (640)" "640" "$(stat -c %a $T/htpasswd)" +check "duplicado → 409" '"existe":true' "$(curl "${LEO[@]}" -X POST $U/api/usuarios -d '{"usuario":"adriana","password_inicial":"operaciones2026"}')" +check "crea usuario Y miembro nuevos" '"nuevo_miembro":true' "$(curl "${LEO[@]}" -X POST $U/api/usuarios -d '{"usuario":"nuevo1","password_inicial":"nuevo1pass","nombre":"Nuevo Uno","area":"campo"}')" +check "GET /api/usuarios sigue igual (lista del htpasswd)" '"usuarios":["adriana","bob","leo","nuevo1"]' "$(curl "${LEO[@]}" $U/api/usuarios)" +check "la cuenta nueva cambia su propia contraseña por /api/password" '"reset":false' "$(curl -s -H 'X-Remote-User: nuevo1' -H 'Content-Type: application/json' -X POST $U/api/password -d '{"actual":"nuevo1pass","nueva":"otraclave2026","confirma":"otraclave2026"}')" +check "bitácora _accesos.csv" "leo,alta,adriana" "$(cat $T/equipo/_accesos.csv)" +check "DELETE no admin → 403" "Solo un administrador" "$(curl "${BOB[@]}" -X DELETE "$U/api/usuario?u=nuevo1")" +check "DELETE a sí mismo → 400" "a ti mismo" "$(curl "${LEO[@]}" -X DELETE "$U/api/usuario?u=leo")" +check "DELETE inexistente → 404" "no tiene acceso" "$(curl "${LEO[@]}" -X DELETE "$U/api/usuario?u=nadie")" +check "último admin con acceso (alan admin sin login quita a leo) → 409" "ltimo administrador" "$(curl "${ALAN[@]}" -X DELETE "$U/api/usuario?u=leo")" +curl "${LEO[@]}" -X POST $U/api/usuarios -d '{"usuario":"alan","password_inicial":"alanclave2026"}' > /dev/null +check "con dos admins con acceso, alan sí puede quitar a leo" '"usuario":"leo"' "$(curl "${ALAN[@]}" -X DELETE "$U/api/usuario?u=leo")" +check "leo ya no está en el htpasswd" "" "$(grep -c '^leo:' $T/htpasswd | sed 's/^0$//')" +check "miembro leo desactivado (no borrado)" '"activo": false' "$(python3 -c "import json;print(json.dumps(json.load(open('$T/equipo/leo.json')),indent=1))")" +check "DELETE nuevo1 (admin alan)" '"miembro_desactivado":true' "$(curl "${ALAN[@]}" -X DELETE "$U/api/usuario?u=nuevo1")" + +echo "## responsable obligatorio y resumen" +check "crear sin responsable → 400" "Falta el responsable" "$(curl "${BOB[@]}" -X POST $U/api/tareas -d '{"titulo":"x"}')" +check "crear con ids (y 'Jesús' con acento → jesus)" '"responsable_ids":["michelle","jesus"]' "$(curl "${BOB[@]}" -X POST $U/api/tareas -d '{"id":"t-x1","titulo":"x","responsable":"Michelle, Jesús"}')" +check "texto libre → se acepta con aviso" "sin registrar en el equipo: Proveedor X" "$(curl "${BOB[@]}" -X POST $U/api/tareas -d '{"id":"t-x2","titulo":"y","responsable":"leo / Proveedor X"}')" +check "lista como array también vale" '"responsable":"alan, daniel"' "$(curl "${BOB[@]}" -X POST $U/api/tareas -d '{"id":"t-x3","titulo":"z","responsable":["alan","daniel"]}')" +check "PATCH responsable vacío → 400" "Falta el responsable" "$(curl "${BOB[@]}" -X PATCH "$U/api/tarea?id=t-x1" -d '{"responsable":""}')" +check "PATCH sin tocar responsable sigue funcionando" '"estado":"en-curso"' "$(curl "${BOB[@]}" -X PATCH "$U/api/tarea?id=t-x1" -d '{"estado":"en-curso"}')" +check "filtro por id" '"id":"t-x1"' "$(curl -s "$U/api/tareas?responsable=jesus")" +check "filtro con acento" '"id":"t-x1"' "$(curl -s "$U/api/tareas?responsable=jes%C3%BAs")" +check "GET tareas trae el mapa equipo" '"michelle":{' "$(curl -s "$U/api/tareas?responsable=michelle" | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)["equipo"], separators=(",",":")))')" +R=$(curl -s $U/api/tareas/resumen) +check "resumen por_responsable (jesus)" '"nombre": "Jes' "$(echo "$R" | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)["por_responsable"]["jesus"], indent=1))')" +check "resumen por_responsable cuenta abiertos" '"abiertos": 3' "$(echo "$R" | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)["por_responsable"]["jesus"], indent=1))')" +check "resumen conserva por_pista" '"por_pista"' "$R" + +echo; echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T" +[ $fail -eq 0 ] diff --git a/docs/admin.html b/docs/admin.html index ce14be1..d73d635 100644 --- a/docs/admin.html +++ b/docs/admin.html @@ -18,7 +18,7 @@

Administración de actas

Solo para el administrador.

-
← Tablero
+
← TableroUsuarios y equipo →

Comprobando permisos…

+ + + diff --git a/docs/tableros/index.md b/docs/tableros/index.md index ca49cd0..bea9ec5 100644 --- a/docs/tableros/index.md +++ b/docs/tableros/index.md @@ -25,6 +25,10 @@ Las páginas que abres para saber **dónde está el proyecto hoy**: qué tareas 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. 5. El Kanban sustituye al *Task Tracker* de Notion, que queda como histórico (sus tareas se importaron una vez). +## [Equipo](equipo.html) + +Quién es quién y qué tiene cada uno: miembros, área, si tienen acceso al sitio y sus pendientes abiertos y vencidos. El administrador crea aquí los accesos (ya no por `htpasswd` en el servidor). + ## Relacionados - [Detalle por tarjeta y módulo](../tablero.html) — qué serie pasó cada acta del lote actual. diff --git a/docs/tableros/ingenieria.html b/docs/tableros/ingenieria.html index 6ec15cc..8685308 100644 --- a/docs/tableros/ingenieria.html +++ b/docs/tableros/ingenieria.html @@ -16,6 +16,7 @@ Ingeniería Producción Tarjetas y hardware + 👥 Equipo diff --git a/docs/tableros/kanban.html b/docs/tableros/kanban.html index e2d42ef..bfdfe6c 100644 --- a/docs/tableros/kanban.html +++ b/docs/tableros/kanban.html @@ -17,6 +17,7 @@ Producción Inventario Sitios + 👥 Equipo diff --git a/docs/tableros/kanban.js b/docs/tableros/kanban.js index 22b8f01..5dcfd23 100644 --- a/docs/tableros/kanban.js +++ b/docs/tableros/kanban.js @@ -11,14 +11,43 @@ kb.recargar(); 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= (solo admin). Cada cambio guarda quién y cuándo en el historial. + 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. */ (function () { const PISTAS = { ingenieria: 'Ingeniería', produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', campo: 'Campo' }; const ESTADOS = [['por-hacer', 'Por hacer'], ['en-curso', 'En curso'], ['bloqueado', 'Bloqueado'], ['hecho', 'Hecho']]; const VERSIONES = ['—', 'V5.5', 'V5.6', 'V5.7', 'v6']; - const RESPONSABLES = ['leo', 'michelle', 'aldair', 'jesús', 'alan', 'daniel', 'claude']; + // Lista de respaldo si /api/equipo no responde; lo normal es que el equipo venga del servidor. + const RESPONSABLES = ['leo', 'adriana', 'michelle', 'jesus', 'alan', 'daniel', 'aldair', 'gibran', 'claude']; const BADGE = { 'V5.5': 'badge-v55', 'V5.6': 'badge-v56', 'V5.7': 'badge-v57', 'v6': 'badge-v6' }; + // ---- equipo: id → {nombre, activo, area}. 'Jesús' ≡ 'jesus' (sin acentos, minúsculas). + const norm = s => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim().replace(/[^a-z0-9_\-.]/g, ''); + let EQUIPO = {}; + let equipoProm = null; + function cargarEquipo() { + if (!equipoProm) equipoProm = fetch('/api/equipo?todos=1', { cache: 'no-store' }).then(r => r.json()).then(j => { + const m = {}; (j.miembros || []).forEach(x => m[x.id] = { nombre: x.nombre, activo: x.activo !== false, area: x.area, tiene_login: x.tiene_login }); + return m; + }).catch(() => ({})).then(m => { if (!Object.keys(m).length) RESPONSABLES.forEach(id => m[id] = { nombre: id, activo: true }); EQUIPO = Object.assign(EQUIPO, m); return EQUIPO; }); + return equipoProm; + } + function usarEquipo(mapa) { if (mapa && Object.keys(mapa).length) EQUIPO = Object.assign(EQUIPO, mapa); if (!Object.keys(EQUIPO).length) RESPONSABLES.forEach(id => EQUIPO[id] = { nombre: id, activo: true }); return EQUIPO; } + const partesResp = t => String((t && t.responsable) || '').split(/[,;\/]+/).map(x => x.trim()).filter(Boolean); + // nombres a mostrar: cada parte del campo responsable → nombre del miembro, o el texto tal cual + function nombresResp(t, eq) { + eq = eq || EQUIPO; + return partesResp(t).map(p => { const a = norm(p.replace(/\s+/g, '')), b = norm(p); return (eq[a] || eq[b] || {}).nombre || p; }); + } + function idsResp(t, eq) { + eq = eq || EQUIPO; + if (Array.isArray(t.responsable_ids)) return t.responsable_ids; + return partesResp(t).map(p => { const a = norm(p.replace(/\s+/g, '')), b = norm(p); return eq[a] ? a : (eq[b] ? b : null); }).filter(Boolean); + } + const libresResp = (t, eq) => { eq = eq || EQUIPO; return partesResp(t).filter(p => !eq[norm(p.replace(/\s+/g, ''))] && !eq[norm(p)]); }; + const el = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a) { if (p === 'html') e.innerHTML = a[p]; else if (p === 'text') e.textContent = a[p]; else e.setAttribute(p, a[p]); } k.forEach(c => c != null && e.append(c)); return e; }; const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }; const hoyISO = () => { const d = new Date(); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 10); }; @@ -87,7 +116,11 @@ const corte = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10); return tareas.filter(t => { if (filtro.pista && t.pista !== filtro.pista) return false; - if (filtro.responsable && !String(t.responsable || '').toLowerCase().includes(filtro.responsable)) return false; + if (filtro.responsable) { + const f = filtro.responsable; + if (EQUIPO[f]) { if (!idsResp(t).includes(f)) return false; } + else if (!String(t.responsable || '').toLowerCase().includes(f)) return false; + } if (filtro.version && t.version_tarjeta !== filtro.version) return false; if (t.estado === 'hecho') { if (filtro.hechas === 'ninguna') return false; @@ -107,7 +140,7 @@ const meta = el('div', { class: 'meta' }); if (!opts.pista) meta.append(el('span', { class: 'chip p-' + t.pista, text: PISTAS[t.pista] || t.pista })); if (t.version_tarjeta && t.version_tarjeta !== '—') meta.append(el('span', { class: BADGE[t.version_tarjeta] || 'chip', text: t.version_tarjeta })); - if (t.responsable) meta.append(el('span', { text: '👤 ' + t.responsable })); + if (t.responsable) meta.append(el('span', { text: '👤 ' + nombresResp(t).join(', '), title: t.responsable })); if (t.fecha_limite) meta.append(el('span', { class: 'fecha' + (t.vencida ? ' mal' : ''), text: (t.vencida ? '⚠ venció ' : '📅 ') + fechaCorta(t.fecha_limite) })); (t.etiquetas || []).slice(0, 3).forEach(x => meta.append(el('span', { class: 'chip', text: x }))); c.append(meta); @@ -139,9 +172,10 @@ }); // responsables presentes en los datos (más los conocidos) selResp.querySelectorAll('option:not(:first-child)').forEach(o => o.remove()); - const set = new Set(RESPONSABLES); - tareas.forEach(t => String(t.responsable || '').split(/[,/]+/).map(s => s.trim().toLowerCase()).filter(Boolean).forEach(s => set.add(s))); - [...set].sort().forEach(r => selResp.append(el('option', { value: r, text: r }))); + Object.entries(EQUIPO).filter(([, m]) => m.activo !== false).sort((a, b) => a[1].nombre.localeCompare(b[1].nombre)) + .forEach(([id, m]) => selResp.append(el('option', { value: id, text: m.nombre }))); + const libres = new Set(); tareas.forEach(t => libresResp(t).forEach(x => libres.add(x.toLowerCase()))); + [...libres].sort().forEach(x => selResp.append(el('option', { value: x, text: x + ' (sin registrar)' }))); selResp.value = filtro.responsable; const venc = vis.filter(t => t.vencida).length; cnt.textContent = vis.length + ' pendiente(s)' + (venc ? ' · ' + venc + ' vencido(s)' : '') + (vis.length !== tareas.length ? ' · de ' + tareas.length : ''); @@ -151,6 +185,7 @@ try { const j = await api('/api/tareas'); tareas = j.tareas || []; admin = !!j.admin; user = j.user || ''; + usarEquipo(j.equipo); pintar(); if (opts.onCargado) opts.onCargado(tareas, { admin, user }); } catch (e) { mensaje('No se pudo cargar el tablero: ' + e.message, false); } @@ -192,15 +227,24 @@ const fDesc = el('textarea', { rows: '3', placeholder: 'Detalle, criterio de terminado, notas' }); fDesc.value = t.descripcion || ''; const fPista = el('select'); Object.entries(PISTAS).forEach(([k, v]) => fPista.append(el('option', { value: k, text: v }))); fPista.value = t.pista; const fEst = el('select'); ESTADOS.forEach(([k, v]) => fEst.append(el('option', { value: k, text: v }))); fEst.value = t.estado; - const fResp = el('input', { type: 'text', value: t.responsable || '', list: 'kb-resp', placeholder: 'leo, michelle, aldair, jesús…' }); - let dl = document.getElementById('kb-resp'); if (!dl) { dl = el('datalist', { id: 'kb-resp' }); document.body.append(dl); } - dl.innerHTML = ''; RESPONSABLES.forEach(r => dl.append(el('option', { value: r }))); + // responsable: chips de miembros activos (varios) + texto libre opcional. Obligatorio. + const fResp = el('div', { class: 'chips' }); + const yaIds = idsResp(t); + Object.entries(EQUIPO).filter(([id, m]) => m.activo !== false || yaIds.includes(id)).sort((a, b) => a[1].nombre.localeCompare(b[1].nombre)).forEach(([id, m]) => { + const l = el('label', { class: 'chip-sel' + (yaIds.includes(id) ? ' on' : '') }); + const ck = el('input', { type: 'checkbox', value: id }); ck.checked = yaIds.includes(id); + ck.addEventListener('change', () => l.classList.toggle('on', ck.checked)); + l.append(ck, document.createTextNode(m.nombre + (m.activo === false ? ' (inactivo)' : ''))); fResp.append(l); + }); + const fLibre = el('input', { type: 'text', value: libresResp(t).join(', '), placeholder: 'Otro (texto libre, solo si la persona aún no está en Equipo)', style: 'margin-top:6px' }); + const leerResp = () => [...fResp.querySelectorAll('input:checked')].map(c => c.value).concat(fLibre.value.split(',').map(x => x.trim()).filter(Boolean)).join(', '); const fFecha = el('input', { type: 'date', value: t.fecha_limite || '' }); const fVer = el('select'); VERSIONES.forEach(v => fVer.append(el('option', { value: v, text: v === '—' ? '— (no aplica)' : v }))); fVer.value = t.version_tarjeta || '—'; const fEtq = el('input', { type: 'text', value: (t.etiquetas || []).join(', '), placeholder: 'layout, pedido 570, bloqueante' }); cuerpo.append(campo('Título', fTit), campo('Descripción', fDesc)); - const g1 = el('div', { class: 'dos-col' }); g1.append(campo('Pista', fPista), campo('Estado', fEst), campo('Responsable', fResp), campo('Fecha límite', fFecha), campo('Versión de tarjeta', fVer), campo('Etiquetas (separadas por coma)', fEtq)); cuerpo.append(g1); + const g1 = el('div', { class: 'dos-col' }); g1.append(campo('Pista', fPista), campo('Estado', fEst), campo('Fecha límite', fFecha), campo('Versión de tarjeta', fVer), campo('Etiquetas (separadas por coma)', fEtq)); cuerpo.append(g1); + const cResp = campo('Responsable (obligatorio; puede ser más de uno)', fResp); cResp.append(fLibre); cuerpo.append(cResp); // enlaces const enlBox = el('div', { class: 'enlaces-ed' }); @@ -243,19 +287,21 @@ 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 () => { - const d = { titulo: fTit.value.trim(), descripcion: fDesc.value.trim(), pista: fPista.value, estado: fEst.value, responsable: fResp.value.trim(), + const d = { titulo: fTit.value.trim(), descripcion: fDesc.value.trim(), pista: fPista.value, estado: fEst.value, responsable: leerResp(), fecha_limite: fFecha.value || null, version_tarjeta: fVer.value, etiquetas: fEtq.value.split(',').map(s => s.trim()).filter(Boolean), enlaces: [...enlBox.querySelectorAll('.fila')].map(f => ({ titulo: f.children[0].value.trim(), url: f.children[1].value.trim() })).filter(e => e.url), bloqueada_por: [...lst.querySelectorAll('input:checked')].map(c => c.value) }; if (!d.titulo) { m.className = 'msg err'; m.textContent = 'Escribe el título.'; return; } + if (!d.responsable) { m.className = 'msg err'; m.textContent = 'Elige al menos un responsable del equipo (o escribe uno en texto libre).'; fResp.scrollIntoView({ block: 'center' }); return; } guardar.disabled = true; try { - if (nuevo) await api('/api/tareas', json('POST', d)); + let r; + if (nuevo) r = await api('/api/tareas', json('POST', d)); else { const cambios = {}; Object.keys(d).forEach(k => { if (JSON.stringify(d[k]) !== JSON.stringify(t[k] == null ? (Array.isArray(d[k]) ? [] : (k === 'fecha_limite' ? null : '')) : t[k])) cambios[k] = d[k]; }); - if (Object.keys(cambios).length) await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', cambios)); + if (Object.keys(cambios).length) r = await api('/api/tarea?id=' + encodeURIComponent(t.id), json('PATCH', cambios)); } - dlg.close(); mensaje(nuevo ? 'Pendiente creado.' : 'Cambios guardados.', true); recargar(); + dlg.close(); mensaje((nuevo ? 'Pendiente creado.' : 'Cambios guardados.') + (r && r.aviso ? ' ⚠ ' + r.aviso : ''), !(r && r.aviso)); recargar(); } catch (e) { m.className = 'msg err'; m.textContent = e.message; } guardar.disabled = false; }); @@ -280,5 +326,5 @@ return { recargar, abrir, get tareas() { return tareas; }, get admin() { return admin; } }; } - window.Kanban = { montar, PISTAS, ESTADOS, VERSIONES, RESPONSABLES }; + window.Kanban = { montar, PISTAS, ESTADOS, VERSIONES, RESPONSABLES, cargarEquipo, usarEquipo, nombresResp, idsResp, norm, get equipo() { return EQUIPO; } }; })(); diff --git a/docs/tableros/produccion.html b/docs/tableros/produccion.html index 248899b..89d64c1 100644 --- a/docs/tableros/produccion.html +++ b/docs/tableros/produccion.html @@ -17,6 +17,7 @@ Producción Detalle por tarjeta Inventario + 👥 Equipo @@ -36,6 +37,7 @@ +