Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016X4CAujMuib2tDqt9JRg6X
2143 lines
96 KiB
Python
2143 lines
96 KiB
Python
#!/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")))
|