Files
IPSExtractor/server/app/main.py
T

188 lines
5.9 KiB
Python

"""Servidor de tracking de IPSExtractor.
Persiste el historial de lotes en SQLite y distribuye actualizaciones
en tiempo real vía WebSockets al dashboard web.
"""
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.encoders import jsonable_encoder
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager
from typing import Dict, List, Any, Optional
from pydantic import BaseModel
import uuid
import os
from datetime import datetime
from .schemas.batch import BatchCreate, BatchUpdate, BatchRead, BatchStatus
from .database import init_db, insert_batch, update_batch, get_all_batches, get_batch, insert_log, get_logs
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(title="IPSExtractor Tracking API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Cache en memoria para lotes activos (para broadcast rápido)
active_batches: Dict[str, Dict[str, Any]] = {}
active_connections: List[WebSocket] = []
async def broadcast(message: dict):
json_compatible_message = jsonable_encoder(message)
disconnected = []
for connection in active_connections:
try:
await connection.send_json(json_compatible_message)
except Exception:
disconnected.append(connection)
for conn in disconnected:
active_connections.remove(conn)
@app.post("/batches", response_model=BatchRead)
async def create_batch(batch: BatchCreate):
batch_id = str(uuid.uuid4())
now = datetime.now()
new_batch = {
"id": batch_id,
**batch.model_dump(),
"status": BatchStatus.PROCESSING,
"current_radicado_index": 0,
"success_count": 0,
"error_count": 0,
"total_tokens": 0,
"total_cost_usd": 0.0,
"last_error": None,
"created_at": now,
"updated_at": now,
}
active_batches[batch_id] = new_batch
insert_batch(new_batch)
await broadcast({"event": "batch_created", "data": new_batch})
return new_batch
@app.get("/batches", response_model=List[BatchRead])
async def list_batches():
# Retorna historial completo desde SQLite
db_batches = get_all_batches()
# Sobreescribe con datos en memoria si el lote está activo (más fresco)
result = []
for b in db_batches:
if b["id"] in active_batches:
result.append(active_batches[b["id"]])
else:
result.append(b)
return result
@app.get("/batches/{batch_id}", response_model=BatchRead)
async def get_batch_endpoint(batch_id: str):
if batch_id in active_batches:
return active_batches[batch_id]
batch = get_batch(batch_id)
if not batch:
raise HTTPException(status_code=404, detail="Lote no encontrado")
return batch
@app.patch("/batches/{batch_id}", response_model=BatchRead)
async def update_batch_endpoint(batch_id: str, update: BatchUpdate):
if batch_id in active_batches:
batch = active_batches[batch_id]
update_data = update.model_dump(exclude_unset=True)
for key, value in update_data.items():
batch[key] = value
batch["updated_at"] = datetime.now()
# Si el lote se completó, sacarlo del cache activo
if batch.get("status") in (BatchStatus.COMPLETED, BatchStatus.FAILED):
active_batches.pop(batch_id, None)
update_batch(batch_id, update_data)
await broadcast({"event": "batch_updated", "data": batch})
return batch
# Lote no está en memoria (completado), actualizar solo en DB
batch = get_batch(batch_id)
if not batch:
raise HTTPException(status_code=404, detail="Lote no encontrado")
update_data = update.model_dump(exclude_unset=True)
update_batch(batch_id, update_data)
batch.update(update_data)
return batch
class LogEntry(BaseModel):
message: str
level: str = "info"
radicado: Optional[str] = None
class LogRead(BaseModel):
id: int
batch_id: str
timestamp: str
level: str
radicado: Optional[str]
message: str
@app.post("/batches/{batch_id}/logs", status_code=201)
async def add_log(batch_id: str, entry: LogEntry):
"""Añade una entrada de log para un lote."""
insert_log(batch_id, entry.message, entry.level, entry.radicado)
# Broadcast log también por WebSocket para actualizaciones en vivo
await broadcast({"event": "log_added", "batch_id": batch_id, "data": {
"level": entry.level, "radicado": entry.radicado, "message": entry.message
}})
return {"ok": True}
@app.get("/batches/{batch_id}/logs", response_model=List[LogRead])
async def list_logs(batch_id: str, limit: int = 200):
"""Retorna el historial de logs de un lote."""
return get_logs(batch_id, limit)
@app.get("/download/{batch_id}")
async def download_csv(batch_id: str):
"""Sirve el archivo CSV generado por el lote de extracción."""
batch = get_batch(batch_id)
if not batch:
raise HTTPException(status_code=404, detail="Lote no encontrado")
csv_path = batch.get("csv_output_path")
if not csv_path:
raise HTTPException(status_code=404, detail="Este lote no tiene un archivo CSV asociado")
if not os.path.isfile(csv_path):
raise HTTPException(status_code=404, detail=f"Archivo CSV no encontrado en el servidor: {csv_path}")
filename = os.path.basename(csv_path)
return FileResponse(
path=csv_path,
media_type="text/csv",
filename=filename,
)
@app.websocket("/ws/tracking")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
active_connections.append(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
if websocket in active_connections:
active_connections.remove(websocket)