"""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}"', }, )