feat: add backend FastAPI structure and Supabase schema
This commit is contained in:
154
server/app/api/routes/eta_router.py
Normal file
154
server/app/api/routes/eta_router.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Endpoints del Módulo B con caching
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.data.repositories.ruta_repository import SQLiteRutaRepository
|
||||
from app.services.simulador import obtener_simulador
|
||||
from app.services.ws_manager import ws_manager
|
||||
from app.core.cache import cached, cache_client, invalidate_route_cache
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.db.database import get_connection
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _repo() -> SQLiteRutaRepository:
|
||||
return SQLiteRutaRepository()
|
||||
|
||||
|
||||
# ── GET /eta/{address_id} con caché ─────────────────────────────────────────
|
||||
|
||||
@router.get("/eta/{address_id}", summary="Ventana ETA para un domicilio")
|
||||
@cached(prefix="eta", ttl=30)
|
||||
async def get_eta(
|
||||
address_id: int,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Devuelve ETA + ventana horaria solo para el domicilio solicitado.
|
||||
Cacheado por 30 segundos para evitar consultas repetidas.
|
||||
"""
|
||||
# Verificar que el domicilio pertenece al usuario (RBAC)
|
||||
conn = get_connection()
|
||||
addr = conn.execute(
|
||||
"SELECT user_id FROM addresses WHERE id = ?", (address_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if not addr or addr["user_id"] != current_user["id"]:
|
||||
raise HTTPException(status_code=403, detail="No autorizado")
|
||||
|
||||
resultado = _repo().calcular_eta(address_id)
|
||||
if not resultado:
|
||||
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
|
||||
|
||||
return {
|
||||
"address_id": resultado.address_id,
|
||||
"route_id": resultado.route_id,
|
||||
"status": resultado.status,
|
||||
"eta_minutos": resultado.eta_minutos,
|
||||
"ventana": {
|
||||
"inicio": resultado.ventana_inicio,
|
||||
"fin": resultado.ventana_fin,
|
||||
},
|
||||
"mensaje": resultado.mensaje,
|
||||
"cached": False,
|
||||
}
|
||||
|
||||
|
||||
# ── WS /ws/{address_id} ───────────────────────────────────────────────
|
||||
|
||||
@router.websocket("/ws/{address_id}")
|
||||
async def websocket_address(websocket: WebSocket, address_id: int):
|
||||
"""WebSocket para recibir notificaciones en tiempo real"""
|
||||
zona_key = str(address_id)
|
||||
await ws_manager.conectar(websocket, zona_key)
|
||||
logger.info(f"[WS] Cliente conectado — address_id={address_id}")
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text() # mantener vivo
|
||||
except WebSocketDisconnect:
|
||||
ws_manager.desconectar(websocket, zona_key)
|
||||
logger.info(f"[WS] Cliente desconectado — address_id={address_id}")
|
||||
|
||||
|
||||
# ── POST /alerts/breakdown ────────────────────────────────────────────
|
||||
|
||||
class BreakdownPayload(BaseModel):
|
||||
route_id: str
|
||||
mensaje: Optional[str] = "El camión reportó una falla mecánica."
|
||||
|
||||
|
||||
@router.post("/alerts/breakdown", summary="Reportar avería de camión")
|
||||
async def reportar_averia(payload: BreakdownPayload):
|
||||
"""Endpoint para reportar avería - invalida caché automáticamente"""
|
||||
sim = obtener_simulador(payload.route_id)
|
||||
await sim.forzar_averia(payload.mensaje)
|
||||
|
||||
# Invalidar caché de esta ruta
|
||||
await invalidate_route_cache(payload.route_id)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"route_id": payload.route_id,
|
||||
"mensaje": "Avería registrada y usuarios notificados",
|
||||
}
|
||||
|
||||
|
||||
# ── Admin / Demo ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/admin/route/{route_id}/start", summary="Iniciar simulación")
|
||||
async def iniciar_ruta(route_id: str):
|
||||
"""Iniciar el simulador de camión"""
|
||||
obtener_simulador(route_id).iniciar()
|
||||
await invalidate_route_cache(route_id)
|
||||
return {"ok": True, "mensaje": f"Simulador {route_id} iniciado"}
|
||||
|
||||
|
||||
@router.post("/admin/route/{route_id}/delay", summary="Forzar retraso")
|
||||
async def forzar_retraso(route_id: str, mensaje: str = "El camión reportó un retraso."):
|
||||
"""Forzar retraso en la ruta"""
|
||||
await obtener_simulador(route_id).forzar_retraso(mensaje)
|
||||
await invalidate_route_cache(route_id)
|
||||
return {"ok": True, "mensaje": "Retraso notificado"}
|
||||
|
||||
|
||||
@router.get("/admin/route/{route_id}/status", summary="Estado interno del camión")
|
||||
async def estado_ruta(route_id: str):
|
||||
"""Obtener estado actual del camión"""
|
||||
repo = _repo()
|
||||
ts = repo.obtener_truck_status(route_id)
|
||||
if not ts:
|
||||
raise HTTPException(status_code=404, detail="Ruta no encontrada")
|
||||
return {
|
||||
"route_id": ts.route_id,
|
||||
"current_position_id": ts.current_position_id,
|
||||
"status": ts.status.value,
|
||||
"last_update": ts.last_update.isoformat(),
|
||||
"ws_clientes_activos": ws_manager.zonas_activas(),
|
||||
}
|
||||
|
||||
|
||||
# ── Endpoint para limpiar caché (admin) ──────────────────────────────
|
||||
|
||||
@router.post("/admin/cache/clear", summary="Limpiar toda la caché")
|
||||
async def clear_cache():
|
||||
"""Limpiar caché de Redis y memoria"""
|
||||
await cache_client.clear_all()
|
||||
return {"ok": True, "mensaje": "Caché limpiada"}
|
||||
|
||||
|
||||
@router.get("/admin/cache/stats", summary="Estadísticas de caché")
|
||||
async def cache_stats():
|
||||
"""Estadísticas del sistema de caché"""
|
||||
return {
|
||||
"enabled": cache_client.enabled,
|
||||
"redis_available": cache_client.redis_client is not None,
|
||||
"memory_cache_size": len(cache_client.memory_cache),
|
||||
}
|
||||
Reference in New Issue
Block a user