feat: Version 1.10.0 - Refactorizacion, optimizacion UI y mejoras de seguridad
- Extraccion de helpers en backend: audit_helpers.py, helpers.py - Modularizacion de schemas en archivos individuales por dominio - Reduccion de audit.py en 953 lineas (74% del archivo) - Reduccion de tickets.py en 655 lineas (60% del archivo) - Expansion de auth.py con recuperacion de contrasenia y tokens - Nuevos modulos: core/email.py, core/cache.py - Reorganizacion de scripts a backend/scripts/ - Frontend: refactorizacion de audit page con array-driven components - Frontend: correccion de 11 errores ortograficos en tickets page - Frontend: proxy Docker corregido en vite.config.js - Frontend: nuevas rutas forgot-password, reset-password, organization, profile - Nuevas utilidades TS: colorUtils.ts, dateFormats.ts - 5 nuevos archivos de tests unitarios en backend/tests/unit/ - Eliminacion de 3 scripts temporales de prueba - Documentacion tecnica: CAMBIOS_v1.10.0.md, OPTIMIZACIONES_RENDIMIENTO.md
This commit is contained in:
308
backend/app/core/cache.py
Normal file
308
backend/app/core/cache.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Redis Caching Service - ServiceManagerWeb
|
||||
|
||||
Servicio centralizado para manejo de caché con Redis.
|
||||
"""
|
||||
|
||||
from redis import asyncio as aioredis
|
||||
from typing import Optional, Any, Union
|
||||
import json
|
||||
import structlog
|
||||
from functools import wraps
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""
|
||||
Servicio de caché usando Redis.
|
||||
|
||||
Proporciona métodos para get/set/delete de datos con serialización JSON.
|
||||
Usa un singleton pattern para compartir la conexión Redis.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_redis = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
async def connect(self):
|
||||
"""Conectar a Redis si aún no está conectado."""
|
||||
if self._redis is None:
|
||||
try:
|
||||
self._redis = await aioredis.from_url(
|
||||
settings.REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5
|
||||
)
|
||||
logger.info("Redis cache connected", url=settings.REDIS_URL)
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect to Redis", error=str(e))
|
||||
self._redis = None
|
||||
|
||||
async def disconnect(self):
|
||||
"""Cerrar conexión Redis."""
|
||||
if self._redis:
|
||||
await self._redis.close()
|
||||
self._redis = None
|
||||
logger.info("Redis cache disconnected")
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
"""
|
||||
Obtener valor del cache.
|
||||
|
||||
Args:
|
||||
key: Clave del cache
|
||||
|
||||
Returns:
|
||||
Valor deserializado o None si no existe
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping cache get", key=key)
|
||||
return None
|
||||
|
||||
try:
|
||||
value = await self._redis.get(key)
|
||||
if value:
|
||||
logger.debug("Cache hit", key=key)
|
||||
return json.loads(value)
|
||||
logger.debug("Cache miss", key=key)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Cache get error", key=key, error=str(e))
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: int = 300
|
||||
) -> bool:
|
||||
"""
|
||||
Guardar valor en cache.
|
||||
|
||||
Args:
|
||||
key: Clave del cache
|
||||
value: Valor a guardar (será serializado a JSON)
|
||||
ttl: Tiempo de vida en segundos (default: 5 minutos)
|
||||
|
||||
Returns:
|
||||
True si se guardó exitosamente
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping cache set", key=key)
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized = json.dumps(value, default=str)
|
||||
await self._redis.setex(key, ttl, serialized)
|
||||
logger.debug("Cache set", key=key, ttl=ttl)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Cache set error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""
|
||||
Eliminar clave del cache.
|
||||
|
||||
Args:
|
||||
key: Clave a eliminar
|
||||
|
||||
Returns:
|
||||
True si se eliminó exitosamente
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping cache delete", key=key)
|
||||
return False
|
||||
|
||||
try:
|
||||
await self._redis.delete(key)
|
||||
logger.debug("Cache delete", key=key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Cache delete error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
async def delete_pattern(self, pattern: str) -> int:
|
||||
"""
|
||||
Eliminar todas las claves que coincidan con el patrón.
|
||||
|
||||
Args:
|
||||
pattern: Patrón de búsqueda (ej: "tickets:tenant:*")
|
||||
|
||||
Returns:
|
||||
Número de claves eliminadas
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
logger.warning("Redis not available, skipping pattern delete", pattern=pattern)
|
||||
return 0
|
||||
|
||||
try:
|
||||
keys = []
|
||||
async for key in self._redis.scan_iter(pattern):
|
||||
keys.append(key)
|
||||
|
||||
if keys:
|
||||
deleted = await self._redis.delete(*keys)
|
||||
logger.info("Cache pattern delete", pattern=pattern, deleted=deleted)
|
||||
return deleted
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error("Cache pattern delete error", pattern=pattern, error=str(e))
|
||||
return 0
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
"""
|
||||
Verificar si una clave existe en cache.
|
||||
|
||||
Args:
|
||||
key: Clave a verificar
|
||||
|
||||
Returns:
|
||||
True si existe
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._redis.exists(key) > 0
|
||||
except Exception as e:
|
||||
logger.error("Cache exists error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
async def incr(self, key: str, amount: int = 1) -> Optional[int]:
|
||||
"""
|
||||
Incrementar un contador en cache.
|
||||
|
||||
Args:
|
||||
key: Clave del contador
|
||||
amount: Cantidad a incrementar
|
||||
|
||||
Returns:
|
||||
Nuevo valor del contador
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await self._redis.incrby(key, amount)
|
||||
except Exception as e:
|
||||
logger.error("Cache incr error", key=key, error=str(e))
|
||||
return None
|
||||
|
||||
async def expire(self, key: str, ttl: int) -> bool:
|
||||
"""
|
||||
Establecer tiempo de expiración a una clave existente.
|
||||
|
||||
Args:
|
||||
key: Clave a expirar
|
||||
ttl: Tiempo de vida en segundos
|
||||
|
||||
Returns:
|
||||
True si se estableció exitosamente
|
||||
"""
|
||||
if self._redis is None:
|
||||
await self.connect()
|
||||
|
||||
if self._redis is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self._redis.expire(key, ttl)
|
||||
except Exception as e:
|
||||
logger.error("Cache expire error", key=key, error=str(e))
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
cache = CacheService()
|
||||
|
||||
|
||||
def cache_key(*parts: str) -> str:
|
||||
"""
|
||||
Helper para construir claves de cache consistentes.
|
||||
|
||||
Args:
|
||||
*parts: Partes de la clave a unir
|
||||
|
||||
Returns:
|
||||
Clave formateada
|
||||
|
||||
Example:
|
||||
cache_key("tickets", "tenant", tenant_id) -> "tickets:tenant:123"
|
||||
"""
|
||||
return ":".join(str(part) for part in parts)
|
||||
|
||||
|
||||
def cached(
|
||||
key_prefix: str,
|
||||
ttl: int = 300,
|
||||
key_builder: Optional[callable] = None
|
||||
):
|
||||
"""
|
||||
Decorator para cachear resultados de funciones async.
|
||||
|
||||
Args:
|
||||
key_prefix: Prefijo para la clave de cache
|
||||
ttl: Tiempo de vida en segundos
|
||||
key_builder: Función opcional para construir la clave
|
||||
|
||||
Example:
|
||||
@cached("categories", ttl=600)
|
||||
async def get_categories(tenant_id: str):
|
||||
return await db.query(Category).all()
|
||||
"""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Construir clave de cache
|
||||
if key_builder:
|
||||
key = key_builder(*args, **kwargs)
|
||||
else:
|
||||
# Default: usar nombre de función y args
|
||||
key_parts = [key_prefix, func.__name__]
|
||||
key_parts.extend(str(arg) for arg in args)
|
||||
key_parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
|
||||
key = cache_key(*key_parts)
|
||||
|
||||
# Intentar obtener del cache
|
||||
cached_value = await cache.get(key)
|
||||
if cached_value is not None:
|
||||
return cached_value
|
||||
|
||||
# Si no está en cache, ejecutar función
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# Guardar en cache
|
||||
await cache.set(key, result, ttl=ttl)
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
Reference in New Issue
Block a user