feat: add backend FastAPI structure and Supabase schema

This commit is contained in:
Alan Alonso
2026-05-23 00:41:13 -06:00
parent 17cdde7dbb
commit e6eb466c14
38 changed files with 1760 additions and 0 deletions

View 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),
}