#!/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= · GET /api/inventario/export.csv GET /api/sitios · POST /api/sitios (admin) · PATCH /api/sitio?id= (admin) · GET /api/sitio/unidades?id= Equipo y accesos (14-oct): los miembros del equipo NO son lo mismo que los usuarios con login. GET /api/equipo · POST /api/equipo (admin) · PATCH /api/equipo/miembro?id= (admin) POST /api/usuarios (admin: crea login en el htpasswd + miembro) · DELETE /api/usuario?u= (admin: quita el login) 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() return jsonify(ok=True, user=u, admin=(u.lower() in ADMINS)) @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 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 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", "otro") UNIDADES_INV = ("pza", "m") TIPOS_MOV = ("entrada", "salida", "ajuste") # 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", "correo", "telefono", "activo", "creado_por", "creado_en", "modificado_por", "modificado_en"] USUARIO_RE = re.compile(r"^[a-z0-9][a-z0-9_\-.]{1,29}$") def _norm_id(s): """'Jesús' → 'jesus': minúsculas, sin acentos, solo [a-z0-9_-.].""" s = unicodedata.normalize("NFKD", str(s or "")).encode("ascii", "ignore").decode().lower().strip() return re.sub(r"[^a-z0-9_\-.]", "", s) def _miembro_valido(d, base=None): t = dict(base) if base else {} alta = base is None if alta or "nombre" in d: n = _texto(d.get("nombre"), 80) if not n: return None, "Falta el nombre del miembro." t["nombre"] = n if alta or "rol" in d: t["rol"] = _texto(d.get("rol"), 120) if alta or "area" in d: a = _norm_id(d.get("area")) or "produccion" if a not in AREAS: return None, f"Área inválida: {a}. Usa una de {', '.join(AREAS)}." t["area"] = a for k, n in (("correo", 120), ("telefono", 40), ("notas", 1000)): if alta or k in d: t[k] = _texto(d.get(k), n) if alta or "activo" in d: t["activo"] = _bool(d.get("activo"), True) return t, None def _guardar_miembro(t): _escribir_json(_ruta_registro(EQUIPO_DIR, t["id"]), t) _reescribir_indice(EQUIPO_DIR, _listar_json(EQUIPO_DIR), COLS_EQUIPO) def _miembros(): """{id: miembro} de todos (activos e inactivos).""" return {m["id"]: m for m in _listar_json(EQUIPO_DIR) if m.get("id")} def _logins(): """Usuarios que existen en el htpasswd (en minúsculas), o set() si no hay archivo.""" lineas = _leer_htpasswd() or [] return {l.split(":", 1)[0].strip().lower() for l in lineas if ":" in l and not l.startswith("#")} def _registrar_acceso(quien_lo_hizo, accion, usuario): """Bitácora de altas y bajas de acceso: equipo/_accesos.csv.""" os.makedirs(EQUIPO_DIR, exist_ok=True) fp = os.path.join(EQUIPO_DIR, "_accesos.csv") nuevo = not os.path.exists(fp) with open(fp, "a", newline="", encoding="utf-8") as f: w = csv.writer(f) if nuevo: w.writerow(["cuando", "quien", "accion", "usuario"]) w.writerow([ahora(), quien_lo_hizo, accion, usuario]) class _candado_htpasswd: """flock sobre un archivo aparte, para que dos workers no pisen el htpasswd al mismo tiempo.""" def __enter__(self): os.makedirs(os.path.dirname(HTPASSWD_LOCK) or ".", exist_ok=True) self.f = open(HTPASSWD_LOCK, "a+") fcntl.flock(self.f, fcntl.LOCK_EX) return self def __exit__(self, *a): fcntl.flock(self.f, fcntl.LOCK_UN) self.f.close() def _cuenta_vacia(): c = {e: 0 for e in ESTADOS} c.update({"abiertos": 0, "vencidas": 0}) return c def _resumen_pendientes_por_miembro(): """{id: {estado: n, 'abiertos': n, 'vencidas': n}} contando responsable_ids de cada pendiente.""" hoy = datetime.date.today().isoformat() out = {} for t in _listar_json(TAREAS_DIR): ids = t.get("responsable_ids") if ids is None: ids, _ = _ids_responsable(t.get("responsable")) for rid in ids: r = out.setdefault(rid, _cuenta_vacia()) e = t.get("estado") if e in ESTADOS: r[e] += 1 if e != "hecho": r["abiertos"] += 1 if _vencida(t, hoy): r["vencidas"] += 1 return out @app.get("/api/equipo") def equipo_listar(): todos = request.args.get("todos") == "1" logins = _logins() pend = _resumen_pendientes_por_miembro() out = [] for m in _listar_json(EQUIPO_DIR): if not todos and m.get("activo", True) is False: continue m["tiene_login"] = m["id"].lower() in logins m["es_admin"] = m["id"].lower() in ADMINS m["pendientes"] = pend.get(m["id"], _cuenta_vacia()) out.append(m) out.sort(key=lambda m: (not m.get("activo", True), AREAS.index(m["area"]) if m.get("area") in AREAS else 99, m.get("nombre", ""))) # logins que no son miembros (cuentas viejas): se listan aparte para que el admin los vea sueltos = sorted(u for u in logins if u not in {m["id"].lower() for m in _listar_json(EQUIPO_DIR)}) return jsonify(ok=True, total=len(out), miembros=out, areas=AREAS, admins=sorted(ADMINS), logins_sin_miembro=sueltos if is_admin() else [], htpasswd_ok=_leer_htpasswd() is not None, pass_min=PASS_MIN, admin=is_admin(), user=current_user()) @app.post("/api/equipo") def equipo_crear(): if not is_admin(): return jsonify(ok=False, error="Solo un administrador da de alta miembros del equipo."), 403 u = quien() if not u: return _sin_usuario() d = _json_body() if d is None: return jsonify(ok=False, error="JSON inválido"), 400 t, err = _miembro_valido(d) if err: return jsonify(ok=False, error=err), 400 rid = _norm_id(d.get("id") or t["nombre"].split()[0]) if not rid: return jsonify(ok=False, error="El id debe tener letras o números (p. ej. adriana)."), 400 if os.path.exists(_ruta_registro(EQUIPO_DIR, rid)): return jsonify(ok=False, existe=True, error=f"Ya existe el miembro {rid}."), 409 t["id"] = rid t.update({"creado_por": u, "creado_en": ahora(), "modificado_por": u, "modificado_en": ahora()}) t["historial"] = [{"quien": u, "cuando": t["creado_en"], "cambio": "miembro creado"}] _guardar_miembro(t) t["tiene_login"] = rid in _logins() t["es_admin"] = rid in ADMINS return jsonify(ok=True, miembro=t), 201 @app.patch("/api/equipo/miembro") def equipo_modificar(): if not is_admin(): return jsonify(ok=False, error="Solo un administrador edita el equipo."), 403 u = quien() if not u: return _sin_usuario() rid = _norm_id(request.args.get("id")) base = _leer_json(_ruta_registro(EQUIPO_DIR, rid)) if rid else None if not base: return jsonify(ok=False, error=f"No existe el miembro {rid}."), 404 d = _json_body() if d is None: return jsonify(ok=False, error="JSON inválido"), 400 for k in ("id", "tiene_login", "es_admin", "pendientes", "creado_por", "creado_en", "modificado_por", "modificado_en", "historial"): d.pop(k, None) t, err = _miembro_valido(d, base) if err: return jsonify(ok=False, error=err), 400 cambios = [f"{k}: {_corto(base.get(k))} → {_corto(t.get(k))}" for k in d if k in t and t.get(k) != base.get(k)] if not cambios: return jsonify(ok=True, miembro=t, sin_cambios=True) _hist(t, u, "; ".join(cambios)) _guardar_miembro(t) t["tiene_login"] = rid in _logins() t["es_admin"] = rid in ADMINS return jsonify(ok=True, miembro=t) @app.post("/api/usuarios") def usuarios_crear(): """Alta de acceso al sitio (línea en el htpasswd, mismo hash $apr1$ que /api/password) + miembro del equipo.""" if not is_admin(): return jsonify(ok=False, error="Solo un administrador crea accesos."), 403 yo = quien() if not yo: return _sin_usuario() d = _json_body() if d is None: return jsonify(ok=False, error="JSON inválido"), 400 usuario = _texto(d.get("usuario"), 30).lower() if not USUARIO_RE.match(usuario): return jsonify(ok=False, error="El usuario debe ser corto, en minúsculas y sin espacios ni acentos (p. ej. adriana)."), 400 password = str(d.get("password_inicial") or "") if len(password) < PASS_MIN: return jsonify(ok=False, error=f"La contraseña inicial debe tener al menos {PASS_MIN} caracteres."), 400 m_in = {"nombre": d.get("nombre") or usuario, "rol": d.get("rol", ""), "area": d.get("area", "produccion"), "correo": d.get("correo", ""), "telefono": d.get("telefono", ""), "activo": True} existente = _leer_json(_ruta_registro(EQUIPO_DIR, usuario)) t, err = _miembro_valido({k: v for k, v in m_in.items() if existente is None or d.get(k) not in (None, "")}, existente) if err: return jsonify(ok=False, error=err), 400 with _candado_htpasswd(): lineas = _leer_htpasswd() if lineas is None: return jsonify(ok=False, error="No encuentro el archivo de usuarios en el servidor (HTPASSWD_FILE)."), 500 if any(l.split(":", 1)[0].strip().lower() == usuario for l in lineas if ":" in l): return jsonify(ok=False, existe=True, error=f"El usuario {usuario} ya tiene acceso. Si olvidó su contraseña, restablécela en Mi cuenta."), 409 salt = "".join(ITOA64[b % 64] for b in os.urandom(8)) lineas = [l for l in lineas if l.strip()] + [usuario + ":" + apr1(password, salt)] try: _escribir_htpasswd(lineas) except PermissionError: return jsonify(ok=False, error="El servicio no tiene permiso de escribir el archivo de usuarios. " "Avisa a Leo (chown leo:www-data + chmod 640 de hbox-docs.htpasswd)."), 500 except OSError as e: return jsonify(ok=False, error="No se pudo guardar el acceso: " + str(e)), 500 t["id"] = usuario if existente is None: t.update({"creado_por": yo, "creado_en": ahora(), "historial": []}) t["activo"] = True _hist(t, yo, "acceso al sitio creado") _guardar_miembro(t) _registrar_acceso(yo, "alta", usuario) t["tiene_login"] = True t["es_admin"] = usuario in ADMINS return jsonify(ok=True, usuario=usuario, miembro=t, nuevo_miembro=existente is None), 201 @app.delete("/api/usuario") def usuario_borrar(): """Quita el acceso (línea del htpasswd) y desactiva al miembro. Nunca borra el miembro ni sus pendientes.""" if not is_admin(): return jsonify(ok=False, error="Solo un administrador quita accesos."), 403 yo = quien() if not yo: return _sin_usuario() usuario = _texto(request.args.get("u"), 30).lower() if not usuario: return jsonify(ok=False, error="falta u"), 400 if usuario == yo.lower(): return jsonify(ok=False, error="No puedes quitarte el acceso a ti mismo."), 400 with _candado_htpasswd(): lineas = _leer_htpasswd() if lineas is None: return jsonify(ok=False, error="No encuentro el archivo de usuarios en el servidor (HTPASSWD_FILE)."), 500 presentes = {l.split(":", 1)[0].strip().lower() for l in lineas if ":" in l} if usuario not in presentes: return jsonify(ok=False, error=f"El usuario {usuario} no tiene acceso."), 404 if usuario in ADMINS and len(presentes & ADMINS) <= 1: return jsonify(ok=False, error="Es el último administrador con acceso; no se puede quitar."), 409 restantes = [l for l in lineas if not (":" in l and l.split(":", 1)[0].strip().lower() == usuario)] try: _escribir_htpasswd(restantes) except PermissionError: return jsonify(ok=False, error="El servicio no tiene permiso de escribir el archivo de usuarios. " "Avisa a Leo (chown leo:www-data + chmod 640 de hbox-docs.htpasswd)."), 500 except OSError as e: return jsonify(ok=False, error="No se pudo guardar: " + str(e)), 500 m = _leer_json(_ruta_registro(EQUIPO_DIR, usuario)) if m: m["activo"] = False _hist(m, yo, "acceso al sitio retirado; miembro desactivado") _guardar_miembro(m) _registrar_acceso(yo, "baja", usuario) return jsonify(ok=True, usuario=usuario, miembro_desactivado=bool(m)) # ------------------------------------------------------------------ tareas (tablero) COLS_TAREAS = ["id", "titulo", "pista", "estado", "responsable", "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): if "responsable_ids" not in t: # pendientes guardados antes del 14-oct 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.get("responsable_ids") if ids is None: ids = _ids_responsable(t.get("responsable"), miembros)[0] for rid in (ids or ["_sin_equipo"]): r = por_resp.setdefault(rid, _cuenta_vacia()) if e in ESTADOS: r[e] += 1 if e != "hecho": r["abiertos"] += 1 if _vencida(t, hoy): r["vencidas"] += 1 if _vencida(t, hoy): vencidas.append({k: t.get(k) for k in ("id", "titulo", "pista", "estado", "responsable", "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", "ubicacion", "proveedor", "codigo_proveedor", "modificado_por", "modificado_en"] COLS_MOV = ["id", "cuando", "quien", "item_id", "tipo", "cantidad", "delta", "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)) 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)) @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 = [] for it in _listar_json(INV_ITEMS()): 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, 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) _guardar_item(t) if stock_inicial: _registrar_movimiento(t, "entrada", stock_inicial, "stock inicial", "", u) 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): """Escribe el movimiento y recalcula el stock del ítem como suma de todos sus movimientos.""" actual = _stock_desde_movimientos(item["id"]) if tipo == "entrada": delta = cantidad elif tipo == "salida": delta = -cantidad else: # ajuste: 'cantidad' es el conteo físico; el delta es lo que falta para llegar a él delta = cantidad - actual 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, "motivo": motivo, "referencia": referencia, "quien": u, "cuando": ahora(), "stock_antes": actual, "stock_despues": actual + delta} _escribir_json(_ruta_registro(INV_MOVS(), m["id"]), m) _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 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 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 o ajuste."), 400 try: cantidad = int(d.get("cantidad")) except Exception: return jsonify(ok=False, error="La cantidad debe ser un número entero."), 400 if tipo != "ajuste" and cantidad <= 0: return jsonify(ok=False, error="La cantidad debe ser mayor que cero."), 400 if tipo == "ajuste" and cantidad < 0: return jsonify(ok=False, error="El conteo físico no puede ser negativo."), 400 motivo = _texto(d.get("motivo"), 200) if not motivo: return jsonify(ok=False, error="Escribe el motivo del movimiento."), 400 referencia = _texto(d.get("referencia"), 120) if tipo == "salida" and _stock_desde_movimientos(rid) - cantidad < 0: return jsonify(ok=False, error=f"No hay stock suficiente registrado ({_stock_desde_movimientos(rid)} {item.get('unidad')}). " "Registra primero la entrada o haz un ajuste con el conteo físico."), 409 m = _registrar_movimiento(item, tipo, cantidad, motivo, referencia, u) 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))) movs = _movs_de(rid) if rid else sorted(_listar_json(INV_MOVS()), key=lambda m: m.get("cuando", "")) 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) w.writerow(["codigo", "descripcion", "categoria", "aplica_a", "unidad", "stock", "minimo", "alerta", "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) 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("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")))