feat: Add poblacion_apta service to docker-compose, enhance API gateway for new service integration, and update README with service details. Introduce new database tables for poblacion_apta_jobs and poblacion_apta_rows, and implement functionality for handling cop/mod calculations in the cleaning service.
This commit is contained in:
@@ -0,0 +1,517 @@
|
||||
"""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,
|
||||
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"),
|
||||
) -> Response:
|
||||
subset_norm = (subset or "aptos").strip().lower()
|
||||
if subset_norm not in {"aptos", "no_aptos", "errores"}:
|
||||
raise HTTPException(status_code=400, detail="subset debe ser aptos o no_aptos")
|
||||
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"
|
||||
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}"',
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user