Cuentas en bloque, contraseña inicial y sitio/correo en Equipo

backend/cuentas.py: restablece en bloque (todos menos leo y claude) y da de alta
a varios desde backend/cuentas/2026-10-23_alta-sitios.json (jesush, miguel, joel,
antonio, danielg); la contraseña la pide en la terminal. Miembros con sitio,
correo y correo_alt; marca contrasena_inicial con aviso en el sitio hasta que la
persona la cambie. Los ids de responsable se recalculan con el equipo de hoy.
Lote 2026-10-23_usuarios-campo. Pruebas: tests-cuentas.sh (47).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X4CAujMuib2tDqt9JRg6X
This commit is contained in:
Leopoldo Rodríguez 2026-10-23 14:14:50 -06:00
parent 02521e14f4
commit 4cc18b517f
16 changed files with 731 additions and 68 deletions

View File

@ -30,6 +30,7 @@ Adriana, Michelle, Jesús and the field technicians work from it; Claude closes
| **Ruta a producción**, version cards, fase, pedido, próximas fechas, map pins | `docs/datos/proyecto.json` | Claude or Leo | edit by hand (keep the one-array-per-line format); step states `hecho · ahora · bloqueado · falta`, card tone `hecho · ahora · aviso · bloqueado`; bump `actualizado` | | **Ruta a producción**, version cards, fase, pedido, próximas fechas, map pins | `docs/datos/proyecto.json` | Claude or Leo | edit by hand (keep the one-array-per-line format); step states `hecho · ahora · bloqueado · falta`, card tone `hecho · ahora · aviso · bloqueado`; bump `actualizado` |
| **Instalaciones** (per-site installation board, 22-oct) | `docs/tableros/instalaciones.md` + `.js`/`.css`; data: field actas via `GET /api/instalaciones` (`backend/instalaciones.py`, hooked at the end of `app.py`) | automatic | stages envío → recepción → instalación → **prueba en sitio** (acta `prueba-sitio`; only a passed test counts for the 1–2 per-site goal) → en operación (7 days after the test, no failure) + «con falla»; colour, nota and meta per site from `proyecto.json`; forms accept `?serie=&sitio=&<clave>=` prefill (`acta.js`) | | **Instalaciones** (per-site installation board, 22-oct) | `docs/tableros/instalaciones.md` + `.js`/`.css`; data: field actas via `GET /api/instalaciones` (`backend/instalaciones.py`, hooked at the end of `app.py`) | automatic | stages envío → recepción → instalación → **prueba en sitio** (acta `prueba-sitio`; only a passed test counts for the 1–2 per-site goal) → en operación (7 days after the test, no failure) + «con falla»; colour, nota and meta per site from `proyecto.json`; forms accept `?serie=&sitio=&<clave>=` prefill (`acta.js`) |
| **Inventario por sitio** (23-oct) | `docs/tableros/inventario.md` + `.js`/`.css`; data: server `/srv/projects/drogheda/inventario` via `/api/inventario` | everyone in the UI | tabs ICAVE · TIMSA · EIT · LCT · Querétaro + «Todo» (`?sitio=eit&n=2`); every movement carries `sitio` (none = qro); `envio` leaves a site (or `fuera`: purchase in transit / material shipped before counting) and stays «en camino» until the destination presses «Recibir» (`recepcion`); item `por_instalacion` = the installation kit; team rules in `docs/inventario/index.md`; tests `backend/tests-1023.sh` | | **Inventario por sitio** (23-oct) | `docs/tableros/inventario.md` + `.js`/`.css`; data: server `/srv/projects/drogheda/inventario` via `/api/inventario` | everyone in the UI | tabs ICAVE · TIMSA · EIT · LCT · Querétaro + «Todo» (`?sitio=eit&n=2`); every movement carries `sitio` (none = qro); `envio` leaves a site (or `fuera`: purchase in transit / material shipped before counting) and stays «en camino» until the destination presses «Recibir» (`recepcion`); item `por_instalacion` = the installation kit; team rules in `docs/inventario/index.md`; tests `backend/tests-1023.sh` |
| **Equipo y accesos** (14/23-oct) | server `/srv/projects/drogheda/equipo` (one JSON per member) + `/etc/nginx/hbox-docs.htpasswd` | Leo (admin) in Equipo and Mi cuenta; in bulk `backend/cuentas.py` run by Leo on the server | member fields `sitio`, `correo`, `correo_alt` (two email domains), `contrasena_inicial` (set when someone else sets the password, cleared when the person changes it; banner from `docs/js/sitio.js`, admin-only pill in Equipo); new people go in `backend/cuentas/AAAA-MM-DD_*.json` (never passwords) → `cuentas.py todo|alta`; task `responsable_ids` are recomputed from the text with today's team |
| **Últimas decisiones** (Ingeniería) | `docs/tableros/ingenieria.md` | Claude or Leo | new `<li><b>DD-mmm (hora)</b> — …</li>` at the top, with the decision number (D-xx-n) and links | | **Últimas decisiones** (Ingeniería) | `docs/tableros/ingenieria.md` | Claude or Leo | new `<li><b>DD-mmm (hora)</b> — …</li>` at the top, with the decision number (D-xx-n) and links |
| **Bitácora → Documentos por fecha** | `docs/bitacora.md` | Claude or Leo | newest first under the month: `**DD oct**` + `: [Title](path.md#anchor): what it decides or changes` | | **Bitácora → Documentos por fecha** | `docs/bitacora.md` | Claude or Leo | newest first under the month: `**DD oct**` + `: [Title](path.md#anchor): what it decides or changes` |
| **Documentos recientes** (home) | automatic from `date:` front matter (`hooks/documentos.py`) | — | give every new page a `date:`; pages under «Historia» are left out | | **Documentos recientes** (home) | automatic from `date:` front matter (`hooks/documentos.py`) | — | give every new page a `date:`; pages under «Historia» are left out |

View File

@ -459,6 +459,49 @@ recepción completa y con faltante, conteo físico, envío desde fuera, errores
| Un envío no sale en la pestaña del sitio que lo espera | se registró con otro destino, o ya se recibió | tocar el código del ítem: sus movimientos dicen a dónde iba y quién lo recibió | | Un envío no sale en la pestaña del sitio que lo espera | se registró con otro destino, o ya se recibió | tocar el código del ítem: sus movimientos dicen a dónde iba y quién lo recibió |
| El total de un ítem subió sin que llegara nada | un envío desde «Proveedor o fuera del inventario» entra al total al registrarse (va en camino) | así debe ser; si al recibir llega menos, la diferencia baja | | El total de un ítem subió sin que llegara nada | un envío desde «Proveedor o fuera del inventario» entra al total al registrarse (va en camino) | así debe ser; si al recibir llega menos, la diferencia baja |
---
## A10 · 23-oct: cuentas nuevas, contraseña inicial para todos y correo de cada quien
Qué cambia:
- `backend/cuentas.py` (nuevo; `deploy.sh` ya lo copia junto con `backend/cuentas/`): restablece contraseñas en bloque
y da de alta a varios. Primera corrida (`cuentas/2026-10-23_alta-sitios.json`): altas de **jesush** (Jesús Honorato,
TIMSA), **miguel** (Miguel Ángel Peña, EIT), **joel** (Joel Orantes, EIT), **antonio** (Antonio Ventura, LCT) y
**danielg** (Daniel González Cruz, ensamble, Querétaro); el usuario `jesus` pasa a llamarse **Jesús Romero**; se
llenan los correos que ya conocíamos; y **todos menos leo y claude** quedan con la misma contraseña inicial.
- **Equipo**: columnas **Sitio** y **Correo**, filtro por sitio, campos «Sitio» y «Otro correo» (el del otro dominio)
y, para el administrador, la marca **contraseña inicial**.
- Quien sigue con la contraseña inicial ve una franja arriba en todas las páginas, con la liga a Mi cuenta; se quita
sola cuando la cambia.
- Los pendientes que tenían a alguien como texto libre («Miguel») ya cuentan como suyos en cuanto se da de alta.
**Cambió `backend/app.py`: reinicia el servicio antes de correr `cuentas.py` y antes de aplicar el lote.**
```bash
cd ~/Documents/Claude/Projects/Drogheda/hbox-docs
mkdocs build --strict
git add -A && git commit -m "Cuentas en bloque, contraseña inicial y sitio/correo en Equipo" && git push
cd .. && git add hbox-docs CLAUDE.md && git commit -m "hbox-docs: cuentas del 23-oct" && git push && cd hbox-docs
./scripts/deploy.sh
ssh leo@git.helgen.online 'sudo systemctl restart actas-hbox'
ssh -t leo@git.helgen.online 'cd /srv/projects/drogheda/hbox-docs/backend && venv/bin/python3 cuentas.py todo cuentas/2026-10-23_alta-sitios.json'
./scripts/tablero.sh revisar && ./scripts/tablero.sh aplicar # lote 2026-10-23_usuarios-campo
```
`cuentas.py` muestra el plan (altas, a quién restablece, qué datos llena) y pide «si»; luego la contraseña, dos veces y
sin eco. No la escribas en ningún archivo ni en un chat de grupo: pásala en persona o por mensaje directo. Para revisar
después (sin contraseñas ni hashes):
`ssh leo@git.helgen.online 'cd /srv/projects/drogheda/hbox-docs/backend && venv/bin/python3 cuentas.py lista'`.
Pruebas en la Mac (opcional, necesita Flask): `bash backend/tests-cuentas.sh .` (47 revisiones).
| Síntoma | Causa | Arreglo |
|---|---|---|
| `cuentas.py`: «No encuentro el archivo de usuarios» o `PermissionError` | no se corrió como `leo`, o el htpasswd no es de leo | correr como leo; `sudo chown leo:www-data /etc/nginx/hbox-docs.htpasswd && sudo chmod 640 /etc/nginx/hbox-docs.htpasswd` |
| Alguien no puede entrar después | escribió otra contraseña, o su cuenta salió como «sin miembro activo» y no se tocó | `cuentas.py lista`; restablécesela en Mi cuenta (admin) |
| Hay que deshacer el restablecimiento | — | el archivo anterior quedó en `/srv/projects/drogheda/equipo/_respaldos/htpasswd-AAAAMMDD-hhmmss`: cópialo encima de `/etc/nginx/hbox-docs.htpasswd` como leo |
| No sale la franja de la contraseña ni la columna Sitio | servicio sin reiniciar | el `restart` de arriba |
## B · Verificación después de desplegar ## B · Verificación después de desplegar
```bash ```bash

View File

@ -195,7 +195,9 @@ Requisito en el servidor: el servicio debe poder **escribir** el htpasswd (`chow
`chmod 640`, §7 de DESPLIEGUE.md). `chmod 640`, §7 de DESPLIEGUE.md).
Campos del miembro: `id` (= usuario del sitio si lo tiene), `nombre`, `rol`, `area` ∈ direccion·operaciones· 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`. ingenieria·layout·produccion·pruebas·fabricacion·software·campo·asistente, `sitio` (icave·timsa·eit·lzc·qro o vacío; 23-oct),
`correo`, `correo_alt` (el del otro dominio; 23-oct), `telefono`, `activo` y `contrasena_inicial` (23-oct: la contraseña
la puso alguien más; esta marca solo la ve un administrador).
```bash ```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" # activos, con tiene_login, es_admin y pendientes {abiertos, vencidas, por estado}
@ -222,3 +224,27 @@ no importan) y también texto libre si la persona aún no está dada de alta: se
incluye `equipo` (id → nombre) para pintar nombres. `GET /api/tareas/resumen` agrega `por_responsable` 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 `{id: {por-hacer, en-curso, bloqueado, hecho, abiertos, vencidas, nombre}}`; los pendientes sin miembro
registrado cuentan bajo `_sin_equipo`. registrado cuentan bajo `_sin_equipo`.
**Desde el 23-oct** los ids se recalculan del texto con el equipo de ese momento cada vez que se leen (`GET /api/tareas`,
`/api/tareas/resumen`, Equipo): si alguien estaba como texto libre («Miguel») y se da de alta como `miguel`, sus
pendientes ya cuentan como suyos sin volver a guardarlos. Nunca quita a nadie: los miembros no se borran.
### Cuentas en bloque: `cuentas.py` (23-oct)
Para restablecer contraseñas de muchos o dar de alta a varios de una vez, en el servidor y como `leo`:
```bash
cd /srv/projects/drogheda/hbox-docs/backend
venv/bin/python3 cuentas.py lista # cuentas, nombre, sitio, correo y si sigue con la inicial
venv/bin/python3 cuentas.py todo cuentas/2026-10-23_alta-sitios.json # altas + datos + todos a la misma contraseña (menos leo y claude)
venv/bin/python3 cuentas.py alta cuentas/OTRO.json # solo altas y datos
venv/bin/python3 cuentas.py restablecer --solo ana,beto # solo a esos
```
La contraseña la pide el script (dos veces, sin eco); nunca va en un archivo ni como argumento. Muestra el plan y pide
«si»; deja un respaldo del htpasswd en `equipo/_respaldos/` (modo 600). Las cuentas sin miembro activo no se tocan (se
listan para decidir si se quitan). A quien se le restablece o se le da de alta le queda `contrasena_inicial: true` en su
miembro: `/api/whoami` la devuelve, `docs/js/sitio.js` muestra una franja con la liga a Mi cuenta y Equipo la marca
(solo admin). Se quita sola cuando la persona cambia su contraseña (`POST /api/password` propio); un restablecimiento
desde Mi cuenta (admin) o desde `/api/usuarios` la vuelve a poner. En el archivo de altas, los datos de quien ya existe
solo se llenan si están vacíos. Pruebas: `bash backend/tests-cuentas.sh .` (47).

View File

@ -41,6 +41,9 @@ autenticado crea y modifica, solo un administrador borra:
Equipo y accesos (14-oct): los miembros del equipo NO son lo mismo que los usuarios con login. 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) 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) POST /api/usuarios (admin: crea login en el htpasswd + miembro) · DELETE /api/usuario?u= (admin: quita el login)
(23-oct) cada miembro lleva «sitio» (icave, timsa, eit, lzc, qro o vacío), «correo» y «correo_alt» (el del otro
dominio). «contrasena_inicial» = su contraseña la puso alguien más (alta, restablecimiento o backend/cuentas.py);
se quita sola cuando la persona la cambia en Mi cuenta. /api/whoami la devuelve y el sitio muestra un aviso.
Inicio (21-oct): Inicio (21-oct):
GET /api/hoy · PUT /api/hoy (solo HOY_EDITORES, por omisión leo y adriana, y los administradores): GET /api/hoy · PUT /api/hoy (solo HOY_EDITORES, por omisión leo y adriana, y los administradores):
el enfoque de la semana que se ve en la página de inicio; guarda las 20 versiones anteriores. el enfoque de la semana que se ve en la página de inicio; guarda las 20 versiones anteriores.
@ -128,7 +131,8 @@ def salud():
@app.get("/api/whoami") @app.get("/api/whoami")
def whoami(): def whoami():
u = current_user() u = current_user()
return jsonify(ok=True, user=u, admin=(u.lower() in ADMINS)) m = _leer_json(_ruta_registro(EQUIPO_DIR, _norm_id(u))) if u else None # contraseña inicial: ver _marcar_contrasena
return jsonify(ok=True, user=u, admin=(u.lower() in ADMINS), contrasena_inicial=bool(m and m.get("contrasena_inicial")))
@app.get("/api/acta") @app.get("/api/acta")
def get_acta(): def get_acta():
@ -569,6 +573,7 @@ def cambiar_password():
if not es_reset and nueva == actual: if not es_reset and nueva == actual:
return jsonify(ok=False, error="La nueva contraseña es igual a la actual."), 400 return jsonify(ok=False, error="La nueva contraseña es igual a la actual."), 400
with _candado_htpasswd(): # cuentas.py y los otros endpoints de accesos también escriben el archivo
lineas = _leer_htpasswd() lineas = _leer_htpasswd()
if lineas is None: if lineas is None:
return jsonify(ok=False, error="No encuentro el archivo de usuarios en el servidor."), 500 return jsonify(ok=False, error="No encuentro el archivo de usuarios en el servidor."), 500
@ -597,6 +602,9 @@ def cambiar_password():
except OSError as e: except OSError as e:
return jsonify(ok=False, error="No se pudo guardar: " + str(e)), 500 return jsonify(ok=False, error="No se pudo guardar: " + str(e)), 500
_marcar_contrasena(destino, es_reset, yo)
if es_reset:
_registrar_acceso(yo, "contraseña restablecida", destino)
return jsonify(ok=True, usuario=destino, reset=es_reset) return jsonify(ok=True, usuario=destino, reset=es_reset)
@ -935,9 +943,10 @@ def _ruta_registro(carpeta, rid):
# Un miembro del equipo es quien puede ser responsable de un pendiente. Tener login al sitio es # 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. # 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", COLS_EQUIPO = ["id", "nombre", "rol", "area", "sitio", "correo", "correo_alt", "telefono", "activo", "contrasena_inicial",
"modificado_por", "modificado_en"] "creado_por", "creado_en", "modificado_por", "modificado_en"]
USUARIO_RE = re.compile(r"^[a-z0-9][a-z0-9_\-.]{1,29}$") USUARIO_RE = re.compile(r"^[a-z0-9][a-z0-9_\-.]{1,29}$")
CORREO_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _norm_id(s): def _norm_id(s):
@ -961,9 +970,18 @@ def _miembro_valido(d, base=None):
if a not in AREAS: if a not in AREAS:
return None, f"Área inválida: {a}. Usa una de {', '.join(AREAS)}." return None, f"Área inválida: {a}. Usa una de {', '.join(AREAS)}."
t["area"] = a t["area"] = a
for k, n in (("correo", 120), ("telefono", 40), ("notas", 1000)): if alta or "sitio" in d: # dónde trabaja (23-oct); vacío = sin sitio fijo
s = _sitio_inv(d.get("sitio"), omision="")
if s is None:
return None, "Sitio inválido: usa icave, timsa, eit, lzc o qro (o déjalo vacío)."
t["sitio"] = s
for k, n in (("correo", 120), ("correo_alt", 120), ("telefono", 40), ("notas", 1000)):
if alta or k in d: if alta or k in d:
t[k] = _texto(d.get(k), n) t[k] = _texto(d.get(k), n)
if k.startswith("correo"):
t[k] = t[k].lower()
if t[k] and not CORREO_RE.match(t[k]):
return None, f"Correo inválido: {t[k]}"
if alta or "activo" in d: if alta or "activo" in d:
t["activo"] = _bool(d.get("activo"), True) t["activo"] = _bool(d.get("activo"), True)
return t, None return t, None
@ -974,6 +992,19 @@ def _guardar_miembro(t):
_reescribir_indice(EQUIPO_DIR, _listar_json(EQUIPO_DIR), COLS_EQUIPO) _reescribir_indice(EQUIPO_DIR, _listar_json(EQUIPO_DIR), COLS_EQUIPO)
def _marcar_contrasena(usuario, inicial, por, cambio=None):
"""Anota en el miembro si su contraseña es la inicial (la puso alguien más: alta, restablecimiento, cuentas.py) o ya
es la suya (la cambió en Mi cuenta). Nunca guarda la contraseña ni su hash. Sin miembro, no se anota."""
m = _leer_json(_ruta_registro(EQUIPO_DIR, _norm_id(usuario)))
if not m:
return None
m["contrasena_inicial"] = bool(inicial)
m["contrasena_cambiada_en"] = ahora()
_hist(m, por, cambio or ("contraseña restablecida: queda la inicial hasta que la cambie" if inicial else "cambió su contraseña"))
_guardar_miembro(m)
return m
def _miembros(): def _miembros():
"""{id: miembro} de todos (activos e inactivos).""" """{id: miembro} de todos (activos e inactivos)."""
return {m["id"]: m for m in _listar_json(EQUIPO_DIR) if m.get("id")} return {m["id"]: m for m in _listar_json(EQUIPO_DIR) if m.get("id")}
@ -1020,10 +1051,9 @@ def _resumen_pendientes_por_miembro():
"""{id: {estado: n, 'abiertos': n, 'vencidas': n}} contando responsable_ids de cada pendiente.""" """{id: {estado: n, 'abiertos': n, 'vencidas': n}} contando responsable_ids de cada pendiente."""
hoy = datetime.date.today().isoformat() hoy = datetime.date.today().isoformat()
out = {} out = {}
miembros = _miembros()
for t in _listar_json(TAREAS_DIR): for t in _listar_json(TAREAS_DIR):
ids = t.get("responsable_ids") ids, _ = _ids_responsable(t.get("responsable"), miembros) # con el equipo de hoy (ver GET /api/tareas)
if ids is None:
ids, _ = _ids_responsable(t.get("responsable"))
for rid in ids: for rid in ids:
r = out.setdefault(rid, _cuenta_vacia()) r = out.setdefault(rid, _cuenta_vacia())
e = t.get("estado") e = t.get("estado")
@ -1048,6 +1078,8 @@ def equipo_listar():
m["tiene_login"] = m["id"].lower() in logins m["tiene_login"] = m["id"].lower() in logins
m["es_admin"] = m["id"].lower() in ADMINS m["es_admin"] = m["id"].lower() in ADMINS
m["pendientes"] = pend.get(m["id"], _cuenta_vacia()) m["pendientes"] = pend.get(m["id"], _cuenta_vacia())
if not is_admin(): # quién sigue con la contraseña inicial solo lo ve un administrador
m.pop("contrasena_inicial", None); m.pop("contrasena_cambiada_en", None)
out.append(m) 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", ""))) 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 # logins que no son miembros (cuentas viejas): se listan aparte para que el admin los vea
@ -1131,7 +1163,8 @@ def usuarios_crear():
if len(password) < PASS_MIN: if len(password) < PASS_MIN:
return jsonify(ok=False, error=f"La contraseña inicial debe tener al menos {PASS_MIN} caracteres."), 400 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"), 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} "sitio": d.get("sitio", ""), "correo": d.get("correo", ""), "correo_alt": d.get("correo_alt", ""),
"telefono": d.get("telefono", ""), "activo": True}
existente = _leer_json(_ruta_registro(EQUIPO_DIR, usuario)) 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) 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: if err:
@ -1155,6 +1188,8 @@ def usuarios_crear():
if existente is None: if existente is None:
t.update({"creado_por": yo, "creado_en": ahora(), "historial": []}) t.update({"creado_por": yo, "creado_en": ahora(), "historial": []})
t["activo"] = True t["activo"] = True
t["contrasena_inicial"] = True # la contraseña inicial la puso el admin; se quita cuando la persona la cambie
t["contrasena_cambiada_en"] = ahora()
_hist(t, yo, "acceso al sitio creado") _hist(t, yo, "acceso al sitio creado")
_guardar_miembro(t) _guardar_miembro(t)
_registrar_acceso(yo, "alta", usuario) _registrar_acceso(yo, "alta", usuario)
@ -1341,7 +1376,9 @@ def tareas_listar():
miembros = _miembros() 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 # Los ids se sacan del texto con el equipo de HOY (23-oct): si alguien que estaba como texto libre («Miguel»)
# se da de alta después, sus pendientes ya cuentan como suyos sin volver a guardarlos. Nunca quita a nadie:
# los miembros no se borran, solo se desactivan.
t["responsable_ids"] = _ids_responsable(t.get("responsable"), miembros)[0] t["responsable_ids"] = _ids_responsable(t.get("responsable"), miembros)[0]
if not t.get("sitios"): # pendientes de antes del 22-oct: Querétaro if not t.get("sitios"): # pendientes de antes del 22-oct: Querétaro
t["sitios"] = list(SITIO_OMISION) t["sitios"] = list(SITIO_OMISION)
@ -1537,9 +1574,7 @@ def tareas_resumen():
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") ids = t["responsable_ids"] = _ids_responsable(t.get("responsable"), miembros)[0] # con el equipo de hoy
if ids is None:
ids = _ids_responsable(t.get("responsable"), miembros)[0]
for rid in (ids or ["_sin_equipo"]): for rid in (ids or ["_sin_equipo"]):
r = por_resp.setdefault(rid, _cuenta_vacia()) r = por_resp.setdefault(rid, _cuenta_vacia())
if e in ESTADOS: if e in ESTADOS:

315
backend/cuentas.py Normal file
View File

@ -0,0 +1,315 @@
#!/usr/bin/env python3
"""Cuentas del sitio en bloque (23-oct-2026): restablecer contraseñas y dar de alta a varios de una vez.
Se corre EN EL SERVIDOR como leo (el dueño de /etc/nginx/hbox-docs.htpasswd), junto al app.py desplegado:
cd /srv/projects/drogheda/hbox-docs/backend
venv/bin/python3 cuentas.py lista # quién tiene acceso (sin contraseñas)
venv/bin/python3 cuentas.py todo cuentas/2026-10-23_alta-sitios.json # altas + datos + restablecer a todos
venv/bin/python3 cuentas.py alta cuentas/ARCHIVO.json # solo las altas y los datos del archivo
venv/bin/python3 cuentas.py restablecer [--excepto leo,claude] [--solo ana,beto]
La contraseña se pide en la terminal (dos veces, sin eco): nunca va en un archivo ni como argumento, porque se vería
en `ps`. Para pruebas existe --password-stdin (la lee de la entrada estándar). Antes de escribir muestra el plan y pide
«si» (o --si). Usa las mismas funciones y rutas que el servicio (lee las Environment= de actas-hbox.service): hash
$apr1$, el candado del archivo y los miembros del equipo. Antes de escribir deja un respaldo del archivo de usuarios en
equipo/_respaldos/ (modo 600, contiene los hashes: no lo copies a otro lado).
«todo» y «restablecer» tocan a todos los que tienen acceso, salvo --excepto (por omisión leo y claude) y salvo las
cuentas sin miembro activo, que se listan para que decidas si se quitan (Equipo → «Quitar acceso»). A quien se le
restablece o se le da de alta le queda la marca «contraseña inicial»: el sitio le muestra un aviso para cambiarla en
Mi cuenta y Equipo (admin) muestra quién falta.
Archivo de altas (JSON):
{"alta": [{"usuario": "jesush", "nombre": "…", "area": "campo", "sitio": "timsa", "rol": "…", "correo": "…"}],
"actualizar": [{"usuario": "jesus", "nombre": "Jesús Romero", "correo": "…"}]}
En «actualizar» (y en las altas de quien ya era miembro) correo, correo_alt, sitio, rol y teléfono solo se llenan si
están vacíos; el nombre solo si el nuevo completa al actual («Jesús» → «Jesús Romero»). Nada se borra.
"""
import argparse
import getpass
import json
import os
import shutil
import sys
import unicodedata
AQUI = os.path.dirname(os.path.abspath(__file__))
EXCEPTO = "leo,claude"
CAMPOS_SI_VACIO = ("correo", "correo_alt", "sitio", "rol", "telefono")
def _entorno_del_servicio():
"""Las mismas rutas que el servicio: las Environment= de actas-hbox.service, sin pisar lo que ya venga fijado."""
ruta = os.path.join(AQUI, "actas-hbox.service")
if not os.path.isfile(ruta):
return
with open(ruta, encoding="utf-8") as f:
for linea in f:
linea = linea.strip()
if linea.startswith("Environment="):
k, _, v = linea[len("Environment="):].partition("=")
if k.strip():
os.environ.setdefault(k.strip(), v.strip())
_entorno_del_servicio()
sys.path.insert(0, AQUI)
import app as A # noqa: E402 (después de fijar el entorno: app.py lee las rutas al importarse)
def _plano(s):
return unicodedata.normalize("NFKD", str(s or "")).encode("ascii", "ignore").decode().lower().strip()
def _nombre_sitio(s):
return {i: c for i, _, c in A.SITIOS_TAREA}.get(s, s or "—")
def _logins():
lineas = A._leer_htpasswd()
if lineas is None:
sys.exit(f"No encuentro el archivo de usuarios: {A.HTPASSWD}")
return [l.split(":", 1)[0].strip() for l in lineas if ":" in l and not l.lstrip().startswith("#")]
def _leer_archivo(ruta):
if not ruta:
return {"alta": [], "actualizar": []}
with open(ruta, encoding="utf-8") as f:
d = json.load(f)
alta, act = d.get("alta") or [], d.get("actualizar") or []
vistos = set()
for p in alta + act:
u = str(p.get("usuario") or "").strip().lower()
if not A.USUARIO_RE.match(u):
sys.exit(f"Usuario inválido en {ruta}: {u!r} (minúsculas, sin espacios ni acentos)")
if u in vistos:
sys.exit(f"{u} aparece dos veces en {ruta}")
vistos.add(u)
p["usuario"] = u
datos = {k: v for k, v in p.items() if k != "usuario"}
_, err = A._miembro_valido(datos, {"nombre": u, "area": "produccion"} if p in act else None)
if err:
sys.exit(f"{u}: {err}")
return {"alta": alta, "actualizar": act}
def _cambios_de_datos(base, nuevo):
"""Qué se llena de «nuevo» en el miembro «base» sin pisar nada que ya tenga."""
c = {}
for k in CAMPOS_SI_VACIO:
v = str(nuevo.get(k) or "").strip()
if v and not str(base.get(k) or "").strip():
c[k] = v
n = str(nuevo.get("nombre") or "").strip()
if n and _plano(n) != _plano(base.get("nombre")) and _plano(n).startswith(_plano(base.get("nombre"))):
c["nombre"] = n
return c
def hacer_plan(accion, archivo, excepto, solo):
logins = _logins()
presentes = {u.lower() for u in logins}
miembros = A._miembros()
d = _leer_archivo(archivo)
excepto = {x.strip().lower() for x in (excepto or "").split(",") if x.strip()}
solo = {x.strip().lower() for x in (solo or "").split(",") if x.strip()}
plan = {"crear": [], "restablecer": [], "omitidos": [], "sin_miembro": [], "datos": [], "logins": len(logins)}
if accion in ("todo", "alta"):
for p in d["alta"]:
if p["usuario"] in presentes: # ya tenía acceso: solo se completan sus datos
base = miembros.get(p["usuario"])
c = _cambios_de_datos(base, p) if base else {k: v for k, v in p.items() if k != "usuario"}
if c:
plan["datos"].append((p["usuario"], c, base is None))
else:
plan["crear"].append(p)
for p in d["actualizar"]:
base = miembros.get(p["usuario"])
if not base:
plan["omitidos"].append((p["usuario"], "no es miembro del equipo: no se actualiza"))
continue
c = _cambios_de_datos(base, p)
if c:
plan["datos"].append((p["usuario"], c, False))
if accion in ("todo", "restablecer"):
nuevos = {p["usuario"] for p in plan["crear"]}
for u in sorted(presentes):
if u in nuevos:
continue
if solo and u not in solo:
continue
if u in excepto:
plan["omitidos"].append((u, "excepto"))
continue
m = miembros.get(u)
if not m or m.get("activo", True) is False:
plan["sin_miembro"].append(u)
continue
plan["restablecer"].append(u)
for u in sorted(solo - presentes):
plan["omitidos"].append((u, "no tiene acceso al sitio"))
return plan
def mostrar_plan(plan):
print(f"Archivo de usuarios: {A.HTPASSWD} ({plan['logins']} cuentas)")
print(f"Miembros del equipo: {A.EQUIPO_DIR}")
if plan["crear"]:
print(f"\nAltas nuevas ({len(plan['crear'])}):")
for p in plan["crear"]:
print(f" + {p['usuario']:<10} {p.get('nombre', '')} · {p.get('area', 'produccion')} · {_nombre_sitio(p.get('sitio'))}"
+ (f" · {p['correo']}" if p.get("correo") else " · (sin correo)"))
if plan["restablecer"]:
print(f"\nContraseña restablecida ({len(plan['restablecer'])}): " + ", ".join(plan["restablecer"]))
if plan["datos"]:
print(f"\nDatos de miembros ({len(plan['datos'])}; solo se llena lo vacío):")
for u, c, nuevo in plan["datos"]:
print(f" ~ {u:<10} " + ("(miembro nuevo) " if nuevo else "") + "; ".join(f"{k} → {v}" for k, v in c.items()))
for u, por in plan["omitidos"]:
print(f" · no se toca {u}: {por}")
if plan["sin_miembro"]:
print("\nCuentas con acceso pero sin miembro activo (no se tocan; si ya no se usan, quítalas en Equipo → "
"«Quitar acceso» o con htpasswd -D): " + ", ".join(plan["sin_miembro"]))
if not (plan["crear"] or plan["restablecer"] or plan["datos"]):
print("\nNada que hacer.")
def pedir_password(desde_stdin=False):
if desde_stdin:
pw = sys.stdin.readline().rstrip("\n")
confirma = pw
else:
pw = getpass.getpass("Contraseña para todos (no se ve al escribir): ")
confirma = getpass.getpass("Repítela: ")
if pw != confirma:
sys.exit("Las dos contraseñas no coinciden. No se cambió nada.")
if len(pw) < A.PASS_MIN:
sys.exit(f"La contraseña debe tener al menos {A.PASS_MIN} caracteres. No se cambió nada.")
if pw != pw.strip():
sys.exit("La contraseña empieza o termina con un espacio; escríbela sin espacios a los lados. No se cambió nada.")
return pw
def _respaldo():
carpeta = os.path.join(A.EQUIPO_DIR, "_respaldos")
os.makedirs(carpeta, mode=0o700, exist_ok=True)
destino = os.path.join(carpeta, "htpasswd-" + A.ahora().replace(":", "").replace("-", "").replace("T", "-"))
shutil.copyfile(A.HTPASSWD, destino)
os.chmod(destino, 0o600)
return destino
def aplicar(plan, password, yo):
sal = lambda: "".join(A.ITOA64[b % 64] for b in os.urandom(8))
resp = _respaldo()
nuevos = {p["usuario"]: p for p in plan["crear"]}
reset = set(plan["restablecer"])
with A._candado_htpasswd():
lineas = A._leer_htpasswd()
presentes = {l.split(":", 1)[0].strip().lower() for l in lineas if ":" in l}
salida = []
for l in lineas:
if ":" in l and l.split(":", 1)[0].strip().lower() in reset:
u = l.split(":", 1)[0].strip()
l = u + ":" + A.apr1(password, sal())
salida.append(l)
salida = [l for l in salida if l.strip()]
for u in nuevos:
if u not in presentes: # alguien la creó mientras tanto: no se duplica
salida.append(u + ":" + A.apr1(password, sal()))
A._escribir_htpasswd(salida)
hecho = {"creados": [], "restablecidos": [], "datos": []}
for u, p in nuevos.items():
base = A._leer_json(A._ruta_registro(A.EQUIPO_DIR, u))
datos = {k: v for k, v in p.items() if k != "usuario"}
if base:
t, err = A._miembro_valido(_cambios_de_datos(base, p), base)
else:
t, err = A._miembro_valido(datos)
if not err:
t.update({"id": u, "creado_por": yo, "creado_en": A.ahora(), "historial": []})
if err:
print(f" ! {u}: {err} (el acceso sí quedó creado)")
continue
t["id"] = u
t["activo"] = True
t["contrasena_inicial"] = True
t["contrasena_cambiada_en"] = A.ahora()
A._hist(t, yo, "acceso al sitio creado (cuentas.py); queda la contraseña inicial hasta que la cambie")
A._guardar_miembro(t)
A._registrar_acceso(yo, "alta", u)
hecho["creados"].append(u)
for u in sorted(reset):
A._marcar_contrasena(u, True, yo, "contraseña restablecida en bloque (cuentas.py): queda la inicial hasta que la cambie")
A._registrar_acceso(yo, "contraseña restablecida", u)
hecho["restablecidos"].append(u)
for u, c, nuevo in plan["datos"]:
base = A._leer_json(A._ruta_registro(A.EQUIPO_DIR, u))
if nuevo or not base: # tenía acceso pero no era miembro
t, err = A._miembro_valido(dict(c, nombre=c.get("nombre") or u))
if not err:
t.update({"id": u, "activo": True, "creado_por": yo, "creado_en": A.ahora(), "historial": []})
else:
t, err = A._miembro_valido(c, base)
if err:
print(f" ! {u}: {err}")
continue
A._hist(t, yo, "datos (cuentas.py): " + "; ".join(f"{k}: {A._corto((base or {}).get(k))} → {A._corto(v)}" for k, v in c.items()))
A._guardar_miembro(t)
hecho["datos"].append(u)
return hecho, resp
def lista():
miembros = A._miembros()
logins = _logins()
print(f"{A.HTPASSWD}: {len(logins)} cuentas\n")
print(f"{'usuario':<11} {'nombre':<24} {'sitio':<6} {'correo':<28} contraseña")
for u in sorted(logins, key=str.lower):
m = miembros.get(u.lower()) or {}
estado = ("inicial" if m.get("contrasena_inicial") else "propia" if "contrasena_inicial" in m else "—")
if not m:
estado = "sin miembro"
elif m.get("activo", True) is False:
estado += " · inactivo"
print(f"{u:<11} {str(m.get('nombre') or '')[:24]:<24} {_nombre_sitio(m.get('sitio')) if m.get('sitio') else '':<6} "
f"{str(m.get('correo') or '')[:28]:<28} {estado}")
def main(argv=None):
ap = argparse.ArgumentParser(description="Cuentas del sitio en bloque (ver el encabezado de cuentas.py).")
ap.add_argument("accion", choices=("lista", "todo", "alta", "restablecer"))
ap.add_argument("archivo", nargs="?", help="JSON con «alta» y «actualizar» (todo, alta)")
ap.add_argument("--excepto", default=EXCEPTO, help=f"no restablecer a estos (por omisión {EXCEPTO})")
ap.add_argument("--solo", default="", help="restablecer solo a estos, separados por coma")
ap.add_argument("--si", action="store_true", help="no preguntar antes de escribir")
ap.add_argument("--password-stdin", action="store_true", help="leer la contraseña de la entrada estándar (pruebas)")
a = ap.parse_args(argv)
if a.accion == "lista":
return lista()
if a.accion in ("todo", "alta") and not a.archivo:
ap.error(f"«{a.accion}» necesita el archivo de altas")
plan = hacer_plan(a.accion, a.archivo, a.excepto, a.solo)
mostrar_plan(plan)
if not (plan["crear"] or plan["restablecer"] or plan["datos"]):
return 0
if not a.si:
if input("\n¿Continuar? Escribe si: ").strip().lower() not in ("si", "sí"):
print("No se cambió nada.")
return 1
password = pedir_password(a.password_stdin) if (plan["crear"] or plan["restablecer"]) else None
yo = os.environ.get("SUDO_USER") or getpass.getuser()
hecho, resp = aplicar(plan, password, yo)
print(f"\nListo. Respaldo del archivo anterior: {resp}")
for k, v in hecho.items():
if v:
print(f" {k}: {', '.join(v)}")
if hecho["creados"] or hecho["restablecidos"]:
print("\nCada quien entra con su usuario y esa contraseña, y la cambia en Mi cuenta (el sitio se lo recuerda).")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,23 @@
{
"_nota": "Altas del 23-oct-2026 (Leo): responsables de instalación por sitio y técnicos. Se corre con «cuentas.py todo»: también restablece a todos (menos leo y claude) a la misma contraseña, que el script pide en la terminal; aquí no va ninguna contraseña. Los responsables principales de sitio no son jefes de los demás: son quien responde por el sitio. Correos: los que ya conocíamos; los vacíos los llena Leo en Equipo → Editar.",
"alta": [
{ "usuario": "jesush", "nombre": "Jesús Honorato", "area": "campo", "sitio": "timsa",
"rol": "Responsable principal de instalación en TIMSA (Manzanillo); técnico de campo", "correo": "jesus@helgen.tech" },
{ "usuario": "miguel", "nombre": "Miguel Ángel Peña", "area": "campo", "sitio": "eit",
"rol": "Responsable principal de instalación en EIT (Ensenada)", "correo": "" },
{ "usuario": "joel", "nombre": "Joel Orantes", "area": "campo", "sitio": "eit",
"rol": "Técnico de instalación en EIT (Ensenada)", "correo": "" },
{ "usuario": "antonio", "nombre": "Antonio Ventura", "area": "campo", "sitio": "lzc",
"rol": "Responsable principal de instalación en LCT (Lázaro Cárdenas)", "correo": "" },
{ "usuario": "danielg", "nombre": "Daniel González Cruz", "area": "produccion", "sitio": "qro",
"rol": "Técnico de ensamble (Querétaro)", "correo": "" }
],
"actualizar": [
{ "usuario": "jesus", "nombre": "Jesús Romero", "correo": "jesusr@helgen.tech" },
{ "usuario": "alan", "sitio": "icave", "correo": "alan@helgen.tech" },
{ "usuario": "daniel", "correo": "daniel@helgen.tech" },
{ "usuario": "adriana", "correo": "adriana@helgen.tech" },
{ "usuario": "gibranlp", "correo": "gibran@helgen.tech" },
{ "usuario": "leo", "correo": "leo@helgen.tech", "correo_alt": "ceo@helgen.tech" }
]
}

View File

@ -0,0 +1,68 @@
{
"lote": "2026-10-23_usuarios-campo",
"autor": "claude",
"motivo": "Cuentas del 23-oct (Leo): altas de los responsables de sitio y técnicos, contraseña inicial para todos menos Leo, correo y sitio de cada quien; siguientes pasos del cotizador",
"fuente": "/tableros/equipo/",
"cambios": [
{ "id": "t-alta-responsables-campo",
"poner": { "estado": "hecho" },
"nota": "23-oct: cuentas creadas (cuentas.py): jesush = Jesús Honorato (TIMSA), miguel = Miguel Ángel Peña (EIT), joel = Joel Orantes (EIT), antonio = Antonio Ventura (LCT), danielg = Daniel González Cruz (ensamble, Querétaro). El usuario jesus ahora dice Jesús Romero." },
{ "id": "t-eit-material", "poner": { "responsable": "adriana, miguel" },
"nota": "23-oct: Miguel Ángel Peña ya tiene cuenta (miguel): el pendiente queda a su nombre." },
{ "id": "t-eit-instalar", "poner": { "responsable": "alan, daniel, miguel, joel" },
"nota": "23-oct: Miguel (miguel) es el responsable principal de EIT y Joel Orantes (joel) el técnico de instalación del sitio; Alán y Daniel siguen como líderes de instalación." },
{ "id": "t-lct-instalar", "poner": { "responsable": "alan, daniel, antonio" },
"nota": "23-oct: Antonio Ventura ya tiene cuenta (antonio): responsable principal de LCT." },
{ "id": "t-lct-herreria", "poner": { "responsable": "leo, antonio" },
"nota": "23-oct: Antonio Ventura ya tiene cuenta (antonio)." },
{ "id": "t-timsa-instalar", "poner": { "responsable": "alan, daniel, jesush" },
"nota": "23-oct: Jesús Honorato ya tiene cuenta (jesush): responsable principal de TIMSA." },
{ "id": "t-conteo-sitios", "poner": { "responsable": "alan, jesush, miguel, antonio" },
"nota": "23-oct: los responsables de TIMSA, EIT y LCT ya tienen cuenta (jesush, miguel, antonio): registran el conteo de su sitio ellos mismos." },
{ "crear": { "id": "t-cambio-contrasenas",
"titulo": "Que cada quien cambie su contraseña inicial en Mi cuenta",
"pista": "gestion", "estado": "por-hacer", "responsable": "adriana", "version_tarjeta": "—",
"sitios": ["qro"], "fecha_limite": "2026-10-27",
"descripcion": "Con el restablecimiento del 23-oct todos (menos Leo) entran con la misma contraseña inicial, y con ella cualquiera podría entrar como otro. El sitio le muestra a cada quien un aviso hasta que la cambia; en Equipo, el administrador ve quién sigue con la marca «contraseña inicial». Recordarlo en el standup hasta que no quede nadie.",
"enlaces": [{ "titulo": "Equipo", "url": "/tableros/equipo/" }, { "titulo": "Mi cuenta", "url": "/cuenta.html" }],
"etiquetas": ["quick-win"] },
"si_no_hay": ["contrasena inicial"] },
{ "crear": { "id": "t-correos-equipo",
"titulo": "Completar el correo de cada quien en Equipo (los dos dominios)",
"pista": "gestion", "estado": "por-hacer", "responsable": "leo", "version_tarjeta": "—",
"sitios": ["qro"],
"descripcion": "Equipo → Editar: «Correo» (para los avisos por correo) y «Otro correo» si la persona tiene cuenta en el otro dominio. Ya quedaron los que conocíamos: leo, adriana, alan, daniel, gibranlp, jesus (jesusr@) y jesush (jesus@); revisar esos dos. Faltan miguel, joel, antonio, danielg, michelle, aldair, abraham, eduardo, jacobo, jose y juan.",
"enlaces": [{ "titulo": "Equipo", "url": "/tableros/equipo/" }],
"etiquetas": ["quick-win"] },
"si_no_hay": ["correo de cada quien"] },
{ "crear": { "id": "t-cotizador-espejo",
"titulo": "Espejo de cotizador-helgen (GitHub) en git.helgen.online y copia en Projects para que Claude lo lea",
"pista": "gestion", "estado": "por-hacer", "responsable": "leo", "version_tarjeta": "—",
"sitios": ["qro"],
"descripcion": "Gitea → + → New Migration → GitHub: https://github.com/efrainsosa27/cotizador-helgen, token clásico de GitHub con permiso «repo», marcar «This repository will be a mirror», dueño Helgen-Services. Después clonarlo en ~/Documents/Claude/Projects/cotizador-helgen: el repositorio es privado y Claude no lo ve desde GitHub.",
"etiquetas": ["cotizador"] },
"si_no_hay": ["espejo de cotizador"] },
{ "crear": { "id": "t-cotizador-integracion",
"titulo": "Propuesta: ligar cotizaciones y compras al inventario (¿una base de datos SQL para todo?)",
"pista": "compras", "estado": "por-hacer", "responsable": "leo, claude", "version_tarjeta": "—",
"sitios": ["qro"], "bloqueada_por": ["t-cotizador-espejo"],
"descripcion": "Idea de Leo (23-oct): que la cotización, la orden de compra y la llegada del material sean un solo hilo que termine en el inventario por sitio. Claude revisa el cotizador (lenguaje, datos, dónde guarda) y propone cómo unirlos y si conviene pasar inventario y compras a una base de datos SQL. Se decide con Leo antes de programar.",
"enlaces": [{ "titulo": "Inventario", "url": "/tableros/inventario/" }],
"etiquetas": ["cotizador", "decisión"] },
"si_no_hay": ["ligar cotizaciones"] },
{ "crear": { "id": "t-login-correo",
"titulo": "Entrar al sitio con la cuenta de correo (Google) y avisos por correo: opciones",
"pista": "gestion", "estado": "por-hacer", "responsable": "leo, claude", "version_tarjeta": "—",
"sitios": ["qro"],
"descripcion": "Hoy se entra con usuario y contraseña de nginx. Idea de Leo (23-oct): entrar con la cuenta de correo o de Google y mandar avisos por correo. Hay dos dominios de correo: definir cuál cuenta para entrar y desde cuál se mandan los avisos. Usa el «Correo» y «Otro correo» de cada quien en Equipo (t-correos-equipo).",
"enlaces": [{ "titulo": "Equipo", "url": "/tableros/equipo/" }],
"etiquetas": ["decisión"] },
"si_no_hay": ["entrar al sitio con la cuenta de correo"] }
]
}

97
backend/tests-cuentas.sh Normal file
View File

@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Pruebas de cuentas.py y de la marca «contraseña inicial» (23-oct). Todo en carpetas temporales: no toca nada real.
# Uso: bash tests-cuentas.sh <ruta-a-hbox-docs> [puerto]
set -u
REPO=${1:?ruta a hbox-docs}; PORT=${2:-8094}
B=$(cd "$REPO/backend" && pwd)
T=$(mktemp -d)
export ACTAS_DIR=$T/actas HTPASSWD_FILE=$T/htpasswd HTPASSWD_LOCK=$T/.lock ACTAS_ADMINS=leo PORT
mkdir -p "$ACTAS_DIR" "$T/equipo"
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:240}"; fi; }
py() { python3 -c "import sys; sys.path.insert(0, '$B'); import app as A, json, os; $1"; }
# punto de partida: 7 cuentas con contraseñas conocidas; luz sin miembro; adriana ya tiene correo
py "
open('$HTPASSWD_FILE', 'w').write(''.join(u + ':' + A.apr1('vieja-' + u, 'salt' + u[:4]) + '\n' for u in ('leo','adriana','alan','jesus','daniel','claude','luz')))
for u, n, a, c in (('leo','Leopoldo Rodríguez','direccion',''), ('adriana','Adriana','operaciones','adriana@otro.mx'),
('alan','Alán Basurto','campo',''), ('jesus','Jesús','pruebas',''), ('daniel','Daniel Martínez','campo',''),
('claude','Claude','asistente','')):
json.dump({'id': u, 'nombre': n, 'area': a, 'rol': '', 'correo': c, 'telefono': '', 'activo': True}, open('$T/equipo/' + u + '.json', 'w'), ensure_ascii=False)
"
ANTES=$(sha256sum "$HTPASSWD_FILE" | cut -c1-16)
LEO_ANTES=$(grep '^leo:' "$HTPASSWD_FILE")
ALTA=$B/cuentas/2026-10-23_alta-sitios.json
verifica() { py "
l = dict(x.split(':', 1) for x in open('$HTPASSWD_FILE').read().split())
r = []
for u in '$1'.split():
h = l.get(u, ''); p = h.split('\$')
r.append(u + ('=ok' if h and A.apr1('$2', p[2]) == h else '=NO'))
print(' '.join(r))"; }
echo "## plan y «no»"
OUT=$(echo no | python3 "$B/cuentas.py" todo "$ALTA" 2>&1)
check "muestra las 5 altas" "Altas nuevas (5)" "$OUT"
check "restablece a 4 (leo y claude excepto; luz sin miembro)" "Contraseña restablecida (4): adriana, alan, daniel, jesus" "$OUT"
check "avisa de luz" "sin miembro activo" "$OUT"
check "jesus pasa a Jesús Romero" "nombre → Jesús Romero" "$OUT"
check "con «no» no escribe" "No se cambió nada" "$OUT"
check "el archivo sigue igual" "$ANTES" "$(sha256sum "$HTPASSWD_FILE" | cut -c1-16)"
echo "## todo, de verdad"
OUT=$(printf 'Helgen2026!\n' | python3 "$B/cuentas.py" todo "$ALTA" --si --password-stdin 2>&1)
check "termina" "Listo." "$OUT"
check "creados" "creados: jesush, miguel, joel, antonio, danielg" "$OUT"
check "restablecidos y nuevos entran con la nueva" "adriana=ok alan=ok jesus=ok daniel=ok jesush=ok miguel=ok joel=ok antonio=ok danielg=ok" "$(verifica 'adriana alan jesus daniel jesush miguel joel antonio danielg' 'Helgen2026!')"
check "leo no cambia" "$LEO_ANTES" "$(grep '^leo:' "$HTPASSWD_FILE")"
check "claude y luz siguen con la vieja" "claude=ok luz=ok" "$(verifica 'claude' 'vieja-claude') $(verifica 'luz' 'vieja-luz')"
check "compatible con htpasswd/nginx (openssl)" "ok" "$(H=$(grep '^miguel:' "$HTPASSWD_FILE" | cut -d: -f2); S=$(echo "$H" | cut -d'$' -f3); [ "$(openssl passwd -apr1 -salt "$S" 'Helgen2026!')" = "$H" ] && echo ok || echo NO)"
check "sin líneas repetidas" "12" "$(cut -d: -f1 "$HTPASSWD_FILE" | sort -u | wc -l | tr -d ' ')"
check "respaldo con modo 600" "600" "$(stat -c %a "$T"/equipo/_respaldos/htpasswd-* | head -1)"
M=$(py "print(json.dumps({u: json.load(open('$T/equipo/' + u + '.json')) for u in ('jesush','miguel','jesus','adriana','leo','alan')}, ensure_ascii=False))")
check "jesush: TIMSA, campo, correo" '"sitio": "timsa"' "$(echo "$M" | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)["jesush"], ensure_ascii=False))')"
check "nuevos con contraseña inicial" "True True" "$(echo "$M" | python3 -c 'import sys,json; m=json.load(sys.stdin); print(m["jesush"]["contrasena_inicial"], m["miguel"]["contrasena_inicial"])')"
check "jesus: nombre y correo" "Jesús Romero jesusr@helgen.tech True" "$(echo "$M" | python3 -c 'import sys,json; m=json.load(sys.stdin)["jesus"]; print(m["nombre"], m["correo"], m["contrasena_inicial"])')"
check "adriana conserva su correo" "adriana@otro.mx" "$(echo "$M" | python3 -c 'import sys,json; print(json.load(sys.stdin)["adriana"]["correo"])')"
check "leo: correos, sin marca" "leo@helgen.tech ceo@helgen.tech None" "$(echo "$M" | python3 -c 'import sys,json; m=json.load(sys.stdin)["leo"]; print(m["correo"], m["correo_alt"], m.get("contrasena_inicial"))')"
check "alan: sitio ICAVE" "icave" "$(echo "$M" | python3 -c 'import sys,json; print(json.load(sys.stdin)["alan"]["sitio"])')"
check "bitácora de accesos" "contraseña restablecida,adriana" "$(cat "$T/equipo/_accesos.csv")"
check "lista sin contraseñas" "inicial" "$(python3 "$B/cuentas.py" lista 2>&1)"
check "lista no muestra hashes" "0" "$(python3 "$B/cuentas.py" lista 2>&1 | grep -c 'apr1')"
echo "## otra vez (no duplica) y errores"
OUT=$(printf 'Helgen2026!\n' | python3 "$B/cuentas.py" todo "$ALTA" --si --password-stdin 2>&1)
check "segunda corrida: ya no hay altas" "Contraseña restablecida (9)" "$OUT"
check "sigue sin repetidos (12 cuentas, 0 dobles)" "12/0" "$(cut -d: -f1 "$HTPASSWD_FILE" | wc -l | tr -d ' ')/$(cut -d: -f1 "$HTPASSWD_FILE" | sort | uniq -d | wc -l | tr -d ' ')"
check "contraseña corta → no cambia nada" "al menos 8" "$(printf 'corta\n' | python3 "$B/cuentas.py" restablecer --solo alan --si --password-stdin 2>&1)"
check "--solo" "Contraseña restablecida (1): alan" "$(echo no | python3 "$B/cuentas.py" restablecer --solo alan 2>&1)"
echo "## el servicio: whoami, Mi cuenta, Equipo"
python3 "$B/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"
como() { local u=$1; shift; curl -s -H "X-Remote-User: $u" -H "Content-Type: application/json" "$@"; }
check "whoami de adriana: inicial" '"contrasena_inicial":true' "$(como adriana $U/api/whoami)"
check "whoami de leo: no" '"contrasena_inicial":false' "$(como leo $U/api/whoami)"
check "adriana la cambia" '"reset":false' "$(como adriana -X POST $U/api/password -d '{"actual":"Helgen2026!","nueva":"una-frase-larga","confirma":"una-frase-larga"}')"
check "ya no es la inicial" '"contrasena_inicial":false' "$(como adriana $U/api/whoami)"
check "historial dice que la cambió" "cambi" "$(py "print(json.load(open('$T/equipo/adriana.json'))['historial'][-1]['cambio'])")"
check "leo se la restablece" '"reset":true' "$(como leo -X POST $U/api/password -d '{"usuario":"adriana","nueva":"Helgen2026!","confirma":"Helgen2026!"}')"
check "vuelve a ser la inicial" '"contrasena_inicial":true' "$(como adriana $U/api/whoami)"
check "Equipo (no admin) no muestra la marca" "0" "$(como joel $U/api/equipo | grep -c contrasena_inicial)"
check "Equipo (admin) sí" "contrasena_inicial" "$(como leo $U/api/equipo)"
check "sitio por alias (Ensenada → eit)" '"sitio":"eit"' "$(como leo -X PATCH "$U/api/equipo/miembro?id=joel" -d '{"sitio":"Ensenada","correo":"Joel@Helgen.tech"}')"
check "correo en minúsculas" '"correo":"joel@helgen.tech"' "$(como leo $U/api/equipo)"
check "correo inválido → 400" "Correo inv" "$(como leo -X PATCH "$U/api/equipo/miembro?id=joel" -d '{"correo":"joel"}')"
check "sitio inválido → 400" "Sitio inv" "$(como leo -X PATCH "$U/api/equipo/miembro?id=joel" -d '{"sitio":"marte"}')"
check "la marca no se cambia por PATCH" "True" "$(como leo -X PATCH "$U/api/equipo/miembro?id=miguel" -d '{"contrasena_inicial":false,"rol":"Responsable principal de instalación en EIT"}' >/dev/null; py "print(json.load(open('$T/equipo/miguel.json'))['contrasena_inicial'])")"
check "alta desde Equipo: marca inicial y sitio" '"contrasena_inicial":true' "$(como leo -X POST $U/api/usuarios -d '{"usuario":"prueba","password_inicial":"Helgen2026!","nombre":"Prueba","area":"campo","sitio":"lct","correo":"prueba@helgen.tech"}')"
check "índice CSV con las columnas nuevas" "sitio" "$(head -1 "$T/equipo/_indice.csv")"
echo "## alguien que estaba como texto libre cuenta en cuanto se da de alta"
check "pendiente con «Pepe» (todavía no es miembro)" "sin registrar" "$(como leo -X POST $U/api/tareas -d '{"id":"t-pepe","titulo":"Prueba Pepe","pista":"campo","responsable":"adriana, Pepe"}')"
check "alta de Pepe" '"usuario":"pepe"' "$(como leo -X POST $U/api/usuarios -d '{"usuario":"pepe","password_inicial":"Helgen2026!","nombre":"Pepe","area":"campo"}')"
check "el tablero ya se lo cuenta a Pepe" "t-pepe" "$(como leo "$U/api/tareas?responsable=pepe")"
check "Equipo: Pepe con 1 abierto" "1" "$(como leo $U/api/equipo | python3 -c 'import sys,json; print([m["pendientes"]["abiertos"] for m in json.load(sys.stdin)["miembros"] if m["id"]=="pepe"][0])')"
echo
echo "RESULTADO: $ok correctas · $fail fallidas · datos en $T"

View File

@ -17,6 +17,8 @@
.admin{border:1px solid var(--linea)} .admin{border:1px solid var(--linea)}
button.enviar{width:100%;margin-top:6px} button.enviar{width:100%;margin-top:6px}
.ver{font-size:13px;color:var(--naranja);cursor:pointer;user-select:none;margin-top:6px;display:inline-block} .ver{font-size:13px;color:var(--naranja);cursor:pointer;user-select:none;margin-top:6px;display:inline-block}
.inicial{display:block;margin-top:8px;padding:8px 10px;border-radius:8px;background:var(--hg-aviso-bg, #3A3020);
color:var(--hg-texto-fuerte, inherit);font-size:14px}
</style> </style>
</head> </head>
<body> <body>
@ -95,6 +97,8 @@ function aviso(el, texto, bien) {
yo = j.user || ''; yo = j.user || '';
$('quien').innerHTML = yo $('quien').innerHTML = yo
? 'Entraste como <b>' + yo + '</b>' + (j.admin ? ' · administrador' : '') ? 'Entraste como <b>' + yo + '</b>' + (j.admin ? ' · administrador' : '')
+ (j.contrasena_inicial ? '<span class="inicial" id="inicial"><b>Sigues con la contraseña inicial</b> que te dieron. '
+ 'Cámbiala aquí abajo: en «Contraseña actual» va esa misma.</span>' : '')
: 'No puedo identificarte. Vuelve a entrar al sitio y recarga esta página.'; : 'No puedo identificarte. Vuelve a entrar al sitio y recarga esta página.';
if (j.admin) { if (j.admin) {
$('bloqueAdmin').style.display = ''; $('bloqueAdmin').style.display = '';
@ -150,6 +154,7 @@ $('guardar').addEventListener('click', async () => {
const j = await r.json(); const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status)); if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
aviso($('msg'), '<b>Contraseña cambiada.</b>' + AVISO_SESION, true); aviso($('msg'), '<b>Contraseña cambiada.</b>' + AVISO_SESION, true);
if ($('inicial')) $('inicial').remove();
$('actual').value = $('nueva').value = $('confirma').value = ''; $('actual').value = $('nueva').value = $('confirma').value = '';
$('barra').style.width = '0'; $('barra').style.width = '0';
} catch (e) { } catch (e) {

View File

@ -473,7 +473,7 @@
"y": 80.34, "y": 80.34,
"etiqueta": "der", "etiqueta": "der",
"estado": "amarillo", "estado": "amarillo",
"responsable": "Antonio", "responsable": "Antonio Ventura",
"nota": "se trata aparte: el material debería estar; falta la herrería de los soportes para las RTG", "nota": "se trata aparte: el material debería estar; falta la herrería de los soportes para las RTG",
"meta": "1–2 unidades instaladas y probadas" "meta": "1–2 unidades instaladas y probadas"
}, },
@ -485,7 +485,7 @@
"y": 7.92, "y": 7.92,
"etiqueta": "der", "etiqueta": "der",
"estado": "rojo", "estado": "rojo",
"responsable": "Miguel", "responsable": "Miguel Ángel Peña",
"nota": "esperando el material que se envió desde Manzanillo", "nota": "esperando el material que se envió desde Manzanillo",
"meta": "1–2 unidades instaladas y probadas" "meta": "1–2 unidades instaladas y probadas"
}, },

View File

@ -1,6 +1,8 @@
/* Sitio Drogheda: detalles que necesitan datos del servidor en todas las páginas. /* Sitio Drogheda: detalles que necesitan datos del servidor en todas las páginas.
- Junto a «Pendientes» en el menú lateral, cuántos hay abiertos (de /api/tareas/resumen). - Junto a «Pendientes» en el menú lateral, cuántos hay abiertos (de /api/tareas/resumen).
Si el servidor no responde, el menú queda igual. */ - (23-oct) Quien sigue con la contraseña inicial (la que le dieron al darle acceso o al restablecerla) ve una
franja arriba con la liga a Mi cuenta. Lo dice /api/whoami; el servidor quita la marca cuando la cambia.
Si el servidor no responde, la página queda igual. */
(function () { (function () {
function cuentaPendientes() { function cuentaPendientes() {
var ligas = [].slice.call(document.querySelectorAll('.md-nav--primary a.md-nav__link')).filter(function (a) { var ligas = [].slice.call(document.querySelectorAll('.md-nav--primary a.md-nav__link')).filter(function (a) {
@ -28,6 +30,22 @@
}) })
.catch(function () {}); .catch(function () {});
} }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', cuentaPendientes); function avisoContrasena() {
else cuentaPendientes(); fetch('/api/whoami', { cache: 'no-store', credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
if (!j || !j.contrasena_inicial || document.querySelector('.hg-pass-inicial')) return;
var d = document.createElement('div');
d.className = 'hg-pass-inicial';
d.setAttribute('role', 'status');
d.innerHTML = '<b>Sigues con la contraseña inicial.</b> Cámbiala en <a href="/cuenta.html">Mi cuenta</a>: '
+ 'así nadie más puede entrar con tu usuario.';
var main = document.querySelector('.md-main');
if (main) main.parentNode.insertBefore(d, main); else document.body.insertBefore(d, document.body.firstChild);
})
.catch(function () {});
}
function arrancar() { cuentaPendientes(); avisoContrasena(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', arrancar);
else arrancar();
})(); })();

View File

@ -441,3 +441,9 @@ svg.hg-i { fill: none !important; stroke: currentColor; }
.badge-v3, .badge-v4, .badge-v55, .badge-v56, .badge-v57, .badge-v6, .badge-fw { border: 1px solid #000; color: #000; } .badge-v3, .badge-v4, .badge-v55, .badge-v56, .badge-v57, .badge-v6, .badge-fw { border: 1px solid #000; color: #000; }
@page { margin: 12mm 14mm; } @page { margin: 12mm 14mm; }
} }
/* Aviso de contraseña inicial (23-oct-2026, js/sitio.js): franja bajo el encabezado hasta que la persona la cambie */
.hg-pass-inicial { background: var(--hg-aviso-bg); color: var(--hg-texto-fuerte); font-size: 14px; line-height: 1.45;
padding: 9px 16px; text-align: center; border-bottom: 1px solid color-mix(in srgb, var(--hg-aviso) 45%, transparent); }
.hg-pass-inicial a { color: var(--hg-aviso); font-weight: 700; text-decoration: underline; }
@media print { .hg-pass-inicial { display: none; } }

View File

@ -1,11 +1,13 @@
--- ---
title: "Equipo" title: "Equipo"
description: "Quién es quién: miembros, área, acceso al sitio y pendientes de cada quien." description: "Quién es quién: miembros, sitio, área, correo, acceso al sitio y pendientes de cada quien."
template: tablero.html template: tablero.html
hide: hide:
- toc - toc
--- ---
<style>.tb-equipo td.correo { min-width: 11em; font-size: 13.5px; } .tb-equipo td.correo a, .tb-equipo td.correo .id { overflow-wrap: normal; word-break: normal; }</style>
<div class="tb tb-equipo"> <div class="tb tb-equipo">
<header class="tb-cab"> <header class="tb-cab">
<div class="tb-cab__t"><h1>Equipo</h1><span class="meta" id="meta">Cargando…</span></div> <div class="tb-cab__t"><h1>Equipo</h1><span class="meta" id="meta">Cargando…</span></div>
@ -15,18 +17,20 @@ hide:
<details class="info tb-como"> <details class="info tb-como">
<summary>Cómo se usa</summary> <summary>Cómo se usa</summary>
<ol> <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>Aquí está <b>quién es quién</b>: nombre, <b>sitio</b> donde trabaja (ICAVE, TIMSA, EIT, LCT o Querétaro), rol, área, correo, si tiene acceso al sitio y cuántos pendientes abiertos y vencidos trae cada quien. Filtra por sitio con la lista de arriba.</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><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>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>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>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><b>Contraseña inicial:</b> mientras alguien entre con la contraseña que le dieron (al darle acceso o al restablecerla), el sitio le muestra arriba un aviso para cambiarla, y aquí el administrador ve la marca «contraseña inicial». La marca se quita sola cuando la persona la cambia en Mi cuenta.</li>
<li><b>Correo:</b> el de cada quien, para mandar avisos por correo más adelante. Si alguien tiene cuenta en los dos dominios de Helgen, el segundo va en «Otro correo». Lo edita el administrador con «Editar».</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>«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> <li>Si no tienes acceso o no apareces, pídeselo a Leo.</li>
</ol> </ol>
</details> </details>
<div class="msg" id="aviso"></div> <div class="msg" id="aviso"></div>
<div id="acciones" style="display:none;margin:0 0 12px"><button id="bNuevo"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Nuevo usuario o miembro</button></div> <div class="acciones" style="margin:0 0 12px"><select id="fSitio" aria-label="Sitio"><option value="">Sitio: todos</option></select><span id="acciones" style="display:none"><button id="bNuevo"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Nuevo usuario o miembro</button></span><span class="cnt" id="cnt"></span></div>
<div id="cont" class="tblwrap"></div> <div id="cont" class="tblwrap"></div>
<section class="bloque mis-pend" id="detalle" style="display:none"></section> <section class="bloque mis-pend" id="detalle" style="display:none"></section>
@ -40,6 +44,11 @@ const esc = s => { const d = document.createElement('div'); d.textContent = s ==
const fmt = iso => (iso || '').replace('T', ' ').slice(0, 16); const fmt = iso => (iso || '').replace('T', ' ').slice(0, 16);
const $ = id => document.getElementById(id); 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 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' };
// Sitio de cada miembro (23-oct): dónde trabaja; vacío = sin sitio fijo
const SITIO = { icave: 'ICAVE · Veracruz', timsa: 'TIMSA · Manzanillo', eit: 'EIT · Ensenada', lzc: 'LCT · Lázaro Cárdenas', qro: 'Querétaro' };
const SITIO_C = { icave: 'ICAVE', timsa: 'TIMSA', eit: 'EIT', lzc: 'LCT', qro: 'Querétaro' };
Object.entries(SITIO).forEach(([k, v]) => document.getElementById('fSitio').append(el('option', { value: k, text: 'Sitio: ' + v })));
document.getElementById('fSitio').append(el('option', { value: '-', text: 'Sin sitio fijo' }));
const PISTAS = Kanban.PISTAS, ESTADOS = Object.fromEntries(Kanban.ESTADOS); const PISTAS = Kanban.PISTAS, ESTADOS = Object.fromEntries(Kanban.ESTADOS);
let MIEMBROS = [], ADMIN = false, USER = '', PASS_MIN = 8, AREAS = Object.keys(AREA), HTPASSWD_OK = true; let MIEMBROS = [], ADMIN = false, USER = '', PASS_MIN = 8, AREAS = Object.keys(AREA), HTPASSWD_OK = true;
const dlg = $('dlg'), aviso = $('aviso'); const dlg = $('dlg'), aviso = $('aviso');
@ -51,20 +60,29 @@ function campo(l, i, ayuda) { const d = el('div'); d.append(el('label', { text:
function pintar() { function pintar() {
const cont = $('cont'); cont.innerHTML = ''; 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; } if (!MIEMBROS.length) { cont.append(el('div', { class: 'vacio', text: 'Todavía no hay miembros. Corre la siembra o crea el primero.' })); return; }
const fs = $('fSitio').value;
const vis = MIEMBROS.filter(m => !fs || (fs === '-' ? !m.sitio : m.sitio === fs));
$('cnt').textContent = fs ? vis.length + ' de ' + MIEMBROS.length : '';
if (!vis.length) { cont.append(el('div', { class: 'vacio', text: 'Nadie en ese sitio todavía. Edita a un miembro para ponerle su sitio.' })); return; }
const t = el('table', { class: 't' }); 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>'; t.innerHTML = '<tr><th>Nombre</th><th>Sitio</th><th>Rol</th><th>Área</th><th>Correo</th><th>Acceso</th><th class="num">Abiertos</th><th class="num">Vencidos</th>' + (ADMIN ? '<th></th>' : '') + '</tr>';
MIEMBROS.forEach(m => { vis.forEach(m => {
const tr = el('tr', { class: m.activo === false ? 'inactivo' : '' }); const tr = el('tr', { class: m.activo === false ? 'inactivo' : '' });
const nom = el('span', { class: 'nom' }, Kanban.avatar(m.id), document.createTextNode(m.nombre)); nom.addEventListener('click', () => detalle(m)); const nom = el('span', { class: 'nom' }, Kanban.avatar(m.id), document.createTextNode(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', null, nom, el('span', { class: 'id', text: m.id + (m.telefono ? ' · ' + m.telefono : '') })));
tr.append(el('td', { text: SITIO_C[m.sitio] || '—' }));
tr.append(el('td', { text: m.rol || '—' })); 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', 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>' })); const co = el('td', { class: 'correo' });
tr.append(el('td', { html: m.es_admin ? '<span class="pill info">admin</span>' : '' })); const partido = c => esc(c).replace('@', '<wbr>@'); // si no cabe, corta antes de la arroba y no a media palabra
if (m.correo) co.append(el('a', { href: 'mailto:' + m.correo, html: partido(m.correo) })); else co.append(el('span', { class: 'id', text: '—' }));
if (m.correo_alt) co.append(el('span', { class: 'id', html: partido(m.correo_alt) }));
tr.append(co);
tr.append(el('td', { html: (m.activo === false ? '<span class="pill falla">inactivo</span> ' : '') + (m.tiene_login ? '<span class="pill ok">sí</span>' : '<span class="pill pend">no</span>') + (m.es_admin ? ' <span class="pill info">admin</span>' : '')
+ (ADMIN && m.tiene_login && m.contrasena_inicial ? ' <span class="pill aviso" title="Entra con la contraseña que le dieron; el sitio le pide cambiarla en Mi cuenta">contraseña inicial</span>' : '') }));
const p = m.pendientes || {}; const p = m.pendientes || {};
tr.append(el('td', { class: 'num', html: p.abiertos ? '<a href="/tableros/pendientes/?responsable=' + encodeURIComponent(m.id) + '"><b>' + p.abiertos + '</b></a>' : '0' })); tr.append(el('td', { class: 'num', html: p.abiertos ? '<a href="/tableros/pendientes/?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', { 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) { if (ADMIN) {
const acc = el('td', { style: 'white-space:nowrap' }); const acc = el('td', { style: 'white-space:nowrap' });
const be = el('button', { class: 'sec', text: 'Editar' }); be.addEventListener('click', () => editar(m)); acc.append(be); const be = el('button', { class: 'sec', text: 'Editar' }); be.addEventListener('click', () => editar(m)); acc.append(be);
@ -87,7 +105,8 @@ async function cargar() {
pintar(); pintar();
$('acciones').style.display = ADMIN ? '' : 'none'; $('acciones').style.display = ADMIN ? '' : 'none';
const act = MIEMBROS.filter(m => m.activo !== false), con = act.filter(m => m.tiene_login).length; 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)' : '') : ''); const ini = ADMIN ? act.filter(m => m.tiene_login && m.contrasena_inicial).length : 0;
$('meta').textContent = act.length + ' miembros activos · ' + con + ' con acceso al sitio' + (ini ? ' · ' + ini + ' con contraseña inicial' : '') + ' · ' + (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 && !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); 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); } const q = new URLSearchParams(location.search).get('id'); if (q) { const m = MIEMBROS.find(x => x.id === q); if (m) detalle(m); }
@ -135,9 +154,11 @@ function formMiembro(c, m, conAcceso) {
f.nombre = el('input', { type: 'text', maxlength: '80', value: m.nombre || '', placeholder: 'Nombre y apellido' }); 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.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.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.sitio = el('select'); f.sitio.append(el('option', { value: '', text: '— sin sitio fijo —' })); Object.entries(SITIO).forEach(([k, v]) => f.sitio.append(el('option', { value: k, text: v }))); f.sitio.value = m.sitio || '';
f.correo = el('input', { type: 'email', maxlength: '120', value: m.correo || '', placeholder: 'nombre@helgen.tech' });
f.correo_alt = el('input', { type: 'email', maxlength: '120', value: m.correo_alt || '', placeholder: 'el del otro dominio, si tiene' });
f.telefono = el('input', { type: 'tel', maxlength: '40', value: m.telefono || '' }); 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); 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('Sitio', f.sitio, 'Dónde trabaja: el puerto o Querétaro.'), campo('Teléfono', f.telefono), campo('Correo', f.correo, 'Para los avisos por correo.'), campo('Otro correo', f.correo_alt)); c.append(g);
if (conAcceso) { if (conAcceso) {
f.acceso = el('input', { type: 'checkbox', style: 'width:auto' }); f.acceso.checked = !m.tiene_login && HTPASSWD_OK; f.acceso.disabled = !HTPASSWD_OK; 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); 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);
@ -151,7 +172,7 @@ function formMiembro(c, m, conAcceso) {
} }
return f; 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() }); const leerMiembro = f => ({ nombre: f.nombre.value.trim(), rol: f.rol.value.trim(), area: f.area.value, sitio: f.sitio.value, correo: f.correo.value.trim().toLowerCase(), correo_alt: f.correo_alt.value.trim().toLowerCase(), telefono: f.telefono.value.trim() });
function nuevo(base) { function nuevo(base) {
let f; 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')); } }, 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')); } },
@ -178,7 +199,8 @@ function editar(m) {
abrirDialogo('Editar ' + m.nombre, c => { abrirDialogo('Editar ' + m.nombre, c => {
f = formMiembro(c, m, false); f = formMiembro(c, m, false);
const h = el('div', { class: 'hist' }); 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') })); 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.tiene_login && m.contrasena_inicial ? ' · <b>sigue con la contraseña inicial</b> (desde ' + esc(fmt(m.contrasena_cambiada_en)) + ')' : '') }));
(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) }))); (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); c.append(h);
}, [['Guardar', async () => { }, [['Guardar', async () => {
@ -200,6 +222,7 @@ async function quitarAcceso(m) {
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); } 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)); $('bNuevo').addEventListener('click', () => nuevo(null));
$('fSitio').addEventListener('change', pintar);
cargar(); cargar();
})(); })();
</script> </script>

View File

@ -18,7 +18,7 @@ Las páginas que abres para saber **dónde está el proyecto hoy**: qué pendien
| En qué **vehículo** está cada unidad y si ya pasó la **prueba en sitio** | [Instalaciones](instalaciones.md) | Una tarjeta por unidad en cada sitio: enviada, recibida, instalada, probada en sitio y en operación, más «con falla»; vehículo (número económico, tipo, 12/24 V), sus actas y el botón de la siguiente acta. Solo las probadas cuentan para la meta de 1–2 por sitio | | En qué **vehículo** está cada unidad y si ya pasó la **prueba en sitio** | [Instalaciones](instalaciones.md) | Una tarjeta por unidad en cada sitio: enviada, recibida, instalada, probada en sitio y en operación, más «con falla»; vehículo (número económico, tipo, 12/24 V), sus actas y el botón de la siguiente acta. Solo las probadas cuentan para la meta de 1–2 por sitio |
| Qué hay **en cada puerto** | [Sitios](sitio.md) | Unidades instaladas, envíos, diagnósticos, daños y contactos de ICAVE, LCT, EIT y TIMSA | | Qué hay **en cada puerto** | [Sitios](sitio.md) | Unidades instaladas, envíos, diagnósticos, daños y contactos de ICAVE, LCT, EIT y TIMSA |
| Qué se acordó en las **juntas** (standup y quick wins) | [Juntas](juntas.md) | La agenda fija, la cadencia y el registro de cada junta con su lote | | Qué se acordó en las **juntas** (standup y quick wins) | [Juntas](juntas.md) | La agenda fija, la cadencia y el registro de cada junta con su lote |
| **Quién es quién** | [Equipo](equipo.md) | Miembros, área, acceso al sitio y pendientes abiertos y vencidos de cada quien. El administrador crea aquí los accesos | | **Quién es quién** | [Equipo](equipo.md) | Miembros con su sitio y correo de cada quien, área, acceso al sitio y pendientes abiertos y vencidos; filtro por sitio. El administrador crea aquí los accesos y ve quién sigue con la contraseña inicial |
| **Todo sobre una serie** | [Expediente de unidad](unidad.md) | Cada acta, foto y log de una unidad en una línea de tiempo, de la tarjeta al sitio | | **Todo sobre una serie** | [Expediente de unidad](unidad.md) | Cada acta, foto y log de una unidad en una línea de tiempo, de la tarjeta al sitio |
| Las **visitas de diagnóstico** | [Visitas de campo](campo.md) | Todas las actas de diagnóstico en campo y en banco, con fotos | | Las **visitas de diagnóstico** | [Visitas de campo](campo.md) | Todas las actas de diagnóstico en campo y en banco, con fotos |

View File

@ -52,16 +52,16 @@ Si en la junta alguien mueve un pendiente a mano, mejor: el lote no lo vuelve a
*Junta del 22-oct.* En el standup, cada responsable dice cómo va su sitio. Por experiencia, **Alán y Daniel** son los *Junta del 22-oct.* En el standup, cada responsable dice cómo va su sitio. Por experiencia, **Alán y Daniel** son los
líderes de instalación en todos los sitios. líderes de instalación en todos los sitios.
| Sitio | Responsable de campo | Pendientes | | Sitio | Responsable principal | También en el sitio | Pendientes |
|---|---|---| |---|---|---|---|
| ICAVE (Veracruz) | Alán | [ICAVE](pendientes.md?sitio=icave) | | ICAVE (Veracruz) | Alán Basurto (`alan`) | | [ICAVE](pendientes.md?sitio=icave) |
| EIT (Ensenada) | Miguel | [EIT](pendientes.md?sitio=eit) | | EIT (Ensenada) | Miguel Ángel Peña (`miguel`) | Joel Orantes (`joel`) | [EIT](pendientes.md?sitio=eit) |
| LCT (Lázaro Cárdenas) | Antonio | [LCT](pendientes.md?sitio=lzc): se trata aparte; falta la herrería de los soportes para las RTG | | LCT (Lázaro Cárdenas) | Antonio Ventura (`antonio`) | | [LCT](pendientes.md?sitio=lzc): se trata aparte; falta la herrería de los soportes para las RTG |
| TIMSA (Manzanillo) | Jesús Honorato | [TIMSA](pendientes.md?sitio=timsa) | | TIMSA (Manzanillo) | Jesús Honorato (`jesush`) | | [TIMSA](pendientes.md?sitio=timsa) |
Miguel, Antonio y Jesús Honorato todavía no tienen cuenta en el sitio: mientras se dan de alta, sus pendientes llevan su El responsable principal no es jefe de los demás: es quien responde por el sitio. Desde el 23-oct todos tienen cuenta
nombre como texto. Ojo con los dos Jesús: el usuario `jesus` del sitio es **Jesús Romero**, el técnico de instalación; (el usuario va entre paréntesis) y su sitio se ve en [Equipo](equipo.md). Ojo con los dos Jesús: el usuario `jesus` es
**Jesús Honorato** es el técnico de campo y responsable en Manzanillo. **Jesús Romero**, el técnico de instalación; **Jesús Honorato** (`jesush`) es el técnico de campo y responsable en Manzanillo.
## Cadencia ## Cadencia

View File

@ -17,7 +17,10 @@ echo "Sitio publicado en $REMOTE:$REMOTE_PATH (puerto $PORT)"
# 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/seed_1013.py \
backend/parche-nginx-api.py backend/actas-hbox.service backend/tablero.py backend/instalaciones.py "$REMOTE:$BACKEND_PATH/" backend/parche-nginx-api.py backend/actas-hbox.service backend/tablero.py backend/instalaciones.py \
backend/cuentas.py "$REMOTE:$BACKEND_PATH/"
# Archivos de altas para cuentas.py (23-oct): sin contraseñas; la contraseña la pide el script en la terminal.
rsync -az -e "ssh -p $PORT" backend/cuentas/ "$REMOTE:$BACKEND_PATH/cuentas/"
echo "Backend copiado en $REMOTE:$BACKEND_PATH" echo "Backend copiado en $REMOTE:$BACKEND_PATH"
# Tablero (21-oct): baja la foto a _estado/ (para que Claude lea el tablero) y avisa si hay lotes # Tablero (21-oct): baja la foto a _estado/ (para que Claude lea el tablero) y avisa si hay lotes