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.core.config import settings
__all__ = ["settings"]

198
server/app/core/cache.py Normal file
View 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
View 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()

View 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")