524 lines
15 KiB
Python
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="IPSExtractor 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}"',
|
|
},
|
|
)
|