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,3 @@
from app.api.routes.eta_router import router
__all__ = ["router"]

View File

@@ -0,0 +1,3 @@
from app.api.routes.eta_router import router
__all__ = ["router"]

View File

@@ -0,0 +1,43 @@
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from app.db.database import get_connection
from app.core.dependencies import get_current_user
router = APIRouter()
class AddressCreate(BaseModel):
lat: float
lng: float
alias: Optional[str] = None
address_text: str
@router.post("/addresses")
async def create_address(
address: AddressCreate,
current_user: dict = Depends(get_current_user)
):
# Determine route based on location (simplified - in production use PostGIS)
route_id = "RUTA-01" # Mock: should calculate based on lat/lng
conn = get_connection()
cursor = conn.execute(
"INSERT INTO addresses (user_id, alias, lat, lng, route_id) VALUES (?, ?, ?, ?, ?) RETURNING id",
(current_user["id"], address.alias, address.lat, address.lng, route_id)
)
address_id = cursor.fetchone()[0]
conn.commit()
conn.close()
return {"id": address_id, "route_id": route_id}
@router.get("/addresses")
async def get_addresses(current_user: dict = Depends(get_current_user)):
conn = get_connection()
addresses = conn.execute(
"SELECT id, alias, lat, lng, route_id FROM addresses WHERE user_id = ?",
(current_user["id"],)
).fetchall()
conn.close()
return [dict(addr) for addr in addresses]

View File

@@ -0,0 +1,77 @@
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel, EmailStr
import jwt
from passlib.context import CryptContext
from app.core.config import settings
from app.db.database import get_connection
router = APIRouter()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class UserRegister(BaseModel):
email: EmailStr
phone: str | None = None
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_token(user_id: int) -> str:
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": str(user_id), "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
@router.post("/register", response_model=TokenResponse)
async def register(user: UserRegister):
conn = get_connection()
existing = conn.execute(
"SELECT id FROM users WHERE email = ?", (user.email,)
).fetchone()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
password_hash = hash_password(user.password)
cursor = conn.execute(
"INSERT INTO users (email, phone, password_hash) VALUES (?, ?, ?) RETURNING id",
(user.email, user.phone, password_hash)
)
user_id = cursor.fetchone()[0]
# Create default preferences
conn.execute(
"INSERT INTO notification_preferences (user_id) VALUES (?)",
(user_id,)
)
conn.commit()
conn.close()
token = create_token(user_id)
return TokenResponse(access_token=token)
@router.post("/login", response_model=TokenResponse)
async def login(user: UserLogin):
conn = get_connection()
db_user = conn.execute(
"SELECT id, password_hash FROM users WHERE email = ?",
(user.email,)
).fetchone()
conn.close()
if not db_user or not verify_password(user.password, db_user[1]):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_token(db_user[0])
return TokenResponse(access_token=token)

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

View File

@@ -0,0 +1,82 @@
"""
Guía de separación de residuos - endpoint cacheado
"""
from fastapi import APIRouter
from app.core.cache import cached
router = APIRouter()
# Guía de separación (cache por 24 horas)
RECYCLING_GUIDE = {
"categories": [
{
"name": "Orgánico",
"color": "#4CAF50",
"icon": "leaf",
"items": [
"Restos de comida",
"Cáscaras de fruta",
"Hojas y césped",
"Cáscaras de huevo",
"Café y filtros de papel",
"Servilletas de papel",
"Restos de poda"
]
},
{
"name": "Reciclable",
"color": "#2196F3",
"icon": "recycle",
"items": [
"Plástico (PET, HDPE, PP)",
"Vidrio (botellas, frascos)",
"Papel y cartón (limpio y seco)",
"Latas de aluminio",
"Envases Tetra Pak",
"Periódicos y revistas"
]
},
{
"name": "Sanitario",
"color": "#9C27B0",
"icon": "medical-services",
"items": [
"Pañales desechables",
"Toallas sanitarias",
"Papel higiénico usado",
"Algodón y gasas",
"Cubrebocas",
"Jeringas (en contenedor especial)"
]
},
{
"name": "Peligroso",
"color": "#F44336",
"icon": "warning",
"items": [
"Pilas y baterías",
"Aceite de cocina usado",
"Pinturas y solventes",
"Químicos de limpieza",
"Medicamentos caducados",
"Focos y fluorescentes",
"Electrónicos"
]
}
],
"tips": [
"Lava los envases reciclables antes de desecharlos",
"No mezcles residuos peligrosos con la basura común",
"Los residuos sanitarios deben ir en bolsa aparte",
"El aceite de cocina debe almacenarse en botella cerrada"
]
}
@router.get("/recycling-guide")
@cached(prefix="guide", ttl=86400) # 24 horas de caché
async def get_recycling_guide():
"""
Guía de separación de residuos.
Funciona offline en el cliente (cacheable por 24 horas).
"""
return RECYCLING_GUIDE