morphos / backend /app /routers /lab.py
Jose Salazar
Aislar por clínica los resultados de analizador
1339cdc
Raw
History Blame Contribute Delete
3.82 kB
"""Endpoints de la integración de analizadores.
- POST /api/lab/ingesta (puente local → backend, autenticado por API key de dispositivo)
- GET /api/lab/resultados (navegador → backend, autenticado por sesión) — match por muestra
Dos zonas de confianza: la ingesta NO usa cookie/CSRF (el puente es headless, la API key es
la auth); la consulta usa la sesión existente. El mapeo código→analito ocurre aquí (backend),
única fuente de verdad.
"""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from .. import db
from ..config import obtener_config
from ..lab.almacen import almacen
from ..lab.mapeo import mapear_resultado
from ..schemas_lab import (
RespuestaIngesta,
ResultadoAnalizador,
ResultadoMapeado,
ResumenPendiente,
)
from ..security.authz import tenant_de_sesion, usuario_actual
from ..security.device import verificar_dispositivo
from ..security.rate_limit import limiter
router = APIRouter()
@router.post("/lab/ingesta", response_model=RespuestaIngesta)
@limiter.limit(obtener_config().limite_lab_ingesta)
async def post_ingesta(
request: Request, # requerido por slowapi
cuerpo: ResultadoAnalizador,
tenant: str = Depends(verificar_dispositivo), # 503 sin keys / 401 sin Bearer válido
) -> RespuestaIngesta:
mapeado = mapear_resultado(cuerpo)
mapeado.tenant = tenant # del servidor, a partir de la API key; nunca del cuerpo
almacen.guardar(mapeado)
if obtener_config().lab_persistir:
# A un hilo como el resto de SQLite: el analizador manda en ráfaga
# (`limite_lab_ingesta` = 120/minute) y esto corre por cada muestra.
await asyncio.to_thread(
db.guardar_resultado_lab,
mapeado.muestra_id.strip().lower(),
mapeado.momento.isoformat(),
mapeado.model_dump_json(),
)
return RespuestaIngesta(
muestra_id=mapeado.muestra_id,
analitos_mapeados=len(mapeado.analitos),
no_mapeados=mapeado.no_mapeados,
)
@router.get("/lab/resultados", response_model=ResultadoMapeado)
@limiter.limit(obtener_config().limite_lab_consulta)
async def get_resultados(
request: Request,
muestra: str = Query(..., min_length=1, max_length=128),
sesion: dict = Depends(usuario_actual), # 401 si no hay sesión
) -> ResultadoMapeado:
res = almacen.obtener(muestra, tenant_de_sesion(sesion))
if res is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"No hay resultados para esa muestra todavía.",
)
return res
@router.get("/lab/pendientes", response_model=list[ResumenPendiente])
@limiter.limit(obtener_config().limite_lab_consulta)
async def get_pendientes(
request: Request,
sesion: dict = Depends(usuario_actual),
) -> list[ResumenPendiente]:
"""Cola de resultados recibidos (más recientes primero) para elegir sin teclear el ID.
Desactivada por defecto (`MORPHOS_LAB_PENDIENTES_HABILITADO`): el almacén no está
segmentado, así que esto enumera las muestras de TODAS las clínicas y cada ID abre el panel
completo en `/lab/resultados`. 404 —y no 403— para que apagada sea indistinguible de no
existir. Ver el comentario en `config.py`: apagarla reduce el volcado masivo, no sustituye
al filtrado por tenant.
"""
if not obtener_config().lab_pendientes_habilitado:
raise HTTPException(status.HTTP_404_NOT_FOUND, "No disponible.")
return [
ResumenPendiente(
muestra_id=r.muestra_id,
instrumento_id=r.instrumento_id,
momento=r.momento,
analitos=len(r.analitos),
no_mapeados=len(r.no_mapeados),
)
for r in almacen.pendientes(tenant_de_sesion(sesion))
]