#!/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 · PATCH/DELETE /api/tarea?id= · GET /api/tareas/resumen · POST /api/tareas/orden 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) """ 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") 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", "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) return t, 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) hoy = datetime.date.today().isoformat() miembros = _miembros() out = [] for t in _listar_json(TAREAS_DIR): if "responsable_ids" not in t: # pendientes guardados antes del 14-oct t["responsable_ids"] = _ids_responsable(t.get("responsable"), miembros)[0] if pista and t.get("pista") != pista: continue if estado and t.get("estado") != estado: continue if resp and 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, admin=is_admin(), user=current_user()) @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 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) @app.delete("/api/tarea") def tarea_borrar(): if not is_admin(): return jsonify(ok=False, error="Solo un administrador puede 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) if not os.path.isfile(ruta): return jsonify(ok=False, error=f"No existe el pendiente {rid}."), 404 os.remove(ruta) _reescribir_indice(TAREAS_DIR, _listar_json(TAREAS_DIR), COLS_TAREAS) return jsonify(ok=True, borrado=rid) @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()) if __name__ == "__main__": app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8081")))