Files
IPSExtractor/services/poblacion_apta/api/main.py
T

524 lines
15 KiB
Python

"""API REST del microservicio Población apta."""
from __future__ import annotations
import json
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy import delete, select
from domain.csv_io import load_csv_bytes
from domain.verificador_st import (
consolidado_to_records,
export_no_aptos_csv,
export_paso_bot_csv,
export_todos_csv,
process_cleaning_sources,
)
from domain.verificador_st.core import cargar_origen_servicio_desde_bytes
from infrastructure.database import (
CleanedRow,
CleaningJob,
Job,
PoblacionAptaJob,
PoblacionAptaRow,
Task,
init_db,
session_scope,
utcnow,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(
title="Flujo IPS Población Apta Service",
version="1.0.0",
lifespan=lifespan,
)
class PoblacionAptaRequest(BaseModel):
job_id: str | None = None
cleaning_job_ids: list[str] = Field(default_factory=list, min_length=1)
def serialize_job(job: PoblacionAptaJob) -> dict[str, Any]:
finished = (
job.updated_at.isoformat()
if job.status in {"completed", "failed"}
else None
)
return {
"id": job.id,
"job_id": job.job_id,
"status": job.status,
"request": job.request,
"log": job.log,
"summary": job.summary,
"error": job.error,
"created_at": job.created_at.isoformat(),
"updated_at": job.updated_at.isoformat(),
"started_at": job.created_at.isoformat(),
"finished_at": finished,
}
def _validate_job_id(session: Any, job_id: str | None) -> None:
if not job_id:
return
if session.get(Job, job_id) is None:
raise HTTPException(status_code=404, detail="Trabajo (job) no encontrado")
def _task_title(request: dict[str, Any]) -> str:
source = str(request.get("source") or "cleaning")
if source == "csv":
files = request.get("filenames") or []
n = len(files) if isinstance(files, list) else 0
return f"Población apta ({n} CSV)" if n else "Población apta (CSV)"
n = len(request.get("cleaning_job_ids") or [])
return f"Población apta ({n} limpiezas)"
def _create_task(
session: Any,
*,
job_id: str | None,
poblacion_apta_job_id: str,
status: str,
request: dict[str, Any],
error: str | None = None,
title: str | None = None,
started_at: Any | None = None,
) -> str | None:
if not job_id:
return None
now = utcnow()
task = Task(
job_id=job_id,
type="poblacion_apta",
status=status,
title=title or _task_title(request),
request=request,
result={"poblacion_apta_job_id": poblacion_apta_job_id},
error=error,
started_at=started_at or now,
finished_at=now,
updated_at=now,
)
session.add(task)
session.flush()
return task.id
def _persist_rows(
session: Any,
*,
job_id: str,
records: list[dict[str, Any]],
) -> None:
session.execute(
delete(PoblacionAptaRow).where(PoblacionAptaRow.poblacion_apta_job_id == job_id)
)
for idx, row in enumerate(records, start=1):
session.add(
PoblacionAptaRow(
poblacion_apta_job_id=job_id,
cleaning_job_id=str(row.get("cleaning_job_id") or "") or None,
row_index=idx,
row_json=row,
)
)
def _run_and_persist(
*,
pa_job_id: str,
desktop_job_id: str | None,
request_dump: dict[str, Any],
sources: list[dict[str, Any]],
mapa_servicios: dict[str, str] | None = None,
) -> dict[str, Any]:
try:
consolidado, summary, log = process_cleaning_sources(
sources, mapa_servicios=mapa_servicios
)
records = consolidado_to_records(consolidado)
with session_scope() as session:
_persist_rows(session, job_id=pa_job_id, records=records)
job = session.get(PoblacionAptaJob, pa_job_id)
if job is None:
raise RuntimeError("No se encontró el job de población apta recién creado.")
job.status = "completed"
job.log = log
job.summary = summary
job.updated_at = utcnow()
_create_task(
session,
job_id=desktop_job_id,
poblacion_apta_job_id=pa_job_id,
status="completed",
request=request_dump,
started_at=job.created_at,
)
session.flush()
return serialize_job(job)
except HTTPException:
raise
except Exception as exc:
with session_scope() as session:
job = session.get(PoblacionAptaJob, pa_job_id)
started = job.created_at if job is not None else None
if job is not None:
job.status = "failed"
job.error = str(exc)
job.updated_at = utcnow()
_create_task(
session,
job_id=desktop_job_id,
poblacion_apta_job_id=pa_job_id,
status="failed",
request=request_dump,
error=str(exc),
started_at=started,
)
raise HTTPException(status_code=500, detail=str(exc)) from exc
def _load_cleaning_sources(
session: Any,
cleaning_ids: list[str],
desktop_job_id: str | None,
) -> list[dict[str, Any]]:
sources: list[dict[str, Any]] = []
for cid in cleaning_ids:
cleaning = session.get(CleaningJob, cid)
if cleaning is None:
raise HTTPException(status_code=404, detail=f"Job de limpieza no encontrado: {cid}")
if cleaning.status != "completed":
raise HTTPException(
status_code=400,
detail=f"Job de limpieza {cid} no está completed (status={cleaning.status})",
)
if desktop_job_id and cleaning.job_id and cleaning.job_id != desktop_job_id:
raise HTTPException(
status_code=400,
detail=f"Job de limpieza {cid} no pertenece al trabajo {desktop_job_id}",
)
rows = session.scalars(
select(CleanedRow)
.where(CleanedRow.cleaning_job_id == cid)
.order_by(CleanedRow.row_index, CleanedRow.id)
).all()
sources.append(
{
"cleaning_job_id": cid,
"label": f"limpieza_{cid[:8]}",
"rows": [r.row_json for r in rows],
}
)
return sources
async def _parse_origen_servicio(
upload: UploadFile | None,
) -> tuple[dict[str, str], str | None]:
"""Lee el archivo Origen Servicio opcional. Devuelve (mapa, filename)."""
if upload is None:
return {}, None
filename = upload.filename or "origen_servicio.xlsx"
content = await upload.read()
if not content:
raise HTTPException(status_code=400, detail="El archivo Origen Servicio está vacío")
try:
mapa = cargar_origen_servicio_desde_bytes(content, filename)
except Exception as exc: # noqa: BLE001
raise HTTPException(
status_code=400,
detail=f"No se pudo leer Origen Servicio: {exc}",
) from exc
if not mapa:
raise HTTPException(
status_code=400,
detail=(
"Origen Servicio sin columnas de radicado/servicio reconocibles "
"o sin filas válidas"
),
)
return mapa, filename
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/jobs")
def run_poblacion_apta(request: PoblacionAptaRequest) -> dict[str, Any]:
"""Crea job desde limpiezas (JSON). Sin Origen Servicio; usar /jobs/from-cleaning."""
cleaning_ids = [cid.strip() for cid in request.cleaning_job_ids if cid and cid.strip()]
if not cleaning_ids:
raise HTTPException(status_code=400, detail="cleaning_job_ids es requerido (mínimo 1)")
request_dump = request.model_dump()
request_dump["cleaning_job_ids"] = cleaning_ids
request_dump["source"] = "cleaning"
with session_scope() as session:
_validate_job_id(session, request.job_id)
sources = _load_cleaning_sources(session, cleaning_ids, request.job_id)
job = PoblacionAptaJob(
job_id=request.job_id,
status="running",
request=request_dump,
updated_at=utcnow(),
)
session.add(job)
session.flush()
pa_job_id = job.id
desktop_job_id = request.job_id
return _run_and_persist(
pa_job_id=pa_job_id,
desktop_job_id=desktop_job_id,
request_dump=request_dump,
sources=sources,
)
# Ruta estática ANTES de /jobs/{job_id} para evitar 405 (job_id="from-cleaning").
@app.post("/jobs/from-cleaning")
async def run_poblacion_apta_from_cleaning(
cleaning_job_ids: str = Form(...),
job_id: str | None = Form(None),
origen_servicio: UploadFile | None = File(None),
) -> dict[str, Any]:
"""Limpiezas + Origen Servicio opcional (multipart)."""
try:
parsed = json.loads(cleaning_job_ids)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=400,
detail="cleaning_job_ids debe ser un JSON array de ids",
) from exc
if not isinstance(parsed, list):
raise HTTPException(status_code=400, detail="cleaning_job_ids debe ser un array")
cleaning_ids = [str(cid).strip() for cid in parsed if str(cid).strip()]
if not cleaning_ids:
raise HTTPException(status_code=400, detail="cleaning_job_ids es requerido (mínimo 1)")
mapa, origen_filename = await _parse_origen_servicio(origen_servicio)
request_dump: dict[str, Any] = {
"source": "cleaning",
"job_id": job_id,
"cleaning_job_ids": cleaning_ids,
"origen_servicio": origen_filename,
}
with session_scope() as session:
_validate_job_id(session, job_id)
sources = _load_cleaning_sources(session, cleaning_ids, job_id)
job = PoblacionAptaJob(
job_id=job_id,
status="running",
request=request_dump,
updated_at=utcnow(),
)
session.add(job)
session.flush()
pa_job_id = job.id
desktop_job_id = job_id
return _run_and_persist(
pa_job_id=pa_job_id,
desktop_job_id=desktop_job_id,
request_dump=request_dump,
sources=sources,
mapa_servicios=mapa or None,
)
# Ruta estática ANTES de /jobs/{job_id} para evitar 405 (job_id="from-csv").
@app.post("/jobs/from-csv")
async def run_poblacion_apta_from_csv(
files: list[UploadFile] = File(...),
job_id: str | None = Form(None),
origen_servicio: UploadFile | None = File(None),
) -> dict[str, Any]:
if not files:
raise HTTPException(status_code=400, detail="Se requiere al menos un archivo CSV")
sources: list[dict[str, Any]] = []
filenames: list[str] = []
delimiters: list[str] = []
for upload in files:
filename = upload.filename or "upload.csv"
content = await upload.read()
if not content:
raise HTTPException(status_code=400, detail=f"El archivo {filename} está vacío")
try:
df, delimiter = load_csv_bytes(content, filename)
except Exception as exc: # noqa: BLE001
raise HTTPException(
status_code=400,
detail=f"No se pudo leer {filename}: {exc}",
) from exc
if df.empty:
raise HTTPException(status_code=400, detail=f"El CSV {filename} no tiene filas")
records = df.fillna("").astype(str).to_dict(orient="records")
sources.append(
{
"cleaning_job_id": "",
"label": filename,
"rows": records,
}
)
filenames.append(filename)
delimiters.append(delimiter)
mapa, origen_filename = await _parse_origen_servicio(origen_servicio)
request_dump: dict[str, Any] = {
"source": "csv",
"job_id": job_id,
"filenames": filenames,
"delimiters": delimiters,
"cleaning_job_ids": [],
"origen_servicio": origen_filename,
}
with session_scope() as session:
_validate_job_id(session, job_id)
job = PoblacionAptaJob(
job_id=job_id,
status="running",
request=request_dump,
updated_at=utcnow(),
)
session.add(job)
session.flush()
pa_job_id = job.id
desktop_job_id = job_id
return _run_and_persist(
pa_job_id=pa_job_id,
desktop_job_id=desktop_job_id,
request_dump=request_dump,
sources=sources,
mapa_servicios=mapa or None,
)
@app.get("/jobs")
def list_jobs(job_id: str | None = None) -> list[dict[str, Any]]:
with session_scope() as session:
stmt = select(PoblacionAptaJob).order_by(PoblacionAptaJob.created_at.desc())
if job_id:
stmt = stmt.where(PoblacionAptaJob.job_id == job_id)
jobs = session.scalars(stmt).all()
return [serialize_job(job) for job in jobs]
@app.get("/jobs/{job_id}")
def get_job(job_id: str) -> dict[str, Any]:
if job_id in {"from-csv", "from-cleaning"}:
raise HTTPException(status_code=405, detail=f"Use POST /jobs/{job_id}")
with session_scope() as session:
job = session.get(PoblacionAptaJob, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job de población apta no encontrado")
return serialize_job(job)
@app.get("/jobs/{job_id}/rows")
def get_rows(
job_id: str,
limit: int = 500,
offset: int = 0,
observacion: str | None = Query(None),
) -> dict[str, Any]:
with session_scope() as session:
job = session.get(PoblacionAptaJob, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job de población apta no encontrado")
stmt = (
select(PoblacionAptaRow)
.where(PoblacionAptaRow.poblacion_apta_job_id == job_id)
.order_by(PoblacionAptaRow.row_index, PoblacionAptaRow.id)
)
rows = session.scalars(stmt).all()
payload = [row.row_json for row in rows]
if observacion:
obs = observacion.strip().upper()
if obs == "PASO_BOT":
payload = [r for r in payload if str(r.get("OBSERVACION", "")).strip() == "PASO_BOT"]
else:
payload = [
r
for r in payload
if obs in str(r.get("OBSERVACION", "")).upper()
]
total = len(payload)
page = payload[offset : offset + limit]
return {
"job_id": job_id,
"rows": page,
"total": total,
"limit": limit,
"offset": offset,
}
@app.get("/jobs/{job_id}/export.csv")
def export_csv(
job_id: str,
subset: str = Query("aptos", description="aptos | no_aptos | todos"),
) -> Response:
subset_norm = (subset or "aptos").strip().lower()
if subset_norm in {"all", "completo", "consolidado"}:
subset_norm = "todos"
if subset_norm not in {"aptos", "no_aptos", "errores", "todos"}:
raise HTTPException(status_code=400, detail="subset debe ser aptos, no_aptos o todos")
if subset_norm == "errores":
subset_norm = "no_aptos"
with session_scope() as session:
job = session.get(PoblacionAptaJob, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job de población apta no encontrado")
rows = session.scalars(
select(PoblacionAptaRow)
.where(PoblacionAptaRow.poblacion_apta_job_id == job_id)
.order_by(PoblacionAptaRow.row_index, PoblacionAptaRow.id)
).all()
payload = [r.row_json for r in rows]
if subset_norm == "aptos":
csv_text = export_paso_bot_csv(payload)
filename = f"poblacion_apta_aptos_{job_id[:8]}.csv"
elif subset_norm == "todos":
csv_text = export_todos_csv(payload)
filename = f"poblacion_apta_todos_{job_id[:8]}.csv"
else:
csv_text = export_no_aptos_csv(payload)
filename = f"poblacion_apta_no_aptos_{job_id[:8]}.csv"
return Response(
content=csv_text.encode("utf-8-sig"),
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)