Equipo y usuarios (roster, accesos desde el sitio), responsable obligatorio, pendiente ferretería EIT/TIMSA (14-oct)
This commit is contained in:
parent
9f3d41ed49
commit
7a8f95aa8c
@ -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** |
|
| **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** |
|
| **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** |
|
| **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` |
|
| 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
|
**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/
|
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 |
|
| Síntoma | Causa | Arreglo |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Los tableros dicen «No se pudo cargar (HTTP 404)» | backend viejo | paso 2 (`git pull` + restart) |
|
| 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 |
|
| «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 |
|
| 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 |
|
| «Solo un administrador…» al borrar o editar sitios/plan | ese usuario no está en `ACTAS_ADMINS` | así debe ser; pide a Leo |
|
||||||
|
|||||||
@ -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 |
|
| `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) |
|
| `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`.
|
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
|
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/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 |
|
| `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 |
|
| `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
|
## Backend y despliegue
|
||||||
|
|
||||||
| Ruta | Cambio |
|
| Ruta | Cambio |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `backend/app.py` | colecciones `tareas`, `plan`, `inventario` (+ `movimientos`), `sitios`; `/api/actas` y `/api/unidad` exponen `sitio`/`fecha`/`componentes` |
|
| `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, 12 pendientes; `--extra` para importar Notion) |
|
| `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-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/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`) |
|
| `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» |
|
| `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» |
|
||||||
|
|||||||
@ -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/` |
|
| `INVENTARIO_DIR` | `/srv/projects/drogheda/inventario` | `items/` y `movimientos/` |
|
||||||
| `SITIOS_DIR` | `/srv/projects/drogheda/sitios` | un JSON por sitio (`icave.json`…) |
|
| `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) |
|
| `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);
|
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`
|
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
|
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"`),
|
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.
|
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`.
|
||||||
|
|||||||
104
backend/TESTS-1014.md
Normal file
104
backend/TESTS-1014.md
Normal file
@ -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 <tmp>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Á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.
|
||||||
@ -18,6 +18,8 @@ Environment=ACTAS_ADMINS=leo
|
|||||||
#Environment=INVENTARIO_DIR=/srv/projects/drogheda/inventario
|
#Environment=INVENTARIO_DIR=/srv/projects/drogheda/inventario
|
||||||
#Environment=SITIOS_DIR=/srv/projects/drogheda/sitios
|
#Environment=SITIOS_DIR=/srv/projects/drogheda/sitios
|
||||||
#Environment=PLAN_FILE=/srv/projects/drogheda/plan-produccion.json
|
#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=HTPASSWD_FILE=/etc/nginx/hbox-docs.htpasswd
|
||||||
Environment=PASS_MIN=8
|
Environment=PASS_MIN=8
|
||||||
Environment=PORT=8081
|
Environment=PORT=8081
|
||||||
|
|||||||
362
backend/app.py
362
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/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/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=
|
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
|
from flask import Flask, request, jsonify, abort, send_file
|
||||||
|
|
||||||
app = Flask(__name__)
|
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"))
|
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"))
|
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"))
|
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")
|
PISTAS = ("ingenieria", "produccion", "mecanica", "compras", "campo")
|
||||||
|
AREAS = ("direccion", "operaciones", "ingenieria", "layout", "produccion", "pruebas", "fabricacion",
|
||||||
|
"software", "campo", "asistente")
|
||||||
ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho")
|
ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho")
|
||||||
VERSIONES_TARJETA = ("V5.5", "V5.6", "V5.7", "v6", "—")
|
VERSIONES_TARJETA = ("V5.5", "V5.6", "V5.7", "v6", "—")
|
||||||
CATEGORIAS_INV = ("tarjeta", "modulo", "gabinete", "caja", "fuente", "bateria", "antena", "cable",
|
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")
|
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)
|
# ------------------------------------------------------------------ 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"]
|
"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):
|
def _tarea_valida(d, base=None):
|
||||||
"""Normaliza los campos de una tarjeta del tablero. Devuelve (tarea, error).
|
"""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
|
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)}."
|
return None, f"Estado inválido: {e}. Usa uno de {', '.join(ESTADOS)}."
|
||||||
pon("estado", e)
|
pon("estado", e)
|
||||||
if alta or "responsable" in d:
|
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:
|
if alta or "fecha_limite" in d:
|
||||||
f = d.get("fecha_limite")
|
f = d.get("fecha_limite")
|
||||||
if f not in (None, "") and not _fecha_o_none(f):
|
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()
|
pista = _texto(request.args.get("pista"), 20).lower()
|
||||||
estado = _texto(request.args.get("estado"), 20).lower()
|
estado = _texto(request.args.get("estado"), 20).lower()
|
||||||
resp = _texto(request.args.get("responsable"), 80).lower()
|
resp = _texto(request.args.get("responsable"), 80).lower()
|
||||||
|
resp_id = _norm_id(resp)
|
||||||
version = _texto(request.args.get("version"), 10)
|
version = _texto(request.args.get("version"), 10)
|
||||||
hoy = datetime.date.today().isoformat()
|
hoy = datetime.date.today().isoformat()
|
||||||
|
miembros = _miembros()
|
||||||
out = []
|
out = []
|
||||||
for t in _listar_json(TAREAS_DIR):
|
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:
|
if pista and t.get("pista") != pista:
|
||||||
continue
|
continue
|
||||||
if estado and t.get("estado") != estado:
|
if estado and t.get("estado") != estado:
|
||||||
continue
|
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
|
continue
|
||||||
if version and t.get("version_tarjeta") != version:
|
if version and t.get("version_tarjeta") != version:
|
||||||
continue
|
continue
|
||||||
@ -969,7 +1287,8 @@ def tareas_listar():
|
|||||||
out.append(t)
|
out.append(t)
|
||||||
out.sort(key=lambda t: (ESTADOS.index(t.get("estado")) if t.get("estado") in ESTADOS else 9,
|
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", "")))
|
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())
|
versiones=VERSIONES_TARJETA, admin=is_admin(), user=current_user())
|
||||||
|
|
||||||
|
|
||||||
@ -997,9 +1316,10 @@ def tareas_crear():
|
|||||||
t["modificado_por"] = u
|
t["modificado_por"] = u
|
||||||
t["modificado_en"] = t["creado_en"]
|
t["modificado_en"] = t["creado_en"]
|
||||||
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "creado en " + t["estado"]}]
|
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "creado en " + t["estado"]}]
|
||||||
|
aviso = t.pop("_aviso", None)
|
||||||
_guardar_tarea(t)
|
_guardar_tarea(t)
|
||||||
t["vencida"] = _vencida(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")
|
@app.patch("/api/tarea")
|
||||||
@ -1023,6 +1343,7 @@ def tarea_modificar():
|
|||||||
t, err = _tarea_valida(d, base)
|
t, err = _tarea_valida(d, base)
|
||||||
if err:
|
if err:
|
||||||
return jsonify(ok=False, error=err), 400
|
return jsonify(ok=False, error=err), 400
|
||||||
|
aviso = t.pop("_aviso", None)
|
||||||
cambios = []
|
cambios = []
|
||||||
for k in d:
|
for k in d:
|
||||||
if k in t and t.get(k) != base.get(k):
|
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))}")
|
cambios.append(f"{k}: {_corto(base.get(k))} → {_corto(t.get(k))}")
|
||||||
nota = _texto(d.get("_nota"), 300)
|
nota = _texto(d.get("_nota"), 300)
|
||||||
if not cambios and not nota:
|
if not cambios and not nota:
|
||||||
if t.get("orden") != base.get("orden"):
|
if t.get("orden") != base.get("orden") or t.get("responsable_ids") != base.get("responsable_ids"):
|
||||||
# solo cambió la posición: se guarda sin entrada en el historial
|
# solo cambió la posición (o se completaron los ids): se guarda sin entrada en el historial
|
||||||
t["modificado_por"] = u
|
t["modificado_por"] = u
|
||||||
t["modificado_en"] = ahora()
|
t["modificado_en"] = ahora()
|
||||||
_guardar_tarea(t)
|
_guardar_tarea(t)
|
||||||
t["vencida"] = _vencida(t)
|
t["vencida"] = _vencida(t)
|
||||||
return jsonify(ok=True, tarea=t)
|
return jsonify(ok=True, tarea=t, aviso=aviso)
|
||||||
t["vencida"] = _vencida(t)
|
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)
|
_hist(t, u, ("; ".join(cambios) + (" — " + nota if nota else "")) if cambios else nota)
|
||||||
_guardar_tarea(t)
|
_guardar_tarea(t)
|
||||||
t["vencida"] = _vencida(t)
|
t["vencida"] = _vencida(t)
|
||||||
return jsonify(ok=True, tarea=t)
|
return jsonify(ok=True, tarea=t, aviso=aviso)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/tareas/orden")
|
@app.post("/api/tareas/orden")
|
||||||
@ -1099,15 +1420,30 @@ def tareas_resumen():
|
|||||||
conteo = {p: {e: 0 for e in ESTADOS} for p in PISTAS}
|
conteo = {p: {e: 0 for e in ESTADOS} for p in PISTAS}
|
||||||
vencidas = []
|
vencidas = []
|
||||||
total = 0
|
total = 0
|
||||||
|
miembros = _miembros()
|
||||||
|
por_resp = {}
|
||||||
for t in _listar_json(TAREAS_DIR):
|
for t in _listar_json(TAREAS_DIR):
|
||||||
total += 1
|
total += 1
|
||||||
p, e = t.get("pista"), t.get("estado")
|
p, e = t.get("pista"), t.get("estado")
|
||||||
if p in conteo and e in conteo[p]:
|
if p in conteo and e in conteo[p]:
|
||||||
conteo[p][e] += 1
|
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):
|
if _vencida(t, hoy):
|
||||||
vencidas.append({k: t.get(k) for k in ("id", "titulo", "pista", "estado", "responsable", "fecha_limite")})
|
r["vencidas"] += 1
|
||||||
|
if _vencida(t, hoy):
|
||||||
|
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 "")
|
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
|
# ------------------------------------------------------------------ plan de producción
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Siembra inicial del 13-oct-2026: plan de producción, los 4 sitios, los ítems de inventario
|
"""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á.
|
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):
|
Uso (en el servidor, como el usuario del servicio, normalmente leo):
|
||||||
cd /srv/projects/drogheda/hbox-docs
|
cd /srv/projects/drogheda/hbox-docs
|
||||||
@ -31,6 +33,19 @@ def marca(u):
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ datos
|
# ------------------------------------------------------------------ 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 = [
|
SITIOS = [
|
||||||
{"id": "icave", "nombre": "ICAVE", "cliente": "Hutchison Ports", "ubicacion": "Veracruz, Ver.",
|
{"id": "icave", "nombre": "ICAVE", "cliente": "Hutchison Ports", "ubicacion": "Veracruz, Ver.",
|
||||||
"alias": ["ICAVE (Veracruz)", "Oficina ICAVE"], "orden": 10,
|
"alias": ["ICAVE (Veracruz)", "Oficina ICAVE"], "orden": 10,
|
||||||
@ -99,7 +114,7 @@ TAREAS = [
|
|||||||
"enlaces": [{"titulo": "Próximos pasos v6", "url": "/hardware/v6-proximos-pasos/"},
|
"enlaces": [{"titulo": "Próximos pasos v6", "url": "/hardware/v6-proximos-pasos/"},
|
||||||
{"titulo": "Plan de corrección", "url": "/hardware/v6-plan-correccion/"}],
|
{"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"]},
|
"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",
|
"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 "
|
"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.",
|
"(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",
|
"titulo": "Acta de instalación en campo: formulario nuevo + instrucciones",
|
||||||
"enlaces": [{"titulo": "Acta de instalación", "url": "/formularios/instalacion.html"}],
|
"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."},
|
"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
|
# ------------------------------------------------------------------ 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)"
|
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, {}
|
n, orden = 0, {}
|
||||||
existentes = backend._listar_json(backend.TAREAS_DIR)
|
existentes = backend._listar_json(backend.TAREAS_DIR)
|
||||||
for t in existentes:
|
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
|
print(" pendiente sin id, se omite:", d.get("titulo")); continue
|
||||||
if os.path.isfile(backend._ruta_registro(backend.TAREAS_DIR, rid)):
|
if os.path.isfile(backend._ruta_registro(backend.TAREAS_DIR, rid)):
|
||||||
continue
|
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)
|
t, err = backend._tarea_valida(d)
|
||||||
if err:
|
if err:
|
||||||
print(" pendiente", rid, "->", err); continue
|
print(" pendiente", rid, "->", err); continue
|
||||||
@ -208,14 +272,15 @@ def sembrar_tareas(u, lista, origen="seed_1013"):
|
|||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
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("--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("--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("--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()
|
a = ap.parse_args()
|
||||||
partes = {p.strip() for p in a.solo.split(",")}
|
partes = {p.strip() for p in a.solo.split(",")}
|
||||||
print("Carpetas:", {"tareas": backend.TAREAS_DIR, "inventario": backend.INVENTARIO_DIR,
|
print("Carpetas:", {"tareas": backend.TAREAS_DIR, "inventario": backend.INVENTARIO_DIR,
|
||||||
"sitios": backend.SITIOS_DIR, "plan": backend.PLAN_FILE})
|
"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.path.dirname(backend.PLAN_FILE)):
|
||||||
os.makedirs(d, exist_ok=True)
|
os.makedirs(d, exist_ok=True)
|
||||||
if "plan" in partes:
|
if "plan" in partes:
|
||||||
@ -224,14 +289,17 @@ def main():
|
|||||||
print(sembrar_sitios(a.usuario))
|
print(sembrar_sitios(a.usuario))
|
||||||
if "inventario" in partes:
|
if "inventario" in partes:
|
||||||
print(sembrar_inventario(a.usuario))
|
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:
|
if "tareas" in partes:
|
||||||
print(sembrar_tareas(a.usuario, TAREAS))
|
print(sembrar_tareas(a.usuario, TAREAS))
|
||||||
|
print(completar_responsables(a.usuario))
|
||||||
if a.extra:
|
if a.extra:
|
||||||
with open(a.extra, encoding="utf-8") as f:
|
with open(a.extra, encoding="utf-8") as f:
|
||||||
extra = json.load(f)
|
extra = json.load(f)
|
||||||
if not isinstance(extra, list):
|
if not isinstance(extra, list):
|
||||||
sys.exit("--extra debe ser una lista JSON de pendientes")
|
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.")
|
print("Listo.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -22,10 +22,10 @@ check "actas vacías" '"total":0' "$(curl -s $U/api/actas)"
|
|||||||
echo "## tareas"
|
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 "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 (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 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"}')"
|
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"}')"
|
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","bloqueada_por":["t-a"]}' > /dev/null
|
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 pista" '"total":1' "$(curl -s "$U/api/tareas?pista=compras")"
|
||||||
check "GET filtro responsable" '"total":1' "$(curl -s "$U/api/tareas?responsable=mich")"
|
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")"
|
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)"
|
echo "## siembra (idempotente)"
|
||||||
S1=$(python3 $REPO/backend/seed_1013.py --usuario leo 2>&1)
|
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"
|
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)
|
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"
|
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 "--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"
|
echo; echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T"
|
||||||
[ $fail -eq 0 ]
|
[ $fail -eq 0 ]
|
||||||
|
|||||||
106
backend/tests-1014.sh
Normal file
106
backend/tests-1014.sh
Normal file
@ -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 <ruta-a-hbox-docs> [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 ]
|
||||||
@ -18,7 +18,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<header class="top"><h1>Administración de actas</h1><p id="sub">Solo para el administrador.</p></header>
|
<header class="top"><h1>Administración de actas</h1><p id="sub">Solo para el administrador.</p></header>
|
||||||
<div style="margin:2px 0 10px"><a class="volver" href="./tablero.html">← Tablero</a></div>
|
<div style="margin:2px 0 10px;display:flex;gap:16px;flex-wrap:wrap"><a class="volver" href="./tablero.html">← Tablero</a><a class="volver" href="tableros/equipo.html">Usuarios y equipo →</a></div>
|
||||||
<div id="cont"><p>Comprobando permisos…</p></div>
|
<div id="cont"><p>Comprobando permisos…</p></div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@ -72,6 +72,7 @@
|
|||||||
<h2>Si olvidaste tu contraseña</h2>
|
<h2>Si olvidaste tu contraseña</h2>
|
||||||
<p class="sub" style="margin:0">
|
<p class="sub" style="margin:0">
|
||||||
No hay recuperación por correo: pídele a Leo que te la restablezca desde esta misma página.
|
No hay recuperación por correo: pídele a Leo que te la restablezca desde esta misma página.
|
||||||
|
Si todavía no tienes acceso, pídeselo a Leo (Administración → <a href="tableros/equipo.html">Usuarios y equipo</a>).
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
226
docs/tableros/equipo.html
Normal file
226
docs/tableros/equipo.html
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Equipo y accesos — HBOX</title>
|
||||||
|
<link rel="stylesheet" href="tablero.css">
|
||||||
|
<style>
|
||||||
|
table.t td.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||||
|
table.t th.num{text-align:right}
|
||||||
|
table.t tr.inactivo td{opacity:.55}
|
||||||
|
table.t td .nom{font-weight:700;cursor:pointer;color:var(--naranja)}
|
||||||
|
table.t td .nom:hover{text-decoration:underline}
|
||||||
|
table.t td .id{font-size:11.5px;color:var(--tinta2);display:block}
|
||||||
|
table.t td button{padding:5px 9px;font-size:12.5px;margin:1px 2px 1px 0}
|
||||||
|
.area{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:99px;background:var(--pend-bg);color:var(--tinta2)}
|
||||||
|
.pass-una-vez{background:var(--aviso-bg);border:1px solid var(--aviso);border-radius:10px;padding:12px 14px;margin-top:10px;font-size:14.5px;color:#7a4a00}
|
||||||
|
.pass-una-vez code{font-size:17px;font-weight:700;letter-spacing:.04em;user-select:all}
|
||||||
|
.mis-pend .card{cursor:default}
|
||||||
|
@media(max-width:760px){#cont table.t{min-width:760px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<header class="top">
|
||||||
|
<div><h1>Equipo y accesos</h1><div class="meta" id="meta">Cargando…</div></div>
|
||||||
|
<nav>
|
||||||
|
<a href="../">← Documentación</a>
|
||||||
|
<a href="kanban.html">Pendientes</a>
|
||||||
|
<a href="ingenieria.html">Ingeniería</a>
|
||||||
|
<a href="produccion.html">Producción</a>
|
||||||
|
<a class="activo" href="equipo.html">Equipo</a>
|
||||||
|
<a href="../cuenta.html">Mi cuenta</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<details class="como">
|
||||||
|
<summary>Cómo se usa</summary>
|
||||||
|
<ol>
|
||||||
|
<li>Aquí está <b>quién es quién</b>: nombre, rol, área, si tiene acceso al sitio y cuántos pendientes abiertos y vencidos trae cada quien.</li>
|
||||||
|
<li><b>Todo pendiente tiene responsable.</b> Al crearlo en el tablero se elige de esta lista; puede haber más de uno.</li>
|
||||||
|
<li>Un miembro <b>sin acceso al sitio</b> también puede ser responsable (Adriana hoy): el pendiente se le asigna igual y alguien con acceso lo mueve por ella.</li>
|
||||||
|
<li>Toca un nombre para ver sus pendientes abiertos y abrir el tablero filtrado.</li>
|
||||||
|
<li>Los <b>accesos los crea Leo aquí</b> (botón «Nuevo usuario / miembro»), ya no con <code>htpasswd</code> en el servidor. La contraseña inicial se muestra <b>una sola vez</b>: pásala en persona o por mensaje directo; la persona la cambia en <a href="../cuenta.html">Mi cuenta</a>.</li>
|
||||||
|
<li>«Desactivar» oculta al miembro de las listas sin borrar nada; «Quitar acceso» le cierra la entrada al sitio y lo desactiva. Nadie se borra: el historial se queda.</li>
|
||||||
|
<li>Si no tienes acceso o no apareces, pídeselo a Leo.</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="msg" id="aviso"></div>
|
||||||
|
<div id="acciones" style="display:none;margin:0 0 12px"><button id="bNuevo">+ Nuevo usuario / miembro</button></div>
|
||||||
|
<div id="cont" class="tblwrap"></div>
|
||||||
|
<section class="bloque mis-pend" id="detalle" style="display:none"></section>
|
||||||
|
</div>
|
||||||
|
<dialog id="dlg"></dialog>
|
||||||
|
<script src="kanban.js"></script>
|
||||||
|
<script>
|
||||||
|
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 fmt = iso => (iso || '').replace('T', ' ').slice(0, 16);
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const AREA = { direccion: 'Dirección', operaciones: 'Operaciones', ingenieria: 'Ingeniería', layout: 'Layout', produccion: 'Producción', pruebas: 'Pruebas', fabricacion: 'Fabricación', software: 'Software', campo: 'Campo', asistente: 'Asistente' };
|
||||||
|
const PISTAS = Kanban.PISTAS, ESTADOS = Object.fromEntries(Kanban.ESTADOS);
|
||||||
|
let MIEMBROS = [], ADMIN = false, USER = '', PASS_MIN = 8, AREAS = Object.keys(AREA), HTPASSWD_OK = true;
|
||||||
|
const dlg = $('dlg'), aviso = $('aviso');
|
||||||
|
function mensaje(t, bien) { aviso.className = 'msg ' + (bien ? 'ok' : 'err'); aviso.innerHTML = t; clearTimeout(aviso._t); aviso._t = setTimeout(() => aviso.className = 'msg', bien ? 5000 : 10000); }
|
||||||
|
async function api(url, opts) { const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {})); let j = null; try { j = await r.json(); } catch (e) {} if (!r.ok || !j || !j.ok) throw new Error((j && j.error) || ('HTTP ' + r.status)); return j; }
|
||||||
|
const json = (m, b) => ({ method: m, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) });
|
||||||
|
function campo(l, i, ayuda) { const d = el('div'); d.append(el('label', { text: l }), i); if (ayuda) d.append(el('div', { style: 'font-size:12.5px;color:var(--tinta2);margin-top:3px', text: ayuda })); return d; }
|
||||||
|
|
||||||
|
function pintar() {
|
||||||
|
const cont = $('cont'); cont.innerHTML = '';
|
||||||
|
if (!MIEMBROS.length) { cont.append(el('div', { class: 'vacio', text: 'Todavía no hay miembros. Corre la siembra o crea el primero.' })); return; }
|
||||||
|
const t = el('table', { class: 't' });
|
||||||
|
t.innerHTML = '<tr><th>Nombre</th><th>Rol</th><th>Área</th><th>Acceso</th><th>Admin</th><th class="num">Abiertos</th><th class="num">Vencidos</th><th>Activo</th>' + (ADMIN ? '<th></th>' : '') + '</tr>';
|
||||||
|
MIEMBROS.forEach(m => {
|
||||||
|
const tr = el('tr', { class: m.activo === false ? 'inactivo' : '' });
|
||||||
|
const nom = el('span', { class: 'nom', text: m.nombre }); nom.addEventListener('click', () => detalle(m));
|
||||||
|
tr.append(el('td', null, nom, el('span', { class: 'id', text: m.id + (m.correo ? ' · ' + m.correo : '') + (m.telefono ? ' · ' + m.telefono : '') })));
|
||||||
|
tr.append(el('td', { text: m.rol || '—' }));
|
||||||
|
tr.append(el('td', null, el('span', { class: 'area', text: AREA[m.area] || m.area })));
|
||||||
|
tr.append(el('td', { html: m.tiene_login ? '<span class="pill ok">sí</span>' : '<span class="pill pend">no</span>' }));
|
||||||
|
tr.append(el('td', { html: m.es_admin ? '<span class="pill info">admin</span>' : '' }));
|
||||||
|
const p = m.pendientes || {};
|
||||||
|
tr.append(el('td', { class: 'num', html: p.abiertos ? '<a href="kanban.html?responsable=' + encodeURIComponent(m.id) + '"><b>' + p.abiertos + '</b></a>' : '0' }));
|
||||||
|
tr.append(el('td', { class: 'num', html: p.vencidas ? '<span style="color:var(--falla);font-weight:700">' + p.vencidas + '</span>' : '0' }));
|
||||||
|
tr.append(el('td', { html: m.activo === false ? '<span class="pill falla">inactivo</span>' : '<span class="pill ok">activo</span>' }));
|
||||||
|
if (ADMIN) {
|
||||||
|
const acc = el('td', { style: 'white-space:nowrap' });
|
||||||
|
const be = el('button', { class: 'sec', text: 'Editar' }); be.addEventListener('click', () => editar(m)); acc.append(be);
|
||||||
|
if (m.activo !== false) { const bd = el('button', { class: 'sec', text: 'Desactivar' }); bd.addEventListener('click', () => desactivar(m)); acc.append(bd); }
|
||||||
|
else { const ba = el('button', { class: 'sec', text: 'Reactivar' }); ba.addEventListener('click', () => reactivar(m)); acc.append(ba); }
|
||||||
|
if (m.tiene_login && m.id.toLowerCase() !== USER.toLowerCase()) { const bq = el('button', { class: 'peligro', text: 'Quitar acceso' }); bq.addEventListener('click', () => quitarAcceso(m)); acc.append(bq); }
|
||||||
|
if (!m.tiene_login && m.activo !== false) { const bc = el('button', { text: 'Crear acceso' }); bc.addEventListener('click', () => nuevo(m)); acc.append(bc); }
|
||||||
|
tr.append(acc);
|
||||||
|
}
|
||||||
|
t.append(tr);
|
||||||
|
});
|
||||||
|
cont.append(t);
|
||||||
|
}
|
||||||
|
async function cargar() {
|
||||||
|
$('meta').textContent = 'Actualizando…';
|
||||||
|
try {
|
||||||
|
const j = await api('/api/equipo?todos=1');
|
||||||
|
MIEMBROS = j.miembros || []; ADMIN = !!j.admin; USER = j.user || ''; PASS_MIN = j.pass_min || 8; AREAS = j.areas || AREAS; HTPASSWD_OK = j.htpasswd_ok !== false;
|
||||||
|
Kanban.usarEquipo(Object.fromEntries(MIEMBROS.map(m => [m.id, { nombre: m.nombre, activo: m.activo !== false, area: m.area }])));
|
||||||
|
pintar();
|
||||||
|
$('acciones').style.display = ADMIN ? '' : 'none';
|
||||||
|
const act = MIEMBROS.filter(m => m.activo !== false), con = act.filter(m => m.tiene_login).length;
|
||||||
|
$('meta').textContent = act.length + ' miembros activos · ' + con + ' con acceso al sitio · ' + (USER ? USER + (ADMIN ? ' (admin)' : '') : '');
|
||||||
|
if (ADMIN && !HTPASSWD_OK) mensaje('El servicio no encuentra el archivo de usuarios (HTPASSWD_FILE): se pueden editar miembros, pero no crear ni quitar accesos. Avisa a Leo.', false);
|
||||||
|
if (ADMIN && (j.logins_sin_miembro || []).length) mensaje('Cuentas con acceso que no están en el equipo: <b>' + j.logins_sin_miembro.map(esc).join(', ') + '</b>. Dales de alta como miembro (Nuevo usuario / miembro, sin marcar «crear acceso») para que puedan ser responsables.', false);
|
||||||
|
const q = new URLSearchParams(location.search).get('id'); if (q) { const m = MIEMBROS.find(x => x.id === q); if (m) detalle(m); }
|
||||||
|
} catch (e) { $('cont').innerHTML = ''; $('cont').append(el('div', { class: 'err', text: 'No se pudo cargar el equipo: ' + e.message })); $('meta').textContent = 'Error'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- pendientes de un miembro ----------
|
||||||
|
async function detalle(m) {
|
||||||
|
const sec = $('detalle'); sec.style.display = ''; sec.innerHTML = '';
|
||||||
|
sec.append(el('h2', { text: 'Pendientes de ' + m.nombre }), el('p', { class: 'sub', html: esc(m.rol || '') + ' · ' + esc(AREA[m.area] || m.area) + ' · <a href="kanban.html?responsable=' + encodeURIComponent(m.id) + '">Abrir en el tablero →</a>' }));
|
||||||
|
const lista = el('div', { text: 'Cargando…' }); sec.append(lista);
|
||||||
|
sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
try {
|
||||||
|
const j = await api('/api/tareas?responsable=' + encodeURIComponent(m.id));
|
||||||
|
const abiertas = (j.tareas || []).filter(t => t.estado !== 'hecho');
|
||||||
|
lista.innerHTML = '';
|
||||||
|
if (!abiertas.length) { lista.append(el('div', { class: 'vacio', text: 'Sin pendientes abiertos.' })); return; }
|
||||||
|
abiertas.forEach(t => {
|
||||||
|
const c = el('div', { class: 'card p-' + t.pista + (t.vencida ? ' vencida' : '') });
|
||||||
|
c.append(el('div', { class: 'tit', html: '<a href="kanban.html?id=' + encodeURIComponent(t.id) + '" style="color:inherit;text-decoration:none">' + esc(t.titulo) + '</a>' }));
|
||||||
|
c.append(el('div', { class: 'meta', html: '<span class="chip p-' + t.pista + '">' + esc(PISTAS[t.pista] || t.pista) + '</span><span class="chip">' + esc(ESTADOS[t.estado] || t.estado) + '</span>'
|
||||||
|
+ (t.fecha_limite ? '<span class="fecha' + (t.vencida ? ' mal' : '') + '">' + (t.vencida ? '⚠ venció ' : '📅 ') + esc(t.fecha_limite) + '</span>' : '')
|
||||||
|
+ ((t.responsable_ids || []).length > 1 ? '<span>👥 ' + esc(Kanban.nombresResp(t).join(', ')) + '</span>' : '') }));
|
||||||
|
lista.append(c);
|
||||||
|
});
|
||||||
|
} catch (e) { lista.textContent = 'No se pudieron leer los pendientes: ' + e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- diálogos (admin) ----------
|
||||||
|
function abrirDialogo(titulo, cuerpoFn, botones) {
|
||||||
|
dlg.innerHTML = '';
|
||||||
|
const cab = el('div', { class: 'dcab' }); cab.append(el('h2', { text: titulo }));
|
||||||
|
const x = el('button', { type: 'button', text: '✕' }); x.addEventListener('click', () => dlg.close()); cab.append(x); dlg.append(cab);
|
||||||
|
const cuerpo = el('div', { class: 'dcuerpo' }); dlg.append(cuerpo);
|
||||||
|
const m = el('div', { class: 'msg' });
|
||||||
|
cuerpoFn(cuerpo, m); cuerpo.append(m);
|
||||||
|
const pie = el('div', { class: 'dpie' }); dlg.append(pie);
|
||||||
|
const cancelar = el('button', { type: 'button', class: 'sec', text: botones.length ? 'Cancelar' : 'Cerrar' }); cancelar.addEventListener('click', () => dlg.close()); pie.append(cancelar);
|
||||||
|
botones.forEach(([txt, fn]) => { const b = el('button', { type: 'button', text: txt }); b.addEventListener('click', async () => { b.disabled = true; try { await fn(m, b, pie); } catch (e) { m.className = 'msg err'; m.textContent = e.message; } b.disabled = false; }); pie.append(b); });
|
||||||
|
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
|
||||||
|
}
|
||||||
|
function formMiembro(c, m, conAcceso) {
|
||||||
|
m = m || {}; const f = {};
|
||||||
|
f.id = el('input', { type: 'text', maxlength: '30', value: m.id || '', placeholder: 'adriana (minúsculas, sin acentos; será el usuario del sitio)', autocapitalize: 'none' }); if (m.id) f.id.disabled = true;
|
||||||
|
f.nombre = el('input', { type: 'text', maxlength: '80', value: m.nombre || '', placeholder: 'Nombre y apellido' });
|
||||||
|
f.rol = el('input', { type: 'text', maxlength: '120', value: m.rol || '', placeholder: 'Técnico de pruebas · Layout de PCB · Operaciones' });
|
||||||
|
f.area = el('select'); AREAS.forEach(a => f.area.append(el('option', { value: a, text: AREA[a] || a }))); f.area.value = m.area || 'produccion';
|
||||||
|
f.correo = el('input', { type: 'email', maxlength: '120', value: m.correo || '' });
|
||||||
|
f.telefono = el('input', { type: 'tel', maxlength: '40', value: m.telefono || '' });
|
||||||
|
const g = el('div', { class: 'dos-col' }); g.append(campo('Usuario (id)', f.id), campo('Nombre', f.nombre), campo('Rol', f.rol), campo('Área', f.area), campo('Correo', f.correo), campo('Teléfono', f.telefono)); c.append(g);
|
||||||
|
if (conAcceso) {
|
||||||
|
f.acceso = el('input', { type: 'checkbox', style: 'width:auto' }); f.acceso.checked = !m.tiene_login && HTPASSWD_OK; f.acceso.disabled = !HTPASSWD_OK;
|
||||||
|
const l = el('label', { class: 'chip-sel', style: 'margin-top:12px' }); l.append(f.acceso, document.createTextNode(' Crear acceso al sitio (usuario y contraseña)')); c.append(l);
|
||||||
|
f.pass = el('input', { type: 'text', autocomplete: 'off', placeholder: 'mínimo ' + PASS_MIN + ' caracteres; se muestra una sola vez' });
|
||||||
|
const cp = campo('Contraseña inicial', f.pass, 'La persona la cambia en Mi cuenta la primera vez que entre.'); c.append(cp);
|
||||||
|
const gen = el('button', { type: 'button', class: 'sec', text: 'Generar una', style: 'padding:6px 10px;font-size:13px' });
|
||||||
|
gen.addEventListener('click', () => { const A = 'abcdefghjkmnpqrstuvwxyz23456789'; let p = ''; const r = new Uint32Array(12); crypto.getRandomValues(r); r.forEach(v => p += A[v % A.length]); f.pass.value = p.slice(0, 4) + '-' + p.slice(4, 8) + '-' + p.slice(8, 12); });
|
||||||
|
cp.append(gen);
|
||||||
|
const sync = () => { cp.style.display = f.acceso.checked ? '' : 'none'; }; f.acceso.addEventListener('change', sync); sync();
|
||||||
|
if (!HTPASSWD_OK) c.append(el('div', { class: 'err', text: 'No se pueden crear accesos: el servicio no encuentra el archivo de usuarios.' }));
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
const leerMiembro = f => ({ nombre: f.nombre.value.trim(), rol: f.rol.value.trim(), area: f.area.value, correo: f.correo.value.trim(), telefono: f.telefono.value.trim() });
|
||||||
|
function nuevo(base) {
|
||||||
|
let f;
|
||||||
|
abrirDialogo(base ? 'Crear acceso para ' + base.nombre : 'Nuevo usuario / miembro', c => { f = formMiembro(c, base, true); if (base) { f.acceso.checked = true; f.acceso.dispatchEvent(new Event('change')); } },
|
||||||
|
[['Guardar', async (m, b, pie) => {
|
||||||
|
const id = Kanban.norm(f.id.value); const d = leerMiembro(f);
|
||||||
|
if (!id) throw new Error('Escribe el usuario (id).');
|
||||||
|
if (!d.nombre) throw new Error('Escribe el nombre.');
|
||||||
|
if (f.acceso.checked) {
|
||||||
|
if (f.pass.value.length < PASS_MIN) throw new Error('La contraseña inicial debe tener al menos ' + PASS_MIN + ' caracteres.');
|
||||||
|
const j = await api('/api/usuarios', json('POST', Object.assign({ usuario: id, password_inicial: f.pass.value }, d)));
|
||||||
|
const pw = f.pass.value;
|
||||||
|
dlg.querySelector('.dcuerpo').innerHTML = '';
|
||||||
|
dlg.querySelector('.dcuerpo').append(el('div', { class: 'pass-una-vez', html: '✓ Acceso creado para <b>' + esc(j.usuario) + '</b>.<br>Contraseña inicial (no se vuelve a mostrar): <code>' + esc(pw) + '</code><br><small>Pásasela en persona o por mensaje directo y dile que la cambie en Mi cuenta.</small>' }));
|
||||||
|
pie.innerHTML = ''; const ok = el('button', { type: 'button', text: 'Listo' }); ok.addEventListener('click', () => dlg.close()); pie.append(ok);
|
||||||
|
cargar(); return;
|
||||||
|
}
|
||||||
|
if (base) throw new Error('Marca «Crear acceso al sitio» para darle acceso.');
|
||||||
|
await api('/api/equipo', json('POST', Object.assign({ id }, d)));
|
||||||
|
dlg.close(); mensaje('Miembro ' + esc(d.nombre) + ' creado (sin acceso al sitio).', true); cargar();
|
||||||
|
}]]);
|
||||||
|
}
|
||||||
|
function editar(m) {
|
||||||
|
let f;
|
||||||
|
abrirDialogo('Editar ' + m.nombre, c => {
|
||||||
|
f = formMiembro(c, m, false);
|
||||||
|
const h = el('div', { class: 'hist' });
|
||||||
|
h.append(el('div', { html: '<b>Creado</b> por ' + esc(m.creado_por) + ' el ' + esc(fmt(m.creado_en)) + (m.tiene_login ? ' · tiene acceso al sitio' : ' · sin acceso al sitio') }));
|
||||||
|
(m.historial || []).slice().reverse().slice(0, 10).forEach(x => h.append(el('div', { html: esc(fmt(x.cuando)) + ' · <b>' + esc(x.quien) + '</b> · ' + esc(x.cambio) })));
|
||||||
|
c.append(h);
|
||||||
|
}, [['Guardar', async () => {
|
||||||
|
const d = leerMiembro(f); const cambios = {};
|
||||||
|
Object.keys(d).forEach(k => { if (d[k] !== (m[k] || '')) cambios[k] = d[k]; });
|
||||||
|
if (Object.keys(cambios).length) await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', cambios));
|
||||||
|
dlg.close(); mensaje('Guardado.', true); cargar();
|
||||||
|
}]]);
|
||||||
|
}
|
||||||
|
async function desactivar(m) {
|
||||||
|
if (!confirm('¿Desactivar a ' + m.nombre + '? Deja de aparecer en las listas de responsables; sus pendientes y su historial se quedan.' + (m.tiene_login ? ' Su acceso al sitio NO se quita (usa «Quitar acceso» para eso).' : ''))) return;
|
||||||
|
try { await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', { activo: false })); mensaje(m.nombre + ' desactivado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
|
||||||
|
}
|
||||||
|
async function reactivar(m) {
|
||||||
|
try { await api('/api/equipo/miembro?id=' + encodeURIComponent(m.id), json('PATCH', { activo: true })); mensaje(m.nombre + ' reactivado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
|
||||||
|
}
|
||||||
|
async function quitarAcceso(m) {
|
||||||
|
if (!confirm('¿Quitar el acceso al sitio a ' + m.nombre + ' (' + m.id + ')? Ya no podrá entrar y quedará como inactivo. Sus actas y pendientes se conservan.')) return;
|
||||||
|
try { await api('/api/usuario?u=' + encodeURIComponent(m.id), { method: 'DELETE' }); mensaje('Acceso de ' + m.id + ' retirado.', true); cargar(); } catch (e) { mensaje(e.message, false); }
|
||||||
|
}
|
||||||
|
$('bNuevo').addEventListener('click', () => nuevo(null));
|
||||||
|
cargar();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -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.
|
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).
|
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
|
## Relacionados
|
||||||
|
|
||||||
- [Detalle por tarjeta y módulo](../tablero.html) — qué serie pasó cada acta del lote actual.
|
- [Detalle por tarjeta y módulo](../tablero.html) — qué serie pasó cada acta del lote actual.
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
<a class="activo" href="ingenieria.html">Ingeniería</a>
|
<a class="activo" href="ingenieria.html">Ingeniería</a>
|
||||||
<a href="produccion.html">Producción</a>
|
<a href="produccion.html">Producción</a>
|
||||||
<a href="../hardware/">Tarjetas y hardware</a>
|
<a href="../hardware/">Tarjetas y hardware</a>
|
||||||
|
<a href="equipo.html">👥 Equipo</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
<a href="produccion.html">Producción</a>
|
<a href="produccion.html">Producción</a>
|
||||||
<a href="../inventario/inventario.html">Inventario</a>
|
<a href="../inventario/inventario.html">Inventario</a>
|
||||||
<a href="../sitios/">Sitios</a>
|
<a href="../sitios/">Sitios</a>
|
||||||
|
<a href="equipo.html">👥 Equipo</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@ -11,14 +11,43 @@
|
|||||||
kb.recargar();
|
kb.recargar();
|
||||||
|
|
||||||
Servidor: GET /api/tareas · POST /api/tareas · PATCH /api/tarea?id= · POST /api/tareas/orden ·
|
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 () {
|
(function () {
|
||||||
const PISTAS = { ingenieria: 'Ingeniería', produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', campo: 'Campo' };
|
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 ESTADOS = [['por-hacer', 'Por hacer'], ['en-curso', 'En curso'], ['bloqueado', 'Bloqueado'], ['hecho', 'Hecho']];
|
||||||
const VERSIONES = ['—', 'V5.5', 'V5.6', 'V5.7', 'v6'];
|
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' };
|
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 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 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); };
|
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);
|
const corte = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10);
|
||||||
return tareas.filter(t => {
|
return tareas.filter(t => {
|
||||||
if (filtro.pista && t.pista !== filtro.pista) return false;
|
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 (filtro.version && t.version_tarjeta !== filtro.version) return false;
|
||||||
if (t.estado === 'hecho') {
|
if (t.estado === 'hecho') {
|
||||||
if (filtro.hechas === 'ninguna') return false;
|
if (filtro.hechas === 'ninguna') return false;
|
||||||
@ -107,7 +140,7 @@
|
|||||||
const meta = el('div', { class: 'meta' });
|
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 (!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.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) }));
|
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 })));
|
(t.etiquetas || []).slice(0, 3).forEach(x => meta.append(el('span', { class: 'chip', text: x })));
|
||||||
c.append(meta);
|
c.append(meta);
|
||||||
@ -139,9 +172,10 @@
|
|||||||
});
|
});
|
||||||
// responsables presentes en los datos (más los conocidos)
|
// responsables presentes en los datos (más los conocidos)
|
||||||
selResp.querySelectorAll('option:not(:first-child)').forEach(o => o.remove());
|
selResp.querySelectorAll('option:not(:first-child)').forEach(o => o.remove());
|
||||||
const set = new Set(RESPONSABLES);
|
Object.entries(EQUIPO).filter(([, m]) => m.activo !== false).sort((a, b) => a[1].nombre.localeCompare(b[1].nombre))
|
||||||
tareas.forEach(t => String(t.responsable || '').split(/[,/]+/).map(s => s.trim().toLowerCase()).filter(Boolean).forEach(s => set.add(s)));
|
.forEach(([id, m]) => selResp.append(el('option', { value: id, text: m.nombre })));
|
||||||
[...set].sort().forEach(r => selResp.append(el('option', { value: r, text: r })));
|
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;
|
selResp.value = filtro.responsable;
|
||||||
const venc = vis.filter(t => t.vencida).length;
|
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 : '');
|
cnt.textContent = vis.length + ' pendiente(s)' + (venc ? ' · ' + venc + ' vencido(s)' : '') + (vis.length !== tareas.length ? ' · de ' + tareas.length : '');
|
||||||
@ -151,6 +185,7 @@
|
|||||||
try {
|
try {
|
||||||
const j = await api('/api/tareas');
|
const j = await api('/api/tareas');
|
||||||
tareas = j.tareas || []; admin = !!j.admin; user = j.user || '';
|
tareas = j.tareas || []; admin = !!j.admin; user = j.user || '';
|
||||||
|
usarEquipo(j.equipo);
|
||||||
pintar();
|
pintar();
|
||||||
if (opts.onCargado) opts.onCargado(tareas, { admin, user });
|
if (opts.onCargado) opts.onCargado(tareas, { admin, user });
|
||||||
} catch (e) { mensaje('No se pudo cargar el tablero: ' + e.message, false); }
|
} 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 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 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 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…' });
|
// responsable: chips de miembros activos (varios) + texto libre opcional. Obligatorio.
|
||||||
let dl = document.getElementById('kb-resp'); if (!dl) { dl = el('datalist', { id: 'kb-resp' }); document.body.append(dl); }
|
const fResp = el('div', { class: 'chips' });
|
||||||
dl.innerHTML = ''; RESPONSABLES.forEach(r => dl.append(el('option', { value: r })));
|
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 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 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' });
|
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));
|
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
|
// enlaces
|
||||||
const enlBox = el('div', { class: 'enlaces-ed' });
|
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 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' });
|
const guardar = el('button', { type: 'button', text: nuevo ? 'Crear pendiente' : 'Guardar cambios' });
|
||||||
guardar.addEventListener('click', async () => {
|
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),
|
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),
|
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) };
|
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.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;
|
guardar.disabled = true;
|
||||||
try {
|
try {
|
||||||
if (nuevo) await api('/api/tareas', json('POST', d));
|
let r;
|
||||||
|
if (nuevo) r = await api('/api/tareas', json('POST', d));
|
||||||
else {
|
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]; });
|
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; }
|
} catch (e) { m.className = 'msg err'; m.textContent = e.message; }
|
||||||
guardar.disabled = false;
|
guardar.disabled = false;
|
||||||
});
|
});
|
||||||
@ -280,5 +326,5 @@
|
|||||||
return { recargar, abrir, get tareas() { return tareas; }, get admin() { return admin; } };
|
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; } };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
<a class="activo" href="produccion.html">Producción</a>
|
<a class="activo" href="produccion.html">Producción</a>
|
||||||
<a href="../tablero.html">Detalle por tarjeta</a>
|
<a href="../tablero.html">Detalle por tarjeta</a>
|
||||||
<a href="../inventario/inventario.html">Inventario</a>
|
<a href="../inventario/inventario.html">Inventario</a>
|
||||||
|
<a href="equipo.html">👥 Equipo</a>
|
||||||
<button id="refrescar" style="padding:6px 12px">Actualizar</button>
|
<button id="refrescar" style="padding:6px 12px">Actualizar</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@ -36,6 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog id="dlgPlan"></dialog>
|
<dialog id="dlgPlan"></dialog>
|
||||||
|
<script src="kanban.js"></script>
|
||||||
<script>
|
<script>
|
||||||
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 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 esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; };
|
||||||
@ -48,7 +50,7 @@ const FORM = { 'prueba-v5': '../formularios/v5.html', 'prueba-v4': '../formulari
|
|||||||
'prueba-ethernet-serie': '../formularios/ethernet-serie.html', 'envio': '../formularios/envio.html', 'instalacion-campo': '../formularios/instalacion.html' };
|
'prueba-ethernet-serie': '../formularios/ethernet-serie.html', 'envio': '../formularios/envio.html', 'instalacion-campo': '../formularios/instalacion.html' };
|
||||||
const PISTAS = { produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', ingenieria: 'Ingeniería', campo: 'Campo' };
|
const PISTAS = { produccion: 'Producción', mecanica: 'Mecánica', compras: 'Compras', ingenieria: 'Ingeniería', campo: 'Campo' };
|
||||||
const cont = document.getElementById('cont'), meta = document.getElementById('meta');
|
const cont = document.getElementById('cont'), meta = document.getElementById('meta');
|
||||||
let PLAN = null, ADMIN = false;
|
let PLAN = null, ADMIN = false, EQUIPO = {};
|
||||||
|
|
||||||
async function api(url, opts) {
|
async function api(url, opts) {
|
||||||
const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {}));
|
const r = await fetch(url, Object.assign({ cache: 'no-store' }, opts || {}));
|
||||||
@ -143,7 +145,7 @@ function render(plan, actas, series, danos, bloqueos, inv) {
|
|||||||
bloqueos.forEach(t => {
|
bloqueos.forEach(t => {
|
||||||
const c = el('div', { class: 'card p-' + t.pista });
|
const c = el('div', { class: 'card p-' + t.pista });
|
||||||
c.append(el('div', { class: 'tit', html: '<a href="kanban.html?id=' + encodeURIComponent(t.id) + '" style="color:inherit;text-decoration:none">' + esc(t.titulo) + '</a>' }));
|
c.append(el('div', { class: 'tit', html: '<a href="kanban.html?id=' + encodeURIComponent(t.id) + '" style="color:inherit;text-decoration:none">' + esc(t.titulo) + '</a>' }));
|
||||||
c.append(el('div', { class: 'meta', html: '<span class="chip p-' + t.pista + '">' + esc(PISTAS[t.pista] || t.pista) + '</span>' + (t.responsable ? '<span>👤 ' + esc(t.responsable) + '</span>' : '<span>sin responsable</span>') + (t.fecha_limite ? '<span class="fecha' + (t.vencida ? ' mal' : '') + '">📅 ' + esc(t.fecha_limite) + '</span>' : '') }));
|
c.append(el('div', { class: 'meta', html: '<span class="chip p-' + t.pista + '">' + esc(PISTAS[t.pista] || t.pista) + '</span>' + (t.responsable ? '<span title="' + esc(t.responsable) + '">👤 ' + esc(Kanban.nombresResp(t, EQUIPO).join(', ')) + '</span>' : '<span>sin responsable</span>') + (t.fecha_limite ? '<span class="fecha' + (t.vencida ? ' mal' : '') + '">📅 ' + esc(t.fecha_limite) + '</span>' : '') }));
|
||||||
if (t.descripcion) c.append(el('div', { style: 'font-size:13px;color:var(--tinta2);margin-top:4px', text: t.descripcion.length > 160 ? t.descripcion.slice(0, 159) + '…' : t.descripcion }));
|
if (t.descripcion) c.append(el('div', { style: 'font-size:13px;color:var(--tinta2);margin-top:4px', text: t.descripcion.length > 160 ? t.descripcion.slice(0, 159) + '…' : t.descripcion }));
|
||||||
b3.append(c);
|
b3.append(c);
|
||||||
});
|
});
|
||||||
@ -220,7 +222,7 @@ async function cargar() {
|
|||||||
const [p, a, s, d, t, i] = await Promise.all([
|
const [p, a, s, d, t, i] = await Promise.all([
|
||||||
api('/api/plan'), api('/api/actas'), api('/api/series').catch(() => ({ series: [] })), api('/api/danos').catch(() => ({ series: [] })),
|
api('/api/plan'), api('/api/actas'), api('/api/series').catch(() => ({ series: [] })), api('/api/danos').catch(() => ({ series: [] })),
|
||||||
api('/api/tareas?estado=bloqueado').catch(() => ({ tareas: [] })), api('/api/inventario?alertas=1').catch(() => ({ items: [] }))]);
|
api('/api/tareas?estado=bloqueado').catch(() => ({ tareas: [] })), api('/api/inventario?alertas=1').catch(() => ({ items: [] }))]);
|
||||||
PLAN = p.plan; ADMIN = !!p.admin;
|
PLAN = p.plan; ADMIN = !!p.admin; EQUIPO = Kanban.usarEquipo(t.equipo);
|
||||||
const bloqueos = (t.tareas || []).filter(x => ['produccion', 'mecanica', 'compras'].includes(x.pista));
|
const bloqueos = (t.tareas || []).filter(x => ['produccion', 'mecanica', 'compras'].includes(x.pista));
|
||||||
render(PLAN, a.actas || [], s.series || [], d.series || [], bloqueos, i.items || []);
|
render(PLAN, a.actas || [], s.series || [], d.series || [], bloqueos, i.items || []);
|
||||||
meta.textContent = 'Actualizado ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }) + ' · ' + (a.total || 0) + ' actas · ' + (s.series || []).length + ' series con registro';
|
meta.textContent = 'Actualizado ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' }) + ' · ' + (a.total || 0) + ' actas · ' + (s.series || []).length + ' series con registro';
|
||||||
|
|||||||
@ -107,6 +107,11 @@ dialog input,dialog select,dialog textarea{width:100%}
|
|||||||
.lista-chk{max-height:140px;overflow:auto;border:1px solid var(--linea);border-radius:9px;padding:6px 10px;font-size:13.5px}
|
.lista-chk{max-height:140px;overflow:auto;border:1px solid var(--linea);border-radius:9px;padding:6px 10px;font-size:13.5px}
|
||||||
.lista-chk label{display:flex;gap:8px;align-items:flex-start;font-weight:500;margin:4px 0;color:var(--tinta)}
|
.lista-chk label{display:flex;gap:8px;align-items:flex-start;font-weight:500;margin:4px 0;color:var(--tinta)}
|
||||||
.lista-chk input{width:auto;margin-top:3px}
|
.lista-chk input{width:auto;margin-top:3px}
|
||||||
|
/* responsables: chips seleccionables (miembros del equipo) */
|
||||||
|
.chips{display:flex;flex-wrap:wrap;gap:6px}
|
||||||
|
.chip-sel{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--linea);border-radius:99px;padding:5px 11px;font-size:13.5px;font-weight:500;cursor:pointer;background:var(--superf);margin:0;color:var(--tinta)}
|
||||||
|
.chip-sel input{width:auto;margin:0;accent-color:var(--naranja)}
|
||||||
|
.chip-sel.on{border-color:var(--naranja);background:var(--naranja-suave);color:var(--naranja);font-weight:700}
|
||||||
.hist{font-size:13px;color:var(--tinta2);max-height:180px;overflow:auto;border-top:1px solid var(--linea);margin-top:12px;padding-top:8px}
|
.hist{font-size:13px;color:var(--tinta2);max-height:180px;overflow:auto;border-top:1px solid var(--linea);margin-top:12px;padding-top:8px}
|
||||||
.hist div{padding:3px 0;border-bottom:1px dashed var(--linea)}
|
.hist div{padding:3px 0;border-bottom:1px dashed var(--linea)}
|
||||||
.hist b{color:var(--tinta)}
|
.hist b{color:var(--tinta)}
|
||||||
|
|||||||
@ -67,6 +67,7 @@ nav:
|
|||||||
- "📋 Tableros":
|
- "📋 Tableros":
|
||||||
- tableros/index.md
|
- tableros/index.md
|
||||||
- "Kanban — tareas del equipo": tableros/kanban.html
|
- "Kanban — tareas del equipo": tableros/kanban.html
|
||||||
|
- "Equipo — miembros, responsables y accesos": tableros/equipo.html
|
||||||
- "Ingeniería — estado por versión de tarjeta": tableros/ingenieria.html
|
- "Ingeniería — estado por versión de tarjeta": tableros/ingenieria.html
|
||||||
- "Producción — plan 570 vs. avance real": tableros/produccion.html
|
- "Producción — plan 570 vs. avance real": tableros/produccion.html
|
||||||
- "Estado de actas (tarjetas, módulos, unidades)": tablero.html
|
- "Estado de actas (tarjetas, módulos, unidades)": tablero.html
|
||||||
|
|||||||
@ -16,7 +16,7 @@ echo "Sitio publicado en $REMOTE:$REMOTE_PATH (puerto $PORT)"
|
|||||||
# El backend NO vive en git en el servidor: se copia igual que el sitio.
|
# El backend NO vive en git en el servidor: se copia igual que el sitio.
|
||||||
# Sin --delete (ahí junto al app.py viven el venv y __pycache__).
|
# Sin --delete (ahí junto al app.py viven el venv y __pycache__).
|
||||||
BACKEND_PATH="$(dirname "$REMOTE_PATH")/backend"
|
BACKEND_PATH="$(dirname "$REMOTE_PATH")/backend"
|
||||||
rsync -az -e "ssh -p $PORT" backend/app.py backend/requirements.txt backend/seed_1013.py \
|
rsync -az -e "ssh -p $PORT" backend/app.py backend/requirements.txt \
|
||||||
backend/parche-nginx-api.py backend/actas-hbox.service "$REMOTE:$BACKEND_PATH/"
|
backend/parche-nginx-api.py backend/actas-hbox.service "$REMOTE:$BACKEND_PATH/"
|
||||||
echo "Backend copiado en $REMOTE:$BACKEND_PATH"
|
echo "Backend copiado en $REMOTE:$BACKEND_PATH"
|
||||||
echo
|
echo
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user