feat: add backend FastAPI structure and Supabase schema
This commit is contained in:
0
lib/core/config/supabase_config.dart
Normal file
0
lib/core/config/supabase_config.dart
Normal file
21
server/.dockerignore
Normal file
21
server/.dockerignore
Normal file
@@ -0,0 +1,21 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
.env
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
logs/*.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.vscode
|
||||
.idea
|
||||
venv
|
||||
.venv
|
||||
7
server/.env
Normal file
7
server/.env
Normal file
@@ -0,0 +1,7 @@
|
||||
SECRET_KEY=mi-clave-secreta-super-segura-cambiar-en-produccion-123456
|
||||
DEBUG=true
|
||||
DATABASE_PATH=/data/basura.db
|
||||
SIM_TICK_SECONDS=10
|
||||
SIM_ETA_ALERT_MINUTES=10
|
||||
SUPABASE_URL=https://qckndtzudciejpnwqfzt.supabase.co
|
||||
SUPABASE_SERVICE_KEY=sb_secret_2y3a_9qD5nRtZl-41CY-jw_LA-smvxC
|
||||
5
server/.env.example
Normal file
5
server/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
SECRET_KEY=your-super-secret-key-change-this-in-production
|
||||
DEBUG=true
|
||||
DATABASE_PATH=/data/basura.db
|
||||
SIM_TICK_SECONDS=10
|
||||
SIM_ETA_ALERT_MINUTES=10
|
||||
0
server/__init__.py
Normal file
0
server/__init__.py
Normal file
0
server/app/__init__.py
Normal file
0
server/app/__init__.py
Normal file
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
|
||||
3
server/app/core/__init__.py
Normal file
3
server/app/core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.core.config import settings
|
||||
|
||||
__all__ = ["settings"]
|
||||
198
server/app/core/cache.py
Normal file
198
server/app/core/cache.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Sistema de caching con Redis y memoria
|
||||
"""
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import Optional, Any, Callable
|
||||
from datetime import datetime, timedelta
|
||||
import redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheClient:
|
||||
"""Cliente unificado de caché"""
|
||||
|
||||
def __init__(self):
|
||||
self.redis_client = None
|
||||
self.memory_cache = {} # Fallback en memoria
|
||||
self.enabled = settings.cache_enabled
|
||||
|
||||
if self.enabled:
|
||||
try:
|
||||
self.redis_client = redis.Redis(
|
||||
host=settings.redis_host,
|
||||
port=settings.redis_port,
|
||||
db=settings.redis_db,
|
||||
password=settings.redis_password,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5
|
||||
)
|
||||
# Probar conexión
|
||||
self.redis_client.ping()
|
||||
logger.info(f"✅ Redis conectado en {settings.redis_host}:{settings.redis_port}")
|
||||
except RedisError as e:
|
||||
logger.warning(f"⚠️ Redis no disponible: {e}. Usando caché en memoria.")
|
||||
self.redis_client = None
|
||||
self.enabled = False
|
||||
|
||||
def _get_key(self, prefix: str, key: str) -> str:
|
||||
"""Genera clave con prefijo"""
|
||||
return f"{prefix}:{key}"
|
||||
|
||||
def _hash_key(self, key: str) -> str:
|
||||
"""Hash de claves largas"""
|
||||
if len(key) > 100:
|
||||
return hashlib.md5(key.encode()).hexdigest()
|
||||
return key
|
||||
|
||||
async def get(self, prefix: str, key: str) -> Optional[Any]:
|
||||
"""Obtener valor del caché"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
cache_key = self._get_key(prefix, self._hash_key(key))
|
||||
|
||||
try:
|
||||
# Intentar Redis primero
|
||||
if self.redis_client:
|
||||
value = self.redis_client.get(cache_key)
|
||||
if value:
|
||||
return json.loads(value)
|
||||
|
||||
# Fallback a memoria
|
||||
if cache_key in self.memory_cache:
|
||||
data, expiry = self.memory_cache[cache_key]
|
||||
if datetime.now() < expiry:
|
||||
return data
|
||||
else:
|
||||
del self.memory_cache[cache_key]
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading cache: {e}")
|
||||
return None
|
||||
|
||||
async def set(self, prefix: str, key: str, value: Any, ttl: int = 60):
|
||||
"""Guardar en caché"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
cache_key = self._get_key(prefix, self._hash_key(key))
|
||||
|
||||
try:
|
||||
serialized = json.dumps(value, default=str)
|
||||
|
||||
if self.redis_client:
|
||||
self.redis_client.setex(cache_key, ttl, serialized)
|
||||
|
||||
# Guardar también en memoria
|
||||
self.memory_cache[cache_key] = (value, datetime.now() + timedelta(seconds=ttl))
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing cache: {e}")
|
||||
|
||||
async def delete(self, prefix: str, key: str):
|
||||
"""Eliminar del caché"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
cache_key = self._get_key(prefix, self._hash_key(key))
|
||||
|
||||
try:
|
||||
if self.redis_client:
|
||||
self.redis_client.delete(cache_key)
|
||||
|
||||
if cache_key in self.memory_cache:
|
||||
del self.memory_cache[cache_key]
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting cache: {e}")
|
||||
|
||||
async def delete_pattern(self, pattern: str):
|
||||
"""Eliminar por patrón"""
|
||||
if not self.enabled or not self.redis_client:
|
||||
return
|
||||
|
||||
try:
|
||||
keys = self.redis_client.keys(pattern)
|
||||
if keys:
|
||||
self.redis_client.delete(*keys)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting pattern: {e}")
|
||||
|
||||
async def clear_all(self):
|
||||
"""Limpiar todo el caché"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
if self.redis_client:
|
||||
self.redis_client.flushdb()
|
||||
self.memory_cache.clear()
|
||||
logger.info("Cache cleared")
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing cache: {e}")
|
||||
|
||||
|
||||
# Singleton
|
||||
cache_client = CacheClient()
|
||||
|
||||
|
||||
def cached(prefix: str, ttl: int = 60, key_builder: Optional[Callable] = None):
|
||||
"""
|
||||
Decorador para cachear respuestas de endpoints
|
||||
|
||||
Uso:
|
||||
@cached(prefix="eta", ttl=30)
|
||||
async def get_eta(address_id: int):
|
||||
...
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
if not cache_client.enabled:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Construir clave
|
||||
if key_builder:
|
||||
cache_key = key_builder(*args, **kwargs)
|
||||
else:
|
||||
# Usar nombre de función y argumentos
|
||||
cache_key = f"{func.__name__}:{str(args)}:{str(kwargs)}"
|
||||
|
||||
# Intentar obtener del caché
|
||||
cached_value = await cache_client.get(prefix, cache_key)
|
||||
if cached_value is not None:
|
||||
logger.debug(f"Cache HIT: {prefix}:{cache_key}")
|
||||
return cached_value
|
||||
|
||||
# Ejecutar función
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Guardar en caché
|
||||
if result is not None:
|
||||
await cache_client.set(prefix, cache_key, result, ttl)
|
||||
logger.debug(f"Cache MISS: {prefix}:{cache_key} saved")
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
async def invalidate_user_cache(user_id: int):
|
||||
"""Invalidar caché relacionada con un usuario"""
|
||||
await cache_client.delete_pattern(f"addresses:user:{user_id}:*")
|
||||
await cache_client.delete_pattern(f"eta:user:{user_id}:*")
|
||||
logger.info(f"Cache invalidated for user {user_id}")
|
||||
|
||||
|
||||
async def invalidate_route_cache(route_id: str):
|
||||
"""Invalidar caché relacionada con una ruta"""
|
||||
await cache_client.delete_pattern(f"eta:route:{route_id}:*")
|
||||
await cache_client.delete_pattern(f"truck_status:{route_id}")
|
||||
logger.info(f"Cache invalidated for route {route_id}")
|
||||
35
server/app/core/config.py
Normal file
35
server/app/core/config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "BasuraApp API"
|
||||
debug: bool = True
|
||||
secret_key: str = "CAMBIA_ESTO_EN_PRODUCCION_clave_super_secreta"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 24 # 24 horas
|
||||
|
||||
# SQLite
|
||||
database_url: str = "sqlite:///./basura.db"
|
||||
|
||||
# Redis
|
||||
redis_host: str = "localhost"
|
||||
redis_port: int = 6379
|
||||
redis_db: int = 0
|
||||
redis_password: Optional[str] = None
|
||||
|
||||
# Cache settings
|
||||
cache_enabled: bool = True
|
||||
cache_ttl_eta: int = 30 # 30 segundos para ETA
|
||||
cache_ttl_addresses: int = 300 # 5 minutos para direcciones
|
||||
cache_ttl_guide: int = 86400 # 24 horas para guía de reciclaje
|
||||
|
||||
# Simulador
|
||||
sim_tick_seconds: int = 10
|
||||
sim_eta_alert_minutes: int = 10
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
36
server/app/core/dependencies.py
Normal file
36
server/app/core/dependencies.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from fastapi import HTTPException, Depends, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.database import get_connection
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.secret_key,
|
||||
algorithms=[settings.algorithm]
|
||||
)
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
conn = get_connection()
|
||||
user = conn.execute(
|
||||
"SELECT id, email, phone FROM users WHERE id = ?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
return dict(user)
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
3
server/app/data/__init__.py
Normal file
3
server/app/data/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.data.repositories.ruta_repository import SQLiteRutaRepository
|
||||
|
||||
__all__ = ["SQLiteRutaRepository"]
|
||||
0
server/app/data/models/__init__.py
Normal file
0
server/app/data/models/__init__.py
Normal file
3
server/app/data/repositories/__init__.py
Normal file
3
server/app/data/repositories/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.data.repositories.ruta_repository import SQLiteRutaRepository
|
||||
|
||||
__all__ = ["SQLiteRutaRepository"]
|
||||
189
server/app/data/repositories/ruta_repository.py
Normal file
189
server/app/data/repositories/ruta_repository.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Capa de Datos — SQLite con soporte de caché
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.db.database import get_connection
|
||||
from app.domain.entities.ruta import (
|
||||
Coordenada, EstadoCamion, ETAResult,
|
||||
NotificationPreferences, PuntoRuta, Ruta, TruckStatus,
|
||||
)
|
||||
|
||||
|
||||
class SQLiteRutaRepository:
|
||||
|
||||
# ── Rutas y puntos ────────────────────────────────────────────────
|
||||
|
||||
def obtener_ruta(self, route_id: str) -> Optional[Ruta]:
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM rutas WHERE id = ?", (route_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return None
|
||||
puntos = [
|
||||
PuntoRuta(
|
||||
orden=p["orden"],
|
||||
nombre=p["nombre"],
|
||||
coordenada=Coordenada(p["lat"], p["lng"]),
|
||||
tiempo_estimado_min=p["tiempo_estimado_min"],
|
||||
)
|
||||
for p in conn.execute(
|
||||
"SELECT * FROM puntos_ruta WHERE ruta_id=? ORDER BY orden",
|
||||
(route_id,),
|
||||
).fetchall()
|
||||
]
|
||||
conn.close()
|
||||
return Ruta(id=row["id"], nombre=row["nombre"],
|
||||
puntos=puntos, turno=row["turno"])
|
||||
|
||||
def obtener_ruta_por_address(self, address_id: int) -> Optional[Ruta]:
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
"SELECT route_id FROM addresses WHERE id = ?", (address_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
return self.obtener_ruta(row["route_id"])
|
||||
|
||||
# ── truck_status ──────────────────────────────────────────
|
||||
|
||||
def obtener_truck_status(self, route_id: str) -> Optional[TruckStatus]:
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM truck_status WHERE route_id = ?", (route_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
return TruckStatus(
|
||||
route_id=row["route_id"],
|
||||
current_position_id=row["current_position_id"],
|
||||
last_update=datetime.fromisoformat(row["last_update"]),
|
||||
status=EstadoCamion(row["status"]),
|
||||
)
|
||||
|
||||
def guardar_truck_status(self, ts: TruckStatus) -> None:
|
||||
conn = get_connection()
|
||||
conn.execute("""
|
||||
INSERT INTO truck_status (route_id, current_position_id, last_update, status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(route_id) DO UPDATE SET
|
||||
current_position_id = excluded.current_position_id,
|
||||
last_update = excluded.last_update,
|
||||
status = excluded.status
|
||||
""", (
|
||||
ts.route_id,
|
||||
ts.current_position_id,
|
||||
ts.last_update.isoformat(),
|
||||
ts.status.value,
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Invalidar caché de esta ruta
|
||||
from app.core.cache import invalidate_route_cache
|
||||
import asyncio
|
||||
asyncio.create_task(invalidate_route_cache(ts.route_id))
|
||||
|
||||
# ── Preferencias de notificación ─────────────────────────────────
|
||||
|
||||
def obtener_preferencias(self, user_id: int) -> NotificationPreferences:
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM notification_preferences WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return NotificationPreferences(user_id=user_id)
|
||||
return NotificationPreferences(
|
||||
user_id=user_id,
|
||||
notify_proximity=bool(row["notify_proximity"]),
|
||||
notify_breakdown=bool(row["notify_breakdown"]),
|
||||
notify_delay=bool(row["notify_delay"]),
|
||||
notify_route_start=bool(row["notify_route_start"]),
|
||||
)
|
||||
|
||||
def obtener_usuarios_por_ruta(self, route_id: str) -> list[dict]:
|
||||
conn = get_connection()
|
||||
rows = conn.execute(
|
||||
"SELECT id as address_id, user_id FROM addresses WHERE route_id = ?",
|
||||
(route_id,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── Templates de notificación ─────────────────────────────────────
|
||||
|
||||
def obtener_template(self, trigger_event: str) -> Optional[dict]:
|
||||
conn = get_connection()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM notification_templates WHERE trigger_event = ?",
|
||||
(trigger_event,),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
# ── ETA calculado con soporte de caché ────────────────────────────
|
||||
|
||||
def calcular_eta(self, address_id: int) -> Optional[ETAResult]:
|
||||
ruta = self.obtener_ruta_por_address(address_id)
|
||||
if not ruta:
|
||||
return None
|
||||
|
||||
ts = self.obtener_truck_status(ruta.id)
|
||||
if not ts:
|
||||
return ETAResult(
|
||||
address_id=address_id, route_id=ruta.id,
|
||||
status="SIN_INICIAR", eta_minutos=None,
|
||||
ventana_inicio=None, ventana_fin=None,
|
||||
mensaje="El camión aún no ha iniciado su ruta.",
|
||||
)
|
||||
|
||||
pos = ts.current_position_id
|
||||
puntos = {p.orden: p for p in ruta.puntos}
|
||||
ultimo = ruta.puntos[-1]
|
||||
actual = puntos.get(pos, ruta.puntos[0])
|
||||
|
||||
eta_min = max(0, ultimo.tiempo_estimado_min - actual.tiempo_estimado_min)
|
||||
|
||||
from datetime import timedelta
|
||||
ahora = datetime.now()
|
||||
llegada = ahora + timedelta(minutes=eta_min)
|
||||
v_ini = (llegada - timedelta(minutes=7)).strftime("%I:%M %p").lstrip("0")
|
||||
v_fin = (llegada + timedelta(minutes=7)).strftime("%I:%M %p").lstrip("0")
|
||||
|
||||
if ts.status == EstadoCamion.APROXIMANDOSE:
|
||||
msg = f"El camión llegará a tu zona entre las {v_ini} y {v_fin}."
|
||||
elif ts.status in (EstadoCamion.AVERIADA, EstadoCamion.RETRASADA):
|
||||
msg = "El camión reportó una incidencia. Te notificaremos cuando se reanude."
|
||||
v_ini = v_fin = None
|
||||
else:
|
||||
msg = f"El camión está en camino. Llegada estimada: {v_ini} – {v_fin}."
|
||||
|
||||
return ETAResult(
|
||||
address_id=address_id,
|
||||
route_id=ruta.id,
|
||||
status=ts.status.value,
|
||||
eta_minutos=eta_min,
|
||||
ventana_inicio=v_ini,
|
||||
ventana_fin=v_fin,
|
||||
mensaje=msg,
|
||||
)
|
||||
|
||||
def guardar_notificacion(self, tipo: str, route_id: str,
|
||||
address_id: int, mensaje: str,
|
||||
eta_minutos: Optional[int]) -> None:
|
||||
conn = get_connection()
|
||||
conn.execute("""
|
||||
INSERT INTO notificaciones
|
||||
(tipo, ruta_id, address_id, mensaje, eta_minutos, creada_en)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (tipo, route_id, address_id, mensaje,
|
||||
eta_minutos, datetime.utcnow().isoformat()))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
3
server/app/db/__init__.py
Normal file
3
server/app/db/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.db.database import get_connection, init_db
|
||||
|
||||
__all__ = ["get_connection", "init_db"]
|
||||
142
server/app/db/database.py
Normal file
142
server/app/db/database.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Base de datos SQLite — esquema unificado con Persona A.
|
||||
Tablas propias del módulo B: truck_status, notificaciones, ws_sessions.
|
||||
"""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = Path("basura.db")
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
conn = get_connection()
|
||||
conn.executescript("""
|
||||
-- ── Tablas de Persona A (las creamos aquí para que el módulo B
|
||||
-- pueda leerlas aunque A no haya corrido aún) ──────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
phone TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
fcm_token TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS addresses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
alias TEXT,
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
notify_proximity BOOLEAN DEFAULT 1,
|
||||
notify_breakdown BOOLEAN DEFAULT 1,
|
||||
notify_delay BOOLEAN DEFAULT 1,
|
||||
notify_route_start BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_templates (
|
||||
id INTEGER PRIMARY KEY,
|
||||
trigger_event TEXT UNIQUE,
|
||||
title TEXT,
|
||||
body TEXT
|
||||
);
|
||||
|
||||
-- ── Tablas del módulo B ───────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS truck_status (
|
||||
route_id TEXT PRIMARY KEY,
|
||||
current_position_id INTEGER DEFAULT 1,
|
||||
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT DEFAULT 'EN_RUTA'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rutas (
|
||||
id TEXT PRIMARY KEY,
|
||||
nombre TEXT NOT NULL,
|
||||
turno TEXT NOT NULL DEFAULT 'mañana'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS puntos_ruta (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ruta_id TEXT NOT NULL REFERENCES rutas(id),
|
||||
orden INTEGER NOT NULL,
|
||||
nombre TEXT NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
tiempo_estimado_min INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notificaciones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tipo TEXT NOT NULL,
|
||||
ruta_id TEXT NOT NULL,
|
||||
address_id INTEGER,
|
||||
mensaje TEXT NOT NULL,
|
||||
eta_minutos INTEGER,
|
||||
creada_en TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
_seed_datos_demo()
|
||||
|
||||
|
||||
def _seed_datos_demo() -> None:
|
||||
conn = get_connection()
|
||||
existe = conn.execute("SELECT 1 FROM rutas WHERE id='RUTA-01'").fetchone()
|
||||
if existe:
|
||||
conn.close()
|
||||
return
|
||||
|
||||
conn.executescript("""
|
||||
-- Ruta de demo (Celaya, Guanajuato)
|
||||
INSERT INTO rutas VALUES ('RUTA-01', 'Ruta 01 — Sector Centro', 'mañana');
|
||||
|
||||
INSERT INTO puntos_ruta (ruta_id, orden, nombre, lat, lng, tiempo_estimado_min)
|
||||
VALUES
|
||||
('RUTA-01', 1, 'Estación Central', 20.5238, -100.8143, 0),
|
||||
('RUTA-01', 2, 'Col. Independencia', 20.5255, -100.8090, 8),
|
||||
('RUTA-01', 3, 'Blvd. A. López Mateos', 20.5271, -100.8021, 18),
|
||||
('RUTA-01', 4, 'Col. Jardines del Bosque', 20.5290, -100.7965, 28),
|
||||
('RUTA-01', 5, 'Mercado Hidalgo', 20.5310, -100.7910, 38);
|
||||
|
||||
INSERT INTO truck_status VALUES ('RUTA-01', 1, CURRENT_TIMESTAMP, 'EN_RUTA');
|
||||
|
||||
-- Usuario de demo
|
||||
INSERT INTO users (email, phone, password_hash)
|
||||
VALUES ('demo@basura.app', '4611234567', 'hashed_demo');
|
||||
|
||||
-- Domicilio de demo asignado a RUTA-01
|
||||
INSERT INTO addresses (user_id, alias, lat, lng, route_id)
|
||||
VALUES (1, 'Casa', 20.5285, -100.7980, 'RUTA-01');
|
||||
|
||||
-- Preferencias por defecto para usuario demo
|
||||
INSERT INTO notification_preferences VALUES (1, 1, 1, 1, 1);
|
||||
|
||||
-- Templates de notificación
|
||||
INSERT INTO notification_templates (trigger_event, title, body) VALUES
|
||||
('ruta_iniciada', 'Ruta iniciada', 'El camión ha comenzado su ruta. Prepárate.'),
|
||||
('aproximandose', '¡Camión cerca!', 'El camión llega en ~{eta} minutos. Saca tu basura.'),
|
||||
('falla_mecanica', 'Aviso de servicio', 'El camión reportó una falla. Te notificaremos cuando se reanude.'),
|
||||
('ruta_tarde', 'Cambio de horario', 'El camión de la mañana pasará en el turno de la tarde.'),
|
||||
('completado', 'Ruta completada', 'El camión completó su paso por tu zona. ¡Hasta mañana!');
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
21
server/app/domain/__init__.py
Normal file
21
server/app/domain/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from app.domain.entities.ruta import (
|
||||
Coordenada,
|
||||
EstadoCamion,
|
||||
ETAResult,
|
||||
NotificationPreferences,
|
||||
PuntoRuta,
|
||||
Ruta,
|
||||
TruckStatus,
|
||||
TipoNotificacion,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Coordenada",
|
||||
"EstadoCamion",
|
||||
"ETAResult",
|
||||
"NotificationPreferences",
|
||||
"PuntoRuta",
|
||||
"Ruta",
|
||||
"TruckStatus",
|
||||
"TipoNotificacion",
|
||||
]
|
||||
21
server/app/domain/entities/__init__.py
Normal file
21
server/app/domain/entities/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from app.domain.entities.ruta import (
|
||||
Coordenada,
|
||||
EstadoCamion,
|
||||
ETAResult,
|
||||
NotificationPreferences,
|
||||
PuntoRuta,
|
||||
Ruta,
|
||||
TruckStatus,
|
||||
TipoNotificacion,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Coordenada",
|
||||
"EstadoCamion",
|
||||
"ETAResult",
|
||||
"NotificationPreferences",
|
||||
"PuntoRuta",
|
||||
"Ruta",
|
||||
"TruckStatus",
|
||||
"TipoNotificacion",
|
||||
]
|
||||
77
server/app/domain/entities/ruta.py
Normal file
77
server/app/domain/entities/ruta.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Entidades del dominio — sin dependencias externas.
|
||||
Alineadas con el esquema de Persona A.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class EstadoCamion(str, Enum):
|
||||
EN_RUTA = "EN_RUTA"
|
||||
APROXIMANDOSE = "APROXIMANDOSE"
|
||||
COMPLETADO = "COMPLETADO"
|
||||
AVERIADA = "AVERIADA" # truck_status: AVERIADA
|
||||
RETRASADA = "RETRASADA" # truck_status: RETRASADA
|
||||
|
||||
|
||||
class TipoNotificacion(str, Enum):
|
||||
RUTA_INICIADA = "ruta_iniciada"
|
||||
APROXIMANDOSE = "aproximandose"
|
||||
COMPLETADO = "completado"
|
||||
FALLA_MECANICA = "falla_mecanica"
|
||||
RUTA_TARDE = "ruta_tarde"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Coordenada:
|
||||
lat: float
|
||||
lng: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class PuntoRuta:
|
||||
orden: int # == current_position_id en truck_status
|
||||
nombre: str
|
||||
coordenada: Coordenada
|
||||
tiempo_estimado_min: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ruta:
|
||||
id: str # ej. "RUTA-01"
|
||||
nombre: str
|
||||
puntos: list[PuntoRuta] = field(default_factory=list)
|
||||
turno: str = "mañana"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TruckStatus:
|
||||
"""Espejo directo de la tabla truck_status de Persona A."""
|
||||
route_id: str
|
||||
current_position_id: int
|
||||
last_update: datetime
|
||||
status: EstadoCamion
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationPreferences:
|
||||
"""Preferencias del usuario — leídas antes de cada notificación."""
|
||||
user_id: int
|
||||
notify_proximity: bool = True
|
||||
notify_breakdown: bool = True
|
||||
notify_delay: bool = True
|
||||
notify_route_start: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class ETAResult:
|
||||
"""Lo que ve el ciudadano — sin coordenadas, sin índice de waypoint."""
|
||||
address_id: int
|
||||
route_id: str
|
||||
status: str
|
||||
eta_minutos: Optional[int]
|
||||
ventana_inicio: Optional[str] # ej. "7:20 pm"
|
||||
ventana_fin: Optional[str] # ej. "7:35 pm"
|
||||
mensaje: str
|
||||
3
server/app/domain/interfaces/__init__.py
Normal file
3
server/app/domain/interfaces/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.domain.interfaces.i_ruta_repository import IRutaRepository
|
||||
|
||||
__all__ = ["IRutaRepository"]
|
||||
32
server/app/domain/interfaces/i_ruta_repository.py
Normal file
32
server/app/domain/interfaces/i_ruta_repository.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Interfaces del dominio.
|
||||
El dominio define QUÉ necesita, no CÓMO se implementa.
|
||||
La capa de Datos implementa estas interfaces.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from app.domain.entities.ruta import EstadoRuta, Ruta
|
||||
|
||||
|
||||
class IRutaRepository(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def obtener_ruta(self, ruta_id: str) -> Optional[Ruta]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def obtener_estado(self, ruta_id: str) -> Optional[EstadoRuta]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def guardar_estado(self, estado: EstadoRuta) -> None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def obtener_ruta_por_zona(self, zona_id: str) -> Optional[Ruta]:
|
||||
"""
|
||||
Devuelve la ruta asignada a una zona.
|
||||
Cumple la restricción de 'túnel': el domicilio solo ve su ruta.
|
||||
"""
|
||||
...
|
||||
48
server/app/main.py
Normal file
48
server/app/main.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Punto de entrada de la aplicación.
|
||||
Ejecutar con: uvicorn app.main:app --reload
|
||||
"""
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes.eta_router import router as eta_router
|
||||
from app.api.routes.auth_router import router as auth_router
|
||||
from app.api.routes.addresses_router import router as addresses_router
|
||||
from app.api.routes.guide_router import router as guide_router # ← IMPORTANTE: agregar esta línea
|
||||
from app.core.config import settings
|
||||
from app.db.database import init_db
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version="0.1.0",
|
||||
description="API de notificación inteligente de recolección de residuos",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
init_db()
|
||||
logging.info("Base de datos inicializada ✓")
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
||||
app.include_router(addresses_router, prefix="/addresses", tags=["Addresses"])
|
||||
app.include_router(eta_router, tags=["ETA / Simulador"])
|
||||
app.include_router(guide_router, tags=["Recycling Guide"])
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
async def health():
|
||||
return {"status": "ok", "app": settings.app_name}
|
||||
4
server/app/services/__init__.py
Normal file
4
server/app/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.services.simulador import SimuladorRuta, obtener_simulador
|
||||
from app.services.ws_manager import ws_manager
|
||||
|
||||
__all__ = ["SimuladorRuta", "obtener_simulador", "ws_manager"]
|
||||
214
server/app/services/simulador.py
Normal file
214
server/app/services/simulador.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Simulador de Ruta — Módulo B
|
||||
|
||||
Avanza `truck_status.current_position_id` cada tick.
|
||||
Antes de cada push verifica `notification_preferences` del usuario.
|
||||
Nunca envía coordenadas al cliente — solo ETA + mensaje.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.data.repositories.ruta_repository import SQLiteRutaRepository
|
||||
from app.domain.entities.ruta import EstadoCamion, TruckStatus, TipoNotificacion
|
||||
from app.services.ws_manager import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimuladorRuta:
|
||||
|
||||
def __init__(self, route_id: str, tick_segundos: int = None):
|
||||
self.route_id = route_id
|
||||
self.tick = tick_segundos or settings.sim_tick_seconds
|
||||
self.repo = SQLiteRutaRepository()
|
||||
self._tarea: Optional[asyncio.Task] = None
|
||||
self._corriendo = False
|
||||
|
||||
# ── Control ────────────────────────────────────────────────────────
|
||||
|
||||
def iniciar(self) -> None:
|
||||
if self._corriendo:
|
||||
return
|
||||
self._corriendo = True
|
||||
self._tarea = asyncio.create_task(self._loop())
|
||||
logger.info(f"[SIM] {self.route_id} iniciada")
|
||||
|
||||
def detener(self) -> None:
|
||||
self._corriendo = False
|
||||
if self._tarea:
|
||||
self._tarea.cancel()
|
||||
|
||||
async def forzar_averia(self, mensaje: str = "Falla mecánica reportada.") -> None:
|
||||
"""Endpoint /alerts/breakdown llama esto."""
|
||||
ts = self.repo.obtener_truck_status(self.route_id)
|
||||
if not ts:
|
||||
return
|
||||
ts.status = EstadoCamion.AVERIADA
|
||||
ts.last_update = datetime.utcnow()
|
||||
self.repo.guardar_truck_status(ts)
|
||||
self.detener()
|
||||
|
||||
await self._broadcast_a_usuarios(
|
||||
tipo=TipoNotificacion.FALLA_MECANICA,
|
||||
eta_minutos=None,
|
||||
mensaje=mensaje,
|
||||
preferencia_key="notify_breakdown",
|
||||
)
|
||||
logger.warning(f"[SIM] Avería registrada en {self.route_id}")
|
||||
|
||||
async def forzar_retraso(self, mensaje: str = "El camión reportó un retraso.") -> None:
|
||||
ts = self.repo.obtener_truck_status(self.route_id)
|
||||
if not ts:
|
||||
return
|
||||
ts.status = EstadoCamion.RETRASADA
|
||||
ts.last_update = datetime.utcnow()
|
||||
self.repo.guardar_truck_status(ts)
|
||||
|
||||
await self._broadcast_a_usuarios(
|
||||
tipo=TipoNotificacion.RUTA_TARDE,
|
||||
eta_minutos=None,
|
||||
mensaje=mensaje,
|
||||
preferencia_key="notify_delay",
|
||||
)
|
||||
|
||||
# ── Loop principal ─────────────────────────────────────────────────
|
||||
|
||||
async def _loop(self) -> None:
|
||||
ruta = self.repo.obtener_ruta(self.route_id)
|
||||
if not ruta or not ruta.puntos:
|
||||
logger.error(f"[SIM] Ruta {self.route_id} sin puntos")
|
||||
return
|
||||
|
||||
# Inicializar truck_status en posición 1
|
||||
ts = TruckStatus(
|
||||
route_id=self.route_id,
|
||||
current_position_id=1,
|
||||
last_update=datetime.utcnow(),
|
||||
status=EstadoCamion.EN_RUTA,
|
||||
)
|
||||
self.repo.guardar_truck_status(ts)
|
||||
|
||||
await self._broadcast_a_usuarios(
|
||||
tipo=TipoNotificacion.RUTA_INICIADA,
|
||||
eta_minutos=ruta.puntos[-1].tiempo_estimado_min,
|
||||
mensaje="El camión ha iniciado su ruta. Prepárate.",
|
||||
preferencia_key="notify_route_start",
|
||||
)
|
||||
|
||||
umbral = settings.sim_eta_alert_minutes
|
||||
ultimo_punto = ruta.puntos[-1]
|
||||
|
||||
for punto in ruta.puntos[1:]:
|
||||
if not self._corriendo:
|
||||
break
|
||||
|
||||
await asyncio.sleep(self.tick)
|
||||
|
||||
eta = max(0, ultimo_punto.tiempo_estimado_min - punto.tiempo_estimado_min)
|
||||
|
||||
# Detectar umbral de proximidad
|
||||
if eta <= umbral and ts.status == EstadoCamion.EN_RUTA:
|
||||
ts.status = EstadoCamion.APROXIMANDOSE
|
||||
tipo = TipoNotificacion.APROXIMANDOSE
|
||||
pref_key = "notify_proximity"
|
||||
msg = (
|
||||
f"El camión llega en ~{eta} minutos. "
|
||||
"Saca tu basura ahora."
|
||||
)
|
||||
else:
|
||||
tipo = TipoNotificacion.RUTA_INICIADA
|
||||
pref_key = "notify_route_start"
|
||||
msg = f"El camión está en camino. Llegada estimada en ~{eta} min."
|
||||
|
||||
ts.current_position_id = punto.orden
|
||||
ts.last_update = datetime.utcnow()
|
||||
self.repo.guardar_truck_status(ts)
|
||||
|
||||
await self._broadcast_a_usuarios(
|
||||
tipo=tipo,
|
||||
eta_minutos=eta,
|
||||
mensaje=msg,
|
||||
preferencia_key=pref_key,
|
||||
)
|
||||
logger.info(f"[SIM] Pos {punto.orden} | ETA {eta} min | {ts.status}")
|
||||
|
||||
if self._corriendo:
|
||||
ts.status = EstadoCamion.COMPLETADO
|
||||
ts.last_update = datetime.utcnow()
|
||||
self.repo.guardar_truck_status(ts)
|
||||
await self._broadcast_a_usuarios(
|
||||
tipo=TipoNotificacion.COMPLETADO,
|
||||
eta_minutos=0,
|
||||
mensaje="El camión completó su paso. ¡Hasta mañana!",
|
||||
preferencia_key=None, # completado siempre se notifica
|
||||
)
|
||||
self._corriendo = False
|
||||
logger.info(f"[SIM] {self.route_id} completada")
|
||||
|
||||
# ── Broadcast respetando preferencias ─────────────────────────────
|
||||
|
||||
async def _broadcast_a_usuarios(
|
||||
self,
|
||||
tipo: TipoNotificacion,
|
||||
eta_minutos: Optional[int],
|
||||
mensaje: str,
|
||||
preferencia_key: Optional[str],
|
||||
) -> None:
|
||||
"""
|
||||
Por cada domicilio en la ruta:
|
||||
1. Consulta las preferencias del usuario.
|
||||
2. Solo envía si la preferencia está activa.
|
||||
3. Persiste la notificación en BD.
|
||||
4. Empuja por WebSocket al address_id correspondiente.
|
||||
"""
|
||||
template = self.repo.obtener_template(tipo.value)
|
||||
if template and "{eta}" in template["body"]:
|
||||
mensaje = template["body"].replace("{eta}", str(eta_minutos or "?"))
|
||||
|
||||
usuarios = self.repo.obtener_usuarios_por_ruta(self.route_id)
|
||||
|
||||
for u in usuarios:
|
||||
user_id = u["user_id"]
|
||||
address_id = u["address_id"]
|
||||
|
||||
# Verificar preferencia
|
||||
if preferencia_key:
|
||||
prefs = self.repo.obtener_preferencias(user_id)
|
||||
if not getattr(prefs, preferencia_key, True):
|
||||
logger.debug(
|
||||
f"[SIM] Usuario {user_id} desactivó {preferencia_key}, skip"
|
||||
)
|
||||
continue
|
||||
|
||||
payload = {
|
||||
"tipo": tipo.value,
|
||||
"address_id": address_id,
|
||||
"eta_minutos": eta_minutos,
|
||||
"mensaje": mensaje,
|
||||
"hora_utc": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# Push WebSocket — el cliente escucha en /ws/{address_id}
|
||||
await ws_manager.broadcast_zona(str(address_id), payload)
|
||||
|
||||
# Persistir
|
||||
self.repo.guardar_notificacion(
|
||||
tipo=tipo.value,
|
||||
route_id=self.route_id,
|
||||
address_id=address_id,
|
||||
mensaje=mensaje,
|
||||
eta_minutos=eta_minutos,
|
||||
)
|
||||
|
||||
|
||||
# ── Registro global ────────────────────────────────────────────────────
|
||||
_simuladores: dict[str, SimuladorRuta] = {}
|
||||
|
||||
|
||||
def obtener_simulador(route_id: str) -> SimuladorRuta:
|
||||
if route_id not in _simuladores:
|
||||
_simuladores[route_id] = SimuladorRuta(route_id)
|
||||
return _simuladores[route_id]
|
||||
55
server/app/services/ws_manager.py
Normal file
55
server/app/services/ws_manager.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Gestor de conexiones WebSocket.
|
||||
Mantiene un registro de qué clientes están conectados y a qué zona pertenecen.
|
||||
El simulador llama a broadcast_zona() para empujar eventos sin polling.
|
||||
"""
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
def __init__(self):
|
||||
# zona_id -> lista de WebSockets activos
|
||||
self._conexiones: dict[str, list[WebSocket]] = defaultdict(list)
|
||||
|
||||
async def conectar(self, websocket: WebSocket, zona_id: str) -> None:
|
||||
await websocket.accept()
|
||||
self._conexiones[zona_id].append(websocket)
|
||||
|
||||
def desconectar(self, websocket: WebSocket, zona_id: str) -> None:
|
||||
conexiones = self._conexiones.get(zona_id, [])
|
||||
if websocket in conexiones:
|
||||
conexiones.remove(websocket)
|
||||
|
||||
async def broadcast_zona(self, zona_id: str, payload: dict) -> None:
|
||||
"""Envía un mensaje a todos los clientes de una zona."""
|
||||
mensaje = json.dumps(payload, ensure_ascii=False)
|
||||
muertos: list[WebSocket] = []
|
||||
|
||||
for ws in self._conexiones.get(zona_id, []):
|
||||
try:
|
||||
await ws.send_text(mensaje)
|
||||
except Exception:
|
||||
muertos.append(ws)
|
||||
|
||||
for ws in muertos:
|
||||
self.desconectar(ws, zona_id)
|
||||
|
||||
async def broadcast_ruta(self, ruta_id: str, payload: dict) -> None:
|
||||
"""
|
||||
Envía a TODAS las zonas de una ruta.
|
||||
El filtro real de privacidad está en el backend (RBAC del endpoint REST).
|
||||
Aquí simplemente distribuimos por zona registrada.
|
||||
"""
|
||||
for zona_id, conexiones in self._conexiones.items():
|
||||
if conexiones:
|
||||
await self.broadcast_zona(zona_id, payload)
|
||||
|
||||
def zonas_activas(self) -> list[str]:
|
||||
return [z for z, ws in self._conexiones.items() if ws]
|
||||
|
||||
|
||||
# Singleton global compartido por el simulador y el router de WebSocket
|
||||
ws_manager = WebSocketManager()
|
||||
3
server/app/use_cases/__init__.py
Normal file
3
server/app/use_cases/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.use_cases.obtener_eta import ObtenerETAUseCase, ETAResponse
|
||||
|
||||
__all__ = ["ObtenerETAUseCase", "ETAResponse"]
|
||||
46
server/app/use_cases/obtener_eta.py
Normal file
46
server/app/use_cases/obtener_eta.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Caso de Uso: ObtenerETA
|
||||
Orquesta la lógica: valida que la zona pertenece al usuario,
|
||||
busca la ruta asignada y devuelve solo el ETA.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from app.domain.interfaces.i_ruta_repository import IRutaRepository
|
||||
|
||||
|
||||
@dataclass
|
||||
class ETAResponse:
|
||||
zona_id: str
|
||||
estado: str
|
||||
eta_minutos: Optional[int]
|
||||
mensaje: str
|
||||
ruta_nombre: str
|
||||
|
||||
|
||||
class ObtenerETAUseCase:
|
||||
def __init__(self, repo: IRutaRepository):
|
||||
self.repo = repo
|
||||
|
||||
def ejecutar(self, zona_id: str) -> Optional[ETAResponse]:
|
||||
ruta = self.repo.obtener_ruta_por_zona(zona_id)
|
||||
if not ruta:
|
||||
return None
|
||||
|
||||
estado = self.repo.obtener_estado(ruta.id)
|
||||
if not estado:
|
||||
return ETAResponse(
|
||||
zona_id=zona_id,
|
||||
estado="sin_iniciar",
|
||||
eta_minutos=None,
|
||||
mensaje="El camión aún no ha iniciado su ruta para hoy.",
|
||||
ruta_nombre=ruta.nombre,
|
||||
)
|
||||
|
||||
return ETAResponse(
|
||||
zona_id=zona_id,
|
||||
estado=estado.estado.value,
|
||||
eta_minutos=estado.eta_minutos,
|
||||
mensaje=estado.mensaje,
|
||||
ruta_nombre=ruta.nombre,
|
||||
)
|
||||
60
server/docker-compose.yml
Normal file
60
server/docker-compose.yml
Normal file
@@ -0,0 +1,60 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: basura-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- basura-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: basura-backend
|
||||
ports:
|
||||
- "0.0.0.0:8000:8000"
|
||||
environment:
|
||||
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production}
|
||||
- DATABASE_PATH=/data/basura.db
|
||||
- DEBUG=${DEBUG:-true}
|
||||
- SIM_TICK_SECONDS=${SIM_TICK_SECONDS:-10}
|
||||
- SIM_ETA_ALERT_MINUTES=${SIM_ETA_ALERT_MINUTES:-10}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_DB=0
|
||||
- CACHE_ENABLED=${CACHE_ENABLED:-true}
|
||||
- CACHE_TTL_ETA=${CACHE_TTL_ETA:-30}
|
||||
- CACHE_TTL_ADDRESSES=${CACHE_TTL_ADDRESSES:-300}
|
||||
- CACHE_TTL_GUIDE=${CACHE_TTL_GUIDE:-86400}
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- basura-network
|
||||
|
||||
networks:
|
||||
basura-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
27
server/dockerfile
Normal file
27
server/dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app/ ./app/
|
||||
COPY tests/ ./tests/
|
||||
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /data
|
||||
|
||||
# Environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV DATABASE_PATH=/data/basura.db
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
15
server/requirements.txt
Normal file
15
server/requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
websockets==12.0
|
||||
apscheduler==3.10.4
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-dotenv==1.0.0
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
httpx==0.25.0
|
||||
pyjwt==2.8.0
|
||||
bcrypt==4.1.2
|
||||
email-validator==2.1.0
|
||||
redis==5.0.1
|
||||
hiredis==2.3.2
|
||||
127
server/schema_supabase.sql
Normal file
127
server/schema_supabase.sql
Normal file
@@ -0,0 +1,127 @@
|
||||
-- ── Esquema Supabase PostgreSQL (migrado de SQLite) ──────────────
|
||||
|
||||
-- Tabla users (Persona A)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
phone TEXT,
|
||||
password_hash TEXT NOT NULL,
|
||||
fcm_token TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabla addresses (Persona A)
|
||||
CREATE TABLE IF NOT EXISTS addresses (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
alias TEXT,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabla notification_preferences (Persona A)
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
notify_proximity BOOLEAN DEFAULT TRUE,
|
||||
notify_breakdown BOOLEAN DEFAULT TRUE,
|
||||
notify_delay BOOLEAN DEFAULT TRUE,
|
||||
notify_route_start BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Tabla notification_templates (Persona A)
|
||||
CREATE TABLE IF NOT EXISTS notification_templates (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
trigger_event TEXT UNIQUE,
|
||||
title TEXT,
|
||||
body TEXT
|
||||
);
|
||||
|
||||
-- ── Tablas del módulo B ───────────────────────────────────────
|
||||
|
||||
-- Tabla truck_status
|
||||
CREATE TABLE IF NOT EXISTS truck_status (
|
||||
route_id TEXT PRIMARY KEY,
|
||||
current_position_id INTEGER DEFAULT 1,
|
||||
last_update TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT DEFAULT 'EN_RUTA'
|
||||
);
|
||||
|
||||
-- Tabla rutas
|
||||
CREATE TABLE IF NOT EXISTS rutas (
|
||||
id TEXT PRIMARY KEY,
|
||||
nombre TEXT NOT NULL,
|
||||
turno TEXT NOT NULL DEFAULT 'mañana'
|
||||
);
|
||||
|
||||
-- Tabla puntos_ruta
|
||||
CREATE TABLE IF NOT EXISTS puntos_ruta (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ruta_id TEXT NOT NULL REFERENCES rutas(id),
|
||||
orden INTEGER NOT NULL,
|
||||
nombre TEXT NOT NULL,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
tiempo_estimado_min INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Tabla notificaciones
|
||||
CREATE TABLE IF NOT EXISTS notificaciones (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tipo TEXT NOT NULL,
|
||||
ruta_id TEXT NOT NULL,
|
||||
address_id BIGINT,
|
||||
mensaje TEXT NOT NULL,
|
||||
eta_minutos INTEGER,
|
||||
creada_en TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- ── Índices para performance ──────────────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_addresses_user_id ON addresses(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_addresses_route_id ON addresses(route_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_puntos_ruta_ruta_id ON puntos_ruta(ruta_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notificaciones_ruta_id ON notificaciones(ruta_id);
|
||||
|
||||
-- ── RLS (Row Level Security) ──────────────────────────────────
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE addresses ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE notificaciones ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policy: users solo ven su propio perfil
|
||||
CREATE POLICY "Users can view own profile" ON users
|
||||
FOR SELECT USING (auth.uid()::TEXT = id::TEXT);
|
||||
|
||||
-- Policy: addresses solo de usuario autenticado
|
||||
CREATE POLICY "Users can view own addresses" ON addresses
|
||||
FOR SELECT USING (user_id = auth.uid()::BIGINT);
|
||||
|
||||
-- Policy: notification_preferences solo de usuario autenticado
|
||||
CREATE POLICY "Users can view own preferences" ON notification_preferences
|
||||
FOR SELECT USING (user_id = auth.uid()::BIGINT);
|
||||
|
||||
-- ── Seed data ──────────────────────────────────────────────────
|
||||
INSERT INTO rutas (id, nombre, turno) VALUES
|
||||
('RUTA-01', 'Ruta 01 — Sector Centro', 'mañana')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO puntos_ruta (ruta_id, orden, nombre, lat, lng, tiempo_estimado_min) VALUES
|
||||
('RUTA-01', 1, 'Estación Central', 20.5238, -100.8143, 0),
|
||||
('RUTA-01', 2, 'Col. Independencia', 20.5255, -100.8090, 8),
|
||||
('RUTA-01', 3, 'Blvd. A. López Mateos', 20.5271, -100.8021, 18),
|
||||
('RUTA-01', 4, 'Col. Jardines del Bosque', 20.5290, -100.7965, 28),
|
||||
('RUTA-01', 5, 'Mercado Hidalgo', 20.5310, -100.7910, 38)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO truck_status (route_id, current_position_id, status) VALUES
|
||||
('RUTA-01', 1, 'EN_RUTA')
|
||||
ON CONFLICT (route_id) DO NOTHING;
|
||||
|
||||
INSERT INTO notification_templates (trigger_event, title, body) VALUES
|
||||
('ruta_iniciada', 'Ruta iniciada', 'El camión ha comenzado su ruta. Prepárate.'),
|
||||
('aproximandose', '¡Camión cerca!', 'El camión llega en ~{eta} minutos. Saca tu basura.'),
|
||||
('falla_mecanica', 'Aviso de servicio', 'El camión reportó una falla. Te notificaremos cuando se reanude.'),
|
||||
('ruta_tarde', 'Cambio de horario', 'El camión de la mañana pasará en el turno de la tarde.'),
|
||||
('completado', 'Ruta completada', 'El camión completó su paso por tu zona. ¡Hasta mañana!')
|
||||
ON CONFLICT (trigger_event) DO NOTHING;
|
||||
0
server/tests/__init__.py
Normal file
0
server/tests/__init__.py
Normal file
Reference in New Issue
Block a user