feat: add backend FastAPI structure and Supabase schema
This commit is contained in:
3
server/app/api/__init__.py
Normal file
3
server/app/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.api.routes.eta_router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
3
server/app/api/routes/__init__.py
Normal file
3
server/app/api/routes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.api.routes.eta_router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
43
server/app/api/routes/addresses_router.py
Normal file
43
server/app/api/routes/addresses_router.py
Normal 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]
|
||||
77
server/app/api/routes/auth_router.py
Normal file
77
server/app/api/routes/auth_router.py
Normal 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)
|
||||
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),
|
||||
}
|
||||
82
server/app/api/routes/guide_router.py
Normal file
82
server/app/api/routes/guide_router.py
Normal 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
|
||||
Reference in New Issue
Block a user