hbox-docs/backend/app.py
Leopoldo Rodríguez 4cc18b517f 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
2026-10-23 14:14:50 -06:00

2324 lines
107 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Actas HBOX — servicio para recibir y consultar formularios firmados de los técnicos.
Reglas:
- Un acta real (serie que NO contiene PRUEBA) se puede enviar UNA sola vez por serie.
Un segundo envío se rechaza (409) hasta que un administrador la borre.
- Al seleccionar una serie ya enviada, el formulario la vuelve a cargar (llena, bloqueada).
- Solo el usuario 'leo' (administrador) puede borrar/reabrir un acta.
- Las series PRUEBA se guardan aparte (_pruebas), no cuentan y se pueden reenviar.
El usuario autenticado llega en la cabecera X-Remote-User (la fija nginx desde $remote_user;
el cliente no puede falsificarla porque nginx la sobrescribe).
Rutas:
GET /api/salud
GET /api/whoami -> {user, admin}
GET /api/acta?tipo=&serie= -> {exists, acta?}
POST /api/actas -> crea (409 si ya existe una real)
DELETE /api/acta?tipo=&serie= -> borra (solo admin)
GET /api/actas -> listado (ingeniería); incluye 'componentes' en actas compuestas
GET /api/historial?tipo=&serie= -> todas las actas de esa serie (tipos repetibles: diagnóstico de campo)
POST /api/adjuntos -> sube fotos (multipart) y devuelve sus rutas
GET /api/adjunto?ruta= -> descarga una foto guardada
DELETE /api/acta?...&archivo= -> borra UNA acta concreta (solo admin; tipos repetibles)
Actas compuestas (integracion, ensamble): traen d["componentes"] = {clave: serie_modulo}. En los tipos
de TIPOS_UNICOS un módulo no puede aparecer en dos actas reales (409).
Colecciones agregadas el 13-oct-2026 (tableros, inventario, sitios) — un JSON por registro + _indice.csv,
campos de auditoría creado_por/creado_en/modificado_por/modificado_en/historial; cualquier usuario
autenticado crea y modifica, solo un administrador borra:
GET/POST /api/tareas (?sitio=eit,timsa filtra por sitio; 22-oct) · PATCH/DELETE /api/tarea?id= · GET /api/tareas/resumen · POST /api/tareas/orden
(DELETE /api/tarea: administradores y TAREAS_BORRAN, por omisión adriana y gibranlp; el pendiente pasa a tareas/_borrados/)
GET /api/plan · PUT /api/plan (admin)
GET/POST /api/inventario · PATCH /api/inventario/item?id= · POST /api/inventario/movimiento
GET /api/inventario/movimientos?item_id=&sitio= · GET /api/inventario/export.csv
(23-oct) inventario por sitio: cada movimiento lleva «sitio»; «envio» saca de un sitio y queda en camino hasta
su «recepcion» en el destino (sitio «fuera» = una compra en camino o material que salió antes de contarlo: no
descuenta de ningún sitio); cada ítem puede llevar «por_instalacion» (cuánto usa una unidad instalada).
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)
(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):
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.
GET /api/actividad?dias=7&limite=30 → lo que pasó: cambios de pendientes (de su historial) y actas
recibidas, del más nuevo al más viejo. Las series PRUEBA no cuentan.
"""
import os, re, json, csv, datetime, uuid, hashlib, unicodedata, fcntl
from flask import Flask, request, jsonify, abort, send_file
app = Flask(__name__)
BASE = os.environ.get("ACTAS_DIR", "/srv/projects/drogheda/actas")
ADJ = os.environ.get("ADJUNTOS_DIR", os.path.join(os.path.dirname(BASE.rstrip("/")) or "/srv/projects/drogheda", "adjuntos"))
ADMINS = {u.strip().lower() for u in os.environ.get("ACTAS_ADMINS", "leo").split(",") if u.strip()}
TIPOS_UNICOS = {"ensamble"} # tipos en los que un componente solo puede usarse en un acta real
# Tipos REPETIBLES: una misma serie puede tener varias actas (cada visita a campo es un evento nuevo).
TIPOS_REPETIBLES = {t.strip() for t in os.environ.get(
"ACTAS_REPETIBLES", "diagnostico-campo,diagnostico-banco,instalacion-campo,reflasheo,dano,envio,recepcion,prueba-bateria-modem,prueba-ethernet-serie").split(",") if t.strip()}
# Subida de fotos
MAX_MB = int(os.environ.get("ADJUNTO_MAX_MB", "8"))
# Archivos de log (capturas por USB del Blackpill, del 4G y del ArduSimple).
LOG_MAX_MB = int(os.environ.get("ARCHIVO_MAX_MB", "20"))
app.config["MAX_CONTENT_LENGTH"] = (max(MAX_MB, LOG_MAX_MB) + 2) * 1024 * 1024
EXT_OK = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
# Lista blanca de logs: texto plano y capturas binarias de u-blox. Nada ejecutable.
EXT_LOG = {".txt", ".log", ".csv", ".nmea", ".ubx", ".json", ".cfg", ".md"}
SAFE = re.compile(r"[^A-Za-z0-9_\-]")
SAFE_FILE = re.compile(r"[^A-Za-z0-9_\-.]")
def slug(s, n=40): return SAFE.sub("", str(s or "").strip())[:n]
def current_user():
return (request.headers.get("X-Remote-User") or "").strip()
def is_admin():
return current_user().lower() in ADMINS
def componentes_en_uso(tipo, serie_actual, componentes):
"""Devuelve [(clave, valor, serie_otra)] para componentes ya usados por OTRA acta real del mismo tipo."""
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta) or not isinstance(componentes, dict):
return []
usados = []
for fn in os.listdir(carpeta):
if not fn.endswith(".json"):
continue
try:
with open(os.path.join(carpeta, fn), encoding="utf-8") as f:
d = json.load(f)
except Exception:
continue
if d.get("serie") == serie_actual or not isinstance(d.get("componentes"), dict):
continue
for k, v in componentes.items():
if v and d["componentes"].get(k) == v:
usados.append((k, v, d.get("serie")))
return usados
def es_repetible(tipo):
return tipo in TIPOS_REPETIBLES
def listar_serie(tipo, serie):
"""Todas las actas guardadas para (tipo, serie), de la más vieja a la más nueva."""
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta):
return []
pref = serie + "_"
return sorted(fn for fn in os.listdir(carpeta)
if fn.startswith(pref) and fn.endswith(".json"))
def find_latest(tipo, serie):
"""Ruta del acta real más reciente para (tipo, serie), o None."""
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta):
return None
pref = serie + "_"
cand = sorted(fn for fn in os.listdir(carpeta)
if fn.startswith(pref) and fn.endswith(".json"))
return os.path.join(carpeta, cand[-1]) if cand else None
@app.get("/api/salud")
def salud():
return jsonify(ok=True, servicio="actas-hbox")
@app.get("/api/whoami")
def whoami():
u = current_user()
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")
def get_acta():
tipo = slug(request.args.get("tipo")); serie = slug(request.args.get("serie"), 24)
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
archivo = SAFE_FILE.sub("", str(request.args.get("archivo") or ""))[:120]
if archivo:
if archivo not in listar_serie(tipo, serie):
return jsonify(ok=True, exists=False), 404
with open(os.path.join(BASE, tipo, archivo), encoding="utf-8") as f:
d = json.load(f)
d["_archivo"] = archivo
return jsonify(ok=True, exists=True, acta=d)
ruta = find_latest(tipo, serie)
if not ruta:
return jsonify(ok=True, exists=False, repetible=es_repetible(tipo))
with open(ruta, encoding="utf-8") as f:
d = json.load(f)
d["_archivo"] = os.path.basename(ruta)
# En un tipo repetible el acta previa NO bloquea: se devuelve como antecedente.
if es_repetible(tipo):
return jsonify(ok=True, exists=False, repetible=True, previa=d,
visitas=len(listar_serie(tipo, serie)))
return jsonify(ok=True, exists=True, acta=d)
@app.post("/api/actas")
def crear():
try:
d = request.get_json(force=True)
except Exception:
return jsonify(ok=False, error="JSON inválido"), 400
if not isinstance(d, dict):
return jsonify(ok=False, error="Se esperaba un objeto"), 400
tipo = slug(d.get("tipo")); serie = slug(d.get("serie"), 24)
tecnico = str(d.get("tecnico", "")).strip()
if not tipo or not serie:
return jsonify(ok=False, error="Faltan 'tipo' o 'serie'"), 400
if not tecnico:
return jsonify(ok=False, error="Falta el nombre del técnico"), 400
if not d.get("conforme"):
return jsonify(ok=False, error="Falta la conformidad del técnico"), 400
es_prueba = "PRUEBA" in serie.upper()
# un acta real solo se puede enviar una vez (salvo tipos repetibles: cada visita es un evento)
if not es_prueba and not es_repetible(tipo) and find_latest(tipo, serie):
return jsonify(ok=False, existe=True,
error=f"Ya existe un acta para {serie}. Pide a Leo que la borre para rehacerla."), 409
# actas compuestas (ensamble): un módulo no puede pertenecer a dos unidades
comps = d.get("componentes")
if isinstance(comps, dict):
comps = {slug(k, 20): slug(v, 20) for k, v in comps.items()}
d["componentes"] = comps
if not es_prueba and tipo in TIPOS_UNICOS:
usados = componentes_en_uso(tipo, serie, comps)
if usados:
det = "; ".join(f"{v} ya está en {s}" for _, v, s in usados)
return jsonify(ok=False, error=f"Módulo ya asignado: {det}. Pide a Leo que revise."), 409
fotos = d.get("fotos")
if isinstance(fotos, list):
d["fotos"] = [f for f in fotos[:60]
if isinstance(f, dict) and isinstance(f.get("ruta"), str)
and os.path.isfile(os.path.join(ADJ, f["ruta"]))]
archivos = d.get("archivos")
if isinstance(archivos, list):
d["archivos"] = [a for a in archivos[:40]
if isinstance(a, dict) and isinstance(a.get("ruta"), str)
and os.path.isfile(os.path.join(ADJ, a["ruta"]))]
ts = datetime.datetime.now().replace(microsecond=0)
iso = ts.isoformat()
d["_recibido"] = iso
d["_usuario"] = current_user()
d["_ip"] = request.headers.get("X-Forwarded-For", request.remote_addr)
d["_prueba"] = es_prueba
carpeta = os.path.join(BASE, tipo, "_pruebas") if es_prueba else os.path.join(BASE, tipo)
os.makedirs(carpeta, exist_ok=True)
archivo = f"{serie}_{iso.replace(':', '-')}.json"
if es_repetible(tipo):
# cada visita es un registro propio. El nombre lleva microsegundos para que el
# orden alfabético SEA el orden cronológico (dos envíos en el mismo segundo no
# se pisan ni se desordenan), y un sufijo aleatorio por si acaso.
micro = datetime.datetime.now().strftime("%f")
archivo = f"{serie}_{iso.replace(':', '-')}-{micro}_{uuid.uuid4().hex[:4]}.json"
with open(os.path.join(carpeta, archivo), "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
if not es_prueba:
idx = os.path.join(carpeta, "_indice.csv")
nuevo = not os.path.exists(idx)
with open(idx, "a", newline="", encoding="utf-8") as f:
w = csv.writer(f)
if nuevo:
w.writerow(["recibido", "fecha", "serie", "maquina", "tecnico", "resultado",
"clasificacion", "accion", "fotos", "logs", "archivo"])
w.writerow([iso, d.get("fecha", ""), serie, d.get("maquina_id", d.get("maquina", "")),
tecnico, d.get("resultado", ""),
d.get("clasificacion", ""), d.get("accion", ""),
len(d.get("fotos") or []), len(d.get("archivos") or []), archivo])
sub = f"{tipo}/_pruebas/{archivo}" if es_prueba else f"{tipo}/{archivo}"
return jsonify(ok=True, archivo=sub, prueba=es_prueba)
@app.delete("/api/acta")
def borrar_acta():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador puede borrar actas."), 403
tipo = slug(request.args.get("tipo")); serie = slug(request.args.get("serie"), 24)
archivo = SAFE_FILE.sub("", str(request.args.get("archivo") or ""))[:120]
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
carpeta = os.path.join(BASE, tipo)
borrados = []
# borrar UNA visita concreta (tipos repetibles), no toda la serie
if archivo:
existentes = listar_serie(tipo, serie)
if archivo in existentes:
os.remove(os.path.join(carpeta, archivo)); borrados.append(archivo)
return jsonify(ok=True, borrados=borrados)
if os.path.isdir(carpeta):
pref = serie + "_"
for fn in list(os.listdir(carpeta)):
if fn.startswith(pref) and fn.endswith(".json"):
os.remove(os.path.join(carpeta, fn)); borrados.append(fn)
idx = os.path.join(carpeta, "_indice.csv")
if os.path.exists(idx):
rows = list(csv.reader(open(idx, encoding="utf-8")))
keep = ([rows[0]] if rows else []) + [r for r in rows[1:] if not (len(r) > 1 and r[1] == serie)]
with open(idx, "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerows(keep)
return jsonify(ok=True, borrados=borrados)
@app.delete("/api/actas")
def borrar_todas():
"""Reset admin: borra TODAS las actas reales de un tipo (opcional: también las de prueba)."""
if not is_admin():
return jsonify(ok=False, error="Solo un administrador puede reiniciar actas."), 403
tipo = slug(request.args.get("tipo"))
incl_pruebas = request.args.get("pruebas") == "1"
if not tipo:
return jsonify(ok=False, error="falta tipo"), 400
carpeta = os.path.join(BASE, tipo)
n = 0
if os.path.isdir(carpeta):
for fn in list(os.listdir(carpeta)):
fp = os.path.join(carpeta, fn)
if fn.endswith(".json"):
os.remove(fp); n += 1
elif fn == "_indice.csv":
os.remove(fp)
if incl_pruebas:
pdir = os.path.join(carpeta, "_pruebas")
if os.path.isdir(pdir):
for fn in list(os.listdir(pdir)):
if fn.endswith(".json"):
os.remove(os.path.join(pdir, fn)); n += 1
return jsonify(ok=True, borradas=n)
@app.get("/api/actas")
def listar():
filas = []
if os.path.isdir(BASE):
for tipo in sorted(os.listdir(BASE)):
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta):
continue
for fn in sorted(os.listdir(carpeta)):
if fn.endswith(".json"):
try:
with open(os.path.join(carpeta, fn), encoding="utf-8") as f:
d = json.load(f)
except Exception:
continue
fila = {"tipo": tipo, "archivo": f"{tipo}/{fn}",
"serie": d.get("serie"), "tecnico": d.get("tecnico"),
"resultado": d.get("resultado"), "recibido": d.get("_recibido")}
if isinstance(d.get("componentes"), dict):
fila["componentes"] = d["componentes"]
for k in ("clasificacion", "accion", "motivo", "maquina", "terminal", "unidad", "resumen",
"sitio", "fecha", "maquina_id"):
if d.get(k):
fila[k] = d[k]
if d.get("archivos"):
fila["archivos"] = [{"ruta": a.get("ruta"), "nombre": a.get("nombre")}
for a in d["archivos"] if a.get("ruta")]
if d.get("fotos"):
fila["fotos"] = [f.get("ruta") for f in d["fotos"] if f.get("ruta")]
fila["archivo_n"] = fn
filas.append(fila)
filas.sort(key=lambda r: (r.get("recibido") or ""), reverse=True)
return jsonify(ok=True, total=len(filas), actas=filas)
# ------------------------------------------------------------------ historial
@app.get("/api/historial")
def historial():
"""Todas las actas de una serie, de la más reciente a la más vieja (visitas a campo)."""
tipo = slug(request.args.get("tipo")); serie = slug(request.args.get("serie"), 24)
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
carpeta = os.path.join(BASE, tipo)
out = []
for fn in listar_serie(tipo, serie):
try:
with open(os.path.join(carpeta, fn), encoding="utf-8") as f:
d = json.load(f)
except Exception:
continue
out.append({"archivo": fn, "recibido": d.get("_recibido"), "tecnico": d.get("tecnico"),
"resultado": d.get("resultado"), "clasificacion": d.get("clasificacion"),
"accion": d.get("accion"), "motivo": d.get("motivo"),
"fotos": len(d.get("fotos") or []),
"archivos": len(d.get("archivos") or []),
"fecha": d.get("fecha")})
out.reverse()
return jsonify(ok=True, total=len(out), visitas=out)
# ------------------------------------------------------------------ adjuntos (fotos)
def _tipo_imagen(cab):
if cab[:3] == b"\xff\xd8\xff":
return ".jpg"
if cab[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if cab[:4] == b"RIFF" and cab[8:12] == b"WEBP":
return ".webp"
return None
@app.post("/api/adjuntos")
def subir_adjuntos():
"""Sube fotos de campo. multipart/form-data: tipo, serie, foto (1..10), etiqueta opcional."""
tipo = slug(request.form.get("tipo")); serie = slug(request.form.get("serie"), 24)
etiqueta = str(request.form.get("etiqueta", ""))[:80]
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
archivos = request.files.getlist("foto")
if not archivos:
return jsonify(ok=False, error="no llegó ninguna foto"), 400
if len(archivos) > 10:
return jsonify(ok=False, error="máximo 10 fotos por envío"), 400
carpeta = os.path.join(ADJ, tipo, serie)
os.makedirs(carpeta, exist_ok=True)
guardadas, rechazadas = [], []
for fh in archivos:
datos = fh.read(MAX_MB * 1024 * 1024 + 1)
if len(datos) > MAX_MB * 1024 * 1024:
rechazadas.append({"nombre": fh.filename, "error": f"pesa más de {MAX_MB} MB"}); continue
ext = _tipo_imagen(datos[:16])
if not ext:
rechazadas.append({"nombre": fh.filename, "error": "no es una imagen JPG/PNG/WEBP"}); continue
nom = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6] + ext
with open(os.path.join(carpeta, nom), "wb") as f:
f.write(datos)
guardadas.append({"ruta": f"{tipo}/{serie}/{nom}", "bytes": len(datos),
"etiqueta": etiqueta, "usuario": current_user(),
"subida": datetime.datetime.now().replace(microsecond=0).isoformat()})
if not guardadas:
return jsonify(ok=False, error=(rechazadas[0]["error"] if rechazadas else "no se guardó nada"),
rechazadas=rechazadas), 400
return jsonify(ok=True, fotos=guardadas, rechazadas=rechazadas)
@app.post("/api/archivos")
def subir_archivos():
"""Sube archivos de log de campo/banco. multipart: tipo, serie, archivo (1..10), etiqueta."""
tipo = slug(request.form.get("tipo")); serie = slug(request.form.get("serie"), 24)
etiqueta = str(request.form.get("etiqueta", ""))[:80]
if not tipo or not serie:
return jsonify(ok=False, error="faltan tipo/serie"), 400
subidos = request.files.getlist("archivo")
if not subidos:
return jsonify(ok=False, error="no llegó ningún archivo"), 400
if len(subidos) > 10:
return jsonify(ok=False, error="máximo 10 archivos por envío"), 400
carpeta = os.path.join(ADJ, tipo, serie, "logs")
os.makedirs(carpeta, exist_ok=True)
guardados, rechazados = [], []
for fh in subidos:
nombre = SAFE_FILE.sub("_", os.path.basename(fh.filename or "captura"))[:80]
ext = os.path.splitext(nombre)[1].lower()
if ext not in EXT_LOG:
rechazados.append({"nombre": nombre,
"error": "extensión no permitida (usa .txt .log .csv .nmea .ubx .json)"})
continue
datos = fh.read(LOG_MAX_MB * 1024 * 1024 + 1)
if len(datos) > LOG_MAX_MB * 1024 * 1024:
rechazados.append({"nombre": nombre, "error": f"pesa más de {LOG_MAX_MB} MB"}); continue
if not datos:
rechazados.append({"nombre": nombre, "error": "el archivo está vacío"}); continue
final = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:4] + "_" + nombre
with open(os.path.join(carpeta, final), "wb") as f:
f.write(datos)
guardados.append({"ruta": f"{tipo}/{serie}/logs/{final}", "nombre": nombre,
"bytes": len(datos), "etiqueta": etiqueta, "usuario": current_user(),
"subida": datetime.datetime.now().replace(microsecond=0).isoformat()})
if not guardados:
return jsonify(ok=False, error=(rechazados[0]["error"] if rechazados else "no se guardó nada"),
rechazados=rechazados), 400
return jsonify(ok=True, archivos=guardados, rechazados=rechazados)
@app.get("/api/adjunto")
def ver_adjunto():
ruta = (request.args.get("ruta") or "").strip().lstrip("/")
if not ruta or ".." in ruta:
return jsonify(ok=False, error="ruta inválida"), 400
fp = os.path.realpath(os.path.join(ADJ, ruta))
if not fp.startswith(os.path.realpath(ADJ) + os.sep) or not os.path.isfile(fp):
return jsonify(ok=False, error="no existe"), 404
return send_file(fp)
@app.errorhandler(413)
def _muy_grande(e):
return jsonify(ok=False, error=f"El archivo pesa demasiado (fotos: máximo {MAX_MB} MB; "
f"logs: máximo {LOG_MAX_MB} MB)."), 413
# ------------------------------------------------- contraseñas (HTTP Basic de nginx)
# El archivo de usuarios de nginx se escribe aquí mismo, sin invocar `htpasswd`:
# pasar una contraseña como argumento de un proceso la deja visible en `ps`.
HTPASSWD = os.environ.get("HTPASSWD_FILE", "/etc/nginx/hbox-docs.htpasswd")
PASS_MIN = int(os.environ.get("PASS_MIN", "8"))
ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
def _to64(v, n):
return "".join(ITOA64[(v >> (6 * i)) & 0x3F] for i in range(n))
def apr1(password, salt):
"""Hash Apache MD5 ($apr1$): lo que escribe htpasswd por defecto y lo que lee nginx."""
pw = password.encode("utf-8")
sl = salt.encode("ascii")
ctx = hashlib.md5(pw + b"$apr1$" + sl)
final = hashlib.md5(pw + sl + pw).digest()
i = len(pw)
while i > 0:
ctx.update(final[:16] if i > 16 else final[:i]); i -= 16
i = len(pw)
while i:
ctx.update(b"\0" if (i & 1) else pw[:1]); i >>= 1
final = ctx.digest()
for i in range(1000):
c = hashlib.md5()
c.update(pw if (i & 1) else final)
if i % 3: c.update(sl)
if i % 7: c.update(pw)
c.update(final if (i & 1) else pw)
final = c.digest()
out = "".join(_to64((final[a] << 16) | (final[b] << 8) | final[c], 4)
for a, b, c in ((0, 6, 12), (1, 7, 13), (2, 8, 14), (3, 9, 15), (4, 10, 5)))
return "$apr1$" + salt + "$" + out + _to64(final[11], 2)
def _leer_htpasswd():
if not os.path.isfile(HTPASSWD):
return None
with open(HTPASSWD, encoding="utf-8") as f:
return f.read().splitlines()
def _escribir_htpasswd(lineas):
"""Reescribe el archivo de usuarios.
Si la carpeta es escribible (p. ej. en pruebas) se hace de forma atómica (temporal + rename)
conservando permisos y dueño. En producción el archivo vive en /etc/nginx, que es de root y
NO es escribible por el servicio aunque el archivo sí lo sea (chown leo:www-data + 640):
entonces se reescribe EN SITIO (r+ / truncate), que solo requiere permiso sobre el archivo.
Las rutas que llaman a esta función toman antes el candado HTPASSWD_LOCK."""
contenido = "\n".join(l for l in lineas if l is not None) + "\n"
carpeta = os.path.dirname(HTPASSWD) or "."
st = os.stat(HTPASSWD)
if os.access(carpeta, os.W_OK):
tmp = os.path.join(carpeta, ".htpasswd.tmp-" + uuid.uuid4().hex[:8])
with open(tmp, "w", encoding="utf-8") as f:
f.write(contenido)
os.chmod(tmp, st.st_mode & 0o7777)
try:
os.chown(tmp, st.st_uid, st.st_gid)
except PermissionError:
pass
os.replace(tmp, HTPASSWD)
return
with open(HTPASSWD, "r+", encoding="utf-8") as f: # PermissionError aquí = archivo no escribible
f.seek(0)
f.write(contenido)
f.truncate()
f.flush()
os.fsync(f.fileno())
@app.get("/api/usuarios")
def usuarios():
"""Lista de usuarios (solo administrador), para el restablecimiento."""
if not is_admin():
return jsonify(ok=False, error="Solo un administrador puede ver la lista."), 403
lineas = _leer_htpasswd()
if lineas is None:
return jsonify(ok=False, error="No encuentro el archivo de usuarios."), 500
return jsonify(ok=True, usuarios=sorted(l.split(":", 1)[0] for l in lineas if ":" in l))
@app.post("/api/password")
def cambiar_password():
yo = current_user()
if not yo:
return jsonify(ok=False, error="No puedo identificarte. Vuelve a iniciar sesión."), 403
try:
d = request.get_json(force=True) or {}
except Exception:
return jsonify(ok=False, error="JSON inválido"), 400
destino = str(d.get("usuario") or yo).strip()
actual = str(d.get("actual") or "")
nueva = str(d.get("nueva") or "")
confirma = str(d.get("confirma") or "")
es_reset = (destino.lower() != yo.lower())
if es_reset and not is_admin():
return jsonify(ok=False, error="Solo puedes cambiar tu propia contraseña."), 403
if len(nueva) < PASS_MIN:
return jsonify(ok=False, error=f"La nueva contraseña debe tener al menos {PASS_MIN} caracteres."), 400
if nueva != confirma:
return jsonify(ok=False, error="Las dos contraseñas nuevas no coinciden."), 400
if not es_reset and nueva == actual:
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()
if lineas is None:
return jsonify(ok=False, error="No encuentro el archivo de usuarios en el servidor."), 500
idx = next((i for i, l in enumerate(lineas)
if ":" in l and l.split(":", 1)[0] == destino), None)
if idx is None:
return jsonify(ok=False, error=f"El usuario {destino} no existe."), 404
guardado = lineas[idx].split(":", 1)[1]
if not es_reset:
partes = guardado.split("$")
if not guardado.startswith("$apr1$") or len(partes) < 4:
return jsonify(ok=False, error="Tu contraseña está guardada en un formato que este "
"servicio no puede verificar. Pide a Leo que la restablezca."), 400
if apr1(actual, partes[2]) != guardado:
return jsonify(ok=False, error="La contraseña actual no es correcta."), 403
salt = "".join(ITOA64[b % 64] for b in os.urandom(8))
lineas[idx] = destino + ":" + apr1(nueva, 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 (permisos de hbox-docs.htpasswd)."), 500
except OSError as e:
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)
# ------------------------------------------------- expediente de una unidad
def _listar_adjuntos(serie):
"""Todas las fotos y logs subidos para esa serie, de cualquier tipo de acta."""
fotos, logs = [], []
if not os.path.isdir(ADJ):
return fotos, logs
for tipo in sorted(os.listdir(ADJ)):
base = os.path.join(ADJ, tipo, serie)
if not os.path.isdir(base):
continue
for fn in sorted(os.listdir(base)):
fp = os.path.join(base, fn)
if os.path.isfile(fp):
fotos.append({"tipo": tipo, "ruta": f"{tipo}/{serie}/{fn}",
"bytes": os.path.getsize(fp), "ts": int(os.path.getmtime(fp))})
dlogs = os.path.join(base, "logs")
if os.path.isdir(dlogs):
for fn in sorted(os.listdir(dlogs)):
fp = os.path.join(dlogs, fn)
if os.path.isfile(fp):
logs.append({"tipo": tipo, "ruta": f"{tipo}/{serie}/logs/{fn}",
"nombre": fn[16:] if len(fn) > 16 else fn,
"bytes": os.path.getsize(fp), "ts": int(os.path.getmtime(fp))})
return fotos, logs
@app.get("/api/unidad")
def expediente():
"""Todo lo que existe en el servidor sobre una serie: actas de cualquier tipo,
fotos, archivos de log, y las actas de otras unidades que la mencionan como componente."""
serie = slug(request.args.get("serie"), 24)
if not serie:
return jsonify(ok=False, error="falta serie"), 400
actas, relacionadas = [], []
if os.path.isdir(BASE):
for tipo in sorted(os.listdir(BASE)):
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta):
continue
for fn in sorted(os.listdir(carpeta)):
if not fn.endswith(".json"):
continue
try:
with open(os.path.join(carpeta, fn), encoding="utf-8") as f:
d = json.load(f)
except Exception:
continue
fila = {"tipo": tipo, "archivo": fn, "serie": d.get("serie"),
"recibido": d.get("_recibido"), "fecha": d.get("fecha"),
"tecnico": d.get("tecnico"), "resultado": d.get("resultado"),
"clasificacion": d.get("clasificacion"), "accion": d.get("accion"),
"motivo": d.get("motivo"), "maquina": d.get("maquina_id") or d.get("maquina"),
"sitio": d.get("sitio") or None,
"fotos": [f2.get("ruta") for f2 in (d.get("fotos") or []) if f2.get("ruta")],
"archivos": [{"ruta": a.get("ruta"), "nombre": a.get("nombre")}
for a in (d.get("archivos") or []) if a.get("ruta")]}
if d.get("serie") == serie:
if isinstance(d.get("componentes"), dict):
fila["componentes"] = d["componentes"] # ensamble/integración: para la cadena de producción
actas.append(fila)
elif isinstance(d.get("componentes"), dict) and serie in d["componentes"].values():
fila["componentes"] = d["componentes"]
relacionadas.append(fila)
# Orden del expediente: manda la FECHA que anotó el técnico (la de la visita);
# la hora de recepción solo desempata. Un acta capturada días después no debe
# aparecer como si el trabajo se hubiera hecho ese día.
def _clave(r):
return ((r.get("fecha") or (r.get("recibido") or "")[:10]), r.get("recibido") or "")
actas.sort(key=_clave, reverse=True)
relacionadas.sort(key=_clave, reverse=True)
fotos, logs = _listar_adjuntos(serie)
marca = estado_serie(serie)
return jsonify(ok=True, serie=serie, actas=actas, relacionadas=relacionadas,
marca=marca["marca"], marca_acta=marca["acta"],
fotos=fotos, logs=logs,
total=len(actas), total_fotos=len(fotos), total_logs=len(logs))
@app.get("/api/series")
def series():
"""Todas las series que tienen algo guardado — para el buscador del expediente."""
vistas = {}
if os.path.isdir(BASE):
for tipo in sorted(os.listdir(BASE)):
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta):
continue
for fn in os.listdir(carpeta):
if fn.endswith(".json"):
ser = fn.rsplit("_", 1)[0] if "_" in fn else fn[:-5]
ser = fn.split("_2")[0] if "_2" in fn else ser
vistas.setdefault(ser, set()).add(tipo)
return jsonify(ok=True, series=[{"serie": k, "tipos": sorted(v)}
for k, v in sorted(vistas.items())])
# ------------------------------------------------- estado de una serie (daños abiertos)
# Un acta de tipo 'dano' MARCA la serie. La marca se levanta con otra acta 'dano' cuyo
# campo 'accion' empiece por "Reparada" o "Baja": lo último que se dijo, manda.
def estado_serie(serie):
"""Devuelve {'marca': None|'DANADA'|'BAJA', 'acta': {...}|None} para una serie."""
carpeta = os.path.join(BASE, "dano")
ultimo = None
# listar_serie() ordena por nombre = cronológico (los repetibles llevan microsegundos).
# Manda la ÚLTIMA acta registrada, no la de fecha de visita más reciente: si alguien
# captura hoy un daño de la semana pasada, hoy es cuando la serie se marca.
for fn in listar_serie("dano", serie) if os.path.isdir(carpeta) else []:
try:
with open(os.path.join(carpeta, fn), encoding="utf-8") as f:
d = json.load(f)
except Exception:
continue
if "PRUEBA" in serie.upper():
continue
ultimo = {"archivo": fn, "fecha": d.get("fecha"), "recibido": d.get("_recibido"),
"tecnico": d.get("tecnico"), "clasificacion": d.get("clasificacion"),
"accion": d.get("accion"), "resumen": d.get("resumen")}
if not ultimo:
return {"marca": None, "acta": None}
acc = str(ultimo.get("accion") or "")
if acc.startswith("Baja"):
return {"marca": "BAJA", "acta": ultimo}
if acc.startswith("Reparada"):
return {"marca": None, "acta": ultimo}
return {"marca": "DANADA", "acta": ultimo}
@app.get("/api/estado")
def estado():
"""Marca de daño/baja de una serie — lo consultan TODOS los formularios al elegirla."""
serie = slug(request.args.get("serie"), 24)
if not serie:
return jsonify(ok=False, error="falta serie"), 400
e = estado_serie(serie)
return jsonify(ok=True, serie=serie, marca=e["marca"], acta=e["acta"])
@app.get("/api/danos")
def danos():
"""Todas las series con marca vigente (dañada o baja), para el tablero."""
out = []
carpeta = os.path.join(BASE, "dano")
if os.path.isdir(carpeta):
vistas = set()
for fn in sorted(os.listdir(carpeta)):
if fn.endswith(".json"):
ser = fn.split("_2")[0] if "_2" in fn else fn[:-5]
vistas.add(ser)
for ser in sorted(vistas):
e = estado_serie(ser)
if e["marca"]:
out.append({"serie": ser, "marca": e["marca"], "acta": e["acta"]})
return jsonify(ok=True, total=len(out), series=out)
# =====================================================================================
# Colecciones 13-oct-2026: tareas (tablero kanban), plan de producción, inventario, sitios
# =====================================================================================
# Mismo esquema que las actas: un archivo JSON por registro dentro de su carpeta, más un
# _indice.csv que se reescribe en cada cambio (para abrirlo en Excel). Los directorios se
# toman de variables de entorno y, si no están, viven junto a ACTAS_DIR.
DATOS_BASE = os.path.dirname(BASE.rstrip("/")) or "/srv/projects/drogheda"
TAREAS_DIR = os.environ.get("TAREAS_DIR", os.path.join(DATOS_BASE, "tareas"))
INVENTARIO_DIR = os.environ.get("INVENTARIO_DIR", os.path.join(DATOS_BASE, "inventario"))
SITIOS_DIR = os.environ.get("SITIOS_DIR", os.path.join(DATOS_BASE, "sitios"))
PLAN_FILE = os.environ.get("PLAN_FILE", os.path.join(DATOS_BASE, "plan-produccion.json"))
EQUIPO_DIR = os.environ.get("EQUIPO_DIR", os.path.join(DATOS_BASE, "equipo"))
# Candado para leer-modificar-escribir el htpasswd desde los endpoints de accesos (gunicorn corre
# 2 workers). Vive en la carpeta de datos porque /etc/nginx no es escribible por el servicio.
HTPASSWD_LOCK = os.environ.get("HTPASSWD_LOCK", os.path.join(DATOS_BASE, ".htpasswd.lock"))
PISTAS = ("ingenieria", "produccion", "mecanica", "compras", "campo", "gestion") # gestion: finanzas, trámites, juntas (22-oct)
# Sitio de cada pendiente (22-oct): dónde pasa el trabajo. Un pendiente puede tocar varios sitios.
# (id, nombre, corto). qro = Querétaro: fabricación e ingeniería (oficina).
SITIOS_TAREA = (("icave", "ICAVE · Veracruz", "ICAVE"), ("timsa", "TIMSA · Manzanillo", "TIMSA"),
("eit", "EIT · Ensenada", "EIT"), ("lzc", "LCT · Lázaro Cárdenas", "LCT"),
("qro", "Querétaro · fabricación e ingeniería", "QRO"))
SITIOS_TAREA_IDS = tuple(s[0] for s in SITIOS_TAREA)
SITIO_OMISION = ["qro"] # pendientes sin sitio (los de antes del 22-oct) cuentan como Querétaro
ALIAS_SITIO = {"lct": "lzc", "lazarocardenas": "lzc", "lazaro-cardenas": "lzc", "veracruz": "icave",
"manzanillo": "timsa", "ensenada": "eit", "queretaro": "qro", "oficina": "qro"}
AREAS = ("direccion", "operaciones", "ingenieria", "layout", "produccion", "pruebas", "fabricacion",
"software", "campo", "asistente")
ESTADOS = ("por-hacer", "en-curso", "bloqueado", "hecho")
VERSIONES_TARJETA = ("V5.5", "V5.6", "V5.7", "v6", "—")
CATEGORIAS_INV = ("tarjeta", "modulo", "gabinete", "caja", "fuente", "bateria", "antena", "cable",
"conector", "componente-critico", "instalacion", "otro")
UNIDADES_INV = ("pza", "m")
# Inventario por sitio (23-oct): entrada, salida y ajuste (conteo físico) pasan EN un sitio; «envio» sale de un sitio
# y queda en camino hasta que se registra su «recepcion» en el destino. Lo de antes del 23-oct es de Querétaro.
TIPOS_MOV = ("entrada", "salida", "ajuste", "envio", "recepcion")
SITIO_INV_OMISION = "qro"
# Origen de un envío que no sale de ningún sitio del inventario: una compra que viene en camino, o material que
# salió de un puerto antes de que ahí se contara. No descuenta de nadie; entra al total al registrarse (en camino).
ORIGEN_FUERA = "fuera"
ALIAS_FUERA = ("fuera", "proveedor", "externo", "compra")
# Tipos de acta que "pasan por un sitio": son los que se listan en la página de cada sitio.
TIPOS_SITIO = ("envio", "recepcion", "instalacion-campo", "diagnostico-campo", "dano")
FECHA_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def ahora():
return datetime.datetime.now().replace(microsecond=0).isoformat()
def quien():
"""Usuario que escribe. Las colecciones nuevas exigen identificarlo: sin X-Remote-User no se escribe."""
return current_user()
def _sin_usuario():
return jsonify(ok=False, error="No puedo identificarte (falta X-Remote-User en nginx). "
"Vuelve a iniciar sesión; si sigue, avisa a Leo."), 403
def _json_body():
try:
d = request.get_json(force=True)
except Exception:
return None
return d if isinstance(d, dict) else None
def _leer_json(path):
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _escribir_json(path, d):
"""Escritura atómica: primero a un temporal en la misma carpeta, luego os.replace."""
carpeta = os.path.dirname(path) or "."
os.makedirs(carpeta, exist_ok=True)
tmp = os.path.join(carpeta, ".tmp-" + uuid.uuid4().hex[:8] + ".json")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
def _listar_json(carpeta):
out = []
if not os.path.isdir(carpeta):
return out
for fn in sorted(os.listdir(carpeta)):
if fn.endswith(".json") and not fn.startswith("."):
d = _leer_json(os.path.join(carpeta, fn))
if isinstance(d, dict):
out.append(d)
return out
def _reescribir_indice(carpeta, registros, columnas):
"""_indice.csv de la colección completa (pequeña): se regenera entero en cada cambio."""
if not os.path.isdir(carpeta):
return
idx = os.path.join(carpeta, "_indice.csv")
with open(idx, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(columnas)
for r in registros:
fila = []
for c in columnas:
v = r.get(c, "")
if isinstance(v, list):
v = " | ".join(str(x.get("titulo", x) if isinstance(x, dict) else x) for x in v)
fila.append("" if v is None else v)
w.writerow(fila)
def _hist(d, usuario, cambio):
d.setdefault("historial", []).append({"quien": usuario, "cuando": ahora(), "cambio": cambio})
d["modificado_por"] = usuario
d["modificado_en"] = ahora()
def _corto(v, n=60):
if isinstance(v, list):
return f"[{len(v)}]" if len(v) > 3 else ", ".join(str(x.get("titulo", x) if isinstance(x, dict) else x) for x in v)
s = "" if v is None else str(v)
return s if len(s) <= n else s[:n - 1] + "…"
def _texto(v, n=4000):
return str(v if v is not None else "").strip()[:n]
def _entero(v, defecto=0):
try:
return int(v)
except Exception:
return defecto
def _bool(v, defecto=True):
if isinstance(v, bool):
return v
if v in (None, ""):
return defecto
return str(v).strip().lower() in ("1", "true", "si", "sí", "yes")
def _fecha_o_none(v):
s = _texto(v, 10)
return s if s and FECHA_RE.match(s) else None
def _enlaces(v):
out = []
for e in (v if isinstance(v, list) else []):
if isinstance(e, dict) and e.get("url"):
url = _texto(e.get("url"), 500)
if url.lower().startswith(("javascript:", "data:")):
continue
out.append({"titulo": _texto(e.get("titulo"), 120) or url, "url": url})
return out[:30]
def _lista_textos(v, n=30, largo=60):
if isinstance(v, str):
v = [x for x in re.split(r"[,\n;]+", v)]
return [_texto(x, largo) for x in (v if isinstance(v, list) else []) if _texto(x, largo)][:n]
def _ruta_registro(carpeta, rid):
return os.path.join(carpeta, rid + ".json")
# ------------------------------------------------------------------ equipo (miembros) y accesos
# Un miembro del equipo es quien puede ser responsable de un pendiente. Tener login al sitio es
# otra cosa: tiene_login se deduce del htpasswd al leer, nunca se guarda.
COLS_EQUIPO = ["id", "nombre", "rol", "area", "sitio", "correo", "correo_alt", "telefono", "activo", "contrasena_inicial",
"creado_por", "creado_en", "modificado_por", "modificado_en"]
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):
"""'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
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:
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:
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 _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():
"""{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 = {}
miembros = _miembros()
for t in _listar_json(TAREAS_DIR):
ids, _ = _ids_responsable(t.get("responsable"), miembros) # con el equipo de hoy (ver GET /api/tareas)
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())
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.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"),
"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))
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
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")
_guardar_miembro(t)
_registrar_acceso(yo, "alta", usuario)
t["tiene_login"] = True
t["es_admin"] = usuario in ADMINS
return jsonify(ok=True, usuario=usuario, miembro=t, nuevo_miembro=existente is None), 201
@app.delete("/api/usuario")
def usuario_borrar():
"""Quita el acceso (línea del htpasswd) y desactiva al miembro. Nunca borra el miembro ni sus pendientes."""
if not is_admin():
return jsonify(ok=False, error="Solo un administrador quita accesos."), 403
yo = quien()
if not yo:
return _sin_usuario()
usuario = _texto(request.args.get("u"), 30).lower()
if not usuario:
return jsonify(ok=False, error="falta u"), 400
if usuario == yo.lower():
return jsonify(ok=False, error="No puedes quitarte el acceso a ti mismo."), 400
with _candado_htpasswd():
lineas = _leer_htpasswd()
if lineas is None:
return jsonify(ok=False, error="No encuentro el archivo de usuarios en el servidor (HTPASSWD_FILE)."), 500
presentes = {l.split(":", 1)[0].strip().lower() for l in lineas if ":" in l}
if usuario not in presentes:
return jsonify(ok=False, error=f"El usuario {usuario} no tiene acceso."), 404
if usuario in ADMINS and len(presentes & ADMINS) <= 1:
return jsonify(ok=False, error="Es el último administrador con acceso; no se puede quitar."), 409
restantes = [l for l in lineas if not (":" in l and l.split(":", 1)[0].strip().lower() == usuario)]
try:
_escribir_htpasswd(restantes)
except PermissionError:
return jsonify(ok=False, error="El servicio no tiene permiso de escribir el archivo de usuarios. "
"Avisa a Leo (chown leo:www-data + chmod 640 de hbox-docs.htpasswd)."), 500
except OSError as e:
return jsonify(ok=False, error="No se pudo guardar: " + str(e)), 500
m = _leer_json(_ruta_registro(EQUIPO_DIR, usuario))
if m:
m["activo"] = False
_hist(m, yo, "acceso al sitio retirado; miembro desactivado")
_guardar_miembro(m)
_registrar_acceso(yo, "baja", usuario)
return jsonify(ok=True, usuario=usuario, miembro_desactivado=bool(m))
# ------------------------------------------------------------------ tareas (tablero)
COLS_TAREAS = ["id", "titulo", "pista", "estado", "responsable", "responsable_ids", "fecha_limite", "version_tarjeta", "sitios",
"orden", "etiquetas", "bloqueada_por", "creado_por", "creado_en", "modificado_por", "modificado_en"]
def _ids_responsable(texto, miembros=None):
"""'leo, Jesús / claude' → (['leo','jesus','claude'], []). Partes que no son miembros se devuelven
como texto libre (segunda lista) para avisar; no se rechazan."""
miembros = _miembros() if miembros is None else miembros
ids, libres = [], []
for parte in re.split(r"[,;/]+", str(texto or "")):
parte = parte.strip()
if not parte:
continue
tokens = [_norm_id(x) for x in parte.split()]
if tokens and all(x in miembros for x in tokens):
ids.extend(x for x in tokens if x not in ids)
elif _norm_id(parte.replace(" ", "")) in miembros:
rid = _norm_id(parte.replace(" ", ""))
if rid not in ids:
ids.append(rid)
else:
libres.append(parte)
return ids, libres
def _tarea_valida(d, base=None):
"""Normaliza los campos de una tarjeta del tablero. Devuelve (tarea, error).
Con base=None es un alta (todos los campos con su valor por defecto); con base es un PATCH
(solo se tocan las llaves que vienen)."""
t = dict(base) if base else {}
alta = base is None
def pon(k, val):
t[k] = val
if alta or "titulo" in d:
titulo = _texto(d.get("titulo"), 200)
if not titulo:
return None, "Falta el título del pendiente."
pon("titulo", titulo)
if alta or "descripcion" in d:
pon("descripcion", _texto(d.get("descripcion"), 4000))
if alta or "pista" in d:
p = _texto(d.get("pista"), 20).lower() or "produccion"
if p not in PISTAS:
return None, f"Pista inválida: {p}. Usa una de {', '.join(PISTAS)}."
pon("pista", p)
if alta or "estado" in d:
e = _texto(d.get("estado"), 20).lower() or "por-hacer"
if e not in ESTADOS:
return None, f"Estado inválido: {e}. Usa uno de {', '.join(ESTADOS)}."
pon("estado", e)
if alta or "responsable" in d:
# Obligatorio: ids de miembros del equipo separados por coma/espacio (o texto libre si la
# persona aún no está dada de alta; se acepta y se avisa). 'Jesús' ≡ 'jesus'.
texto = _texto(d.get("responsable") if not isinstance(d.get("responsable"), list)
else ", ".join(str(x) for x in d.get("responsable")), 120)
if not texto:
return None, "Falta el responsable del pendiente. Elige a alguien del equipo."
ids, libres = _ids_responsable(texto)
pon("responsable", ", ".join(ids + libres))
pon("responsable_ids", ids)
if libres:
t["_aviso"] = ("Responsable(s) sin registrar en el equipo: " + ", ".join(libres)
+ ". Se guarda como texto; da de alta a la persona en Equipo para que cuente en su lista.")
elif base is not None and "responsable_ids" not in t:
t["responsable_ids"] = _ids_responsable(t.get("responsable"))[0] # pendientes de antes del 14-oct
if alta or "fecha_limite" in d:
f = d.get("fecha_limite")
if f not in (None, "") and not _fecha_o_none(f):
return None, "La fecha límite debe ser AAAA-MM-DD."
pon("fecha_limite", _fecha_o_none(f))
if alta or "enlaces" in d:
pon("enlaces", _enlaces(d.get("enlaces")))
if alta or "bloqueada_por" in d:
pon("bloqueada_por", [slug(x, 60) for x in (d.get("bloqueada_por") or []) if slug(x, 60)][:20])
if alta or "orden" in d:
pon("orden", _entero(d.get("orden"), 0))
if alta or "etiquetas" in d:
pon("etiquetas", _lista_textos(d.get("etiquetas"), 15, 40))
if alta or "version_tarjeta" in d:
v = _texto(d.get("version_tarjeta"), 10) or "—"
if v not in VERSIONES_TARJETA:
return None, f"Versión de tarjeta inválida: {v}. Usa una de {', '.join(VERSIONES_TARJETA)}."
pon("version_tarjeta", v)
if alta or "sitios" in d:
ids, err = _sitios_validos(d.get("sitios"), alta)
if err:
return None, err
pon("sitios", ids)
return t, None
def _sitios_validos(v, alta):
"""['eit', 'Manzanillo'] → (['eit', 'timsa'], None). Vacío: en un alta, Querétaro; en un cambio, error."""
if v in (None, "", []):
if alta:
return list(SITIO_OMISION), None
return None, "Elige al menos un sitio (" + ", ".join(SITIOS_TAREA_IDS) + ")."
if isinstance(v, str):
v = [x for x in re.split(r"[,;/\s]+", v) if x]
if not isinstance(v, list):
return None, "«sitios» es una lista de ids: " + ", ".join(SITIOS_TAREA_IDS) + "."
ids = []
for x in v:
k = _norm_id(x)
k = ALIAS_SITIO.get(k, k)
if k not in SITIOS_TAREA_IDS:
return None, (f"Sitio inválido: {x}. Usa: icave (Veracruz), timsa (Manzanillo), eit (Ensenada), "
"lzc (LCT, Lázaro Cárdenas) o qro (Querétaro).")
if k not in ids:
ids.append(k)
return ids, None
def _guardar_tarea(t):
_escribir_json(_ruta_registro(TAREAS_DIR, t["id"]), t)
_reescribir_indice(TAREAS_DIR, _listar_json(TAREAS_DIR), COLS_TAREAS)
def _vencida(t, hoy=None):
hoy = hoy or datetime.date.today().isoformat()
return bool(t.get("fecha_limite")) and t.get("estado") != "hecho" and t["fecha_limite"] < hoy
@app.get("/api/tareas")
def tareas_listar():
pista = _texto(request.args.get("pista"), 20).lower()
estado = _texto(request.args.get("estado"), 20).lower()
resp = _texto(request.args.get("responsable"), 80).lower()
resp_id = _norm_id(resp)
version = _texto(request.args.get("version"), 10)
sitio = {ALIAS_SITIO.get(_norm_id(x), _norm_id(x)) for x in _texto(request.args.get("sitio"), 80).split(",") if x.strip()}
hoy = datetime.date.today().isoformat()
miembros = _miembros()
out = []
for t in _listar_json(TAREAS_DIR):
# 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]
if not t.get("sitios"): # pendientes de antes del 22-oct: Querétaro
t["sitios"] = list(SITIO_OMISION)
if sitio and not sitio.intersection(t["sitios"]):
continue
if pista and t.get("pista") != pista:
continue
if estado and t.get("estado") != estado:
continue
if resp and not (resp_id in t["responsable_ids"] or resp in str(t.get("responsable", "")).lower()):
continue
if version and t.get("version_tarjeta") != version:
continue
t["vencida"] = _vencida(t, hoy)
out.append(t)
out.sort(key=lambda t: (ESTADOS.index(t.get("estado")) if t.get("estado") in ESTADOS else 9,
t.get("orden", 0), t.get("creado_en", "")))
equipo = {k: {"nombre": m.get("nombre"), "activo": m.get("activo", True), "area": m.get("area")} for k, m in miembros.items()}
return jsonify(ok=True, total=len(out), tareas=out, pistas=PISTAS, estados=ESTADOS, equipo=equipo,
versiones=VERSIONES_TARJETA, sitios=[{"id": i, "nombre": n, "corto": c} for i, n, c in SITIOS_TAREA],
admin=is_admin(), user=current_user(), puede_borrar=_puede_borrar_tarea(),
borran=sorted(ADMINS | TAREAS_BORRAN))
@app.post("/api/tareas")
def tareas_crear():
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 = _tarea_valida(d)
if err:
return jsonify(ok=False, error=err), 400
rid = slug(d.get("id"), 60) or ("T" + datetime.datetime.now().strftime("%y%m%d") + "-" + uuid.uuid4().hex[:4])
if os.path.exists(_ruta_registro(TAREAS_DIR, rid)):
return jsonify(ok=False, existe=True, error=f"Ya existe el pendiente {rid}."), 409
t["id"] = rid
if "orden" not in d:
# al final de su columna
mismos = [x for x in _listar_json(TAREAS_DIR) if x.get("estado") == t["estado"]]
t["orden"] = (max([x.get("orden", 0) for x in mismos]) + 1) if mismos else 0
t["creado_por"] = u
t["creado_en"] = ahora()
t["modificado_por"] = u
t["modificado_en"] = t["creado_en"]
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "creado en " + t["estado"]}]
aviso = t.pop("_aviso", None)
_guardar_tarea(t)
t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t, aviso=aviso), 201
@app.patch("/api/tarea")
def tarea_modificar():
u = quien()
if not u:
return _sin_usuario()
rid = slug(request.args.get("id"), 60)
if not rid:
return jsonify(ok=False, error="falta id"), 400
ruta = _ruta_registro(TAREAS_DIR, rid)
base = _leer_json(ruta)
if not base:
return jsonify(ok=False, error=f"No existe el pendiente {rid}."), 404
if not base.get("sitios"): # pendiente de antes del 22-oct: es de Querétaro
base["sitios"] = list(SITIO_OMISION)
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
# id, auditoría e historial no se editan por aquí
for k in ("id", "creado_por", "creado_en", "modificado_por", "modificado_en", "historial"):
d.pop(k, None)
t, err = _tarea_valida(d, base)
if err:
return jsonify(ok=False, error=err), 400
aviso = t.pop("_aviso", None)
cambios = []
for k in d:
if k in t and t.get(k) != base.get(k):
if k == "orden":
continue # la posición dentro de la columna no ensucia el historial
cambios.append(f"{k}: {_corto(base.get(k))} → {_corto(t.get(k))}")
nota = _texto(d.get("_nota"), 300)
if not cambios and not nota:
if t.get("orden") != base.get("orden") or t.get("responsable_ids") != base.get("responsable_ids"):
# solo cambió la posición (o se completaron los ids): se guarda sin entrada en el historial
t["modificado_por"] = u
t["modificado_en"] = ahora()
_guardar_tarea(t)
t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t, aviso=aviso)
t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t, sin_cambios=True, aviso=aviso)
_hist(t, u, ("; ".join(cambios) + (" — " + nota if nota else "")) if cambios else nota)
_guardar_tarea(t)
t["vencida"] = _vencida(t)
return jsonify(ok=True, tarea=t, aviso=aviso)
@app.post("/api/tareas/orden")
def tareas_orden():
"""Renumera una columna completa: {estado, ids:[...]} → orden 0..n. No escribe historial."""
u = quien()
if not u:
return _sin_usuario()
d = _json_body() or {}
estado = _texto(d.get("estado"), 20).lower()
ids = [slug(x, 60) for x in (d.get("ids") or []) if slug(x, 60)]
if estado not in ESTADOS or not ids:
return jsonify(ok=False, error="Se esperaba {estado, ids:[...]}"), 400
n = 0
for i, rid in enumerate(ids):
ruta = _ruta_registro(TAREAS_DIR, rid)
t = _leer_json(ruta)
if not t:
continue
if t.get("estado") != estado or t.get("orden") != i:
if t.get("estado") != estado:
_hist(t, u, f"estado: {t.get('estado')} → {estado}")
t["estado"] = estado
t["orden"] = i
t["modificado_por"] = u
t["modificado_en"] = ahora()
_escribir_json(ruta, t)
n += 1
if n:
_reescribir_indice(TAREAS_DIR, _listar_json(TAREAS_DIR), COLS_TAREAS)
return jsonify(ok=True, actualizadas=n)
# Quién borra pendientes (22-oct, Leo): los administradores (ACTAS_ADMINS) y TAREAS_BORRAN, por omisión
# adriana y gibranlp. Borrar no destruye: el pendiente pasa a tareas/_borrados/ con su historial (sigue en la
# Actividad) y se recupera moviendo el archivo de vuelta. Quien lo esperaba («bloqueada_por») deja de esperarlo.
TAREAS_BORRAN = {u.strip().lower() for u in os.environ.get("TAREAS_BORRAN", "adriana,gibranlp").split(",") if u.strip()}
TAREAS_BORRADOS = os.path.join(TAREAS_DIR, "_borrados")
def _puede_borrar_tarea():
u = current_user().lower()
return bool(u) and (u in ADMINS or u in TAREAS_BORRAN)
def _y_lista(xs):
xs = [str(x) for x in xs if x]
return " y ".join([", ".join(xs[:-1]), xs[-1]]) if len(xs) > 1 else (xs[0] if xs else "")
@app.delete("/api/tarea")
def tarea_borrar():
u = quien()
if not u:
return _sin_usuario()
if not _puede_borrar_tarea():
nombres = {k: {"Leopoldo": "Leo"}.get((m.get("nombre") or k).split(" ")[0], (m.get("nombre") or k).split(" ")[0])
for k, m in _miembros().items()}
return jsonify(ok=False, error="Solo " + _y_lista([nombres.get(x, x) for x in sorted(ADMINS | TAREAS_BORRAN)])
+ " pueden borrar pendientes. Si ya no aplica, muévelo a Hecho."), 403
rid = slug(request.args.get("id"), 60)
if not rid:
return jsonify(ok=False, error="falta id"), 400
ruta = _ruta_registro(TAREAS_DIR, rid)
t = _leer_json(ruta) if os.path.isfile(ruta) else None
if not isinstance(t, dict):
return jsonify(ok=False, error=f"No existe el pendiente {rid}."), 404
_hist(t, u, "borró el pendiente")
t["borrado_por"], t["borrado_en"] = u, t["modificado_en"]
destino = os.path.join(TAREAS_BORRADOS, rid + "__" + re.sub(r"\D", "", t["borrado_en"]) + ".json")
_escribir_json(destino, t) # primero la copia en la papelera; después se quita del tablero
os.remove(ruta)
sueltos = []
for o in _listar_json(TAREAS_DIR):
if rid in (o.get("bloqueada_por") or []):
o["bloqueada_por"] = [x for x in o["bloqueada_por"] if x != rid]
_hist(o, u, f"Ya no espera a «{_corto(t.get('titulo'))}»: se borró ese pendiente.")
_escribir_json(_ruta_registro(TAREAS_DIR, o["id"]), o)
sueltos.append(o["id"])
_reescribir_indice(TAREAS_DIR, _listar_json(TAREAS_DIR), COLS_TAREAS)
return jsonify(ok=True, borrado=rid, papelera=os.path.basename(destino), dependencias=sueltos)
@app.get("/api/tareas/resumen")
def tareas_resumen():
hoy = datetime.date.today().isoformat()
conteo = {p: {e: 0 for e in ESTADOS} for p in PISTAS}
vencidas = []
total = 0
miembros = _miembros()
por_resp = {}
for t in _listar_json(TAREAS_DIR):
total += 1
p, e = t.get("pista"), t.get("estado")
if p in conteo and e in conteo[p]:
conteo[p][e] += 1
ids = t["responsable_ids"] = _ids_responsable(t.get("responsable"), miembros)[0] # con el equipo de hoy
for rid in (ids or ["_sin_equipo"]):
r = por_resp.setdefault(rid, _cuenta_vacia())
if e in ESTADOS:
r[e] += 1
if e != "hecho":
r["abiertos"] += 1
if _vencida(t, hoy):
r["vencidas"] += 1
if _vencida(t, hoy):
vencidas.append({k: t.get(k) for k in ("id", "titulo", "pista", "estado", "responsable", "responsable_ids", "fecha_limite")})
vencidas.sort(key=lambda x: x.get("fecha_limite") or "")
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_DEFECTO = {
"pedido_total": 570,
"nota": "Pedido de 570 HBOX (13-oct-2026). Los reemplazos futuros se hacen en v6.",
"versiones": [
{"version": "V5.6", "objetivo": 113, "nota": "con Mean Well SD-15B", "serie_prefijo": "V56", "fecha": None},
{"version": "V5.7", "objetivo": 327, "nota": "fuente a bordo", "serie_prefijo": "V57", "fecha": None},
{"version": "v6", "objetivo": 130, "nota": "+ reemplazos futuros", "serie_prefijo": "V6", "fecha": None},
],
# Embudo: cada etapa dice de qué tipo(s) de acta se cuenta. 'ok' = solo resultado distinto de FALLA.
"etapas": [
{"clave": "tarjeta", "nombre": "Tarjeta probada", "tipos": ["prueba-v5"], "ok": True},
{"clave": "modulos", "nombre": "Módulos (ArduSimple y 4G)", "tipos": ["modulo-ard", "modulo-4g"], "ok": True},
{"clave": "integracion", "nombre": "Integración", "tipos": ["integracion"], "ok": True},
{"clave": "ensamble", "nombre": "Ensamble", "tipos": ["ensamble"], "ok": True},
{"clave": "prueba-final", "nombre": "Prueba final", "tipos": ["ensamble"], "ok": True,
"nota": "va dentro del acta de ensamble"},
{"clave": "configuracion", "nombre": "Configuración", "tipos": ["reflasheo", "prueba-ethernet-serie"], "ok": True,
"solo_unidades": True},
{"clave": "envio", "nombre": "Envío", "tipos": ["envio"], "ok": False, "solo_unidades": True},
{"clave": "instalacion", "nombre": "Instalación", "tipos": ["instalacion-campo"], "ok": True, "solo_unidades": True},
],
"actualizado_por": None,
"actualizado_en": None,
}
@app.get("/api/plan")
def plan_leer():
p = _leer_json(PLAN_FILE)
if not p:
p = dict(PLAN_DEFECTO)
p["_default"] = True
return jsonify(ok=True, plan=p, admin=is_admin())
@app.put("/api/plan")
def plan_escribir():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador puede cambiar el plan."), 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
base = _leer_json(PLAN_FILE) or dict(PLAN_DEFECTO)
p = {"pedido_total": _entero(d.get("pedido_total", base.get("pedido_total")), 0),
"nota": _texto(d.get("nota", base.get("nota")), 500)}
vers = []
for v in (d.get("versiones") if isinstance(d.get("versiones"), list) else base.get("versiones", [])):
if not isinstance(v, dict) or not _texto(v.get("version"), 10):
continue
vers.append({"version": _texto(v.get("version"), 10), "objetivo": _entero(v.get("objetivo"), 0),
"nota": _texto(v.get("nota"), 200), "serie_prefijo": slug(v.get("serie_prefijo"), 10),
"fecha": _fecha_o_none(v.get("fecha"))})
if not vers:
return jsonify(ok=False, error="El plan necesita al menos una versión con objetivo."), 400
p["versiones"] = vers[:10]
etapas = []
for e in (d.get("etapas") if isinstance(d.get("etapas"), list) else base.get("etapas", [])):
if not isinstance(e, dict) or not slug(e.get("clave"), 30):
continue
etapas.append({"clave": slug(e.get("clave"), 30), "nombre": _texto(e.get("nombre"), 80) or e.get("clave"),
"tipos": [slug(t) for t in (e.get("tipos") or []) if slug(t)][:6],
"ok": _bool(e.get("ok"), True), "solo_unidades": _bool(e.get("solo_unidades"), False),
"nota": _texto(e.get("nota"), 200)})
p["etapas"] = etapas[:12] or PLAN_DEFECTO["etapas"]
p["historial"] = (base.get("historial") or [])[-30:] + [{"quien": u, "cuando": ahora(), "cambio": "plan actualizado"}]
p["actualizado_por"] = u
p["actualizado_en"] = ahora()
_escribir_json(PLAN_FILE, p)
return jsonify(ok=True, plan=p)
# ------------------------------------------------------------------ inventario
INV_ITEMS = lambda: os.path.join(INVENTARIO_DIR, "items")
INV_MOVS = lambda: os.path.join(INVENTARIO_DIR, "movimientos")
COLS_INV = ["id", "codigo", "descripcion", "categoria", "aplica_a", "unidad", "stock", "minimo", "alerta", "por_instalacion",
"ubicacion", "proveedor", "codigo_proveedor", "modificado_por", "modificado_en"]
COLS_MOV = ["id", "cuando", "quien", "item_id", "tipo", "sitio", "destino", "estado", "cantidad", "delta", "delta_sitio",
"stock_despues", "motivo", "referencia"]
def _item_valido(d, base=None):
t = dict(base) if base else {}
alta = base is None
if alta or "codigo" in d:
c = _texto(d.get("codigo"), 40).upper()
if not c:
return None, "Falta el código del ítem."
t["codigo"] = c
if alta or "descripcion" in d:
desc = _texto(d.get("descripcion"), 200)
if not desc:
return None, "Falta la descripción."
t["descripcion"] = desc
if alta or "categoria" in d:
cat = _texto(d.get("categoria"), 30).lower() or "otro"
if cat not in CATEGORIAS_INV:
return None, f"Categoría inválida: {cat}. Usa una de {', '.join(CATEGORIAS_INV)}."
t["categoria"] = cat
if alta or "aplica_a" in d:
t["aplica_a"] = _lista_textos(d.get("aplica_a"), 8, 10)
if alta or "unidad" in d:
un = _texto(d.get("unidad"), 5).lower() or "pza"
if un not in UNIDADES_INV:
return None, "Unidad inválida: usa pza o m."
t["unidad"] = un
if alta or "minimo" in d:
t["minimo"] = max(0, _entero(d.get("minimo"), 0))
if alta or "por_instalacion" in d: # cuánto lleva UNA unidad instalada (0 = no es material de instalación)
t["por_instalacion"] = min(999, max(0, _entero(d.get("por_instalacion"), 0)))
for k, n in (("ubicacion", 120), ("proveedor", 120), ("codigo_proveedor", 60), ("notas", 1000)):
if alta or k in d:
t[k] = _texto(d.get(k), n)
if alta:
t["stock"] = 0
t["alerta"] = bool(t.get("minimo", 0)) and t.get("stock", 0) < t.get("minimo", 0)
return t, None
def _guardar_item(t):
t["alerta"] = bool(t.get("minimo", 0)) and t.get("stock", 0) < t.get("minimo", 0)
_escribir_json(_ruta_registro(INV_ITEMS(), t["id"]), t)
_reescribir_indice(INV_ITEMS(), _listar_json(INV_ITEMS()), COLS_INV)
def _movs_de(item_id):
return sorted([m for m in _listar_json(INV_MOVS()) if m.get("item_id") == item_id],
key=lambda m: m.get("cuando", ""))
def _stock_desde_movimientos(item_id):
return sum(_entero(m.get("delta"), 0) for m in _movs_de(item_id))
def _sitio_inv(v, omision=SITIO_INV_OMISION):
"""'Ensenada', 'EIT', 'eit' → 'eit'. Vacío → la omisión; un sitio que no existe → None."""
s = _norm_id(_texto(v, 40))
if not s:
return omision
s = ALIAS_SITIO.get(s, s)
return s if s in SITIOS_TAREA_IDS else None
def _mov_sitio(m):
return m.get("sitio") or SITIO_INV_OMISION # movimientos de antes del 23-oct: Querétaro
def _mov_delta_sitio(m):
return _entero(m.get("delta_sitio"), 0) if "delta_sitio" in m else _entero(m.get("delta"), 0)
def _stock_en_sitio(item_id, sitio):
return sum(_mov_delta_sitio(m) for m in _movs_de(item_id) if _mov_sitio(m) == sitio)
def _existencias():
"""En una pasada: {item_id: {"sitios": {sitio: n}, "camino": {destino: n}}}."""
out = {}
for m in _listar_json(INV_MOVS()):
iid = m.get("item_id")
if not iid:
continue
e = out.setdefault(iid, {"sitios": {}, "camino": {}})
st = _mov_sitio(m)
if st != ORIGEN_FUERA:
e["sitios"][st] = e["sitios"].get(st, 0) + _mov_delta_sitio(m)
if m.get("tipo") == "envio" and m.get("estado") == "en-camino":
dst = m.get("destino") or ""
e["camino"][dst] = e["camino"].get(dst, 0) + _entero(m.get("cantidad"), 0)
return out
def _envios_abiertos(items=None):
items = items if items is not None else {it["id"]: it for it in _listar_json(INV_ITEMS())}
out = []
for m in _listar_json(INV_MOVS()):
if m.get("tipo") == "envio" and m.get("estado") == "en-camino":
it = items.get(m.get("item_id")) or {}
e = {k: m.get(k) for k in ("id", "item_id", "codigo", "cantidad", "destino", "quien", "cuando", "motivo", "referencia")}
e.update(origen=_mov_sitio(m), descripcion=it.get("descripcion"), unidad=it.get("unidad"))
out.append(e)
out.sort(key=lambda x: x.get("cuando") or "")
return out
@app.get("/api/inventario")
def inventario_listar():
cat = _texto(request.args.get("categoria"), 30).lower()
aplica = _texto(request.args.get("aplica_a"), 10)
solo_alertas = request.args.get("alertas") == "1"
items = []
exis = _existencias()
todos = _listar_json(INV_ITEMS())
for it in todos:
e = exis.get(it.get("id")) or {}
it["por_sitio"] = {k: v for k, v in (e.get("sitios") or {}).items() if v}
it["en_camino"] = {k: v for k, v in (e.get("camino") or {}).items() if v}
it.setdefault("por_instalacion", 0)
it["alerta"] = bool(it.get("minimo", 0)) and it.get("stock", 0) < it.get("minimo", 0)
if cat and it.get("categoria") != cat:
continue
if aplica and aplica not in (it.get("aplica_a") or []):
continue
if solo_alertas and not it["alerta"]:
continue
items.append(it)
items.sort(key=lambda x: (CATEGORIAS_INV.index(x["categoria"]) if x.get("categoria") in CATEGORIAS_INV else 99,
x.get("codigo", "")))
return jsonify(ok=True, total=len(items), alertas=sum(1 for x in items if x["alerta"]), items=items,
categorias=CATEGORIAS_INV, unidades=UNIDADES_INV, tipos_movimiento=TIPOS_MOV,
sitios=[{"id": i, "nombre": n, "corto": c} for i, n, c in SITIOS_TAREA],
envios=_envios_abiertos({it["id"]: it for it in todos}),
admin=is_admin(), user=current_user())
@app.post("/api/inventario")
def inventario_crear():
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 = _item_valido(d)
if err:
return jsonify(ok=False, error=err), 400
rid = slug(d.get("id"), 40) or slug(t["codigo"], 40)
if not rid:
return jsonify(ok=False, error="El código debe tener letras o números."), 400
if os.path.exists(_ruta_registro(INV_ITEMS(), rid)):
return jsonify(ok=False, existe=True, error=f"Ya existe el ítem {rid}."), 409
for otro in _listar_json(INV_ITEMS()):
if otro.get("codigo") == t["codigo"]:
return jsonify(ok=False, existe=True, error=f"El código {t['codigo']} ya lo usa el ítem {otro.get('id')}."), 409
t["id"] = rid
t["creado_por"] = u
t["creado_en"] = ahora()
t["modificado_por"] = u
t["modificado_en"] = t["creado_en"]
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "ítem creado"}]
stock_inicial = _entero(d.get("stock_inicial"), 0)
sitio_inicial = _sitio_inv(d.get("sitio"))
if sitio_inicial is None:
return jsonify(ok=False, error="Sitio inválido para el stock inicial."), 400
_guardar_item(t)
if stock_inicial:
_registrar_movimiento(t, "entrada", stock_inicial, "stock inicial", "", u, sitio=sitio_inicial)
return jsonify(ok=True, item=t), 201
@app.patch("/api/inventario/item")
def inventario_modificar():
u = quien()
if not u:
return _sin_usuario()
rid = slug(request.args.get("id"), 40)
ruta = _ruta_registro(INV_ITEMS(), rid) if rid else ""
base = _leer_json(ruta) if rid else None
if not base:
return jsonify(ok=False, error=f"No existe el ítem {rid}."), 404
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
if "stock" in d:
return jsonify(ok=False, error="El stock no se edita a mano: registra un movimiento (entrada, salida o ajuste)."), 400
for k in ("id", "stock", "alerta", "creado_por", "creado_en", "modificado_por", "modificado_en", "historial"):
d.pop(k, None)
t, err = _item_valido(d, base)
if err:
return jsonify(ok=False, error=err), 400
if "codigo" in d and t["codigo"] != base.get("codigo"):
for otro in _listar_json(INV_ITEMS()):
if otro.get("id") != rid and otro.get("codigo") == t["codigo"]:
return jsonify(ok=False, error=f"El código {t['codigo']} ya lo usa el ítem {otro.get('id')}."), 409
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, item=t, sin_cambios=True)
_hist(t, u, "; ".join(cambios))
_guardar_item(t)
return jsonify(ok=True, item=t)
def _registrar_movimiento(item, tipo, cantidad, motivo, referencia, u, sitio=SITIO_INV_OMISION, destino=None, envio=None):
"""Escribe el movimiento y recalcula el stock del ítem como suma de todos sus movimientos.
Dos cuentas (23-oct): «delta» mueve el total del ítem (lo que tenemos, esté donde esté, incluido lo que va en
camino) y «delta_sitio» mueve lo que hay en «sitio». Un envío no cambia el total: sale del sitio de origen y queda
en camino; su recepción lo pone en el destino (si llegó menos, la diferencia baja del total). Un envío desde
ORIGEN_FUERA (compra en camino) sí sube el total desde que se registra y no descuenta de ningún sitio."""
actual = _stock_desde_movimientos(item["id"])
en_sitio = 0 if sitio == ORIGEN_FUERA else _stock_en_sitio(item["id"], sitio)
if tipo == "entrada":
delta = delta_sitio = cantidad
elif tipo == "salida":
delta = delta_sitio = -cantidad
elif tipo == "ajuste": # 'cantidad' es el conteo físico EN ESE SITIO; el delta es lo que falta para llegar a él
delta = delta_sitio = cantidad - en_sitio
elif tipo == "envio":
delta, delta_sitio = (cantidad, 0) if sitio == ORIGEN_FUERA else (0, -cantidad)
else: # recepción de un envío: llega 'cantidad' al destino
delta_sitio = cantidad
delta = cantidad - _entero(envio.get("cantidad"), 0)
m = {"id": "M" + datetime.datetime.now().strftime("%y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:4],
"item_id": item["id"], "codigo": item.get("codigo"), "tipo": tipo, "cantidad": cantidad, "delta": delta,
"sitio": sitio, "delta_sitio": delta_sitio,
"motivo": motivo, "referencia": referencia, "quien": u, "cuando": ahora(),
"stock_antes": actual, "stock_despues": actual + delta,
"en_sitio_antes": en_sitio, "en_sitio_despues": en_sitio + delta_sitio}
if tipo == "envio":
m["destino"], m["estado"] = destino, "en-camino"
if envio is not None:
m["envio_id"], m["origen"] = envio["id"], _mov_sitio(envio)
_escribir_json(_ruta_registro(INV_MOVS(), m["id"]), m)
if envio is not None: # el envío queda cerrado: ya no está en camino
envio.update(estado="recibido", recibido=cantidad, recepcion_id=m["id"], recibido_por=u, recibido_en=m["cuando"])
_escribir_json(_ruta_registro(INV_MOVS(), envio["id"]), envio)
_reescribir_indice(INV_MOVS(), _listar_json(INV_MOVS()), COLS_MOV)
item["stock"] = _stock_desde_movimientos(item["id"])
item["modificado_por"] = u
item["modificado_en"] = m["cuando"]
_guardar_item(item)
return m
@app.post("/api/inventario/movimiento")
def inventario_movimiento():
u = quien()
if not u:
return _sin_usuario()
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
tipo = _texto(d.get("tipo"), 10).lower()
if tipo not in TIPOS_MOV:
return jsonify(ok=False, error="Tipo de movimiento inválido: entrada, salida, ajuste, envio o recepcion."), 400
nombre_sitio = {i: ("Querétaro" if i == "qro" else c) for i, _, c in SITIOS_TAREA}
envio = None
if tipo == "recepcion": # se recibe un envío que va en camino: el ítem y el destino salen del envío
eid = slug(d.get("envio_id"), 60)
envio = _leer_json(_ruta_registro(INV_MOVS(), eid)) if eid else None
if not envio or envio.get("tipo") != "envio":
return jsonify(ok=False, error=f"No existe el envío {eid}."), 404
if envio.get("estado") != "en-camino":
return jsonify(ok=False, error="Ese envío ya se recibió" + (f" ({envio.get('recibido_por')}, {envio.get('recibido_en')})." if envio.get("recibido_por") else ".")), 409
rid = envio.get("item_id")
else:
rid = slug(d.get("item_id"), 40)
item = _leer_json(_ruta_registro(INV_ITEMS(), rid)) if rid else None
if not item:
return jsonify(ok=False, error=f"No existe el ítem {rid}."), 404
if tipo == "recepcion" and d.get("cantidad") in (None, ""):
cantidad = _entero(envio.get("cantidad"), 0)
else:
try:
cantidad = int(d.get("cantidad"))
except Exception:
return jsonify(ok=False, error="La cantidad debe ser un número entero."), 400
if tipo in ("entrada", "salida", "envio") and cantidad <= 0:
return jsonify(ok=False, error="La cantidad debe ser mayor que cero."), 400
if tipo in ("ajuste", "recepcion") and cantidad < 0:
return jsonify(ok=False, error="La cantidad no puede ser negativa."), 400
if tipo == "recepcion":
sitio = envio.get("destino")
if cantidad > _entero(envio.get("cantidad"), 0):
return jsonify(ok=False, error=f"Se enviaron {envio.get('cantidad')}: no pueden llegar más. Si llegó de más, "
"registra la diferencia como entrada."), 400
elif tipo == "envio" and _norm_id(_texto(d.get("sitio"), 40)) in ALIAS_FUERA:
sitio = ORIGEN_FUERA
else:
sitio = _sitio_inv(d.get("sitio"))
if sitio is None:
return jsonify(ok=False, error="Sitio inválido: usa icave, timsa, eit, lzc o qro"
+ (" (o fuera, si el envío no sale de ningún sitio)." if tipo == "envio" else ".")), 400
destino = None
if tipo == "envio":
destino = _sitio_inv(d.get("destino"), omision=None)
if not destino:
return jsonify(ok=False, error="Elige a qué sitio va el envío."), 400
if destino == sitio:
return jsonify(ok=False, error="El envío tiene que ir a otro sitio."), 400
motivo = _texto(d.get("motivo"), 200)
if not motivo and tipo == "recepcion":
o = _mov_sitio(envio)
motivo = "llegó el envío " + ("(venía de fuera del inventario)" if o == ORIGEN_FUERA else f"desde {nombre_sitio.get(o, o)}")
if not motivo:
return jsonify(ok=False, error="Escribe el motivo del movimiento."), 400
referencia = _texto(d.get("referencia"), 120)
if tipo in ("salida", "envio") and sitio != ORIGEN_FUERA:
hay = _stock_en_sitio(rid, sitio)
if hay - cantidad < 0:
s = nombre_sitio.get(sitio, sitio)
falta = f"No hay stock suficiente en {s}: hay {hay} {item.get('unidad')} registrados. "
if tipo == "envio":
return jsonify(ok=False, error=falta + "Si ese material salió antes de que se contara, en «Sale de» elige "
f"«Proveedor o fuera del inventario»; si no, registra primero el conteo físico de {s}."), 409
return jsonify(ok=False, error=falta + "Registra primero la entrada o el conteo físico de ese sitio."), 409
m = _registrar_movimiento(item, tipo, cantidad, motivo, referencia, u, sitio=sitio, destino=destino, envio=envio)
return jsonify(ok=True, movimiento=m, item=item), 201
@app.get("/api/inventario/movimientos")
def inventario_movimientos():
rid = slug(request.args.get("item_id"), 40)
n = min(500, max(1, _entero(request.args.get("n"), 200)))
sitio = _sitio_inv(request.args.get("sitio"), omision="")
movs = _movs_de(rid) if rid else sorted(_listar_json(INV_MOVS()), key=lambda m: m.get("cuando", ""))
if sitio: # lo que pasó en ese sitio, más lo que va o fue hacia él
movs = [m for m in movs if _mov_sitio(m) == sitio or m.get("destino") == sitio]
for m in movs:
m.setdefault("sitio", SITIO_INV_OMISION)
movs.reverse()
return jsonify(ok=True, total=len(movs), movimientos=movs[:n])
@app.get("/api/inventario/export.csv")
def inventario_export():
import io
buf = io.StringIO()
w = csv.writer(buf)
exis = _existencias()
w.writerow(["codigo", "descripcion", "categoria", "aplica_a", "unidad", "stock", "minimo", "alerta", "por_instalacion"]
+ ["en_" + c.lower() for _, _, c in SITIOS_TAREA] + ["en_camino",
"ubicacion", "proveedor", "codigo_proveedor", "notas", "modificado_por", "modificado_en"])
for it in sorted(_listar_json(INV_ITEMS()), key=lambda x: (x.get("categoria", ""), x.get("codigo", ""))):
alerta = bool(it.get("minimo", 0)) and it.get("stock", 0) < it.get("minimo", 0)
e = exis.get(it.get("id")) or {}
w.writerow([it.get("codigo"), it.get("descripcion"), it.get("categoria"), " ".join(it.get("aplica_a") or []),
it.get("unidad"), it.get("stock", 0), it.get("minimo", 0), "SI" if alerta else "", it.get("por_instalacion", 0)]
+ [(e.get("sitios") or {}).get(i, 0) for i, _, _ in SITIOS_TAREA] + [sum((e.get("camino") or {}).values()),
it.get("ubicacion"), it.get("proveedor"), it.get("codigo_proveedor"), it.get("notas"),
it.get("modificado_por"), it.get("modificado_en")])
nombre = "inventario-hbox-" + datetime.date.today().isoformat() + ".csv"
return app.response_class("" + buf.getvalue(), mimetype="text/csv",
headers={"Content-Disposition": f'attachment; filename="{nombre}"'})
# ------------------------------------------------------------------ sitios
COLS_SITIOS = ["id", "nombre", "cliente", "ubicacion", "activo", "contactos", "modificado_por", "modificado_en"]
def _sitio_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 sitio."
t["nombre"] = n
for k, n in (("cliente", 120), ("ubicacion", 200), ("notas", 2000)):
if alta or k in d:
t[k] = _texto(d.get(k), n)
if alta or "alias" in d:
t["alias"] = _lista_textos(d.get("alias"), 10, 40)
if alta or "contactos" in d:
cs = []
for c in (d.get("contactos") if isinstance(d.get("contactos"), list) else []):
if isinstance(c, dict) and _texto(c.get("nombre"), 80):
cs.append({"nombre": _texto(c.get("nombre"), 80), "rol": _texto(c.get("rol"), 80),
"telefono": _texto(c.get("telefono"), 40), "correo": _texto(c.get("correo"), 120)})
t["contactos"] = cs[:20]
if alta or "activo" in d:
t["activo"] = _bool(d.get("activo"), True)
return t, None
def _guardar_sitio(t):
_escribir_json(_ruta_registro(SITIOS_DIR, t["id"]), t)
_reescribir_indice(SITIOS_DIR, _listar_json(SITIOS_DIR), COLS_SITIOS)
@app.get("/api/sitios")
def sitios_listar():
sitios = _listar_json(SITIOS_DIR)
if request.args.get("todos") != "1":
sitios = [s for s in sitios if s.get("activo", True)]
sitios.sort(key=lambda s: (s.get("orden", 50), s.get("nombre", "")))
return jsonify(ok=True, total=len(sitios), sitios=sitios, admin=is_admin(), user=current_user())
@app.post("/api/sitios")
def sitios_crear():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador da de alta sitios. Pide a Leo que lo cree."), 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 = _sitio_valido(d)
if err:
return jsonify(ok=False, error=err), 400
rid = slug(d.get("id") or t["nombre"], 30).lower()
if not rid:
return jsonify(ok=False, error="El id del sitio debe tener letras o números (p. ej. icave)."), 400
if os.path.exists(_ruta_registro(SITIOS_DIR, rid)):
return jsonify(ok=False, existe=True, error=f"Ya existe el sitio {rid}."), 409
t["id"] = rid
t["orden"] = _entero(d.get("orden"), 50)
t["creado_por"] = u
t["creado_en"] = ahora()
t["modificado_por"] = u
t["modificado_en"] = t["creado_en"]
t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "sitio creado"}]
_guardar_sitio(t)
return jsonify(ok=True, sitio=t), 201
@app.patch("/api/sitio")
def sitio_modificar():
if not is_admin():
return jsonify(ok=False, error="Solo un administrador edita los datos de un sitio."), 403
u = quien()
if not u:
return _sin_usuario()
rid = slug(request.args.get("id"), 30).lower()
base = _leer_json(_ruta_registro(SITIOS_DIR, rid)) if rid else None
if not base:
return jsonify(ok=False, error=f"No existe el sitio {rid}."), 404
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
for k in ("id", "creado_por", "creado_en", "modificado_por", "modificado_en", "historial"):
d.pop(k, None)
t, err = _sitio_valido(d, base)
if err:
return jsonify(ok=False, error=err), 400
if "orden" in d:
t["orden"] = _entero(d.get("orden"), 50)
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, sitio=t, sin_cambios=True)
_hist(t, u, "; ".join(cambios))
_guardar_sitio(t)
return jsonify(ok=True, sitio=t)
def _acta_en_sitio(d, sitio):
"""'sitio' si el acta trae el slug del sitio; 'texto' si solo coincide por nombre/alias
(terminal de la flota, destino del envío); None si no es de este sitio."""
if str(d.get("sitio") or "").lower() == sitio["id"]:
return "sitio"
claves = {sitio["id"].lower(), str(sitio.get("nombre", "")).lower()} | {a.lower() for a in (sitio.get("alias") or [])}
claves.discard("")
for campo in ("terminal", "clasificacion", "destino", "donde"):
v = str(d.get(campo) or "").strip().lower()
if not v:
continue
for k in claves:
if v == k or v.startswith(k + " ") or v.startswith(k + "(") or v.startswith(k + " ("):
return "texto"
return None
@app.get("/api/sitio/unidades")
def sitio_unidades():
"""Unidades y actas que pasaron por un sitio (envío, recepción, instalación, diagnóstico, daño)."""
rid = slug(request.args.get("id"), 30).lower()
sitio = _leer_json(_ruta_registro(SITIOS_DIR, rid)) if rid else None
if not sitio:
return jsonify(ok=False, error=f"No existe el sitio {rid}."), 404
actas = []
for tipo in TIPOS_SITIO:
carpeta = os.path.join(BASE, tipo)
if not os.path.isdir(carpeta):
continue
for fn in sorted(os.listdir(carpeta)):
if not fn.endswith(".json"):
continue
d = _leer_json(os.path.join(carpeta, fn))
if not isinstance(d, dict) or "PRUEBA" in str(d.get("serie", "")).upper():
continue
modo = _acta_en_sitio(d, sitio)
if not modo:
continue
actas.append({"tipo": tipo, "archivo": fn, "serie": d.get("serie"), "fecha": d.get("fecha"),
"recibido": d.get("_recibido"), "tecnico": d.get("tecnico"), "resultado": d.get("resultado"),
"accion": d.get("accion"), "clasificacion": d.get("clasificacion"), "motivo": d.get("motivo"),
"maquina": d.get("maquina_id") or d.get("maquina"), "resumen": d.get("resumen"),
"coincidencia": modo, "fotos": len(d.get("fotos") or [])})
def _clave(r):
return ((r.get("fecha") or (r.get("recibido") or "")[:10]), r.get("recibido") or "")
actas.sort(key=_clave, reverse=True)
unidades = {}
for a in actas:
s = a.get("serie")
if not s:
continue
u = unidades.setdefault(s, {"serie": s, "maquina": None, "instalada": None, "ultima": None, "actas": 0,
"tipos": [], "coincidencia": "texto"})
u["actas"] += 1
if a["tipo"] not in u["tipos"]:
u["tipos"].append(a["tipo"])
if a.get("maquina") and not u["maquina"]:
u["maquina"] = a["maquina"]
if a["coincidencia"] == "sitio":
u["coincidencia"] = "sitio"
if a["tipo"] == "instalacion-campo" and a.get("resultado") != "FALLA":
f = a.get("fecha") or (a.get("recibido") or "")[:10]
if not u["instalada"] or f > u["instalada"]:
u["instalada"] = f
if not u["ultima"]:
u["ultima"] = {"tipo": a["tipo"], "fecha": a.get("fecha") or (a.get("recibido") or "")[:10],
"accion": a.get("accion"), "resultado": a.get("resultado")}
for s, u in unidades.items():
e = estado_serie(s)
u["marca"] = e["marca"]
u["marca_acta"] = e["acta"]
lista = sorted(unidades.values(), key=lambda u: (u["marca"] is None, u["serie"]))
return jsonify(ok=True, sitio=sitio, unidades=lista, actas=actas, total_unidades=len(lista),
total_actas=len(actas), danos=[u for u in lista if u["marca"]], admin=is_admin())
# ------------------------------------------------------------------ inicio: enfoque de la semana y actividad (21-oct)
# El enfoque es un texto corto que escriben Leo y Adriana (HOY_EDITORES) y se ve arriba en la página de
# inicio. La actividad junta lo que ya guardan los pendientes (historial) y las actas (_recibido): no
# hay bitácora aparte que mantener.
HOY_FILE = os.environ.get("HOY_FILE", os.path.join(DATOS_BASE, "enfoque.json"))
HOY_EDITORES = {u.strip().lower() for u in os.environ.get("HOY_EDITORES", "leo,adriana").split(",") if u.strip()}
def _puede_enfoque():
u = current_user().lower()
return bool(u) and (u in HOY_EDITORES or u in ADMINS)
@app.get("/api/hoy")
def hoy_leer():
h = _leer_json(HOY_FILE)
if not isinstance(h, dict) or not h.get("titulo"):
h = None
else:
h = {k: h.get(k) for k in ("titulo", "puntos", "editado_por", "editado_en")}
return jsonify(ok=True, enfoque=h, puede_editar=_puede_enfoque(), editores=sorted(HOY_EDITORES),
user=current_user())
@app.put("/api/hoy")
def hoy_escribir():
u = quien()
if not u:
return _sin_usuario()
if not _puede_enfoque():
return jsonify(ok=False, error="El enfoque de la semana lo escriben " + " y ".join(sorted(HOY_EDITORES))
+ ". Pídeselo a uno de ellos."), 403
d = _json_body()
if d is None:
return jsonify(ok=False, error="JSON inválido"), 400
titulo = _texto(d.get("titulo"), 160)
if not titulo:
return jsonify(ok=False, error="Falta la frase de la semana."), 400
puntos = d.get("puntos")
if isinstance(puntos, str):
puntos = puntos.split("\n")
puntos = [_texto(p, 400) for p in (puntos if isinstance(puntos, list) else []) if _texto(p, 400)][:8]
base = _leer_json(HOY_FILE) or {}
anteriores = list(base.get("historial") or [])
if base.get("titulo"):
anteriores.append({k: base.get(k) for k in ("titulo", "puntos", "editado_por", "editado_en")})
h = {"titulo": titulo, "puntos": puntos, "editado_por": u, "editado_en": ahora(), "historial": anteriores[-20:]}
_escribir_json(HOY_FILE, h)
return jsonify(ok=True, enfoque={k: h[k] for k in ("titulo", "puntos", "editado_por", "editado_en")})
@app.get("/api/actividad")
def actividad():
dias = max(1, min(_entero(request.args.get("dias"), 7), 60))
limite = max(1, min(_entero(request.args.get("limite"), 30), 200))
desde = (datetime.datetime.now() - datetime.timedelta(days=dias)).replace(microsecond=0).isoformat()
eventos = []
for borrados, lista in ((False, _listar_json(TAREAS_DIR)), (True, _listar_json(TAREAS_BORRADOS))):
for t in lista:
for h in (t.get("historial") or []):
cuando = str(h.get("cuando") or "")
if cuando >= desde:
ev = {"tipo": "pendiente", "cuando": cuando, "quien": h.get("quien") or "",
"cambio": _texto(h.get("cambio"), 300), "id": t.get("id"),
"titulo": t.get("titulo"), "pista": t.get("pista"), "estado": t.get("estado")}
if borrados:
ev["borrado"] = True # ya no está en el tablero: la Actividad no lo liga
eventos.append(ev)
if os.path.isdir(BASE):
for tipo in sorted(os.listdir(BASE)):
carpeta = os.path.join(BASE, tipo)
if tipo.startswith((".", "_")) or not os.path.isdir(carpeta):
continue
for fn in os.listdir(carpeta):
ruta = os.path.join(carpeta, fn)
if not fn.endswith(".json"):
continue
try: # filtro barato antes de abrir el archivo
if datetime.datetime.fromtimestamp(os.path.getmtime(ruta)).isoformat() < desde:
continue
except OSError:
continue
d = _leer_json(ruta)
if not isinstance(d, dict) or d.get("_prueba"):
continue
cuando = str(d.get("_recibido") or "")
if cuando < desde:
continue
eventos.append({"tipo": "acta", "cuando": cuando, "quien": d.get("_usuario") or "",
"tecnico": _texto(d.get("tecnico"), 80), "acta": tipo, "serie": d.get("serie"),
"resultado": d.get("resultado") or d.get("clasificacion") or ""})
eventos.sort(key=lambda e: e["cuando"], reverse=True)
equipo = {k: m.get("nombre") for k, m in _miembros().items()}
return jsonify(ok=True, desde=desde, total=len(eventos), eventos=eventos[:limite], equipo=equipo)
# ------------------------------------------------------------------ tablero de Instalaciones (22-oct)
# backend/instalaciones.py: GET /api/instalaciones y el acta repetible «prueba-sitio». Si el archivo
# falta o falla, el resto del servicio arranca igual (el tablero avisa que no hay datos).
try:
import importlib.util as _ilu
_spec = _ilu.spec_from_file_location("instalaciones", os.path.join(os.path.dirname(os.path.abspath(__file__)), "instalaciones.py"))
_inst = _ilu.module_from_spec(_spec)
_spec.loader.exec_module(_inst)
_inst.registrar(app, globals())
except Exception as _e: # noqa: BLE001
app.logger.error("instalaciones.py no se cargó: %s", _e)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8081")))