feat: add backend FastAPI structure and Supabase schema
This commit is contained in:
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")
|
||||
Reference in New Issue
Block a user