FlotillasGPS - Sistema completo de monitoreo de flotillas GPS

Sistema completo para monitoreo y gestion de flotas de vehiculos con:
- Backend FastAPI con PostgreSQL/TimescaleDB
- Frontend React con TypeScript y TailwindCSS
- App movil React Native con Expo
- Soporte para dispositivos GPS, Meshtastic y celulares
- Video streaming en vivo con MediaMTX
- Geocercas, alertas, viajes y reportes
- Autenticacion JWT y WebSockets en tiempo real

Documentacion completa y guias de usuario incluidas.
This commit is contained in:
FlotillasGPS Developer
2026-01-21 08:18:00 +00:00
commit 51d78bacf4
248 changed files with 50171 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
"""
Módulo core - Configuración, seguridad y utilidades base.
"""
from app.core.config import settings
from app.core.database import Base, get_db, engine, async_session_factory
from app.core.security import (
hash_password,
verify_password,
create_access_token,
create_refresh_token,
decode_token,
get_current_user,
get_current_active_admin,
CurrentUser,
CurrentAdmin,
)
from app.core.exceptions import (
AdanException,
NotFoundError,
AlreadyExistsError,
ValidationError,
AuthenticationError,
AuthorizationError,
ExternalServiceError,
register_exception_handlers,
)
__all__ = [
# Config
"settings",
# Database
"Base",
"get_db",
"engine",
"async_session_factory",
# Security
"hash_password",
"verify_password",
"create_access_token",
"create_refresh_token",
"decode_token",
"get_current_user",
"get_current_active_admin",
"CurrentUser",
"CurrentAdmin",
# Exceptions
"AdanException",
"NotFoundError",
"AlreadyExistsError",
"ValidationError",
"AuthenticationError",
"AuthorizationError",
"ExternalServiceError",
"register_exception_handlers",
]

177
backend/app/core/config.py Normal file
View File

@@ -0,0 +1,177 @@
"""
Configuración central de la aplicación.
Utiliza Pydantic BaseSettings para cargar variables de entorno
con validación de tipos y valores por defecto.
"""
from functools import lru_cache
from typing import Any, List, Optional
from pydantic import PostgresDsn, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Configuración de la aplicación cargada desde variables de entorno."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Aplicación
APP_NAME: str = "Adan Fleet Monitor"
APP_VERSION: str = "1.0.0"
DEBUG: bool = False
ENVIRONMENT: str = "development"
API_V1_PREFIX: str = "/api/v1"
# Servidor
HOST: str = "0.0.0.0"
PORT: int = 8000
WORKERS: int = 4
# Base de datos PostgreSQL/TimescaleDB
POSTGRES_HOST: str = "localhost"
POSTGRES_PORT: int = 5432
POSTGRES_USER: str = "adan"
POSTGRES_PASSWORD: str = "adan_secret"
POSTGRES_DB: str = "adan_fleet"
DATABASE_URL: Optional[str] = None
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10
@model_validator(mode="after")
def build_database_url(self) -> "Settings":
"""Construye la URL de conexión a la base de datos."""
if not self.DATABASE_URL:
self.DATABASE_URL = (
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
return self
# Redis
REDIS_HOST: str = "localhost"
REDIS_PORT: int = 6379
REDIS_DB: int = 0
REDIS_PASSWORD: Optional[str] = None
REDIS_URL: Optional[str] = None
@model_validator(mode="after")
def build_redis_url(self) -> "Settings":
"""Construye la URL de conexión a Redis."""
if not self.REDIS_URL:
password_part = f":{self.REDIS_PASSWORD}@" if self.REDIS_PASSWORD else ""
self.REDIS_URL = f"redis://{password_part}{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return self
# Seguridad JWT
SECRET_KEY: str = "your-super-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# CORS
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
CORS_ALLOW_CREDENTIALS: bool = True
CORS_ALLOW_METHODS: List[str] = ["*"]
CORS_ALLOW_HEADERS: List[str] = ["*"]
@field_validator("CORS_ORIGINS", mode="before")
@classmethod
def parse_cors_origins(cls, v: Any) -> List[str]:
"""Parsea los orígenes CORS desde string separado por comas."""
if isinstance(v, str):
return [origin.strip() for origin in v.split(",")]
return v
# Traccar Integration
TRACCAR_HOST: str = "localhost"
TRACCAR_PORT: int = 5055
TRACCAR_API_URL: str = "http://localhost:8082/api"
TRACCAR_USERNAME: Optional[str] = None
TRACCAR_PASSWORD: Optional[str] = None
# MediaMTX Video Server
MEDIAMTX_HOST: str = "localhost"
MEDIAMTX_API_PORT: int = 9997
MEDIAMTX_RTSP_PORT: int = 8554
MEDIAMTX_WEBRTC_PORT: int = 8889
# Meshtastic
MESHTASTIC_ENABLED: bool = False
MESHTASTIC_SERIAL_PORT: Optional[str] = None
MESHTASTIC_TCP_HOST: Optional[str] = None
MESHTASTIC_TCP_PORT: int = 4403
# MQTT (para dispositivos IoT)
MQTT_ENABLED: bool = False
MQTT_HOST: str = "localhost"
MQTT_PORT: int = 1883
MQTT_USERNAME: Optional[str] = None
MQTT_PASSWORD: Optional[str] = None
MQTT_TOPIC_LOCATIONS: str = "adan/locations/#"
MQTT_TOPIC_ALERTS: str = "adan/alerts/#"
# Email (notificaciones)
SMTP_HOST: str = "localhost"
SMTP_PORT: int = 587
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
SMTP_FROM_EMAIL: str = "noreply@adan-fleet.com"
SMTP_FROM_NAME: str = "Adan Fleet Monitor"
SMTP_TLS: bool = True
# Push Notifications (Firebase)
FIREBASE_CREDENTIALS_PATH: Optional[str] = None
FIREBASE_ENABLED: bool = False
# Almacenamiento de archivos
UPLOAD_DIR: str = "/var/lib/adan/uploads"
MAX_UPLOAD_SIZE_MB: int = 100
ALLOWED_IMAGE_TYPES: List[str] = ["image/jpeg", "image/png", "image/webp"]
ALLOWED_VIDEO_TYPES: List[str] = ["video/mp4", "video/webm"]
# Reportes
REPORTS_DIR: str = "/var/lib/adan/reports"
REPORT_RETENTION_DAYS: int = 90
# Geocoding
GEOCODING_PROVIDER: str = "nominatim" # nominatim, google, mapbox
GOOGLE_MAPS_API_KEY: Optional[str] = None
MAPBOX_ACCESS_TOKEN: Optional[str] = None
# Alertas y umbrales
ALERT_SPEED_LIMIT_DEFAULT: int = 120 # km/h
ALERT_IDLE_MINUTES: int = 15
ALERT_BATTERY_LOW_PERCENT: int = 20
ALERT_NO_SIGNAL_MINUTES: int = 30
# Limpieza de datos
LOCATION_RETENTION_DAYS: int = 365
ALERT_RETENTION_DAYS: int = 180
VIDEO_RETENTION_DAYS: int = 30
# Logging
LOG_LEVEL: str = "INFO"
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
LOG_FILE: Optional[str] = None
@lru_cache()
def get_settings() -> Settings:
"""
Obtiene la instancia de configuración (singleton cacheado).
Returns:
Settings: Instancia de configuración de la aplicación.
"""
return Settings()
# Instancia global de configuración
settings = get_settings()

View File

@@ -0,0 +1,140 @@
"""
Configuración de conexión a la base de datos PostgreSQL/TimescaleDB.
Proporciona:
- Engine async para SQLAlchemy
- Session factory async
- Dependency para obtener sesiones en endpoints
- Base declarativa para modelos
"""
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import NullPool
from app.core.config import settings
class Base(DeclarativeBase):
"""Clase base para todos los modelos SQLAlchemy."""
pass
# Configuración del engine según el entorno
if settings.ENVIRONMENT == "testing":
# En testing usamos NullPool para evitar problemas con conexiones
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
poolclass=NullPool,
)
else:
# En producción usamos pool de conexiones
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=settings.DATABASE_POOL_SIZE,
max_overflow=settings.DATABASE_MAX_OVERFLOW,
pool_pre_ping=True, # Verifica conexiones antes de usar
pool_recycle=3600, # Recicla conexiones cada hora
)
# Factory de sesiones async
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency que proporciona una sesión de base de datos.
Yields:
AsyncSession: Sesión de base de datos para usar en el endpoint.
Example:
@router.get("/items")
async def get_items(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Item))
return result.scalars().all()
"""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db() -> None:
"""
Inicializa la base de datos creando todas las tablas.
Nota: En producción se recomienda usar Alembic para migraciones.
Esta función es útil para desarrollo y testing.
"""
async with engine.begin() as conn:
# Importar todos los modelos para que SQLAlchemy los conozca
from app.models import ( # noqa: F401
alerta,
camara,
carga_combustible,
conductor,
configuracion,
dispositivo,
evento_video,
geocerca,
grabacion,
grupo_vehiculos,
mantenimiento,
mensaje,
parada,
poi,
tipo_alerta,
tipo_mantenimiento,
ubicacion,
usuario,
vehiculo,
viaje,
)
await conn.run_sync(Base.metadata.create_all)
async def close_db() -> None:
"""
Cierra el pool de conexiones a la base de datos.
Debe llamarse al apagar la aplicación para liberar recursos.
"""
await engine.dispose()
async def check_db_connection() -> bool:
"""
Verifica que la conexión a la base de datos funcione.
Returns:
bool: True si la conexión es exitosa.
Raises:
Exception: Si no se puede conectar a la base de datos.
"""
try:
async with engine.connect() as conn:
await conn.execute("SELECT 1")
return True
except Exception as e:
raise Exception(f"Error conectando a la base de datos: {e}")

View File

@@ -0,0 +1,280 @@
"""
Excepciones personalizadas para la aplicación.
Define excepciones específicas del dominio y handlers
para convertirlas en respuestas HTTP apropiadas.
"""
from typing import Any, Dict, Optional
from fastapi import HTTPException, Request, status
from fastapi.responses import JSONResponse
class AdanException(Exception):
"""Excepción base para todas las excepciones de la aplicación."""
def __init__(
self,
message: str,
code: str = "ADAN_ERROR",
details: Optional[Dict[str, Any]] = None,
):
self.message = message
self.code = code
self.details = details or {}
super().__init__(self.message)
class NotFoundError(AdanException):
"""Recurso no encontrado."""
def __init__(
self,
resource: str,
identifier: Any = None,
details: Optional[Dict[str, Any]] = None,
):
message = f"{resource} no encontrado"
if identifier:
message = f"{resource} con id '{identifier}' no encontrado"
super().__init__(message, "NOT_FOUND", details)
self.resource = resource
self.identifier = identifier
class AlreadyExistsError(AdanException):
"""El recurso ya existe."""
def __init__(
self,
resource: str,
field: str,
value: Any,
details: Optional[Dict[str, Any]] = None,
):
message = f"{resource} con {field}='{value}' ya existe"
super().__init__(message, "ALREADY_EXISTS", details)
self.resource = resource
self.field = field
self.value = value
class ValidationError(AdanException):
"""Error de validación de datos."""
def __init__(
self,
message: str,
field: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
):
super().__init__(message, "VALIDATION_ERROR", details)
self.field = field
class AuthenticationError(AdanException):
"""Error de autenticación."""
def __init__(
self,
message: str = "Credenciales inválidas",
details: Optional[Dict[str, Any]] = None,
):
super().__init__(message, "AUTHENTICATION_ERROR", details)
class AuthorizationError(AdanException):
"""Error de autorización (permisos insuficientes)."""
def __init__(
self,
message: str = "No tiene permisos para realizar esta acción",
details: Optional[Dict[str, Any]] = None,
):
super().__init__(message, "AUTHORIZATION_ERROR", details)
class ExternalServiceError(AdanException):
"""Error al comunicarse con un servicio externo."""
def __init__(
self,
service: str,
message: str,
details: Optional[Dict[str, Any]] = None,
):
full_message = f"Error en servicio {service}: {message}"
super().__init__(full_message, "EXTERNAL_SERVICE_ERROR", details)
self.service = service
class GeocercaViolationError(AdanException):
"""Violación de geocerca detectada."""
def __init__(
self,
geocerca_id: int,
geocerca_nombre: str,
tipo_violacion: str, # 'entrada' o 'salida'
vehiculo_id: int,
details: Optional[Dict[str, Any]] = None,
):
message = f"Vehículo {vehiculo_id} {tipo_violacion} de geocerca '{geocerca_nombre}'"
super().__init__(message, "GEOCERCA_VIOLATION", details)
self.geocerca_id = geocerca_id
self.geocerca_nombre = geocerca_nombre
self.tipo_violacion = tipo_violacion
self.vehiculo_id = vehiculo_id
class SpeedLimitExceededError(AdanException):
"""Límite de velocidad excedido."""
def __init__(
self,
vehiculo_id: int,
velocidad: float,
limite: float,
details: Optional[Dict[str, Any]] = None,
):
message = f"Vehículo {vehiculo_id} excedió límite de velocidad: {velocidad} km/h (límite: {limite} km/h)"
super().__init__(message, "SPEED_LIMIT_EXCEEDED", details)
self.vehiculo_id = vehiculo_id
self.velocidad = velocidad
self.limite = limite
class DeviceConnectionError(AdanException):
"""Error de conexión con dispositivo."""
def __init__(
self,
dispositivo_id: int,
message: str,
details: Optional[Dict[str, Any]] = None,
):
full_message = f"Error de conexión con dispositivo {dispositivo_id}: {message}"
super().__init__(full_message, "DEVICE_CONNECTION_ERROR", details)
self.dispositivo_id = dispositivo_id
class VideoStreamError(AdanException):
"""Error con stream de video."""
def __init__(
self,
camara_id: int,
message: str,
details: Optional[Dict[str, Any]] = None,
):
full_message = f"Error de video en cámara {camara_id}: {message}"
super().__init__(full_message, "VIDEO_STREAM_ERROR", details)
self.camara_id = camara_id
class MaintenanceRequiredError(AdanException):
"""Mantenimiento requerido para el vehículo."""
def __init__(
self,
vehiculo_id: int,
tipo_mantenimiento: str,
details: Optional[Dict[str, Any]] = None,
):
message = f"Vehículo {vehiculo_id} requiere mantenimiento: {tipo_mantenimiento}"
super().__init__(message, "MAINTENANCE_REQUIRED", details)
self.vehiculo_id = vehiculo_id
self.tipo_mantenimiento = tipo_mantenimiento
class DatabaseError(AdanException):
"""Error de base de datos."""
def __init__(
self,
operation: str,
message: str,
details: Optional[Dict[str, Any]] = None,
):
full_message = f"Error de base de datos en {operation}: {message}"
super().__init__(full_message, "DATABASE_ERROR", details)
self.operation = operation
# ============================================================================
# Exception Handlers para FastAPI
# ============================================================================
async def adan_exception_handler(request: Request, exc: AdanException) -> JSONResponse:
"""Handler para excepciones base de Adan."""
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
if isinstance(exc, NotFoundError):
status_code = status.HTTP_404_NOT_FOUND
elif isinstance(exc, AlreadyExistsError):
status_code = status.HTTP_409_CONFLICT
elif isinstance(exc, ValidationError):
status_code = status.HTTP_422_UNPROCESSABLE_ENTITY
elif isinstance(exc, AuthenticationError):
status_code = status.HTTP_401_UNAUTHORIZED
elif isinstance(exc, AuthorizationError):
status_code = status.HTTP_403_FORBIDDEN
elif isinstance(exc, ExternalServiceError):
status_code = status.HTTP_502_BAD_GATEWAY
return JSONResponse(
status_code=status_code,
content={
"error": {
"code": exc.code,
"message": exc.message,
"details": exc.details,
}
},
)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
"""Handler para HTTPException estándar de FastAPI."""
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"code": "HTTP_ERROR",
"message": exc.detail,
"details": {},
}
},
)
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Handler para excepciones no capturadas."""
# En producción, loguear el error completo pero no exponerlo al cliente
import traceback
traceback.print_exc()
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": {
"code": "INTERNAL_ERROR",
"message": "Error interno del servidor",
"details": {},
}
},
)
def register_exception_handlers(app) -> None:
"""
Registra los handlers de excepciones en la aplicación FastAPI.
Args:
app: Instancia de FastAPI.
"""
app.add_exception_handler(AdanException, adan_exception_handler)
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(Exception, general_exception_handler)

View File

@@ -0,0 +1,285 @@
"""
Módulo de seguridad para autenticación y autorización.
Proporciona:
- Generación y verificación de tokens JWT
- Hashing de contraseñas con bcrypt
- Dependencies para obtener el usuario actual
- Encriptación de datos sensibles
"""
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
# Contexto de hashing para contraseñas
pwd_context = CryptContext(
schemes=["bcrypt"],
deprecated="auto",
bcrypt__rounds=12, # Factor de costo para bcrypt
)
# Esquema de autenticación Bearer
bearer_scheme = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
"""
Genera un hash seguro de una contraseña.
Args:
password: Contraseña en texto plano.
Returns:
str: Hash bcrypt de la contraseña.
"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verifica una contraseña contra su hash.
Args:
plain_password: Contraseña en texto plano a verificar.
hashed_password: Hash almacenado de la contraseña.
Returns:
bool: True si la contraseña coincide.
"""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(
data: dict[str, Any],
expires_delta: Optional[timedelta] = None,
) -> str:
"""
Crea un token JWT de acceso.
Args:
data: Datos a incluir en el payload del token.
expires_delta: Tiempo de expiración personalizado.
Returns:
str: Token JWT codificado.
"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({
"exp": expire,
"iat": datetime.now(timezone.utc),
"type": "access",
})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def create_refresh_token(
data: dict[str, Any],
expires_delta: Optional[timedelta] = None,
) -> str:
"""
Crea un token JWT de refresco.
Args:
data: Datos a incluir en el payload del token.
expires_delta: Tiempo de expiración personalizado.
Returns:
str: Token JWT de refresco codificado.
"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
)
to_encode.update({
"exp": expire,
"iat": datetime.now(timezone.utc),
"type": "refresh",
})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
"""
Decodifica y valida un token JWT.
Args:
token: Token JWT a decodificar.
Returns:
dict: Payload del token decodificado.
Raises:
HTTPException: Si el token es inválido o ha expirado.
"""
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
return payload
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token inválido: {str(e)}",
headers={"WWW-Authenticate": "Bearer"},
)
def verify_token_type(payload: dict[str, Any], expected_type: str) -> None:
"""
Verifica que el token sea del tipo esperado.
Args:
payload: Payload del token decodificado.
expected_type: Tipo esperado ('access' o 'refresh').
Raises:
HTTPException: Si el tipo de token no coincide.
"""
token_type = payload.get("type")
if token_type != expected_type:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Tipo de token inválido. Se esperaba '{expected_type}'",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(bearer_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""
Dependency que obtiene el usuario actual desde el token JWT.
Args:
credentials: Credenciales Bearer del header Authorization.
db: Sesión de base de datos.
Returns:
Usuario: Modelo del usuario autenticado.
Raises:
HTTPException: Si no hay token, es inválido, o el usuario no existe.
"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No se proporcionaron credenciales",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(credentials.credentials)
verify_token_type(payload, "access")
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token inválido: falta el identificador de usuario",
headers={"WWW-Authenticate": "Bearer"},
)
# Importar aquí para evitar importación circular
from app.models.usuario import Usuario
result = await db.execute(select(Usuario).where(Usuario.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario no encontrado",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.activo:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Usuario desactivado",
)
return user
async def get_current_active_admin(
current_user: Annotated[Any, Depends(get_current_user)],
):
"""
Dependency que verifica que el usuario actual sea administrador.
Args:
current_user: Usuario actual obtenido del token.
Returns:
Usuario: Usuario administrador verificado.
Raises:
HTTPException: Si el usuario no es administrador.
"""
if not current_user.es_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Se requieren permisos de administrador",
)
return current_user
def encrypt_sensitive_data(data: str) -> str:
"""
Encripta datos sensibles (ej: contraseñas de cámaras).
Por simplicidad, usa el mismo mecanismo de hash.
En producción, usar Fernet o similar para encriptación reversible.
Args:
data: Datos a encriptar.
Returns:
str: Datos encriptados.
"""
# Para datos que necesitan ser recuperados (como passwords de cámaras),
# se debería usar encriptación simétrica (Fernet)
# Por ahora, usamos base64 + XOR simple como placeholder
# TODO: Implementar encriptación Fernet apropiada
import base64
key = settings.SECRET_KEY[:32].encode()
data_bytes = data.encode()
encrypted = bytes(a ^ b for a, b in zip(data_bytes, key * (len(data_bytes) // len(key) + 1)))
return base64.b64encode(encrypted).decode()
def decrypt_sensitive_data(encrypted_data: str) -> str:
"""
Desencripta datos sensibles.
Args:
encrypted_data: Datos encriptados.
Returns:
str: Datos desencriptados.
"""
import base64
key = settings.SECRET_KEY[:32].encode()
encrypted_bytes = base64.b64decode(encrypted_data.encode())
decrypted = bytes(a ^ b for a, b in zip(encrypted_bytes, key * (len(encrypted_bytes) // len(key) + 1)))
return decrypted.decode()
# Type aliases para uso en endpoints
CurrentUser = Annotated[Any, Depends(get_current_user)]
CurrentAdmin = Annotated[Any, Depends(get_current_active_admin)]