feat: plantilla base workspace SaaS
Some checks failed
Build Producción & Push a Harbor / test (push) Failing after 3s
Build Producción & Push a Harbor / build (push) Has been skipped
Aduanasoft/plantillas-proyectos/pipeline/head There was a failure building this commit

This commit is contained in:
2026-07-21 13:59:00 -05:00
commit bdd089954b
470 changed files with 70022 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
"""
Módulo de Authentication
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,210 @@
"""
DTOs para módulo de autenticación
"""
from typing import Optional
from pydantic import BaseModel, EmailStr, Field
class LoginRequestDTO(BaseModel):
"""DTO para solicitud de login"""
username: str = Field(..., description="Usuario o email")
password: str = Field(..., min_length=6, description="Contraseña")
# Opcional en el primer paso: si no se provee, el backend verifica credenciales
# y devuelve la lista de tenants disponibles en lugar de tokens.
tenant_slug: Optional[str] = Field(None, description="Slug del tenant")
class Config:
json_schema_extra = {
"example": {
"username": "usuario@ejemplo.com",
"password": "password123",
"tenant_slug": "empresa-abc",
}
}
class TokenResponseDTO(BaseModel):
"""DTO para respuesta de token"""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
tenant: Optional["TenantInfoDTO"] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 3600,
}
}
class RefreshTokenRequestDTO(BaseModel):
"""DTO para solicitud de refresh token"""
refresh_token: str = Field(..., description="Refresh token")
class UserInfoResponseDTO(BaseModel):
"""DTO para información de usuario"""
sub: str
email: Optional[str] = None
name: Optional[str] = None
preferred_username: Optional[str] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
avatar_url: Optional[str] = None
roles: list[str] = []
permissions: list[str] = []
class Config:
json_schema_extra = {
"example": {
"sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "usuario@ejemplo.com",
"name": "Juan Pérez",
"preferred_username": "jperez",
"tenant_id": 1,
"roles": ["user", "admin"],
"permissions": ["cat_ports.view", "cat_ports.create"]
}
}
class LogoutRequestDTO(BaseModel):
"""DTO para solicitud de logout"""
refresh_token: str = Field(..., description="Refresh token para invalidar")
username: Optional[str] = Field(None, description="Nombre de usuario para auditoría")
class RegisterRequestDTO(BaseModel):
"""DTO para solicitud de registro"""
username: str = Field(
..., min_length=3, max_length=50, description="Nombre de usuario"
)
email: EmailStr = Field(..., description="Email del usuario")
password: str = Field(..., min_length=8, description="Contraseña")
first_name: str = Field(..., min_length=2, max_length=50, description="Nombre")
last_name: str = Field(..., min_length=2, max_length=50, description="Apellido")
tenant_slug: str = Field(..., description="Slug del tenant")
invite_token: Optional[str] = Field(None, description="Token de invitación local (opcional)")
class Config:
json_schema_extra = {
"example": {
"username": "jperez",
"email": "jperez@ejemplo.com",
"password": "MiPassword123!",
"first_name": "Juan",
"last_name": "Pérez",
"tenant_slug": "empresa-abc",
}
}
class RegisterResponseDTO(BaseModel):
"""DTO para respuesta de registro"""
user_id: str
username: str
email: str
message: str
class Config:
json_schema_extra = {
"example": {
"user_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"username": "jperez",
"email": "jperez@ejemplo.com",
"message": "User registered successfully",
}
}
class ExchangeCodeRequestDTO(BaseModel):
"""DTO para intercambiar authorization code por tokens (OAuth2 flow)"""
code: str = Field(..., description="Authorization code de OAuth2")
redirect_uri: str = Field(..., description="Redirect URI usado en la autorización")
tenant_slug: Optional[str] = Field(None, description="Slug del tenant (opcional)")
class Config:
json_schema_extra = {
"example": {
"code": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2Ii...",
"redirect_uri": "http://localhost:5173/auth/callback",
"tenant_slug": "empresa-abc",
}
}
class SetCookieRequestDTO(BaseModel):
"""DTO para establecer cookies de autenticación"""
access_token: str = Field(..., description="Access token JWT")
refresh_token: str = Field(..., description="Refresh token JWT")
class SwitchTenantRequestDTO(BaseModel):
"""DTO para cambiar de tenant estando autenticado"""
tenant_slug: str = Field(..., description="Slug del tenant destino")
refresh_token: str = Field(..., description="Refresh token actual para emitir nuevos tokens")
class DiscoverTenantsRequestDTO(BaseModel):
"""DTO para descubrir los tenants de un usuario sin necesidad de indicarlo manualmente"""
username: str = Field(..., description="Nombre de usuario o email")
class Config:
json_schema_extra = {
"example": {
"username": "jperez",
}
}
class TenantInfoDTO(BaseModel):
"""Información básica de un tenant para mostrar en el selector de login"""
id: int
name: str
slug: str
class Config:
from_attributes = True
class DiscoverTenantsResponseDTO(BaseModel):
"""Respuesta con los tenants disponibles para un usuario"""
tenants: list[TenantInfoDTO]
class LoginChoiceResponseDTO(BaseModel):
"""
Respuesta del login cuando el usuario pertenece a varios tenants.
Las credenciales ya fueron verificadas; el cliente debe re-enviar con tenant_slug.
"""
status: str = "choose_tenant"
tenants: list[TenantInfoDTO]
class SSOExchangeRequestDTO(BaseModel):
"""DTO para canjear el relay token por KC tokens."""
relay_token: str = Field(..., description="Relay token recibido en la URL")

View File

@@ -0,0 +1,422 @@
"""
Endpoints API para autenticación
"""
from core.database import get_core_db
from core.security import get_current_user
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from .dto import (
ExchangeCodeRequestDTO,
LoginChoiceResponseDTO,
LoginRequestDTO,
LogoutRequestDTO,
RefreshTokenRequestDTO,
RegisterRequestDTO,
RegisterResponseDTO,
SetCookieRequestDTO,
SSOExchangeRequestDTO,
SwitchTenantRequestDTO,
TokenResponseDTO,
UserInfoResponseDTO,
)
from .service import AuthService
router = APIRouter(prefix="/auth", tags=["Authentication"])
security = HTTPBearer()
@router.get("/register/check")
async def check_register(
invite_token: str = Query(..., description="Token de invitación"),
tenant_slug: str = Query(..., description="Slug del tenant"),
email: str = Query(..., description="Email del usuario invitado"),
db: Session = Depends(get_core_db),
):
"""
Valida un token de invitación y verifica si el email ya existe en Keycloak.
No consume el token. Responde con user_exists y datos básicos del usuario si ya existe.
"""
from api.v1.modules.core.invites.service import InviteService
import httpx
from core.config import settings
invite_service = InviteService(db)
# Valida token (lanza 403 si es inválido)
invite_result = invite_service.validate(invite_token, tenant_slug, email)
# Intentar verificar si el email ya existe en el Hub usando service account
user_exists = False
user_info: dict = {}
if settings.HUB_ADMIN_EMAIL and settings.HUB_ADMIN_PASSWORD:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Login con service account
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
)
if login_resp.status_code == 200:
svc_token = login_resp.json().get("access_token", "")
if svc_token:
# Buscar admin por email
admins_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins",
params={"email": email},
headers={"Authorization": f"Bearer {svc_token}"},
)
if admins_resp.status_code == 200:
admins = admins_resp.json()
if isinstance(admins, list):
matches = [a for a in admins if a.get("email", "").lower() == email.lower()]
elif isinstance(admins, dict) and "items" in admins:
matches = [a for a in admins["items"] if a.get("email", "").lower() == email.lower()]
else:
matches = []
if matches:
user_exists = True
a = matches[0]
user_info = {
"username": a.get("username", ""),
"first_name": a.get("first_name", ""),
"last_name": a.get("last_name", ""),
}
except Exception as exc:
import logging
logging.getLogger(__name__).warning("register/check Hub lookup failed: %s", exc)
return {
"email": invite_result.email,
"role": invite_result.role,
"user_exists": user_exists,
**user_info,
}
@router.post("/register", response_model=RegisterResponseDTO, status_code=201)
async def register(
register_data: RegisterRequestDTO, db: Session = Depends(get_core_db)
):
"""
Registra un nuevo usuario en Keycloak
El usuario debe proporcionar:
- username: Nombre de usuario único
- email: Email único
- password: Contraseña (mínimo 8 caracteres)
- first_name: Nombre
- last_name: Apellido
- tenant_slug: Slug del tenant al que pertenece
El usuario se crea automáticamente en Keycloak con:
- Cuenta habilitada
- Rol 'user' asignado por defecto
- Atributos de tenant
"""
service = AuthService(db)
return await service.register(register_data)
@router.post("/login", response_model=None)
async def login(
login_data: LoginRequestDTO,
request: Request, # Inject Request
db: Session = Depends(get_core_db)
):
"""
Autentica usuario con Keycloak y retorna tokens JWT
El usuario debe proporcionar:
- username: Usuario o email
- password: Contraseña
- tenant_slug: Slug del tenant al que pertenece
"""
service = AuthService(db)
import logging
logger = logging.getLogger(__name__)
return await service.login(
login_data=login_data,
ip_address=request.client.host,
user_agent=request.headers.get("user-agent")
)
@router.post("/switch-tenant", response_model=TokenResponseDTO)
async def switch_tenant(
data: SwitchTenantRequestDTO,
db: Session = Depends(get_core_db),
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""
Cambia el tenant activo de un usuario ya autenticado y retorna nuevos tokens JWT.
Requiere:
- Authorization: Bearer <access_token> (para identificar al usuario)
- Body: { tenant_slug, refresh_token }
"""
service = AuthService(db)
# Obtener info del usuario desde el access token actual
user_info = await service.get_user_info(credentials.credentials)
keycloak_user_id = user_info.sub
# El realm se puede inferir del token; usamos el campo tenant_id para buscar el realm actual,
# pero lo más directo es dejar que Keycloak lo resuelva usando la config global.
# Todos los tenants comparten el mismo realm en esta arquitectura.
from api.v1.modules.core.tenants.models import Tenant
from core.database import get_core_db as _gcdb
# Obtener el realm del tenant destino (o default)
tenant = db.query(Tenant).filter(Tenant.slug == data.tenant_slug, Tenant.is_active).first()
if not tenant:
raise HTTPException(status_code=403, detail="Access denied")
return await service.switch_tenant(
keycloak_user_id=keycloak_user_id,
keycloak_realm=tenant.keycloak_realm,
tenant_slug=data.tenant_slug,
refresh_token=data.refresh_token,
)
@router.post("/refresh", response_model=TokenResponseDTO)
async def refresh_token(
refresh_data: RefreshTokenRequestDTO, db: Session = Depends(get_core_db)
):
"""
Refresca el access token usando el refresh token
"""
service = AuthService(db)
return await service.refresh_token(refresh_data)
@router.get("/me", response_model=UserInfoResponseDTO)
async def get_current_user_info(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_core_db),
):
"""
Obtiene información del usuario actual desde el token
"""
service = AuthService(db)
return await service.get_user_info(credentials.credentials)
@router.post("/lazy-link", status_code=200)
async def lazy_link(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_core_db),
):
"""
Vincula un invite pendiente al usuario autenticado (lazy-link).
Se llama después de un SSO login desde el workspace para crear el UserTenant
si hay un invite_token pendiente para el email del usuario.
"""
service = AuthService(db)
try:
await service._link_pending_invite(
credentials.credentials, # username_or_email = token (fallback)
access_token=credentials.credentials,
)
except Exception:
pass
try:
claims = service._decode_kc_user_from_token(credentials.credentials)
service._backfill_company_roles(claims.get("sub", ""))
except Exception:
pass
return {"ok": True}
@router.post("/logout")
async def logout(
logout_data: LogoutRequestDTO,
request: Request, # Inject request for IP/User-Agent
db: Session = Depends(get_core_db),
# Make current_user optional to avoid 401 on expired tokens
# We will try to use it if available, otherwise use DTO
# Note: Depends(get_current_user) raises HTTPException if invalid, so we cannot make it optional easily without changing dependency.
# Instead, we will rely on DTO username since user explicitly asked for this simplified flow.
# But if we want to support both, we can't use strict dependency here if we expect it to work on expired tokens.
# So we remove the strict dependency for now as per "simplified" request.
):
"""
Cierra sesión invalidando el refresh token
"""
# Extract info for logging (optional, but harmless to keep providing context if needed,
# but strictly speaking we can revert to just calling service)
# The original file likely didn't have IP extraction here unless I added it.
# I'll keep it simple.
service = AuthService(db)
return await service.logout(logout_data)
@router.post("/exchange-code", response_model=TokenResponseDTO)
async def exchange_code(
exchange_data: ExchangeCodeRequestDTO, db: Session = Depends(get_core_db)
):
"""
Intercambia un authorization code de OAuth2 por tokens
Este endpoint es útil cuando el frontend usa el flujo de autorización
con proveedores externos (Microsoft, Google, etc.) a través de Keycloak.
El código se obtiene después de que el usuario se autentica con el proveedor
externo y Keycloak lo redirige al frontend con el código en los query params.
"""
service = AuthService(db)
return await service.exchange_code(exchange_data)
@router.post("/set-cookie")
async def set_cookie(
cookie_data: SetCookieRequestDTO,
response: Response,
db: Session = Depends(get_core_db),
):
"""
Establece cookies HttpOnly con los tokens de autenticación
Este endpoint se llama desde el frontend después de una autenticación
SSO exitosa para establecer las cookies de sesión necesarias para
la validación server-side en los layouts protegidos.
Las cookies se configuran como:
- HttpOnly: No accesibles desde JavaScript (mayor seguridad)
- Secure: Solo se envían por HTTPS (en producción)
- SameSite=Lax: Protección contra CSRF
- Max-Age: Tiempo de vida del token
"""
# Validar que los tokens sean válidos decodificándolos
service = AuthService(db)
try:
# Validar el access token
user_info = await service.get_user_info(cookie_data.access_token)
# Establecer las cookies
# Access token cookie
response.set_cookie(
key="access_token",
value=cookie_data.access_token,
httponly=True, # No accesible desde JavaScript
secure=False, # TODO: Cambiar a True en producción con HTTPS
samesite="lax", # Protección CSRF
max_age=3600, # 1 hora (ajustar según configuración del token)
path="/",
)
# Refresh token cookie
response.set_cookie(
key="refresh_token",
value=cookie_data.refresh_token,
httponly=True,
secure=False, # TODO: Cambiar a True en producción con HTTPS
samesite="lax",
max_age=86400, # 24 horas (ajustar según configuración del token)
path="/",
)
return {
"success": True,
"message": "Cookies establecidas correctamente",
"user": user_info,
}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Error validando tokens: {str(e)}")
@router.post("/sso-exchange", response_model=TokenResponseDTO)
async def sso_exchange(
body: SSOExchangeRequestDTO,
response: Response,
db: Session = Depends(get_core_db),
):
"""
Canjea un relay token de un solo uso (generado por el Hub) por KC tokens.
Llamado server-side desde la página /auth/sso del frontend.
Establece cookies HttpOnly con los tokens y devuelve el resultado.
"""
service = AuthService(db)
tokens = await service.sso_exchange(body.relay_token)
_is_prod = False # TODO: leer de settings.ENVIRONMENT == "production"
response.set_cookie(
key="access_token",
value=tokens.access_token,
httponly=True,
secure=_is_prod,
samesite="lax",
max_age=3600,
path="/",
)
response.set_cookie(
key="refresh_token",
value=tokens.refresh_token,
httponly=True,
secure=_is_prod,
samesite="lax",
max_age=86400,
path="/",
)
return tokens
# ---------------------------------------------------------------------------
# Dev-only local auth — solo disponible cuando DEV_LOCAL_AUTH=True
# ---------------------------------------------------------------------------
@router.post("/dev-login")
async def dev_login():
"""
Genera un token local firmado con SECRET_KEY para desarrollo sin Keycloak/Hub.
Disponible únicamente cuando DEV_LOCAL_AUTH=True en el entorno.
"""
from datetime import datetime, timezone, timedelta
from jose import jwt as jose_jwt
from core.config import settings
if not settings.DEV_LOCAL_AUTH:
raise HTTPException(status_code=404, detail="Not found")
now = datetime.now(timezone.utc)
payload = {
"sub": "dev-local-user",
"email": settings.DEV_LOCAL_AUTH_EMAIL,
"preferred_username": settings.DEV_LOCAL_AUTH_EMAIL.split("@")[0],
"name": settings.DEV_LOCAL_AUTH_NAME,
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
"tenant_slug": "dev",
"company_id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
"roles": ["super_admin"],
"permissions": [],
"allowed_systems": ["fixed_asset", "inventory"],
"dev_local": True,
"iat": now,
"exp": now + timedelta(hours=8),
}
token = jose_jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
return {"access_token": token, "token_type": "bearer"}
@router.get("/my-companies")
async def get_my_companies(
current_user: dict = Depends(get_current_user),
):
"""
Retorna las compañías accesibles para el usuario actual.
STUB: implementa con tu modelo de compañías.
En dev-local retorna una compañía ficticia para que el dashboard funcione.
"""
from core.config import settings
if settings.DEV_LOCAL_AUTH and current_user.get("dev_local"):
return [{
"id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
"name": "Empresa Dev Local",
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
"is_active": True,
}]
# Implementa aquí la consulta real a tu tabla de compañías.
return []

View File

@@ -0,0 +1,847 @@
import logging
import httpx
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from core.config import settings
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .dto import (
LoginRequestDTO,
LogoutRequestDTO,
RefreshTokenRequestDTO,
TokenResponseDTO,
UserInfoResponseDTO,
)
logger = logging.getLogger(__name__)
class AuthService:
"""Servicio de autenticación centralizado vía Hub"""
def __init__(self, db: Session):
self.db = db
@staticmethod
def _clean_text(value: Any) -> Optional[str]:
if isinstance(value, str):
cleaned = value.strip()
if cleaned:
return cleaned
return None
def _pick_text(self, *candidates: Any) -> Optional[str]:
for candidate in candidates:
value = self._clean_text(candidate)
if value:
return value
return None
def _decode_kc_user_from_token(self, access_token: str) -> Dict[str, Any]:
try:
claims = jwt.get_unverified_claims(access_token)
return claims if isinstance(claims, dict) else {}
except JWTError:
return {}
except Exception:
return {}
async def _get_kc_admin_user(self, keycloak_user_id: Optional[str]) -> Optional[Dict[str, Any]]:
"""
Fallback de datos de usuario consultando el Hub admin API.
Es opcional y no debe romper /me si falla.
"""
if not keycloak_user_id:
return None
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={
"username": settings.HUB_ADMIN_EMAIL,
"password": settings.HUB_ADMIN_PASSWORD,
},
)
if login_resp.status_code != 200:
return None
admin_token = login_resp.json().get("access_token")
if not admin_token:
return None
user_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins/{keycloak_user_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
if user_resp.status_code == 200:
payload = user_resp.json()
return payload if isinstance(payload, dict) else None
except Exception as exc:
logger.debug("kc_admin_user_lookup_failed: %s", exc)
return None
def _extract_avatar_url(self, *sources: Any) -> Optional[str]:
for source in sources:
if not isinstance(source, dict):
continue
direct = self._pick_text(
source.get("avatar_url"),
source.get("avatarUrl"),
source.get("picture"),
source.get("photo"),
)
if direct:
return direct
attrs = source.get("attributes")
if isinstance(attrs, dict):
attr_candidate = attrs.get("avatar_url")
if isinstance(attr_candidate, list) and attr_candidate:
value = self._clean_text(attr_candidate[0])
if value:
return value
if isinstance(attr_candidate, str):
value = self._clean_text(attr_candidate)
if value:
return value
return None
async def login(
self,
login_data: LoginRequestDTO,
ip_address: str = None,
user_agent: str = None
):
"""
Autentica usuario a través del Hub y obtiene tokens.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json=login_data.model_dump()
)
if response.status_code == 200:
data = response.json()
# Si el Hub devolvió una lista de tenants (hubo login exitoso pero falta seleccionar tenant)
if "tenants" in data:
from .dto import LoginChoiceResponseDTO, TenantInfoDTO
return LoginChoiceResponseDTO(
tenants=[TenantInfoDTO(**t) for t in data["tenants"]]
)
# Si devolvió tokens — lazy-link: verificar si hay invite pendiente
try:
await self._link_pending_invite(login_data.username)
except Exception as exc:
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
try:
login_sub = data.get("sub") or data.get("user_id")
if not login_sub:
claims = self._decode_kc_user_from_token(data.get("access_token", ""))
login_sub = claims.get("sub")
self._backfill_company_roles(login_sub)
except Exception as exc:
logger.warning("backfill_company_roles failed on login (non-blocking): %s", exc)
# Sync de perfil/avatar desde Workspace usando el mismo bearer.
# No bloquea login si Workspace no responde.
access_token = data.get("access_token")
if access_token:
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(access_token)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "login",
"error": str(exc),
},
)
workspace_profile = None
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
# AUDIT LOG: implementa tu servicio de auditoría aquí si lo necesitas.
return TokenResponseDTO(**data)
# Pasar el mensaje de error real del Hub al cliente
try:
hub_detail = response.json().get("detail", None)
except Exception:
hub_detail = None
if response.status_code == 401:
raise HTTPException(status_code=401, detail=hub_detail or "Credenciales inválidas")
logger.error(f"Hub login failed with status {response.status_code}: {response.text}")
raise HTTPException(status_code=response.status_code, detail=hub_detail or "Error en el servidor de autenticación")
except httpx.HTTPError as e:
logger.error(f"Hub unreachable during login: {str(e)}")
raise HTTPException(status_code=503, detail="Authentication service unavailable")
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected login error: {str(e)}")
raise HTTPException(status_code=500, detail="Authentication error")
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
"""
Refresca el access token usando el Hub
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/refresh",
json=refresh_data.model_dump()
)
if response.status_code == 200:
data = response.json()
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(
data.get("access_token", "")
)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "refresh",
"error": str(exc),
},
)
workspace_profile = None
await sync_workspace_profile_for_user(
self.db,
access_token=data.get("access_token"),
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
return TokenResponseDTO(**data)
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
except Exception as e:
logger.error(f"Token refresh error: {str(e)}")
raise HTTPException(status_code=500, detail="Token refresh error")
async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
"""
Obtiene información del usuario desde el Hub
"""
from core.security import verify_token
from core.workspace_profile_sync import sync_workspace_profile_for_user
# Aprovechamos la verificación (y cache) de security.py
user_info = await verify_token(access_token)
kc_user = self._decode_kc_user_from_token(access_token)
keycloak_user_id = self._pick_text(user_info.get("sub"), kc_user.get("sub"))
needs_admin_fallback = any(
not self._clean_text(user_info.get(field))
for field in ("email", "preferred_username")
) or self._extract_avatar_url(user_info) is None
kc_admin_user = None
if needs_admin_fallback:
kc_admin_user = await self._get_kc_admin_user(keycloak_user_id)
first_name = self._pick_text(
user_info.get("first_name"),
user_info.get("given_name"),
kc_user.get("given_name"),
kc_user.get("first_name"),
(kc_admin_user or {}).get("firstName"),
(kc_admin_user or {}).get("first_name"),
)
last_name = self._pick_text(
user_info.get("last_name"),
user_info.get("family_name"),
kc_user.get("family_name"),
kc_user.get("last_name"),
(kc_admin_user or {}).get("lastName"),
(kc_admin_user or {}).get("last_name"),
)
full_name = self._pick_text(
f"{first_name} {last_name}" if first_name and last_name else None,
first_name,
last_name,
)
enriched_user_info = dict(user_info)
enriched_user_info["sub"] = keycloak_user_id or user_info.get("sub")
enriched_user_info["email"] = self._pick_text(
user_info.get("email"),
(kc_admin_user or {}).get("email"),
kc_user.get("email"),
)
enriched_user_info["preferred_username"] = self._pick_text(
user_info.get("preferred_username"),
user_info.get("username"),
kc_user.get("preferred_username"),
kc_user.get("username"),
(kc_admin_user or {}).get("username"),
)
enriched_user_info["avatar_url"] = self._extract_avatar_url(
user_info,
kc_user,
kc_admin_user or {},
)
enriched_user_info["name"] = self._pick_text(
user_info.get("name"),
full_name,
kc_user.get("name"),
enriched_user_info.get("preferred_username"),
)
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=enriched_user_info.get("sub"),
tenant_id=enriched_user_info.get("tenant_id"),
workspace_profile=enriched_user_info,
)
return UserInfoResponseDTO(**enriched_user_info)
async def logout(self, logout_data: LogoutRequestDTO) -> dict:
"""
Cierra sesión a través del Hub
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await client.post(
f"{settings.HUB_URL}api/v1/auth/logout",
json=logout_data.model_dump()
)
return {"message": "Logged out successfully"}
except Exception as e:
logger.error(f"Logout error: {str(e)}")
return {"message": "Logged out"}
async def register(self, register_data: Any) -> Any:
"""
Registra un usuario.
- Si trae invite_token: valida el token local, crea usuario en Hub y
genera la fila UserTenant local, luego consume el token.
- Si no trae invite_token: reenvía directamente al Hub (flujo original).
"""
if getattr(register_data, "invite_token", None):
return await self._register_with_invite(register_data)
# Flujo original — reenviar al Hub sin invite_token
try:
payload = register_data.model_dump(exclude={"invite_token"})
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/register",
json=payload,
)
if response.status_code == 201:
return response.json()
raise HTTPException(status_code=response.status_code, detail=response.text)
except HTTPException:
raise
except Exception as e:
logger.error(f"Registration error: {str(e)}")
raise HTTPException(status_code=500, detail="Registration error")
async def _register_with_invite(self, register_data: Any) -> Any:
"""Flujo de registro con token de invitación local."""
from api.v1.modules.core.invites.service import InviteService
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
invite_service = InviteService(self.db)
# 1. Validar invite token (sin consumir)
invite_result = invite_service.validate(
register_data.invite_token,
register_data.tenant_slug,
str(register_data.email),
)
# 2. Buscar tenant local
tenant = (
self.db.query(Tenant)
.filter(Tenant.slug == register_data.tenant_slug)
.first()
)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant no encontrado")
# 3. Obtener token de service account y gestionar usuario en Hub
hub_user_id = None
try:
async with httpx.AsyncClient(timeout=15.0) as client:
# Login con service account
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={
"username": settings.HUB_ADMIN_EMAIL,
"password": settings.HUB_ADMIN_PASSWORD,
},
)
if login_resp.status_code != 200:
raise HTTPException(status_code=503, detail="No se pudo autenticar con el sistema de autenticación")
svc_token = login_resp.json().get("access_token", "")
# Verificar si el usuario ya existe en el Hub
search_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins",
params={"email": str(register_data.email)},
headers={"Authorization": f"Bearer {svc_token}"},
)
existing_user = None
if search_resp.status_code == 200:
admins = search_resp.json()
items = admins if isinstance(admins, list) else admins.get("items", [])
matches = [a for a in items if a.get("email", "").lower() == str(register_data.email).lower()]
if matches:
existing_user = matches[0]
if existing_user:
# Usuario ya existe — solo vinculamos (no creamos nuevo)
hub_user_id = existing_user.get("id")
else:
# Crear usuario via admin endpoint (no requiere invite_token)
hub_payload = {
"username": register_data.username,
"email": str(register_data.email),
"password": register_data.password,
"first_name": register_data.first_name,
"last_name": register_data.last_name,
"tenant_slug": register_data.tenant_slug,
}
create_resp = await client.post(
f"{settings.HUB_URL}api/v1/hub/admins",
json=hub_payload,
headers={"Authorization": f"Bearer {svc_token}"},
)
if create_resp.status_code in (200, 201):
hub_user_id = create_resp.json().get("id")
else:
try:
detail = create_resp.json().get("detail", create_resp.text)
except Exception:
detail = create_resp.text
raise HTTPException(status_code=create_resp.status_code, detail=detail)
except HTTPException:
raise
except Exception as exc:
logger.error("Hub admin create error during invite flow: %s", exc)
raise HTTPException(status_code=503, detail="Error al crear usuario en el sistema de autenticación")
# 4. Crear fila UserTenant y UserCompanyRole local
if hub_user_id and invite_result.company_id:
try:
ut = UserTenant(
keycloak_user_id=hub_user_id,
tenant_id=tenant.id,
company_id=invite_result.company_id,
role=invite_result.role,
is_active=True,
first_name=register_data.first_name,
last_name=register_data.last_name,
)
self.db.add(ut)
self.db.flush()
except Exception as exc:
logger.warning("Could not create UserTenant (may already exist): %s", exc)
self.db.rollback()
# Asignar UserCompanyRole para que el usuario tenga permisos resueltos
try:
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
company_role_obj = (
self.db.query(CompanyRole)
.filter(
CompanyRole.code == invite_result.role,
CompanyRole.company_id == invite_result.company_id,
CompanyRole.is_active == True,
)
.first()
)
if company_role_obj:
existing_ucr = (
self.db.query(UserCompanyRole)
.filter(
UserCompanyRole.user_id == hub_user_id,
UserCompanyRole.company_role_id == company_role_obj.id,
UserCompanyRole.company_id == invite_result.company_id,
)
.first()
)
if not existing_ucr:
ucr = UserCompanyRole(
user_id=hub_user_id,
company_role_id=company_role_obj.id,
company_id=invite_result.company_id,
tenant_id=tenant.id,
is_active=True,
)
self.db.add(ucr)
self.db.commit()
except Exception as exc:
logger.warning("Could not create UserCompanyRole for invited user: %s", exc)
self.db.rollback()
# 5. Consumir invite token
invite_service.consume_by_id(invite_result.invite_id)
return {
"user_id": hub_user_id or "",
"username": register_data.username,
"email": str(register_data.email),
"message": "Usuario registrado exitosamente",
}
async def exchange_code(self, exchange_data: Any) -> TokenResponseDTO:
"""
Intercambia código por tokens a través del Hub
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/exchange-code",
json=exchange_data.model_dump()
)
if response.status_code == 200:
data = response.json()
# Lazy-link: crear UserTenant si hay invite pendiente
try:
await self._link_pending_invite("", access_token=data.get("access_token", ""))
except Exception as exc:
logger.warning("exchange_code lazy-link failed (non-blocking): %s", exc)
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
try:
ec_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
self._backfill_company_roles(ec_claims.get("sub") or data.get("sub"))
except Exception as exc:
logger.warning("backfill_company_roles failed on exchange_code (non-blocking): %s", exc)
return TokenResponseDTO(**data)
raise HTTPException(status_code=response.status_code, detail="Code exchange failed")
except Exception as e:
logger.error(f"Exchange code error: {str(e)}")
raise HTTPException(status_code=500, detail="Exchange code error")
async def switch_tenant(self, **kwargs) -> TokenResponseDTO:
"""
Cambia de tenant a través del Hub
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/switch-tenant",
json=kwargs
)
if response.status_code == 200:
return TokenResponseDTO(**response.json())
raise HTTPException(status_code=response.status_code, detail="Switch tenant failed")
except Exception as e:
logger.error(f"Switch tenant error: {str(e)}")
raise HTTPException(status_code=500, detail="Switch tenant error")
async def sso_exchange(self, relay_token: str) -> TokenResponseDTO:
"""
Canjea un relay token de un solo uso por KC tokens.
Llama al Hub backend (server-to-server), sin Bearer requerido en el Hub.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/sso-exchange",
json={"relay_token": relay_token},
)
if response.status_code == 200:
data = response.json()
# Lazy-link: crear UserTenant si hay invite pendiente (usuario registrado vía workspace)
try:
await self._link_pending_invite("", access_token=data.get("access_token", ""))
except Exception as exc:
logger.warning("sso_exchange lazy-link failed (non-blocking): %s", exc)
# Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
try:
sso_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
self._backfill_company_roles(sso_claims.get("sub") or data.get("sub"))
except Exception as exc:
logger.warning("backfill_company_roles failed on sso_exchange (non-blocking): %s", exc)
return TokenResponseDTO(
access_token=data["access_token"],
refresh_token=data["refresh_token"],
token_type=data.get("token_type", "bearer"),
expires_in=data.get("expires_in", 3600),
tenant_id=data.get("tenant_id"),
tenant_slug=data.get("tenant_slug"),
)
raise HTTPException(
status_code=response.status_code,
detail=response.json().get("detail", "SSO exchange failed"),
)
except HTTPException:
raise
except Exception as e:
logger.error(f"SSO exchange error: {str(e)}")
raise HTTPException(status_code=500, detail="SSO exchange error")
async def _link_pending_invite(self, username_or_email: str, access_token: str = None) -> None:
"""
Lazy-link: después de un login exitoso comprueba si existe un invite_token
pendiente para el email del usuario. Si lo hay, crea la fila UserTenant
y consume el token.
Si se provee access_token, extrae hub_user_id y email directamente del JWT
sin necesidad de un lookup extra al Hub.
"""
from datetime import datetime, timezone
from api.v1.modules.core.invites.models import InviteToken
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
hub_user_id = None
user_email = username_or_email
# Si tenemos el access_token, extraer info del JWT directamente (sin red)
if access_token:
try:
claims = self._decode_kc_user_from_token(access_token)
hub_user_id = claims.get("sub")
user_email = claims.get("email") or username_or_email
except Exception as exc:
logger.debug("_link_pending_invite: JWT decode failed: %s", exc)
# Sin access_token: buscar usuario en el Hub vía service account
if not hub_user_id:
if not user_email:
return # Sin email ni hub_user_id no podemos buscar el invite
try:
async with httpx.AsyncClient(timeout=10.0) as client:
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
)
if login_resp.status_code != 200:
return
svc_token = login_resp.json().get("access_token", "")
search_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins",
params={"email": username_or_email},
headers={"Authorization": f"Bearer {svc_token}"},
)
if search_resp.status_code == 200:
items = search_resp.json()
items = items if isinstance(items, list) else items.get("items", [])
matches = [
u for u in items
if u.get("email", "").lower() == username_or_email.lower()
or u.get("username", "").lower() == username_or_email.lower()
]
if matches:
hub_user_id = matches[0].get("id")
user_email = matches[0].get("email", username_or_email)
if not hub_user_id:
return
except Exception as exc:
logger.debug("_link_pending_invite: hub lookup failed: %s", exc)
return
now = datetime.now(timezone.utc)
pending = (
self.db.query(InviteToken)
.filter(
InviteToken.email == user_email,
InviteToken.used_at.is_(None),
InviteToken.expires_at > now,
)
.first()
)
if not pending:
return
tenant = (
self.db.query(Tenant)
.filter(Tenant.slug == pending.tenant_slug)
.first()
)
if not tenant:
logger.warning("_link_pending_invite: tenant %s not found", pending.tenant_slug)
return
# Evitar duplicados
existing = (
self.db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == hub_user_id,
UserTenant.tenant_id == tenant.id,
)
.first()
)
if existing:
# Vincular existe, solo consumir el token
pending.used_at = now
self.db.commit()
return
try:
ut = UserTenant(
keycloak_user_id=hub_user_id,
tenant_id=tenant.id,
company_id=pending.company_id,
role=pending.role,
is_active=True,
)
self.db.add(ut)
self.db.flush()
logger.info(
"Lazy-link: UserTenant created for user=%s tenant=%s role=%s",
hub_user_id,
tenant.slug,
pending.role,
)
except Exception as exc:
logger.warning("_link_pending_invite: could not create UserTenant: %s", exc)
self.db.rollback()
# Asignar UserCompanyRole para que el usuario tenga permisos resueltos
if pending.company_id:
try:
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
company_role_obj = (
self.db.query(CompanyRole)
.filter(
CompanyRole.code == pending.role,
CompanyRole.company_id == pending.company_id,
CompanyRole.is_active == True,
)
.first()
)
if company_role_obj:
existing_ucr = (
self.db.query(UserCompanyRole)
.filter(
UserCompanyRole.user_id == hub_user_id,
UserCompanyRole.company_role_id == company_role_obj.id,
UserCompanyRole.company_id == pending.company_id,
)
.first()
)
if not existing_ucr:
ucr = UserCompanyRole(
user_id=hub_user_id,
company_role_id=company_role_obj.id,
company_id=pending.company_id,
tenant_id=tenant.id,
is_active=True,
)
self.db.add(ucr)
except Exception as exc:
logger.warning("_link_pending_invite: could not create UserCompanyRole: %s", exc)
pending.used_at = now
self.db.commit()
def _backfill_company_roles(self, hub_user_id: str) -> None:
"""
Self-healing: para usuarios ya registrados vía invitación que tienen UserTenant
pero no UserCompanyRole (creados antes del fix del flujo de invitación).
Por cada UserTenant activo con role y company_id busca el CompanyRole y crea
el UserCompanyRole si no existe. Non-blocking.
"""
if not hub_user_id:
return
try:
from api.v1.modules.core.user_tenant.models import UserTenant
from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
user_tenants = (
self.db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == hub_user_id,
UserTenant.is_active == True,
UserTenant.company_id.isnot(None),
UserTenant.role.isnot(None),
)
.all()
)
changed = False
for ut in user_tenants:
company_role_obj = (
self.db.query(CompanyRole)
.filter(
CompanyRole.code == ut.role,
CompanyRole.company_id == ut.company_id,
CompanyRole.is_active == True,
)
.first()
)
if not company_role_obj:
continue
existing = (
self.db.query(UserCompanyRole)
.filter(
UserCompanyRole.user_id == hub_user_id,
UserCompanyRole.company_role_id == company_role_obj.id,
UserCompanyRole.company_id == ut.company_id,
)
.first()
)
if not existing:
self.db.add(UserCompanyRole(
user_id=hub_user_id,
company_role_id=company_role_obj.id,
company_id=ut.company_id,
tenant_id=ut.tenant_id,
is_active=True,
))
changed = True
logger.info(
"backfill: UserCompanyRole created for user=%s company=%s role=%s",
hub_user_id, ut.company_id, ut.role,
)
if changed:
self.db.commit()
except Exception as exc:
logger.warning("_backfill_company_roles failed (non-blocking): %s", exc)
self.db.rollback()

View File

@@ -0,0 +1,7 @@
"""
Módulo de dashboard para estadísticas y métricas empresariales
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,109 @@
"""
DTOs para el dashboard de estadísticas y métricas empresariales
"""
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
from datetime import datetime
class KPIMetric(BaseModel):
"""Métrica individual de KPI"""
label: str = Field(..., description="Nombre del indicador")
value: int | float = Field(..., description="Valor actual")
previous_value: Optional[int | float] = Field(
None, description="Valor anterior para comparación"
)
percentage_change: Optional[float] = Field(None, description="Porcentaje de cambio")
trend: Optional[str] = Field(None, description="up, down, stable")
unit: Optional[str] = Field(None, description="Unidad de medida (%, USD, etc)")
class ActivityItem(BaseModel):
"""Item de actividad reciente"""
id: int
type: str = Field(
..., description="Tipo de actividad: invoice, pedimento, client, etc"
)
title: str = Field(..., description="Título descriptivo")
description: Optional[str] = Field(None, description="Descripción adicional")
timestamp: datetime
status: Optional[str] = Field(None, description="Estado del item")
icon: Optional[str] = Field(None, description="Icono a mostrar")
class ChartDataPoint(BaseModel):
"""Punto de datos para gráficas"""
label: str
value: float
category: Optional[str] = None
class DashboardStats(BaseModel):
"""Estadísticas generales del dashboard"""
# KPIs principales
total_invoices: KPIMetric
total_pedimentos: KPIMetric
total_clients: KPIMetric
total_providers: KPIMetric
active_items: KPIMetric
pending_approvals: KPIMetric
# Estadísticas financieras
total_value_imports: Optional[float] = Field(
None, description="Valor total de importaciones"
)
total_value_exports: Optional[float] = Field(
None, description="Valor total de exportaciones"
)
# Datos para gráficas
invoices_by_month: List[ChartDataPoint] = Field(default_factory=list)
pedimentos_by_month: List[ChartDataPoint] = Field(default_factory=list)
operations_by_type: List[ChartDataPoint] = Field(default_factory=list)
top_clients: List[ChartDataPoint] = Field(default_factory=list)
top_providers: List[ChartDataPoint] = Field(default_factory=list)
# Actividad reciente
recent_activity: List[ActivityItem] = Field(default_factory=list)
# Metadata
generated_at: datetime = Field(default_factory=datetime.utcnow)
company_id: int
company_name: Optional[str] = None
class OperationsOverview(BaseModel):
"""Vista general de operaciones"""
total_operations: int
by_type: Dict[str, int] = Field(default_factory=dict)
by_status: Dict[str, int] = Field(default_factory=dict)
avg_processing_time: Optional[float] = Field(
None, description="Tiempo promedio en días"
)
class InventoryMetrics(BaseModel):
"""Métricas de inventario"""
total_items: int
items_in_stock: int
items_low_stock: int
total_value: Optional[float] = None
by_category: Dict[str, int] = Field(default_factory=dict)
class ComplianceMetrics(BaseModel):
"""Métricas de cumplimiento normativo"""
pending_documents: int
expired_permits: int
upcoming_deadlines: int
compliance_score: Optional[float] = Field(
None, description="Score de cumplimiento 0-100"
)

View File

@@ -0,0 +1,84 @@
"""
Endpoints del dashboard para estadísticas y métricas empresariales
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .dto import DashboardStats, OperationsOverview, InventoryMetrics
from .service import DashboardService
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
@router.get("/stats", response_model=DashboardStats)
async def get_dashboard_stats(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene estadísticas completas del dashboard para la compañía especificada.
Incluye:
- KPIs principales (facturas, pedimentos, clientes, proveedores, items)
- Gráficas de tendencias (facturas por mes, operaciones por tipo)
- Top clientes y proveedores
- Actividad reciente
"""
# Validar acceso
tenant_id = validate_access_to_resource(db, company_id, current_user)
# Generar estadísticas
service = DashboardService(db, tenant_id, company_id)
stats = service.get_complete_dashboard_stats()
return stats
@router.get("/operations-overview", response_model=OperationsOverview)
async def get_operations_overview(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene una vista general de las operaciones
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = DashboardService(db, tenant_id, company_id)
# Implementación básica
ops_by_type = service.get_operations_by_type()
return OperationsOverview(
total_operations=sum(int(op.value) for op in ops_by_type),
by_type={op.label: int(op.value) for op in ops_by_type},
by_status={},
)
@router.get("/inventory-metrics", response_model=InventoryMetrics)
async def get_inventory_metrics(
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene métricas de inventario
"""
tenant_id = validate_access_to_resource(db, company_id, current_user)
service = DashboardService(db, tenant_id, company_id)
items_kpi = service.get_items_metrics()
return InventoryMetrics(
total_items=int(items_kpi.value),
items_in_stock=int(items_kpi.value), # Simplificado
items_low_stock=0,
by_category={},
)

View File

@@ -0,0 +1,43 @@
"""
Servicio del dashboard — STUB.
Implementa las métricas de tu proyecto aquí.
"""
from sqlalchemy.orm import Session
from .dto import DashboardStats, KPIMetric, OperationsOverview, InventoryMetrics
class DashboardService:
"""Stub — reemplaza con las consultas de tu proyecto."""
def __init__(self, db: Session, tenant_id: int, company_id: int):
self.db = db
self.tenant_id = tenant_id
self.company_id = company_id
def get_stats(self) -> DashboardStats:
empty_kpi = KPIMetric(label="", value=0, trend="stable")
return DashboardStats(
company_id=self.company_id,
generated_at="",
total_invoices=empty_kpi,
total_pedimentos=empty_kpi,
total_clients=empty_kpi,
total_providers=empty_kpi,
active_items=empty_kpi,
pending_approvals=empty_kpi,
invoices_by_month=[],
operations_by_type=[],
top_clients=[],
top_providers=[],
recent_activity=[],
)
def get_operations_overview(self) -> OperationsOverview:
return OperationsOverview(total_operations=0, by_type={}, by_status={})
def get_inventory_metrics(self) -> InventoryMetrics:
return InventoryMetrics(
total_items=0, items_in_stock=0, items_low_stock=0, by_category={}
)

View File

@@ -0,0 +1,32 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Column, String, Text, DateTime, Integer
from sqlalchemy.dialects.postgresql import UUID
from core.database import Base
class HelpArticle(Base):
"""
Modelo para los artículos de ayuda (Base de Conocimientos).
Sincronizado entre Servidor Central y Clientes.
"""
__tablename__ = "help_articles"
uuid = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
slug = Column(String(255), unique=True, index=True, nullable=False)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
last_editor = Column(String(255), nullable=False)
# Library Mode Fields
category = Column(String(255), nullable=True, default="General")
order = Column(Integer, nullable=True, default=0)
# Removed missing fields to avoid 500 errors (No migration approach)
# content_type = Column(String(50), nullable=False, default="article")
# file_url = Column(String(512), nullable=True)
# file_size = Column(Integer, nullable=True)
# mime_type = Column(String(100), nullable=True)
def __repr__(self):
return f"<HelpArticle(title='{self.title}', slug='{self.slug}')>"

View File

@@ -0,0 +1,273 @@
import mimetypes
import os
import shutil
import uuid
from datetime import datetime
from typing import List, Optional, Dict, Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File
from fastapi.responses import Response
from sqlalchemy.orm import Session
from core.config import settings
from core.database import get_core_db
from core.s3_keys import (
help_asset_key,
help_public_api_path,
help_s3_key_to_public_relative_path,
system_help_object_key,
)
from core.storage_s3 import get_object_bytes, put_object_bytes
from core.security import get_current_user, has_role
from .schemas import HelpArticleInDB, HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
from .services import HelpCenterService
from .tasks import sync_single_article_task
router = APIRouter(prefix="/help-center", tags=["Help Center"])
def verify_sync_token(x_sync_token: str = Header(...)):
if x_sync_token != settings.SYNC_SECRET_TOKEN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Sync Token"
)
def trigger_sync_or_broadcast(article_uuid: UUID):
"""
Helper function to handle synchronization logic.
- If we are a Client (CENTRAL_SERVER_URL is set): Trigger upstream sync.
- If we are the Hub (No CENTRAL_SERVER, but SPOKE_URLS set): Trigger broadcast.
"""
import logging
logger = logging.getLogger(__name__)
try:
logger.info(f"DEBUG: Triggering sync/broadcast for article {article_uuid}")
logger.debug(f"DEBUG: CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'")
# 1. Upstream Sync (Client -> Hub)
if settings.CENTRAL_SERVER_URL and settings.CENTRAL_SERVER_URL != '""':
logger.info(f"DEBUG: Queueing sync_single_article_task for {article_uuid}")
sync_single_article_task.delay(str(article_uuid))
# 2. Downstream Broadcast (Hub -> Spokes)
# Only if we are the Hub (no upstream) and have spokes configured.
elif (not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""') and settings.SPOKE_URLS:
from .tasks import broadcast_help_update
logger.info(f"DEBUG: Queueing broadcast_help_update for {article_uuid}")
# origin_client_uuid is None because this change originated on the Hub itself
broadcast_help_update.delay(str(article_uuid), None)
else:
logger.info(f"DEBUG: No sync/broadcast needed for {article_uuid} (Config empty or Hub mode without spokes)")
except Exception as e:
logger.error(f"ERROR in trigger_sync_or_broadcast for article {article_uuid}: {str(e)}", exc_info=True)
# We don't re-raise here to avoid returning 500 to the user if the save was successful
@router.post("/sync/", response_model=HelpSyncResponse, dependencies=[Depends(verify_sync_token)])
def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core_db)):
"""
Endpoint de sincronización inteligente para artículos de ayuda.
Requiere X-Sync-Token en los headers.
"""
result = HelpCenterService.sync_article(db, sync_data)
# Broadcast to other spokes (Hub logic)
import logging
logger = logging.getLogger(__name__)
logger.info(f"DEBUG: Hub Sync Check. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' SPOKE_URLS='{settings.SPOKE_URLS}'")
if not settings.CENTRAL_SERVER_URL and settings.SPOKE_URLS:
# We are the Hub (no central server to push to) and have Spokes configured
from .tasks import broadcast_help_update
logger.info(f"DEBUG: Triggering broadcast for article {sync_data.article_uuid}")
broadcast_help_update.delay(
str(sync_data.article_uuid),
str(sync_data.origin_client_uuid) if sync_data.origin_client_uuid else None
)
else:
logger.info("DEBUG: Broadcast skipped (Condition failed)")
return result
@router.get("/files/{file_path:path}")
def serve_help_file(file_path: str):
"""Sirve un objeto bajo system/help/ (público vía middleware)."""
if ".." in file_path or file_path.startswith("/"):
raise HTTPException(status_code=404, detail="Not found")
try:
key = system_help_object_key(file_path)
except ValueError:
raise HTTPException(status_code=404, detail="Not found")
if not settings.use_s3_object_storage:
legacy = os.path.join("uploads", "help", file_path)
if not os.path.isfile(legacy):
raise HTTPException(status_code=404, detail="Not found")
with open(legacy, "rb") as f:
data = f.read()
media = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
return Response(content=data, media_type=media)
try:
data = get_object_bytes(key)
except Exception:
raise HTTPException(status_code=404, detail="Not found")
media = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
return Response(content=data, media_type=media)
@router.post("/upload-image/")
async def upload_help_image(
file: UploadFile = File(...),
current_user: Dict[str, Any] = Depends(has_role("admin"))
):
"""Sube una imagen para usar en los artículos."""
try:
file_ext = os.path.splitext(file.filename or "")[1] or ".png"
new_filename = f"{uuid.uuid4()}{file_ext}"
body = await file.read()
if settings.use_s3_object_storage:
key = help_asset_key("", new_filename)
ct = mimetypes.guess_type(new_filename)[0] or "image/png"
put_object_bytes(key, body, content_type=ct)
rel = help_s3_key_to_public_relative_path(key)
return {"url": help_public_api_path(rel)}
os.makedirs("uploads/help", exist_ok=True)
file_location = f"uploads/help/{new_filename}"
with open(file_location, "wb") as f:
f.write(body)
return {"url": f"/api/uploads/help/{new_filename}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/upload-asset/")
async def upload_help_asset(
file: UploadFile = File(...),
current_user: Dict[str, Any] = Depends(has_role("admin"))
):
"""Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca."""
try:
file_ext = os.path.splitext(file.filename or "")[1].lower()
new_filename = f"{uuid.uuid4()}{file_ext}"
subfolder = "assets"
if file_ext == ".pdf":
subfolder = "pdfs"
elif file_ext in [".mp4", ".mov", ".avi"]:
subfolder = "videos"
if settings.use_s3_object_storage:
body = await file.read()
key = help_asset_key(subfolder, new_filename)
ct = file.content_type or mimetypes.guess_type(new_filename)[0] or "application/octet-stream"
put_object_bytes(key, body, content_type=ct)
rel = help_s3_key_to_public_relative_path(key)
return {
"url": help_public_api_path(rel),
"filename": file.filename,
"size": len(body),
"mime_type": file.content_type,
}
folder = f"uploads/help/{subfolder}"
os.makedirs(folder, exist_ok=True)
file_location = f"{folder}/{new_filename}"
body = await file.read()
with open(file_location, "wb") as f:
f.write(body)
file_size = os.path.getsize(file_location)
return {
"url": f"/api/{file_location}",
"filename": file.filename,
"size": file_size,
"mime_type": file.content_type,
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/articles/", response_model=List[HelpArticleInDB])
def list_articles(
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Lista todos los artículos de ayuda."""
return HelpCenterService.get_all(db)
@router.get("/modifications/", response_model=List[HelpArticleInDB], dependencies=[Depends(verify_sync_token)])
def get_modifications(since: datetime, db: Session = Depends(get_core_db)):
"""Obtiene artículos modificados desde la fecha indicada (Polling). Requiere X-Sync-Token."""
return HelpCenterService.get_modifications(db, since)
@router.get("/articles/{article_uuid}/", response_model=HelpArticleInDB)
def get_article(
article_uuid: UUID,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""Obtiene un artículo por UUID."""
article = HelpCenterService.get_by_uuid(db, article_uuid)
if not article:
raise HTTPException(status_code=404, detail="Article not found")
return article
@router.post("/articles/", response_model=HelpArticleInDB, status_code=status.HTTP_201_CREATED)
def create_article(
article: HelpArticleCreate,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(has_role("admin"))
):
"""Crea un nuevo artículo."""
import logging
logger = logging.getLogger(__name__)
logger.info(f"DEBUG: Creating new article: {article.title} by {current_user.get('preferred_username')}")
# Fill last_editor with admin username
if current_user.get('preferred_username'):
article.last_editor = current_user.get('preferred_username')
new_article = HelpCenterService.create(db, article)
logger.info(f"DEBUG: Article created successfully in DB. UUID: {new_article.uuid}")
trigger_sync_or_broadcast(new_article.uuid)
return new_article
@router.patch("/articles/{article_uuid}/", response_model=HelpArticleInDB)
def update_article(
article_uuid: UUID,
article_data: HelpArticleUpdate,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(has_role("admin"))
):
"""Actualiza un artículo."""
if current_user.get('preferred_username'):
article_data.last_editor = current_user.get('preferred_username')
article = HelpCenterService.update(db, article_uuid, article_data)
if not article:
raise HTTPException(status_code=404, detail="Article not found")
trigger_sync_or_broadcast(article.uuid)
return article
@router.delete("/articles/{article_uuid}/", status_code=status.HTTP_204_NO_CONTENT)
def delete_article(
article_uuid: UUID,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(has_role("admin"))
):
"""Elimina un artículo."""
if not HelpCenterService.delete(db, article_uuid):
raise HTTPException(status_code=404, detail="Article not found")
# Broadcast or Sync the deletion?
# Current sync logic relies on sending the *content*. Deletion sync is harder because the article is gone.
# For now, let's at least trigger the logic.
# WARNING: sync_single_article_task expects the article to exist to send it.
# If we deleted it locally, sync_single_article_task will fail or send nothing.
# We need a dedicated 'sync_deletion' task or similar.
# Since the user didn't explicitly ask for deletion sync, I will SKIP adding complex deletion sync
# logic right now to avoid breaking things, but I'll add the hook for completeness.
# Actually, better to NOT trigger sync on delete if we don't handle it, to avoid errors in logs.
return None

View File

@@ -0,0 +1,74 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class HelpArticleBase(BaseModel):
slug: str
title: str
content: str
last_editor: str
category: Optional[str] = "General"
order: Optional[int] = 0
content_type: str = "article"
file_url: Optional[str] = None
file_size: Optional[int] = None
mime_type: Optional[str] = None
context_path: Optional[str] = None
tags: Optional[str] = None
class HelpArticleCreate(HelpArticleBase):
pass
class HelpArticleUpdate(BaseModel):
slug: Optional[str] = None
title: Optional[str] = None
content: Optional[str] = None
last_editor: Optional[str] = None
category: Optional[str] = None
order: Optional[int] = None
content_type: Optional[str] = None
file_url: Optional[str] = None
file_size: Optional[int] = None
mime_type: Optional[str] = None
context_path: Optional[str] = None
tags: Optional[str] = None
class HelpArticleInDB(HelpArticleBase):
uuid: UUID
updated_at: datetime
class Config:
from_attributes = True
class HelpSyncRequest(BaseModel):
article_uuid: UUID
client_updated_at: datetime
client_content: str
client_title: str
client_slug: str
last_editor: str
client_category: Optional[str] = "General"
client_order: Optional[int] = 0
client_content_type: str = "article"
client_file_url: Optional[str] = None
client_file_size: Optional[int] = None
client_mime_type: Optional[str] = None
client_context_path: Optional[str] = None
client_tags: Optional[str] = None
class HelpSyncResponse(BaseModel):
status: str
server_updated_at: Optional[datetime] = None
server_content: Optional[str] = None
server_title: Optional[str] = None
server_slug: Optional[str] = None
server_category: Optional[str] = None
server_order: Optional[int] = None
server_content_type: Optional[str] = None
server_file_url: Optional[str] = None
server_file_size: Optional[int] = None
server_mime_type: Optional[str] = None
server_context_path: Optional[str] = None
server_tags: Optional[str] = None
message: str

View File

@@ -0,0 +1,257 @@
import json
import re
from datetime import datetime, timezone
from typing import List, Optional
from uuid import UUID
from sqlalchemy.orm import Session
from .models import HelpArticle
from .schemas import HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse
class HelpCenterService:
@staticmethod
def _inject_metadata(article: HelpArticle) -> HelpArticle:
if not article or not article.content:
return article
# Look for <!-- a76_metadata: { ... } -->
match = re.search(r'<!-- a76_metadata: (.*?) -->', article.content, re.DOTALL)
if match:
try:
metadata = json.loads(match.group(1))
article.content_type = metadata.get("content_type", "article")
article.file_url = metadata.get("file_url")
article.file_size = metadata.get("file_size")
article.mime_type = metadata.get("mime_type")
article.context_path = metadata.get("context_path")
article.tags = metadata.get("tags")
# Remove metadata from content for clean display if needed,
# but usually better to leave it and let parser handle it or hide it here.
# For now, we just set the attributes.
except Exception:
pass
else:
article.content_type = "article"
article.file_url = None
article.file_size = None
article.mime_type = None
article.context_path = None
article.tags = None
return article
@staticmethod
def _extract_metadata(content: str, data: dict) -> str:
# Remove existing metadata block if any
content = re.sub(r'\n\n<!-- a76_metadata: .*? -->', '', content, flags=re.DOTALL)
metadata = {
"content_type": data.get("content_type", "article"),
"file_url": data.get("file_url"),
"file_size": data.get("file_size"),
"mime_type": data.get("mime_type"),
"context_path": data.get("context_path"),
"tags": data.get("tags")
}
# Only append if there's something meaningful beyond "article"
if (metadata["content_type"] != "article" or
metadata["file_url"] or
metadata["context_path"] or
metadata["tags"]):
content += f"\n\n<!-- a76_metadata: {json.dumps(metadata)} -->"
return content
@staticmethod
def get_all(db: Session) -> List[HelpArticle]:
articles = db.query(HelpArticle).all()
return [HelpCenterService._inject_metadata(a) for a in articles]
@staticmethod
def get_by_uuid(db: Session, article_uuid: UUID) -> Optional[HelpArticle]:
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
return HelpCenterService._inject_metadata(article)
@staticmethod
def get_by_slug(db: Session, slug: str) -> Optional[HelpArticle]:
article = db.query(HelpArticle).filter(HelpArticle.slug == slug).first()
return HelpCenterService._inject_metadata(article)
@staticmethod
def get_modifications(db: Session, since: datetime) -> List[HelpArticle]:
# Ensure timezone awareness
if since.tzinfo is None:
since = since.replace(tzinfo=timezone.utc)
articles = db.query(HelpArticle).filter(HelpArticle.updated_at > since).all()
return [HelpCenterService._inject_metadata(a) for a in articles]
@staticmethod
def create(db: Session, article: HelpArticleCreate) -> HelpArticle:
data = article.model_dump()
# Move metadata into content
data["content"] = HelpCenterService._extract_metadata(data["content"], data)
# Remove virtual fields from data to avoid SQLAlchemy errors
virtual_fields = ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]
for f in virtual_fields:
if f in data:
del data[f]
db_article = HelpArticle(**data)
db.add(db_article)
db.commit()
db.refresh(db_article)
return HelpCenterService._inject_metadata(db_article)
@staticmethod
def update(db: Session, article_uuid: UUID, article_data: HelpArticleUpdate) -> Optional[HelpArticle]:
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
if not db_article:
return None
# Inject metadata to existing article to get current virtual fields
db_article = HelpCenterService._inject_metadata(db_article)
update_data = article_data.model_dump(exclude_unset=True)
# Handle metadata update
if "content" in update_data or any(f in update_data for f in ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]):
# Merge existing metadata with new updates
current_meta = {
"content_type": getattr(db_article, "content_type", "article"),
"file_url": getattr(db_article, "file_url", None),
"file_size": getattr(db_article, "file_size", None),
"mime_type": getattr(db_article, "mime_type", None),
"context_path": getattr(db_article, "context_path", None),
"tags": getattr(db_article, "tags", None)
}
# Update with new data if present
for f in ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]:
if f in update_data:
current_meta[f] = update_data[f]
# Use current content or new content
content = update_data.get("content", db_article.content)
update_data["content"] = HelpCenterService._extract_metadata(content, current_meta)
# Remove virtual fields from data
virtual_fields = ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]
for f in virtual_fields:
if f in update_data:
del update_data[f]
for key, value in update_data.items():
setattr(db_article, key, value)
db.commit()
db.refresh(db_article)
return HelpCenterService._inject_metadata(db_article)
@staticmethod
def delete(db: Session, article_uuid: UUID) -> bool:
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
if not db_article:
return False
db.delete(db_article)
db.commit()
return True
@staticmethod
def sync_article(db: Session, sync_data: HelpSyncRequest) -> HelpSyncResponse:
"""
Lógica de sincronización "Smart Sync" (Last Write Wins).
"""
db_article = db.query(HelpArticle).filter(HelpArticle.uuid == sync_data.article_uuid).first()
client_updated_at = sync_data.client_updated_at
if client_updated_at.tzinfo is None:
client_updated_at = client_updated_at.replace(tzinfo=timezone.utc)
if not db_article:
# Caso A: Artículo nuevo desde el cliente
# Store metadata in content
client_meta = {
"content_type": sync_data.client_content_type,
"file_url": sync_data.client_file_url,
"file_size": sync_data.client_file_size,
"mime_type": sync_data.client_mime_type,
"context_path": sync_data.client_context_path,
"tags": sync_data.client_tags
}
content_with_meta = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
new_article = HelpArticle(
uuid=sync_data.article_uuid,
slug=sync_data.client_slug,
title=sync_data.client_title,
content=content_with_meta,
updated_at=client_updated_at,
last_editor=sync_data.last_editor,
category=sync_data.client_category,
order=sync_data.client_order
)
db.add(new_article)
db.commit()
# Download assets if needed (Images in content and main file)
from .utils import download_file_from_hub, sync_assets_from_content
if sync_data.client_file_url:
download_file_from_hub(sync_data.client_file_url)
sync_assets_from_content(sync_data.client_content)
return HelpSyncResponse(status="OK", message="Article created on server.")
server_updated_at = db_article.updated_at
if server_updated_at.tzinfo is None:
server_updated_at = server_updated_at.replace(tzinfo=timezone.utc)
# Caso A: Cliente es más nuevo
if client_updated_at > server_updated_at:
client_meta = {
"content_type": sync_data.client_content_type,
"file_url": sync_data.client_file_url,
"file_size": sync_data.client_file_size,
"mime_type": sync_data.client_mime_type,
"context_path": sync_data.client_context_path,
"tags": sync_data.client_tags
}
db_article.content = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
db_article.title = sync_data.client_title
db_article.slug = sync_data.client_slug
db_article.updated_at = client_updated_at
db_article.last_editor = sync_data.last_editor
db_article.category = sync_data.client_category
db_article.order = sync_data.client_order
db.commit()
# Download assets if needed (Images in content)
from .utils import download_file_from_hub, sync_assets_from_content
if sync_data.client_file_url:
download_file_from_hub(sync_data.client_file_url)
sync_assets_from_content(sync_data.client_content)
return HelpSyncResponse(status="OK", message="Server updated with client data.")
# Caso B: Servidor es más nuevo
elif server_updated_at > client_updated_at:
# Inject metadata for response
db_article = HelpCenterService._inject_metadata(db_article)
return HelpSyncResponse(
status="UPDATE_REQUIRED",
server_updated_at=server_updated_at,
server_content=db_article.content,
server_title=db_article.title,
server_slug=db_article.slug,
server_category=db_article.category,
server_order=db_article.order,
server_content_type=getattr(db_article, "content_type", "article"),
server_file_url=getattr(db_article, "file_url", None),
server_file_size=getattr(db_article, "file_size", None),
server_mime_type=getattr(db_article, "mime_type", None),
server_context_path=getattr(db_article, "context_path", None),
server_tags=getattr(db_article, "tags", None),
message="Client is outdated. Update required."
)
# Caso C: Iguales
else:
return HelpSyncResponse(status="OK", message="Already in sync.")

View File

@@ -0,0 +1,269 @@
import logging
import httpx
from uuid import UUID
from celery import shared_task
from datetime import datetime, timezone
from core.database import CoreSessionLocal
from core.config import settings
from .models import HelpArticle
from .schemas import HelpSyncRequest, HelpSyncResponse
logger = logging.getLogger(__name__)
@shared_task(name="sync_all_articles_task")
def sync_all_articles_task():
"""
Tarea periódica que recorre todos los artículos locales y los sincroniza con el Central.
Solo se ejecuta si hay un CENTRAL_SERVER_URL configurado (Rol: Cliente/Spoke).
"""
if not settings.CENTRAL_SERVER_URL:
logger.info("Skipping sync: No CENTRAL_SERVER_URL configured (Hub mode).")
return
db = CoreSessionLocal()
try:
articles = db.query(HelpArticle).all()
for article in articles:
sync_single_article(article.uuid)
except Exception as e:
logger.error(f"Error in sync_all_articles_task: {e}")
finally:
db.close()
@shared_task(name="sync_single_article_task")
def sync_single_article_task(article_uuid_str: str):
"""
Sincroniza un único artículo inmediatamente después de una edición local.
"""
sync_single_article(article_uuid_str)
@shared_task(name="broadcast_help_update")
def broadcast_help_update(article_uuid_str: str):
"""
Difunde una actualización de artículo a todos los spokes configurados.
"""
if not settings.SPOKE_URLS:
logger.info("No SPOKE_URLS configured. Skipping broadcast.")
return
spokes = [s.strip() for s in settings.SPOKE_URLS.split(",") if s.strip()]
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
db = CoreSessionLocal()
try:
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid_str).first()
if not article:
logger.error(f"Article {article_uuid_str} not found for broadcast.")
return
sync_payload = HelpSyncRequest(
article_uuid=article.uuid,
client_updated_at=article.updated_at,
client_content=article.content,
client_title=article.title,
client_slug=article.slug,
last_editor=article.last_editor,
client_category=article.category,
client_order=article.order
).model_dump(mode='json')
with httpx.Client() as client:
for spoke_url in spokes:
# Loop Prevention: Skip if the spoke is the origin
try:
logger.info(f"Broadcasting update to {spoke_url}")
response = client.post(
spoke_url,
json=sync_payload,
headers=headers,
timeout=5.0
)
if response.status_code != 200:
logger.warning(f"Broadcast to {spoke_url} failed: {response.status_code}")
except Exception as e:
logger.error(f"Error broadcasting to {spoke_url}: {e}")
except Exception as e:
logger.error(f"Broadcast error: {e}")
finally:
db.close()
def sync_single_article(article_uuid):
"""
Lógica compartida para sincronizar un artículo con el servidor central.
"""
logger.info(f"DEBUG: Syncing article {article_uuid}. CENTRAL_SERVER_URL='{settings.CENTRAL_SERVER_URL}' (Type: {type(settings.CENTRAL_SERVER_URL)})")
if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""':
# Enhanced check to catch literal empty quotes if they slip through
return
db = CoreSessionLocal()
try:
article = db.query(HelpArticle).filter(HelpArticle.uuid == article_uuid).first()
if not article:
return
sync_data = HelpSyncRequest(
article_uuid=article.uuid,
client_updated_at=article.updated_at,
client_content=article.content,
client_title=article.title,
client_slug=article.slug,
last_editor=article.last_editor,
client_category=article.category,
client_order=article.order
)
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
with httpx.Client() as client:
response = client.post(
settings.CENTRAL_SERVER_URL,
json=sync_data.model_dump(mode='json'),
headers=headers,
timeout=10.0
)
if response.status_code == 200:
result = HelpSyncResponse(**response.json())
if result.status == "UPDATE_REQUIRED":
# El servidor tiene una versión más nueva, actualizamos localmente
article.content = result.server_content
article.title = result.server_title
article.slug = result.server_slug
article.updated_at = result.server_updated_at
db.commit()
logger.info(f"Article {article.uuid} updated from server.")
# Download assets if needed
from .utils import download_file_from_hub, sync_assets_from_content
if result.server_file_url:
download_file_from_hub(result.server_file_url)
sync_assets_from_content(result.server_content)
else:
logger.info(f"Article {article.uuid} sync OK: {result.message}")
else:
logger.error(f"Sync failed for article {article.uuid}: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Error syncing article {article.uuid}: {e}")
finally:
db.close()
from sqlalchemy import func
@shared_task(name="sync_from_hub_task")
def sync_from_hub_task():
"""
Tarea de POLLING que el Cliente ejecuta periódicamente.
Consulta al Hub (CENTRAL_SERVER_URL) por artículos modificados desde
la última actualización local.
"""
if not settings.CENTRAL_SERVER_URL:
return
db = CoreSessionLocal()
try:
# 1. Obtener la fecha de la última actualización local
last_local_update = db.query(func.max(HelpArticle.updated_at)).scalar()
if not last_local_update:
# Si no hay datos, traer todo desde el principio de los tiempos
last_local_update = datetime(2000, 1, 1, tzinfo=timezone.utc)
# Asegurar timezone awareness
if last_local_update.tzinfo is None:
last_local_update = last_local_update.replace(tzinfo=timezone.utc)
logger.info(f"Polling Hub for updates since {last_local_update}")
# 2. Consultar al Hub
headers = {"X-Sync-Token": settings.SYNC_SECRET_TOKEN}
# CENTRAL_SERVER_URL es ".../help-center/sync/"
# Queremos ".../help-center/modifications/"
hub_url = settings.CENTRAL_SERVER_URL.replace("/sync/", "/modifications/")
with httpx.Client() as client:
response = client.get(
hub_url,
params={"since": last_local_update.isoformat()},
headers=headers,
timeout=10.0
)
if response.status_code == 200:
articles_data = response.json()
if not articles_data:
logger.info("No updates found.")
return
logger.info(f"Found {len(articles_data)} updates from Hub. Applying...")
# 3. Aplicar actualizaciones
for art_data in articles_data:
try:
# Logic similar to sync_article but simpler (Force Update from Hub)
# We assume Hub is Truth in this Polling flow
# Try to find by UUID
local_article = db.query(HelpArticle).filter(HelpArticle.uuid == art_data['uuid']).first()
# Fallback: find by Slug if UUID doesn't match
if not local_article:
local_article = db.query(HelpArticle).filter(HelpArticle.slug == art_data['slug']).first()
server_updated_at = datetime.fromisoformat(art_data['updated_at'])
if server_updated_at.tzinfo is None:
server_updated_at = server_updated_at.replace(tzinfo=timezone.utc)
if not local_article:
new_article = HelpArticle(
uuid=art_data['uuid'],
slug=art_data['slug'],
title=art_data['title'],
content=art_data['content'],
updated_at=server_updated_at,
last_editor=art_data['last_editor'],
category=art_data.get('category', "General"),
order=art_data.get('order', 0)
)
db.add(new_article)
logger.info(f"Created new article: {art_data['slug']}")
else:
# Update existing article
# If UUID changed in Hub but slug is the same, we update UUID too
local_article.uuid = art_data['uuid']
local_article.slug = art_data['slug']
local_article.title = art_data['title']
local_article.content = art_data['content']
local_article.updated_at = server_updated_at
local_article.last_editor = art_data['last_editor']
local_article.category = art_data.get('category', "General")
local_article.order = art_data.get('order', 0)
logger.info(f"Updated article: {art_data['slug']}")
db.commit() # Commit each article to avoid bulk failure
except Exception as e:
db.rollback()
logger.error(f"Error syncing article {art_data.get('slug', 'unknown')}: {e}")
# Download assets after bulk update (Polling)
from .utils import download_file_from_hub, sync_assets_from_content
for art_data in articles_data:
# art_data contains the virtual fields because it was dumped via HelpArticleInDB
if "file_url" in art_data and art_data['file_url']:
download_file_from_hub(art_data['file_url'])
sync_assets_from_content(art_data.get('content', ''))
logger.info("Polling sync completed successfully.")
else:
logger.error(f"Polling failed: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"Error in sync_from_hub_task: {e}")
finally:
db.close()

View File

@@ -0,0 +1,111 @@
import logging
import mimetypes
import os
import re
from pathlib import Path
from typing import Optional
import httpx
from core.config import settings
from core.s3_keys import SYSTEM_HELP_PREFIX
from core.storage_s3 import object_exists, put_object_bytes
logger = logging.getLogger(__name__)
def _asset_url_to_s3_key(asset_url: str) -> Optional[str]:
"""Deriva la clave S3 bajo system/help/ a partir de una URL de artículo."""
if "/help-center/files/" in asset_url:
rel = asset_url.split("/help-center/files/", 1)[1].lstrip("/")
if ".." in rel:
return None
return f"{SYSTEM_HELP_PREFIX}{rel}"
u = asset_url.replace("/api/uploads/", "uploads/")
if u.startswith("/"):
u = u[1:]
if u.startswith("uploads/help/"):
return f"{SYSTEM_HELP_PREFIX}{u[len('uploads/help/') :]}"
return None
def download_file_from_hub(relative_path: str) -> bool:
"""
Descarga un asset del Hub y lo guarda en MinIO (system/help/...) o en disco si no hay almacenamiento S3 activo.
relative_path: URL parcial, p. ej. '/api/uploads/help/x.png' o '/api/v1/core/help-center/files/pdfs/x.pdf'
"""
if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""':
return False
key = _asset_url_to_s3_key(relative_path)
if not key:
logger.warning("download_file_from_hub: could not map URL to S3 key: %s", relative_path)
return False
if settings.use_s3_object_storage and object_exists(key):
logger.info("S3 object %s already exists, skipping download.", key)
return True
base_url = settings.CENTRAL_SERVER_URL.split("/v1/")[0]
if "/help-center/files/" in relative_path:
rel = relative_path.split("/help-center/files/", 1)[1].lstrip("/")
hub_file_url = f"{base_url.rstrip('/')}/api/v1/core/help-center/files/{rel}"
else:
clean_path = relative_path.replace("/api/uploads/", "uploads/")
if clean_path.startswith("/"):
clean_path = clean_path[1:]
hub_file_url = f"{base_url.rstrip('/')}/{clean_path}"
logger.info("Downloading asset from Hub: %s", hub_file_url)
try:
with httpx.Client() as client:
response = client.get(hub_file_url, timeout=30.0)
if response.status_code != 200:
logger.warning(
"Failed to download %s: Status %s URL: %s",
relative_path,
response.status_code,
hub_file_url,
)
return False
body = response.content
except Exception as e:
logger.error("Error downloading %s: %s", relative_path, str(e))
return False
if settings.use_s3_object_storage:
rel = key[len(SYSTEM_HELP_PREFIX) :]
ct = mimetypes.guess_type(rel)[0] or "application/octet-stream"
try:
put_object_bytes(key, body, content_type=ct)
logger.info("Stored hub asset in S3: %s", key)
return True
except Exception as e:
logger.error("S3 put failed for %s: %s", key, e)
return False
rel = key[len(SYSTEM_HELP_PREFIX) :]
local_path = Path("uploads/help") / rel
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_bytes(body)
logger.info("Stored hub asset locally: %s", local_path)
return True
def sync_assets_from_content(content: str):
"""Parsea markdown y descarga imágenes referenciadas (rutas legacy y nuevas)."""
if not content:
return
patterns = [
r'!\[.*?\]\((/api/uploads/.*?)\)',
r'!\[.*?\]\((/api/v1/core/help-center/files/.*?)\)',
]
seen = set()
for pattern in patterns:
for asset_url in re.findall(pattern, content):
if asset_url in seen:
continue
seen.add(asset_url)
download_file_from_hub(asset_url)

View File

@@ -0,0 +1,51 @@
"""DTOs para el módulo de códigos de invitación."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class CreateInviteCodeDTO(BaseModel):
company_id: Optional[int] = Field(
None, description="Empresa destino (None = cualquier empresa del tenant)"
)
role: str = Field("user", description="Rol asignado al canjear el código")
max_uses: Optional[int] = Field(None, description="Usos máximos (None = ilimitado)")
expires_at: Optional[datetime] = Field(None, description="Expiración (None = sin expiración)")
class InviteCodeResponseDTO(BaseModel):
id: int
code: str
tenant_slug: str
company_id: Optional[int]
role: str
max_uses: Optional[int]
uses_count: int
expires_at: Optional[datetime]
is_active: bool
created_by: str
created_at: datetime
class Config:
from_attributes = True
class ValidateInviteCodeResponseDTO(BaseModel):
code: str
tenant_slug: str
company_id: Optional[int]
role: str
remaining_uses: Optional[int] = Field(
None, description="Usos restantes; None = ilimitado"
)
expires_at: Optional[datetime]
class ConsumeInviteCodeResponseDTO(BaseModel):
success: bool
message: str
tenant_slug: str
company_id: Optional[int]
role: str

View File

@@ -0,0 +1,49 @@
"""Modelo de código de invitación reutilizable para registro."""
from datetime import datetime
from typing import Optional
from api.v1.common.base_models import BaseTimestampMixin
from core.database import Base
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
class InviteCode(Base, BaseTimestampMixin):
"""
Código corto multiuso para invitar usuarios a un tenant/empresa.
A diferencia de InviteToken (único por email), un InviteCode es
compartible: se distribuye como cadena de 8 chars y puede
ser canjeado por múltiples usuarios hasta agotar max_uses.
"""
__tablename__ = "invite_codes"
__table_args__ = {"schema": "core", "extend_existing": True}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# Código legible generado automáticamente (8 chars, sin ambigüedad 0/O/I/l)
code: Mapped[str] = mapped_column(String(16), unique=True, nullable=False, index=True)
# Tenant destino — el usuario debe unirse a este workspace en el Hub
tenant_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
# Empresa destino específica (None = cualquier empresa del tenant)
company_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
# Rol con el que se provisiona el usuario al canjear
role: Mapped[str] = mapped_column(String(50), nullable=False, server_default="user")
# Control de uso
max_uses: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
uses_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0", default=0)
# Expiración (None = sin expiración)
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# keycloak_user_id del admin que generó el código
created_by: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="true", default=True
)

View File

@@ -0,0 +1,149 @@
"""Rutas para gestión de códigos de invitación."""
import logging
from typing import List, Optional
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, Query
from fastapi.security import HTTPBearer
from sqlalchemy.orm import Session
from .dto import (
ConsumeInviteCodeResponseDTO,
CreateInviteCodeDTO,
InviteCodeResponseDTO,
ValidateInviteCodeResponseDTO,
)
from .service import InviteCodeService
router = APIRouter(prefix="/invite-codes", tags=["Invite Codes"])
_bearer = HTTPBearer()
logger = logging.getLogger(__name__)
@router.post("", response_model=InviteCodeResponseDTO, status_code=201)
async def create_invite_code(
data: CreateInviteCodeDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
credentials=Depends(_bearer),
):
"""
Genera un código de invitación reutilizable.
Requiere permiso user.create sobre la empresa (o ser admin del tenant).
"""
company_id = data.company_id
if company_id is not None:
validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["user.create"],
)
else:
# Invitación a nivel tenant: solo roles admin del tenant
roles = set(current_user.get("roles") or [])
if "admin" not in roles and "hub_admin" not in roles:
from fastapi import HTTPException
raise HTTPException(
status_code=403,
detail="Se requiere rol admin para crear invitaciones de nivel tenant",
)
tenant_slug: str = current_user.get("tenant_slug") or ""
created_by: str = current_user.get("sub") or ""
service = InviteCodeService(db)
return await service.create_code(
data=data,
created_by=created_by,
tenant_slug=tenant_slug,
user_access_token=credentials.credentials,
)
@router.get("", response_model=List[InviteCodeResponseDTO])
def list_invite_codes(
company_id: Optional[int] = Query(None, description="Filtrar por empresa"),
include_inactive: bool = Query(False, description="Incluir códigos inactivos/agotados"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Lista los códigos de invitación del tenant.
Filtra opcionalmente por empresa.
"""
if company_id is not None:
validate_access_to_resource(
db,
company_id,
current_user,
required_permissions=["user.create"],
)
else:
roles = set(current_user.get("roles") or [])
if "admin" not in roles and "hub_admin" not in roles:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Se requiere rol admin")
tenant_slug: str = current_user.get("tenant_slug") or ""
service = InviteCodeService(db)
return service.list_codes(
tenant_slug=tenant_slug,
company_id=company_id,
include_inactive=include_inactive,
)
@router.delete("/{code}", status_code=204)
def revoke_invite_code(
code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Revoca (desactiva) un código de invitación."""
roles = set(current_user.get("roles") or [])
if "admin" not in roles and "hub_admin" not in roles:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Se requiere rol admin")
tenant_slug: str = current_user.get("tenant_slug") or ""
service = InviteCodeService(db)
service.revoke_code(code=code, tenant_slug=tenant_slug)
@router.get("/validate/{code}", response_model=ValidateInviteCodeResponseDTO)
def validate_invite_code(
code: str,
db: Session = Depends(get_core_db),
):
"""
Valida un código de invitación sin consumirlo.
Endpoint público — no requiere autenticación.
"""
service = InviteCodeService(db)
return service.validate(code=code)
@router.post("/consume/{code}", response_model=ConsumeInviteCodeResponseDTO)
def consume_invite_code(
code: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Canjea el código: incrementa el contador de usos y crea la relación
UserTenant (usuario ↔ empresa) si el código tiene company_id definido.
Requiere autenticación.
"""
keycloak_user_id: str = current_user.get("sub") or ""
tenant_id: int = current_user.get("tenant_id") or 0
service = InviteCodeService(db)
return service.consume(
code=code,
keycloak_user_id=keycloak_user_id,
tenant_id=tenant_id,
)

View File

@@ -0,0 +1,313 @@
"""Servicio de códigos de invitación reutilizables."""
import logging
import random
import string
from datetime import datetime, timezone
from typing import List, Optional
import httpx
from fastapi import HTTPException
from sqlalchemy.orm import Session
from core.config import settings
from .dto import (
ConsumeInviteCodeResponseDTO,
CreateInviteCodeDTO,
InviteCodeResponseDTO,
ValidateInviteCodeResponseDTO,
)
from .models import InviteCode
logger = logging.getLogger(__name__)
# Charset sin caracteres ambiguos (0/O/I/l/1)
_CODE_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
_CODE_LENGTH = 8
def _generate_code() -> str:
return "".join(random.choices(_CODE_CHARSET, k=_CODE_LENGTH))
def _is_valid(invite: InviteCode) -> bool:
"""True si el código es canjeable en este momento."""
if not invite.is_active:
return False
if invite.max_uses is not None and invite.uses_count >= invite.max_uses:
return False
if invite.expires_at and invite.expires_at < datetime.now(timezone.utc):
return False
return True
class InviteCodeService:
def __init__(self, db: Session):
self.db = db
async def create_code(
self,
data: CreateInviteCodeDTO,
created_by: str,
tenant_slug: str,
user_access_token: str = "",
) -> InviteCodeResponseDTO:
from api.v1.modules.core.tenants.models import Tenant
tenant = (
self.db.query(Tenant)
.filter(Tenant.slug == tenant_slug, Tenant.is_active == True)
.first()
)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant no encontrado")
if data.company_id is not None:
# Implementa la validación de company con tu modelo de compañía.
company = None
if not company:
raise HTTPException(
status_code=404,
detail="Empresa no encontrada o no pertenece al tenant",
)
# Genera código único; reintenta si hay colisión (improbable)
for _ in range(5):
code = _generate_code()
if not self.db.query(InviteCode).filter(InviteCode.code == code).first():
break
else:
raise HTTPException(
status_code=500,
detail="No se pudo generar un código único, intenta de nuevo",
)
invite = InviteCode(
code=code,
tenant_slug=tenant_slug,
company_id=data.company_id,
role=data.role,
max_uses=data.max_uses,
uses_count=0,
expires_at=data.expires_at,
created_by=created_by,
is_active=True,
)
self.db.add(invite)
self.db.commit()
self.db.refresh(invite)
# Registrar el mismo código en el Hub para que funcione en workspace /join
await self._sync_code_to_hub(
code=code,
tenant_slug=tenant_slug,
data=data,
user_access_token=user_access_token,
)
return InviteCodeResponseDTO.model_validate(invite)
async def _sync_code_to_hub(
self,
code: str,
tenant_slug: str,
data: CreateInviteCodeDTO,
user_access_token: str,
) -> None:
"""
Crea el mismo código en Hub's workspace_invite_codes con allowed_systems=['a76'].
Best-effort: si falla, el código sigue válido en A76 pero no en workspace.
"""
if not user_access_token:
logger.warning(
"[invite_code] Sin token para sincronizar '%s' al Hub — "
"el código NO funcionará en workspace /join",
code,
)
return
hub_url = getattr(settings, "HUB_URL", "").rstrip("/")
if not hub_url:
logger.warning("[invite_code] HUB_URL no configurado — código '%s' no sincronizado", code)
return
payload: dict = {
"code": code,
"allowed_systems": [],
"role": data.role,
}
if data.max_uses is not None:
payload["max_uses"] = data.max_uses
if data.expires_at is not None:
payload["expires_at"] = data.expires_at.isoformat()
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{hub_url}/api/v1/hub/invite-codes/{tenant_slug}",
json=payload,
headers={"Authorization": f"Bearer {user_access_token}"},
)
if resp.status_code in (200, 201):
logger.info(
"[invite_code] Código '%s' sincronizado al Hub (tenant=%s)", code, tenant_slug
)
elif resp.status_code == 409:
logger.info(
"[invite_code] Código '%s' ya existe en Hub (tenant=%s) — OK", code, tenant_slug
)
else:
logger.error(
"[invite_code] Hub sync falló code='%s' status=%s body=%s",
code, resp.status_code, resp.text[:300],
)
except Exception as exc:
logger.error("[invite_code] Hub sync excepción code='%s': %s", code, exc)
def list_codes(
self,
tenant_slug: str,
company_id: Optional[int] = None,
include_inactive: bool = False,
) -> List[InviteCodeResponseDTO]:
q = self.db.query(InviteCode).filter(InviteCode.tenant_slug == tenant_slug)
if company_id is not None:
q = q.filter(InviteCode.company_id == company_id)
if not include_inactive:
q = q.filter(InviteCode.is_active == True)
invites = q.order_by(InviteCode.created_at.desc()).all()
return [InviteCodeResponseDTO.model_validate(i) for i in invites]
def revoke_code(self, code: str, tenant_slug: str) -> None:
invite = (
self.db.query(InviteCode)
.filter(InviteCode.code == code, InviteCode.tenant_slug == tenant_slug)
.first()
)
if not invite:
raise HTTPException(status_code=404, detail="Código de invitación no encontrado")
invite.is_active = False
self.db.commit()
def validate(self, code: str) -> ValidateInviteCodeResponseDTO:
"""Valida el código sin consumirlo. Devuelve 403 genérico si no es válido."""
invite = self.db.query(InviteCode).filter(InviteCode.code == code).first()
if not invite or not _is_valid(invite):
raise HTTPException(
status_code=403,
detail="Código de invitación inválido o expirado",
)
remaining: Optional[int] = None
if invite.max_uses is not None:
remaining = invite.max_uses - invite.uses_count
return ValidateInviteCodeResponseDTO(
code=invite.code,
tenant_slug=invite.tenant_slug,
company_id=invite.company_id,
role=invite.role,
remaining_uses=remaining,
expires_at=invite.expires_at,
)
def consume(
self,
code: str,
keycloak_user_id: str,
tenant_id: int,
) -> ConsumeInviteCodeResponseDTO:
"""
Canjea el código:
- Incrementa uses_count.
- Si company_id está definido, crea UserTenant (usuario ↔ empresa).
- Desactiva el código si se agotaron los usos.
"""
invite = self.db.query(InviteCode).filter(InviteCode.code == code).first()
if not invite or not _is_valid(invite):
raise HTTPException(
status_code=403,
detail="Código de invitación inválido o expirado",
)
if invite.company_id is not None:
self._ensure_user_tenant(
keycloak_user_id=keycloak_user_id,
tenant_id=tenant_id,
company_id=invite.company_id,
role=invite.role,
)
invite.uses_count += 1
if invite.max_uses is not None and invite.uses_count >= invite.max_uses:
invite.is_active = False
self.db.commit()
logger.info(
"[invite_code] canjeado code=%s user=%s company_id=%s uses=%d/%s",
invite.code,
keycloak_user_id,
invite.company_id,
invite.uses_count,
invite.max_uses or "",
)
return ConsumeInviteCodeResponseDTO(
success=True,
message="Código canjeado correctamente",
tenant_slug=invite.tenant_slug,
company_id=invite.company_id,
role=invite.role,
)
def _ensure_user_tenant(
self,
keycloak_user_id: str,
tenant_id: int,
company_id: int,
role: str,
) -> None:
"""Crea la fila UserTenant si el usuario aún no tiene acceso a la empresa."""
from sqlalchemy.exc import IntegrityError
from api.v1.modules.core.user_tenant.models import UserTenant
existing = (
self.db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant_id,
UserTenant.company_id == company_id,
)
.first()
)
if existing:
if not existing.is_active:
existing.is_active = True
existing.role = role
self.db.commit()
return
user_tenant = UserTenant(
keycloak_user_id=keycloak_user_id,
tenant_id=tenant_id,
company_id=company_id,
role=role,
is_active=True,
)
self.db.add(user_tenant)
try:
self.db.commit()
except IntegrityError:
self.db.rollback()
logger.warning(
"[invite_code] race: UserTenant ya existe user=%s company=%d",
keycloak_user_id,
company_id,
)

View File

@@ -0,0 +1,32 @@
"""DTOs para el módulo de invitaciones."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
class CreateInviteDTO(BaseModel):
email: EmailStr
company_id: int
role_id: int
class InviteResponseDTO(BaseModel):
id: int
email: str
role: str
expires_at: datetime
invite_url: str
created_at: datetime
class Config:
from_attributes = True
class InviteValidationResult(BaseModel):
email: str
role: str
invite_id: int
tenant_slug: str
company_id: Optional[int] = None

View File

@@ -0,0 +1,42 @@
"""Modelo de token de invitación local para registro de usuarios."""
from datetime import datetime
from typing import Optional
from api.v1.common.base_models import BaseTimestampMixin
from core.database import Base
from sqlalchemy import DateTime, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
class InviteToken(Base, BaseTimestampMixin):
"""
Token de invitación de un solo uso para registro de usuarios.
El token en claro NUNCA se almacena; solo su hash SHA-256.
"""
__tablename__ = "invite_tokens"
__table_args__ = {"schema": "core", "extend_existing": True}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
# sha256(token_plain) — índice único
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
tenant_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(50), nullable=False, server_default="user")
# keycloak_user_id del admin que generó la invitación
created_by: Mapped[str] = mapped_column(String(255), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
product_ids: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
# Específico de Anexo76: empresa destino para crear UserTenant
company_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Token generado en el Hub (para la URL de registro del workspace)
hub_invite_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)

View File

@@ -0,0 +1,49 @@
"""Rutas para gestión de invitaciones de usuarios."""
import logging
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, Request
from fastapi.security import HTTPBearer
from sqlalchemy.orm import Session
from .dto import CreateInviteDTO, InviteResponseDTO
from .service import InviteService
router = APIRouter(prefix="/invites", tags=["Invites"])
_bearer = HTTPBearer()
logger = logging.getLogger(__name__)
@router.post("", response_model=InviteResponseDTO, status_code=201)
async def create_invite(
data: CreateInviteDTO,
request: Request,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
credentials=Depends(_bearer),
):
"""
Genera un token de invitación para que un usuario externo se registre.
Requiere permiso user.create. Usa el token del usuario actual para crear
el invite en el Hub — no requiere credenciales de hub_admin.
"""
validate_access_to_resource(
db,
data.company_id,
current_user,
required_permissions=["user.create"],
)
tenant_slug: str = current_user.get("tenant_slug") or ""
created_by: str = current_user.get("sub") or ""
service = InviteService(db)
return await service.create_invite(
data=data,
created_by=created_by,
tenant_slug=tenant_slug,
user_access_token=credentials.credentials,
)

View File

@@ -0,0 +1,282 @@
"""Servicio de invitaciones de usuarios."""
import hashlib
import logging
import secrets
import ssl
from datetime import datetime, timedelta, timezone
from typing import Optional
import aiosmtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from fastapi import HTTPException
from sqlalchemy.orm import Session
from core.config import settings
from .dto import CreateInviteDTO, InviteResponseDTO, InviteValidationResult
from .models import InviteToken
logger = logging.getLogger(__name__)
INVITE_TTL_HOURS = 48
def _hash_token(token_plain: str) -> str:
return hashlib.sha256(token_plain.encode()).hexdigest()
def _extract_token_from_url(url: str) -> Optional[str]:
"""Extract invite_token query param from a URL string."""
from urllib.parse import urlparse, parse_qs
parsed = urlparse(url)
params = parse_qs(parsed.query)
tokens = params.get("invite_token", [])
return tokens[0] if tokens else None
class InviteService:
def __init__(self, db: Session):
self.db = db
async def create_invite(
self,
data: CreateInviteDTO,
created_by: str,
tenant_slug: str,
user_access_token: str = "",
) -> InviteResponseDTO:
import httpx
from api.v1.modules.core.tenants.models import Tenant
tenant = (
self.db.query(Tenant)
.filter(Tenant.slug == tenant_slug, Tenant.is_active == True)
.first()
)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant no encontrado")
token_plain = secrets.token_urlsafe(32)
token_hash = _hash_token(token_plain)
expires_at = datetime.now(timezone.utc) + timedelta(hours=INVITE_TTL_HOURS)
from api.v1.modules.core.permissions.models import CompanyRole
company_role = (
self.db.query(CompanyRole)
.filter(
CompanyRole.id == data.role_id,
CompanyRole.company_id == data.company_id,
CompanyRole.is_active == True,
)
.first()
)
if not company_role:
raise HTTPException(status_code=404, detail="Rol no encontrado")
# Crear invite en el Hub usando el token del usuario actual.
# El usuario debe tener role='admin' en su tenant dentro del Hub.
# No se requieren credenciales de hub_admin — sin secretos en el .env del cliente.
hub_invite_token: Optional[str] = None
invite_url: str = ""
if not user_access_token:
logger.error(
"[invite] user_access_token vacío — no se puede crear el invite en el Hub. "
"tenant=%s email=%s",
tenant_slug,
data.email,
)
else:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
hub_resp = await client.post(
f"{settings.HUB_URL}api/v1/hub/invites",
json={"email": str(data.email), "tenant_slug": tenant_slug},
headers={"Authorization": f"Bearer {user_access_token}"},
)
if hub_resp.status_code in (200, 201):
hub_data = hub_resp.json()
hub_invite_token = hub_data.get("invite_token") or _extract_token_from_url(hub_data.get("invite_url", ""))
invite_url = hub_data.get("invite_url", "")
else:
logger.error(
"[invite] Hub invite creation falló: status=%s body=%s tenant=%s email=%s",
hub_resp.status_code,
hub_resp.text[:300],
tenant_slug,
data.email,
)
except Exception as exc:
logger.error("[invite] Hub invite creation excepción (non-blocking): %s", exc)
# Fallback: URL del workspace (Hub) si la creación de invitación en Hub falló
if not invite_url:
hub_base = settings.HUB_URL.rstrip("/")
invite_url = (
f"{hub_base}/register"
f"?invite_token={token_plain}"
f"&tenant={tenant_slug}"
f"&email={data.email}"
)
invite = InviteToken(
token_hash=token_hash,
tenant_slug=tenant_slug,
email=str(data.email),
role=company_role.code,
created_by=created_by,
expires_at=expires_at,
company_id=data.company_id,
hub_invite_token=hub_invite_token,
)
self.db.add(invite)
self.db.commit()
self.db.refresh(invite)
# Enviar email (best-effort)
try:
await self._send_invite_email(
to_email=str(data.email),
tenant_name=tenant.name,
invite_url=invite_url,
)
except Exception as exc:
logger.warning(
"Invite email send failed (non-blocking): %s — invite_url=%s",
exc,
invite_url,
)
return InviteResponseDTO(
id=invite.id,
email=invite.email,
role=invite.role,
expires_at=invite.expires_at,
invite_url=invite_url,
created_at=invite.created_at,
)
def validate(
self,
token_plain: str,
tenant_slug: str,
email: Optional[str] = None,
) -> InviteValidationResult:
"""Valida el token sin consumirlo. Lanza 403 genérico por seguridad."""
token_hash = _hash_token(token_plain)
now = datetime.now(timezone.utc)
invite = (
self.db.query(InviteToken)
.filter(
InviteToken.token_hash == token_hash,
InviteToken.tenant_slug == tenant_slug,
InviteToken.used_at.is_(None),
InviteToken.expires_at > now,
)
.first()
)
if not invite:
raise HTTPException(
status_code=403,
detail="Token de invitación inválido o expirado",
)
if email and invite.email.lower() != email.lower():
raise HTTPException(
status_code=403,
detail="Token de invitación inválido o expirado",
)
return InviteValidationResult(
email=invite.email,
role=invite.role,
invite_id=invite.id,
tenant_slug=invite.tenant_slug,
company_id=invite.company_id,
)
def consume_by_id(self, invite_id: int) -> None:
invite = self.db.query(InviteToken).filter(InviteToken.id == invite_id).first()
if invite:
invite.used_at = datetime.now(timezone.utc)
self.db.commit()
async def _send_invite_email(
self,
to_email: str,
tenant_name: str,
invite_url: str,
) -> None:
msg = MIMEMultipart("alternative")
msg["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>"
msg["To"] = to_email
msg["Subject"] = f"Invitación para unirse a {tenant_name} en Mi Aplicación"
html = f"""
<html>
<body style="font-family: Arial, sans-serif; background: #f3f4f6; padding: 40px 0;">
<div style="max-width: 600px; margin: 0 auto; background: #ffffff; border-radius: 8px;
overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
<div style="background: #2563eb; padding: 32px 40px;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Mi Aplicación</h1>
<p style="color: #bfdbfe; margin: 8px 0 0;">Sistema de gestión aduanal</p>
</div>
<div style="padding: 40px;">
<h2 style="color: #111827; font-size: 20px; margin-top: 0;">
Te han invitado a {tenant_name}
</h2>
<p style="color: #4b5563; line-height: 1.6;">
Has recibido una invitación para unirte a <strong>{tenant_name}</strong>
en Mi Aplicación. Haz clic en el botón para crear tu cuenta.
</p>
<div style="text-align: center; margin: 32px 0;">
<a href="{invite_url}"
style="display: inline-block; background: #2563eb; color: #ffffff;
text-decoration: none; padding: 14px 32px; border-radius: 6px;
font-weight: 600; font-size: 16px;">
Aceptar invitación
</a>
</div>
<p style="color: #6b7280; font-size: 13px; line-height: 1.5;">
Este enlace es válido por <strong>48 horas</strong> y es de
<strong>un solo uso</strong>.<br>
Si no esperabas esta invitación, puedes ignorar este correo.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 24px 0;">
<p style="color: #9ca3af; font-size: 12px;">
O copia este enlace en tu navegador:<br>
<span style="color: #2563eb; word-break: break-all;">{invite_url}</span>
</p>
</div>
</div>
</body>
</html>
"""
msg.attach(MIMEText(html, "html"))
ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE
if settings.SMTP_PORT == 465:
async with aiosmtplib.SMTP(
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
use_tls=True,
tls_context=ssl_ctx,
) as smtp:
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
await smtp.send_message(msg)
else:
async with aiosmtplib.SMTP(
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
tls_context=ssl_ctx,
) as smtp:
await smtp.starttls(tls_context=ssl_ctx)
await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
await smtp.send_message(msg)

View File

@@ -0,0 +1,7 @@
"""
Módulo de Licenses
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,156 @@
"""
DTOs para módulo de licencias
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class LicensePlanDTO(str, Enum):
"""Planes de licencia"""
FREE = "free"
BASIC = "basic"
PROFESSIONAL = "professional"
ENTERPRISE = "enterprise"
class LicenseStatusDTO(str, Enum):
"""Estados de licencia"""
ACTIVE = "active"
EXPIRED = "expired"
SUSPENDED = "suspended"
PENDING = "pending"
CANCELLED = "cancelled"
class LicenseCreateDTO(BaseModel):
"""DTO para crear una nueva licencia"""
tenant_id: int = Field(..., description="ID del tenant")
plan: LicensePlanDTO = Field(..., description="Plan de licencia")
max_users: int = Field(default=5, ge=1, description="Número máximo de usuarios")
max_storage_gb: int = Field(
default=10, ge=1, description="Almacenamiento máximo en GB"
)
max_monthly_operations: int = Field(
default=1000, ge=1, description="Operaciones mensuales máximas"
)
feature_api_access: bool = Field(default=True)
feature_advanced_reports: bool = Field(default=False)
feature_integrations: bool = Field(default=False)
feature_dedicated_support: bool = Field(default=False)
starts_at: datetime = Field(..., description="Fecha de inicio de vigencia")
expires_at: datetime = Field(..., description="Fecha de expiración")
class Config:
json_schema_extra = {
"example": {
"tenant_id": 1,
"plan": "professional",
"max_users": 20,
"max_storage_gb": 100,
"max_monthly_operations": 10000,
"feature_api_access": True,
"feature_advanced_reports": True,
"feature_integrations": True,
"feature_dedicated_support": False,
"starts_at": "2025-01-01T00:00:00Z",
"expires_at": "2025-12-31T23:59:59Z",
}
}
class LicenseUpdateDTO(BaseModel):
"""DTO para actualizar una licencia"""
plan: Optional[LicensePlanDTO] = None
status: Optional[LicenseStatusDTO] = None
max_users: Optional[int] = Field(None, ge=1)
max_storage_gb: Optional[int] = Field(None, ge=1)
max_monthly_operations: Optional[int] = Field(None, ge=1)
feature_api_access: Optional[bool] = None
feature_advanced_reports: Optional[bool] = None
feature_integrations: Optional[bool] = None
feature_dedicated_support: Optional[bool] = None
expires_at: Optional[datetime] = None
class LicenseResponseDTO(BaseModel):
"""DTO para respuesta de licencia"""
id: int
tenant_id: int
plan: LicensePlanDTO
status: LicenseStatusDTO
max_users: int
max_storage_gb: int
max_monthly_operations: int
feature_api_access: bool
feature_advanced_reports: bool
feature_integrations: bool
feature_dedicated_support: bool
starts_at: datetime
expires_at: datetime
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class LicenseValidationResponseDTO(BaseModel):
"""DTO para respuesta de validación de licencia"""
is_valid: bool
status: LicenseStatusDTO
plan: LicensePlanDTO
expires_at: datetime
reason: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"is_valid": True,
"status": "active",
"plan": "professional",
"expires_at": "2025-12-31T23:59:59Z",
"reason": None,
}
}
class LicenseUsageResponseDTO(BaseModel):
"""DTO para respuesta de uso de licencia"""
tenant_id: int
period_start: datetime
period_end: datetime
active_users: int
storage_used_gb: int
operations_count: int
api_calls_count: int
# Límites actuales
max_users: int
max_storage_gb: int
max_monthly_operations: int
# Porcentajes de uso
users_usage_percent: float
storage_usage_percent: float
operations_usage_percent: float
class Config:
from_attributes = True

View File

@@ -0,0 +1,102 @@
"""
Modelos ORM para gestión de licencias
"""
import enum
from api.v1.common.base_models import TimestampMixin
from core.database import Base
from sqlalchemy import Boolean, Column, DateTime
from sqlalchemy import Enum as SQLEnum
from sqlalchemy import ForeignKey, Integer
class LicensePlan(enum.Enum):
"""Planes de licencia disponibles"""
FREE = "free"
BASIC = "basic"
PROFESSIONAL = "professional"
ENTERPRISE = "enterprise"
class LicenseStatus(enum.Enum):
"""Estados de licencia"""
ACTIVE = "active"
EXPIRED = "expired"
SUSPENDED = "suspended"
PENDING = "pending"
CANCELLED = "cancelled"
class License(Base, TimestampMixin):
"""
Modelo de Licencia - Control de planes y límites por tenant
"""
__tablename__ = "licenses"
__table_args__ = {"schema": "core"}
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(
Integer, ForeignKey("core.tenants.id"), nullable=False, unique=True, index=True
)
# Plan y características
plan = Column(
SQLEnum(LicensePlan),
default=LicensePlan.FREE,
server_default="FREE",
nullable=False,
)
status = Column(
SQLEnum(LicenseStatus),
default=LicenseStatus.PENDING,
server_default="PENDING",
nullable=False,
)
# Límites del plan
max_users = Column(Integer, server_default="5", nullable=False)
max_storage_gb = Column(Integer, server_default="10", nullable=False)
max_monthly_operations = Column(Integer, server_default="1000", nullable=False)
# Features habilitadas (booleans)
feature_api_access = Column(Boolean, default=True, server_default="true")
feature_advanced_reports = Column(Boolean, default=False, server_default="false")
feature_integrations = Column(Boolean, default=False, server_default="false")
feature_dedicated_support = Column(Boolean, default=False, server_default="false")
# Vigencia
starts_at = Column(DateTime(timezone=True), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
def __repr__(self):
return f"<License(tenant_id={self.tenant_id}, plan={self.plan.value}, status={self.status.value})>"
class LicenseUsage(Base, TimestampMixin):
"""
Modelo para tracking de uso de licencia
"""
__tablename__ = "license_usage"
__table_args__ = {"schema": "core"}
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(
Integer, ForeignKey("core.tenants.id"), nullable=False, index=True
)
# Métricas de uso
period_start = Column(DateTime(timezone=True), nullable=False)
period_end = Column(DateTime(timezone=True), nullable=False)
active_users = Column(Integer, default=0, server_default="0")
storage_used_gb = Column(Integer, default=0, server_default="0")
operations_count = Column(Integer, default=0, server_default="0")
api_calls_count = Column(Integer, default=0, server_default="0")
def __repr__(self):
return f"<LicenseUsage(tenant_id={self.tenant_id}, operations={self.operations_count})>"

View File

@@ -0,0 +1,119 @@
"""
Endpoints API para gestión de licencias
"""
from core.database import get_core_db
from core.security import get_current_user, has_role
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from .dto import (
LicenseCreateDTO,
LicenseResponseDTO,
LicenseUpdateDTO,
LicenseUsageResponseDTO,
LicenseValidationResponseDTO,
)
from .service import LicenseService
router = APIRouter(prefix="/licenses")
@router.post("/", response_model=LicenseResponseDTO, status_code=201)
async def create_license(
license_data: LicenseCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin")),
):
"""
Crea una nueva licencia para un tenant
Requiere rol: admin
"""
service = LicenseService(db)
return service.create_license(license_data)
@router.get("/tenant/{tenant_id}", response_model=LicenseResponseDTO)
async def get_license_by_tenant(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene la licencia de un tenant específico
"""
service = LicenseService(db)
license = service.get_license_by_tenant(tenant_id)
if not license:
raise HTTPException(status_code=404, detail="License not found")
return license
@router.put("/tenant/{tenant_id}", response_model=LicenseResponseDTO)
async def update_license(
tenant_id: int,
license_data: LicenseUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin")),
):
"""
Actualiza la licencia de un tenant
Requiere rol: admin
"""
service = LicenseService(db)
license = service.update_license(tenant_id, license_data)
if not license:
raise HTTPException(status_code=404, detail="License not found")
return license
@router.get("/validate/{tenant_id}", response_model=LicenseValidationResponseDTO)
async def validate_license(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Valida si la licencia de un tenant está activa y vigente
"""
service = LicenseService(db)
validation = service.validate_license(tenant_id)
return LicenseValidationResponseDTO(**validation)
@router.get("/usage/{tenant_id}", response_model=LicenseUsageResponseDTO)
async def get_license_usage(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene el uso actual de la licencia de un tenant
"""
service = LicenseService(db)
usage = service.get_usage(tenant_id)
if not usage:
raise HTTPException(status_code=404, detail="License not found")
return usage
@router.get("/my-license", response_model=LicenseResponseDTO)
async def get_my_license(
request: Request,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene la licencia del tenant del usuario actual
"""
tenant_id = getattr(request.state, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in request")
service = LicenseService(db)
license = service.get_license_by_tenant(tenant_id)
if not license:
raise HTTPException(status_code=404, detail="License not found")
return license

View File

@@ -0,0 +1,260 @@
"""
Servicio de lógica de negocio para licencias
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import (
LicenseCreateDTO,
LicenseResponseDTO,
LicenseUpdateDTO,
LicenseUsageResponseDTO,
)
from .models import License, LicensePlan, LicenseStatus, LicenseUsage
logger = logging.getLogger(__name__)
class LicenseService:
"""Servicio para gestión de licencias"""
def __init__(self, db: Session):
self.db = db
def create_license(self, license_data: LicenseCreateDTO) -> LicenseResponseDTO:
"""
Crea una nueva licencia para un tenant
Args:
license_data: Datos de la licencia
Returns:
LicenseResponseDTO
Raises:
HTTPException: Si el tenant ya tiene licencia o hay error
"""
try:
# Verificar que el tenant no tenga ya una licencia
existing = (
self.db.query(License)
.filter(License.tenant_id == license_data.tenant_id)
.first()
)
if existing:
raise HTTPException(
status_code=400,
detail=f"Tenant {license_data.tenant_id} already has a license",
)
# Crear licencia
db_license = License(
tenant_id=license_data.tenant_id,
plan=LicensePlan(license_data.plan.value),
status=LicenseStatus.ACTIVE,
max_users=license_data.max_users,
max_storage_gb=license_data.max_storage_gb,
max_monthly_operations=license_data.max_monthly_operations,
feature_api_access=license_data.feature_api_access,
feature_advanced_reports=license_data.feature_advanced_reports,
feature_integrations=license_data.feature_integrations,
feature_dedicated_support=license_data.feature_dedicated_support,
starts_at=license_data.starts_at,
expires_at=license_data.expires_at,
)
self.db.add(db_license)
self.db.commit()
self.db.refresh(db_license)
return LicenseResponseDTO.model_validate(db_license)
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating license: {str(e)}")
raise HTTPException(status_code=400, detail="Database integrity error")
except HTTPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"Error creating license: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating license")
def get_license_by_tenant(self, tenant_id: int) -> Optional[LicenseResponseDTO]:
"""
Obtiene la licencia de un tenant
Args:
tenant_id: ID del tenant
Returns:
LicenseResponseDTO o None si no existe
"""
license = self.db.query(License).filter(License.tenant_id == tenant_id).first()
if not license:
return None
return LicenseResponseDTO.model_validate(license)
def update_license(
self, tenant_id: int, license_data: LicenseUpdateDTO
) -> Optional[LicenseResponseDTO]:
"""
Actualiza una licencia
Args:
tenant_id: ID del tenant
license_data: Datos a actualizar
Returns:
LicenseResponseDTO actualizado o None si no existe
"""
license = self.db.query(License).filter(License.tenant_id == tenant_id).first()
if not license:
return None
# Actualizar campos proporcionados
update_data = license_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field in ["plan", "status"]:
# Convertir enums
value = LicensePlan(value) if field == "plan" else LicenseStatus(value)
setattr(license, field, value)
try:
self.db.commit()
self.db.refresh(license)
return LicenseResponseDTO.model_validate(license)
except Exception as e:
self.db.rollback()
logger.error(f"Error updating license for tenant {tenant_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating license")
def validate_license(self, tenant_id: int) -> dict:
"""
Valida si la licencia de un tenant está activa y vigente
Args:
tenant_id: ID del tenant
Returns:
Dict con información de validación
"""
license = self.db.query(License).filter(License.tenant_id == tenant_id).first()
if not license:
return {
"is_valid": False,
"status": "not_found",
"plan": None,
"expires_at": None,
"reason": "License not found",
}
now = datetime.now(timezone.utc)
# Verificar estado
if license.status != LicenseStatus.ACTIVE:
return {
"is_valid": False,
"status": license.status.value,
"plan": license.plan.value,
"expires_at": license.expires_at,
"reason": f"License status is {license.status.value}",
}
# Verificar vigencia
if license.expires_at < now:
# Auto-actualizar a expirada
license.status = LicenseStatus.EXPIRED
self.db.commit()
return {
"is_valid": False,
"status": "expired",
"plan": license.plan.value,
"expires_at": license.expires_at,
"reason": "License has expired",
}
# Licencia válida
return {
"is_valid": True,
"status": license.status.value,
"plan": license.plan.value,
"expires_at": license.expires_at,
"reason": None,
}
def get_usage(self, tenant_id: int) -> Optional[LicenseUsageResponseDTO]:
"""
Obtiene el uso actual de la licencia de un tenant
Args:
tenant_id: ID del tenant
Returns:
LicenseUsageResponseDTO o None
"""
license = self.db.query(License).filter(License.tenant_id == tenant_id).first()
if not license:
return None
# Obtener último registro de uso
usage = (
self.db.query(LicenseUsage)
.filter(LicenseUsage.tenant_id == tenant_id)
.order_by(LicenseUsage.created_at.desc())
.first()
)
if not usage:
# Crear registro inicial si no existe
usage = LicenseUsage(
tenant_id=tenant_id,
period_start=datetime.now(timezone.utc),
period_end=datetime.now(timezone.utc),
active_users=0,
storage_used_gb=0,
operations_count=0,
api_calls_count=0,
)
# Calcular porcentajes
users_usage = (
(usage.active_users / license.max_users * 100)
if license.max_users > 0
else 0
)
storage_usage = (
(usage.storage_used_gb / license.max_storage_gb * 100)
if license.max_storage_gb > 0
else 0
)
operations_usage = (
(usage.operations_count / license.max_monthly_operations * 100)
if license.max_monthly_operations > 0
else 0
)
return LicenseUsageResponseDTO(
tenant_id=tenant_id,
period_start=usage.period_start,
period_end=usage.period_end,
active_users=usage.active_users,
storage_used_gb=usage.storage_used_gb,
operations_count=usage.operations_count,
api_calls_count=usage.api_calls_count,
max_users=license.max_users,
max_storage_gb=license.max_storage_gb,
max_monthly_operations=license.max_monthly_operations,
users_usage_percent=round(users_usage, 2),
storage_usage_percent=round(storage_usage, 2),
operations_usage_percent=round(operations_usage, 2),
)

View File

@@ -0,0 +1,324 @@
# Módulo de Permisos Multi-Tenant
Sistema completo de permisos granulares para aplicaciones multi-tenant con FastAPI y SQLAlchemy.
## 📁 Estructura del Módulo
```
backend/api/v1/modules/core/permissions/
├── __init__.py # Exports del módulo
├── models.py # Modelos SQLAlchemy
├── service.py # Lógica de negocio
├── dependencies.py # Dependencias FastAPI
├── schemas.py # Modelos Pydantic (request/response)
└── routes.py # Endpoints de la API
```
## 🎯 Componentes
### **models.py**
Define los modelos de base de datos:
- `Permission` - Permisos del sistema (ej: "invoice.view", "invoice.edit")
- `ClientRole` - Roles personalizados por cliente
- `RolePermission` - Relación roles-permisos
- `UserClientRole` - Asignación usuario-rol-cliente
- `UserClientPermission` - Permisos directos por usuario
### **service.py**
Contiene la clase `PermissionService` con métodos:
- `get_user_permissions()` - Obtiene todos los permisos de un usuario
- `has_permission()` - Verifica un permiso específico
- `has_all_permissions()` - Verifica múltiples permisos (AND)
- `has_any_permission()` - Verifica múltiples permisos (OR)
- `assign_role_to_user()` - Asigna roles a usuarios
- `grant_direct_permission()` - Concede permisos directos
### **dependencies.py**
Dependencias para proteger rutas:
- `PermissionChecker` - Clase para verificar múltiples permisos
- `RequirePermission` - Clase para verificar un solo permiso
- `get_client_id()` - Extrae el ID del cliente del header
- `get_permission_service()` - Proporciona instancia del servicio
- `get_current_user_permissions()` - Devuelve permisos del usuario
### **schemas.py**
Modelos Pydantic para request/response:
- Responses: `PermissionResponse`, `ClientRoleResponse`, `UserPermissionsResponse`, etc.
- Requests: `AssignRoleRequest`, `GrantPermissionRequest`, `CreateRoleRequest`, etc.
### **routes.py**
Endpoints de la API:
- `GET /permissions/me` - Permisos del usuario actual
- `GET /permissions/available` - Lista todos los permisos
- `GET /permissions/roles` - Lista roles del cliente
- `POST /permissions/roles` - Crea un rol
- `POST /permissions/assign-role` - Asigna rol a usuario
- `POST /permissions/grant-permission` - Concede permiso directo
- Ejemplos de rutas protegidas
## 🚀 Uso Rápido
### Importar el módulo
```python
from api.v1.modules.core.permissions import (
Permission,
ClientRole,
PermissionService,
PermissionChecker,
RequirePermission,
router
)
```
### Registrar las rutas
```python
# En backend/api/v1/router.py
from api.v1.modules.core.permissions import router as permissions_router
api_router = APIRouter()
api_router.include_router(permissions_router)
```
### Proteger una ruta con permiso único
```python
from fastapi import APIRouter, Depends
from api.v1.modules.core.permissions import RequirePermission
router = APIRouter()
@router.get("/invoices")
async def list_invoices(
_: None = Depends(RequirePermission("invoice.view"))
):
return {"invoices": [...]}
```
### Proteger con múltiples permisos
```python
from api.v1.modules.core.permissions import PermissionChecker
@router.post("/invoices")
async def create_invoice(
_: None = Depends(PermissionChecker(
["invoice.view", "invoice.create"],
require_all=True # Requiere TODOS
))
):
return {"created": True}
```
### Usar permisos en la lógica
```python
from api.v1.modules.core.permissions import get_current_user_permissions
@router.get("/dashboard")
async def dashboard(
permissions: set = Depends(get_current_user_permissions)
):
widgets = []
if "invoice.view" in permissions:
widgets.append({"type": "invoices", "data": [...]})
return {"widgets": widgets}
```
## 📊 Base de Datos
### Ejecutar migración
```bash
cd backend
alembic upgrade head
```
Esto crea las tablas y permisos iniciales:
- **invoice.*** - view, create, edit, delete, approve
- **user.*** - view, create, edit, delete
- **report.*** - financial.view, admin.view, export
- **roles.*** - view, create, edit, delete, assign
- **permissions.*** - view, grant
## 🔐 Flujo de Autenticación
1. Usuario hace request con token JWT de Keycloak
2. Header `X-Client-ID` indica el cliente/tenant
3. Sistema extrae `user_id` del token
4. Consulta permisos del usuario en ese cliente
5. Valida si tiene el permiso requerido
6. Devuelve 200 OK o 403 Forbidden
## 💡 Ejemplos Prácticos
### Crear un rol personalizado
```python
from api.v1.modules.core.permissions import PermissionService
from core.database import get_db
db = next(get_db())
service = PermissionService(db)
# Crear rol
role = ClientRole(
client_id=1,
name="Contador",
code="accountant",
description="Acceso a módulo contable"
)
db.add(role)
db.commit()
```
### Asignar permisos a un rol
```python
from api.v1.modules.core.permissions.models import RolePermission
# Obtener permisos de facturación
invoice_perms = db.query(Permission).filter(
Permission.module == "invoice"
).all()
# Asignar al rol
for perm in invoice_perms:
role_perm = RolePermission(
client_role_id=role.id,
permission_id=perm.id
)
db.add(role_perm)
db.commit()
```
### Asignar rol a usuario
```python
service.assign_role_to_user(
user_id="user-uuid-from-keycloak",
client_id=1,
role_id=role.id,
assigned_by="admin-uuid"
)
```
### Conceder permiso temporal
```python
from datetime import datetime, timedelta
service.grant_direct_permission(
user_id="user-uuid",
client_id=1,
permission_code="invoice.delete",
assigned_by="admin-uuid",
expires_at=datetime.utcnow() + timedelta(days=7)
)
```
## ⚡ Optimización de Rendimiento
### 1. Caché con Redis
```python
import redis
from functools import lru_cache
redis_client = redis.Redis(host='localhost', port=6379)
def get_cached_permissions(user_id: str, client_id: int) -> set:
cache_key = f"perms:{user_id}:{client_id}"
cached = redis_client.get(cache_key)
if cached:
return set(cached.decode().split(','))
# Consultar DB
service = PermissionService(db)
permissions = service.get_user_permissions(user_id, client_id)
# Cachear por 5 minutos
redis_client.setex(cache_key, 300, ','.join(permissions))
return permissions
```
### 2. Índices de Base de Datos
Ya están definidos en los modelos:
- Índices compuestos para consultas eficientes
- Índices únicos para prevenir duplicados
- Índices en foreign keys
### 3. Query Optimization
El servicio usa JOINs eficientes en lugar de N+1 queries.
## 🧪 Testing
```python
import pytest
from api.v1.modules.core.permissions import PermissionService
from api.v1.modules.core.permissions.models import Permission, ClientRole
def test_user_has_permission_from_role(db_session):
# Setup
perm = Permission(code="invoice.view", module="invoice", action="view")
db_session.add(perm)
role = ClientRole(client_id=1, code="viewer", name="Viewer")
db_session.add(role)
db_session.commit()
# Test
service = PermissionService(db_session)
assert service.has_permission("user-123", 1, "invoice.view")
```
## 📝 Notas Importantes
- **Client ID**: Por defecto se obtiene del header `X-Client-ID`, pero puede adaptarse a subdominios o JWT
- **User ID**: Se extrae del campo `sub` del token JWT de Keycloak
- **Permisos Directos**: Pueden revocar permisos heredados de roles (`is_granted=False`)
- **Soft Delete**: Los roles y permisos se desactivan (`is_active=False`) en lugar de eliminarse
## 🔗 Integración con Keycloak
Los roles globales de Keycloak pueden coexistir con los roles locales:
```python
@router.get("/protected")
async def protected_route(
current_user: dict = Depends(get_current_user),
permissions: set = Depends(get_current_user_permissions)
):
# Verificar rol global de Keycloak
keycloak_roles = current_user.get("realm_access", {}).get("roles", [])
if "super_admin" in keycloak_roles:
# Super admin tiene acceso total
return {"access": "granted", "level": "global"}
# Verificar permisos a nivel de cliente
if "invoice.view" in permissions:
return {"access": "granted", "level": "client"}
raise HTTPException(403, "No access")
```

View File

@@ -0,0 +1,38 @@
"""
Módulo de permisos multi-tenant.
Proporciona modelos, servicios y rutas para gestión de permisos granulares por companye.
"""
from .models import (
Permission,
CompanyRole,
RolePermission,
UserCompanyRole,
UserCompanyPermission,
)
from .service import PermissionService
from .dependencies import (
PermissionChecker,
RequirePermission,
get_permission_service,
get_current_user_permissions,
)
from .routes import router
__all__ = [
# Models
"Permission",
"CompanyRole",
"RolePermission",
"UserCompanyRole",
"UserCompanyPermission",
# Service
"PermissionService",
# Dependencies
"PermissionChecker",
"RequirePermission",
"get_permission_service",
"get_current_user_permissions",
# Router
"router",
]

View File

@@ -0,0 +1,205 @@
import logging
import os
from typing import Optional, Set, Iterable
from core.config import settings
try:
import redis # type: ignore
except Exception: # pragma: no cover - redis is optional in some envs
redis = None # type: ignore
logger = logging.getLogger(__name__)
class PermissionCache:
"""
Caché de permisos basada en Valkey/Redis.
- Clave por combinación (tenant_id, company_id, user_id)
- Guarda el set de códigos de permiso como string CSV
- TTL controlado por configuración (`PERMISSION_CACHE_TTL_SECONDS`)
Todas las operaciones fallan en modo silencioso para no afectar el flujo
principal de la aplicación si Redis/Valkey no está disponible.
"""
KEY_PREFIX = "permissions:v1"
def __init__(self, client: "redis.Redis | None" = None) -> None: # type: ignore[name-defined]
self.ttl_seconds = int(getattr(settings, "PERMISSION_CACHE_TTL_SECONDS", 300) or 300)
enabled_flag = bool(getattr(settings, "PERMISSION_CACHE_ENABLED", True))
# Si redis no está instalado, deshabilitar caché
if redis is None:
self._client = None
self.enabled = False
return
if client is not None:
self._client = client
self.enabled = enabled_flag
return
url = (
os.getenv("VALKEY_URL")
or os.getenv("REDIS_URL")
or getattr(settings, "VALKEY_URL", "redis://valkey:6379/0")
)
try:
# decode_responses=True para trabajar con str en lugar de bytes
self._client = redis.Redis.from_url(url, decode_responses=True)
# Probar conexión rápida (no crítico si falla)
if enabled_flag:
try:
self._client.ping()
self.enabled = True
except Exception:
logger.warning(
"permission_cache_ping_failed",
extra={"url": url},
)
self.enabled = False
else:
self.enabled = False
except Exception as exc:
logger.warning(
"permission_cache_init_failed",
extra={"url": url, "error": str(exc)},
)
self._client = None
self.enabled = False
# ------------------------------------------------------------------
# Helpers de clave
# ------------------------------------------------------------------
def build_permissions_key(
self,
tenant_id: Optional[int],
company_id: int,
user_id: str,
) -> str:
"""
Construye la clave única de caché para un usuario en una compañía.
"""
tenant_part = str(tenant_id) if tenant_id is not None else "global"
return f"{self.KEY_PREFIX}:tenant:{tenant_part}:company:{company_id}:user:{user_id}"
# ------------------------------------------------------------------
# Operaciones de lectura/escritura
# ------------------------------------------------------------------
def get_permissions(
self,
cache_key: str,
**context: object,
) -> Optional[Set[str]]:
"""
Obtiene el set de permisos desde caché.
Devuelve:
- set[str] si hay caché válido
- None si no hay entrada o si la caché está deshabilitada
"""
if not self.enabled or not self._client:
return None
try:
raw = self._client.get(cache_key)
if raw is None:
return None
if not raw:
return set()
return set(raw.split(","))
except Exception as exc:
logger.warning(
"permission_cache_get_failed",
extra={"cache_key": cache_key, "error": str(exc), **context},
)
return None
def set_permissions(
self,
cache_key: str,
permissions: Iterable[str],
**context: object,
) -> None:
"""
Escribe el set de permisos en caché con TTL.
"""
if not self.enabled or not self._client:
return
try:
value = ",".join(sorted(set(permissions)))
self._client.setex(cache_key, self.ttl_seconds, value)
except Exception as exc:
logger.warning(
"permission_cache_set_failed",
extra={"cache_key": cache_key, "error": str(exc), **context},
)
# ------------------------------------------------------------------
# Invalidaciones
# ------------------------------------------------------------------
def _delete_pattern(self, pattern: str) -> None:
"""
Elimina todas las llaves que coincidan con un patrón.
"""
if not self.enabled or not self._client:
return
try:
# scan_iter evita bloquear Redis en grandes keyspaces
keys = list(self._client.scan_iter(match=pattern))
if keys:
self._client.delete(*keys)
except Exception as exc:
logger.warning(
"permission_cache_delete_pattern_failed",
extra={"pattern": pattern, "error": str(exc)},
)
def invalidate_user(
self,
tenant_id: Optional[int],
company_id: int,
user_id: str,
) -> None:
"""
Invalida el caché de permisos para un usuario específico.
"""
if not self.enabled or not self._client:
return
cache_key = self.build_permissions_key(tenant_id, company_id, user_id)
try:
self._client.delete(cache_key)
except Exception as exc:
logger.warning(
"permission_cache_invalidate_user_failed",
extra={
"cache_key": cache_key,
"tenant_id": tenant_id,
"company_id": company_id,
"user_id": user_id,
"error": str(exc),
},
)
def invalidate_company(self, company_id: int) -> None:
"""
Invalida el caché de permisos para todos los usuarios de una compañía.
"""
pattern = f"{self.KEY_PREFIX}:tenant:*:company:{company_id}:user:*"
self._delete_pattern(pattern)
def invalidate_all(self) -> None:
"""
Elimina TODAS las entradas del caché de permisos.
Úsese con precaución (ej. cleanup_cli).
"""
pattern = f"{self.KEY_PREFIX}:*"
self._delete_pattern(pattern)

View File

@@ -0,0 +1,68 @@
"""
Script CLI para LIMPIEZA TOTAL del sistema de permisos.
Borra todos los roles, asignaciones y el catálogo de permisos.
Úselo con precaución.
Uso:
docker exec -it <container> python3 -m api.v1.modules.core.permissions.cleanup_cli
"""
import sys
import os
import logging
from sqlalchemy import text
# Configurar logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
# Asegurar que el backend esté en el path
sys.path.append(os.path.abspath("."))
sys.path.append(os.path.abspath("backend"))
from core.database import CoreSessionLocal
from api.v1.modules.core.permissions.cache import PermissionCache
def run_cleanup():
"""Ejecuta el borrado de tablas en orden de dependencias."""
logger.warning("INICIANDO LIMPIEZA TOTAL DE PERMISOS Y ROLES...")
db = CoreSessionLocal()
try:
# 1. Borrar asignaciones directas de permisos a usuarios
logger.info("Borrando asignaciones directas de usuario...")
db.execute(text("DELETE FROM core.user_company_permissions"))
# 2. Borrar relación entre roles y permisos
logger.info("Borrando mapeo de roles y permisos...")
db.execute(text("DELETE FROM core.role_permissions"))
# 3. Borrar asignación de roles a usuarios
logger.info("Borrando asignación de roles a usuarios...")
db.execute(text("DELETE FROM core.user_company_roles"))
# 4. Borrar los roles mismos
logger.info("Borrando el catálogo de roles...")
db.execute(text("DELETE FROM core.company_roles"))
# 5. Borrar el catálogo base de permisos
logger.info("Borrando el catálogo base de permisos...")
db.execute(text("DELETE FROM core.permissions"))
db.commit()
# Limpiar también el caché de permisos en Valkey
PermissionCache().invalidate_all()
logger.info("=" * 40)
logger.info("LIMPIEZA COMPLETADA CON ÉXITO")
logger.info("El sistema de permisos está ahora en blanco.")
logger.info("=" * 40)
except Exception as e:
db.rollback()
logger.error(f"Error crítico durante la limpieza: {e}")
sys.exit(1)
finally:
db.close()
if __name__ == "__main__":
run_cleanup()

View File

@@ -0,0 +1,205 @@
"""
Dependencias de FastAPI para verificación de permisos multi-tenant.
Proporciona decoradores y funciones para proteger rutas con permisos específicos.
"""
from typing import List, Optional, Callable
from fastapi import Depends, HTTPException, status, Header
from sqlalchemy.orm import Session
from functools import wraps
from core.database import get_core_db
from core.security import get_current_user # Asumiendo que existe esta función
from .service import PermissionService
# Dependencia para obtener el servicio de permisos
def get_permission_service(db: Session = Depends(get_core_db)) -> PermissionService:
"""
Crea una instancia del servicio de permisos con la sesión de base de datos.
"""
return PermissionService(db)
# Clase para verificación de permisos (puede usarse como dependencia)
class PermissionChecker:
"""
Verificador de permisos que puede usarse como dependencia de FastAPI.
Ejemplo de uso:
@app.get("/invoices")
async def list_invoices(
_: None = Depends(PermissionChecker(["invoice.view"]))
):
return {"invoices": [...]}
"""
def __init__(self, required_permissions: List[str], require_all: bool = True):
"""
Args:
required_permissions: Lista de permisos requeridos
require_all: Si True, requiere TODOS los permisos.
Si False, requiere AL MENOS UNO.
"""
self.required_permissions = required_permissions
self.require_all = require_all
async def __call__(
self,
company_id: int,
current_user: dict = Depends(get_current_user),
permission_service: PermissionService = Depends(get_permission_service),
):
"""
Verifica que el usuario tenga los permisos requeridos.
Lanza HTTPException 403 si no tiene permisos.
"""
user_id = current_user.get("sub") or current_user.get("id")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User ID not found in token",
)
# Verificar permisos sobre la compañía
if self.require_all:
has_access = permission_service.has_all_permissions(
user_id=user_id,
company_id=company_id,
permission_codes=self.required_permissions,
)
else:
has_access = permission_service.has_any_permission(
user_id=user_id,
company_id=company_id,
permission_codes=self.required_permissions,
)
if not has_access:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permissions: {', '.join(self.required_permissions)}",
)
return True
# Función alternativa para verificar un solo permiso
class RequirePermission:
"""
Verificador simple para un único permiso.
Ejemplo:
@app.post("/invoices")
async def create_invoice(
_: None = Depends(RequirePermission("invoice.create"))
):
return {"created": True}
"""
def __init__(self, permission_code: str):
self.permission_code = permission_code
async def __call__(
self,
company_id: int,
current_user: dict = Depends(get_current_user),
permission_service: PermissionService = Depends(get_permission_service),
):
user_id = current_user.get("sub") or current_user.get("id")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User ID not found in token",
)
has_permission = permission_service.has_permission(
user_id=user_id,
company_id=company_id,
permission_code=self.permission_code,
)
if not has_permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permission: {self.permission_code}",
)
return True
# Decorador personalizado para aplicar a funciones (opcional)
def require_permissions(*permissions: str, require_all: bool = True):
"""
Decorador para verificar permisos en funciones.
Útil para lógica de negocio fuera de rutas FastAPI.
Ejemplo:
@require_permissions("invoice.edit", "invoice.view")
def update_invoice_logic(invoice_id: int, user_id: str, company_id: int, db: Session):
# Lógica de actualización
pass
"""
def decorator(func: Callable):
@wraps(func)
def wrapper(*args, **kwargs):
# Extraer user_id, company_id y db de los argumentos
user_id = kwargs.get("user_id")
company_id = kwargs.get("company_id")
db = kwargs.get("db")
if not all([user_id, company_id, db]):
raise ValueError(
"Function must receive 'user_id', 'company_id', and 'db' as keyword arguments"
)
# Verificar permisos
permission_service = PermissionService(db)
if require_all:
has_access = permission_service.has_all_permissions(
user_id=user_id,
company_id=company_id,
permission_codes=list(permissions),
)
else:
has_access = permission_service.has_any_permission(
user_id=user_id,
company_id=company_id,
permission_codes=list(permissions),
)
if not has_access:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permissions: {', '.join(permissions)}",
)
return func(*args, **kwargs)
return wrapper
return decorator
# Función helper para obtener permisos del usuario actual
async def get_current_user_permissions(
company_id: int,
current_user: dict = Depends(get_current_user),
permission_service: PermissionService = Depends(get_permission_service),
) -> set:
"""
Devuelve todos los permisos del usuario actual en la compañía.
Útil para endpoints que necesitan conocer los permisos disponibles.
"""
user_id = current_user.get("sub") or current_user.get("id")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User ID not found in token",
)
return permission_service.get_user_permissions(user_id, company_id, use_cache=True)

View File

@@ -0,0 +1,247 @@
"""
Modelos de permisos multi-tenant para el sistema.
Este módulo define el sistema de permisos granular por compañia/tenant.
"""
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import (
String,
Integer,
ForeignKey,
DateTime,
Boolean,
UniqueConstraint,
Index,
)
from sqlalchemy.orm import relationship, Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
# Modelo para permisos del sistema
# Representa acciones específicas como "invoice.view", "invoice.edit", etc.
class Permission(Base, TimestampMixin):
__tablename__ = "permissions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
code: Mapped[str] = mapped_column(
String(100), unique=True, nullable=False, index=True
)
# Código único del permiso (ej: "invoice.view", "user.edit")
description: Mapped[Optional[str]] = mapped_column(String(255))
# Descripción legible del permiso
module: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
# Módulo al que pertenece (ej: "invoice", "user", "report")
action: Mapped[str] = mapped_column(String(50), nullable=False)
# Acción específica (ej: "view", "edit", "delete", "create")
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="true", nullable=False
)
# Permite desactivar permisos sin eliminarlos
__table_args__ = {"schema": "core", "extend_existing": True}
# Relaciones
role_permissions: Mapped[list["RolePermission"]] = relationship(
"RolePermission", back_populates="permission", cascade="all, delete-orphan"
)
user_company_permissions: Mapped[list["UserCompanyPermission"]] = relationship(
"UserCompanyPermission",
back_populates="permission",
cascade="all, delete-orphan",
)
# Modelo para roles personalizados por compañia/tenant
# Cada compañia puede definir sus propios roles con nombres personalizados
class CompanyRole(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "company_roles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
# Nombre del rol (ej: "Administrador", "Contador", "Vendedor")
code: Mapped[str] = mapped_column(String(100), nullable=False)
# Código único del rol dentro del compañia (ej: "admin", "accountant")
description: Mapped[Optional[str]] = mapped_column(String(255))
# Descripción del rol
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="true", nullable=False
)
# Permite desactivar roles sin eliminarlos
# Restricción: el código del rol debe ser único por compañia
__table_args__ = (
UniqueConstraint(
"company_id", "tenant_id", "code", name="uq_company_role_code"
),
Index(
"ix_company_roles_company_id_is_active",
"company_id",
"tenant_id",
"is_active",
),
{"schema": "core", "extend_existing": True},
)
# Relaciones
role_permissions: Mapped[list["RolePermission"]] = relationship(
"RolePermission", back_populates="company_role", cascade="all, delete-orphan"
)
user_company_roles: Mapped[list["UserCompanyRole"]] = relationship(
"UserCompanyRole",
back_populates="company_role",
cascade="all, delete-orphan",
)
# Tabla de relación entre roles de compañia y permisos
# Define qué permisos tiene cada rol
class RolePermission(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "role_permissions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
company_role_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("core.company_roles.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
permission_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("core.permissions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Restricción: un permiso no puede estar duplicado en el mismo rol
__table_args__ = (
UniqueConstraint("company_role_id", "permission_id", name="uq_role_permission"),
Index("ix_role_permissions_composite", "company_role_id", "permission_id"),
{"schema": "core", "extend_existing": True},
)
# Relaciones
company_role: Mapped["CompanyRole"] = relationship(
"CompanyRole", back_populates="role_permissions"
)
permission: Mapped["Permission"] = relationship(
"Permission", back_populates="role_permissions"
)
# Tabla de relación entre usuarios y roles de compañia
# Define qué roles tiene cada usuario en cada compañia
class UserCompanyRole(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "user_company_roles"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
# ID del usuario (puede ser UUID de Keycloak u otro identificador)
company_role_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("core.company_roles.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="true", nullable=False
)
# Permite desactivar asignaciones sin eliminarlas
assigned_by: Mapped[Optional[str]] = mapped_column(String(100))
# ID del usuario que asignó este rol
# Restricción: un usuario no puede tener el mismo rol duplicado en un compañia
__table_args__ = (
UniqueConstraint(
"user_id",
"company_id",
"tenant_id",
"company_role_id",
name="uq_user_company_role",
),
Index(
"ix_user_company_roles_user_company",
"user_id",
"company_id",
"tenant_id",
"is_active",
),
{"schema": "core", "extend_existing": True},
)
# Relaciones
company_role: Mapped["CompanyRole"] = relationship(
"CompanyRole", back_populates="user_company_roles"
)
# Tabla para permisos directos de usuario por compañia (opcional)
# Permite asignar permisos específicos a un usuario sin necesidad de un rol
# Útil para casos excepcionales o permisos temporales
class UserCompanyPermission(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "user_company_permissions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
user_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
# ID del usuario
permission_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("core.permissions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
is_granted: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="true", nullable=False
)
# True = permiso concedido, False = permiso revocado explícitamente
# Permite revocar permisos que vienen de roles
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="true", nullable=False
)
assigned_by: Mapped[Optional[str]] = mapped_column(String(100))
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
# Permite permisos temporales con fecha de expiración
# Restricción: un usuario no puede tener el mismo permiso duplicado en un compañia
__table_args__ = (
UniqueConstraint(
"user_id",
"company_id",
"tenant_id",
"permission_id",
name="uq_user_company_permission",
),
Index(
"ix_user_company_permissions_composite",
"user_id",
"company_id",
"tenant_id",
"is_active",
),
{"schema": "core", "extend_existing": True},
)
# Relaciones
permission: Mapped["Permission"] = relationship(
"Permission", back_populates="user_company_permissions"
)

View File

@@ -0,0 +1,101 @@
"""
Registro centralizado para la modulación de permisos.
Permite que cada módulo registre sus propios permisos de forma dinámica.
"""
import logging
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class PermissionDefinition:
"""Representa la definición de un permiso en un módulo."""
code: str
description: Optional[str] = None
module: Optional[str] = None
action: Optional[str] = None
is_active: bool = True
def __post_init__(self):
"""Lógica de autocompletado para evitar redundancia."""
# Si el código tiene el formato "modulo.sub.accion" o "modulo.accion"
parts = self.code.split(".")
# Extraer módulo si no se especificó
if not self.module and len(parts) > 1:
self.module = parts[0]
elif not self.module:
self.module = "system" # Default fallback
# Extraer acción si no se especificó (es la última parte del código)
if not self.action and len(parts) > 1:
self.action = parts[-1]
elif not self.action:
self.action = "view" # Default fallback
class PermissionRegistry:
"""
Registro Singleton para permisos de la aplicación.
Cada módulo de la API debe importar este registro y dar de alta sus permisos.
"""
_instance = None
_permissions: Dict[str, PermissionDefinition] = {}
def __new__(cls):
if cls._instance is None:
cls._instance = super(PermissionRegistry, cls).__new__(cls)
cls._permissions = {}
return cls._instance
@classmethod
def register(cls,
code: str,
description: Optional[str] = None,
module: Optional[str] = None,
action: Optional[str] = None) -> None:
"""
Registra un nuevo permiso en el sistema.
"""
if code in cls._permissions:
logger.debug(f"Permiso {code} ya está registrado, actualizando metadatos.")
cls._permissions[code] = PermissionDefinition(
code=code,
description=description,
module=module,
action=action
)
@classmethod
def register_many(cls, permissions_list: List[tuple]) -> None:
"""
Registra múltiples permisos desde una lista de tuplas.
Útil para migrar seeds estáticos.
"""
for item in permissions_list:
if len(item) == 2: # (code, description)
cls.register(code=item[0], description=item[1])
elif len(item) >= 3: # (code, description, module, ...)
cls.register(
code=item[0],
description=item[1],
module=item[2],
action=item[3] if len(item) > 3 else None
)
@classmethod
def get_all(cls) -> List[PermissionDefinition]:
"""Retorna todos los permisos registrados."""
return list(cls._permissions.values())
@classmethod
def get_by_module(cls, module_name: str) -> List[PermissionDefinition]:
"""Retorna los permisos de un módulo específico."""
return [p for p in cls._permissions.values() if p.module == module_name]
# Instancia global para facilitar el acceso
registry = PermissionRegistry()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,331 @@
"""
Esquemas Pydantic para el módulo de permisos.
Define los modelos de request/response para las APIs de permisos.
"""
from typing import List, Optional
from datetime import datetime
from pydantic import BaseModel, Field, ConfigDict
# ============================================================================
# SCHEMAS DE RESPONSE
# ============================================================================
class PermissionResponse(BaseModel):
"""Esquema de respuesta para un permiso individual."""
id: int
code: str = Field(..., description="Código único del permiso (ej: 'invoice.view')")
description: Optional[str] = Field(None, description="Descripción del permiso")
module: str = Field(..., description="Módulo al que pertenece (ej: 'invoice')")
action: str = Field(..., description="Acción específica (ej: 'view', 'edit')")
is_active: bool = Field(..., description="Si el permiso está activo")
model_config = ConfigDict(from_attributes=True)
class CompanyRoleResponse(BaseModel):
"""Esquema de respuesta para un rol de companye."""
id: int
company_id: int = Field(..., description="ID del companye al que pertenece el rol")
name: str = Field(..., description="Nombre del rol (ej: 'Administrador')")
code: str = Field(..., description="Código del rol (ej: 'admin')")
description: Optional[str] = Field(None, description="Descripción del rol")
is_active: bool = Field(..., description="Si el rol está activo")
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class CompanyRoleWithPermissionsResponse(CompanyRoleResponse):
"""Esquema de respuesta para un rol con sus permisos incluidos."""
permissions: List[PermissionResponse] = Field(
default_factory=list, description="Lista de permisos asignados a este rol"
)
class UserPermissionsResponse(BaseModel):
"""Esquema de respuesta para los permisos de un usuario."""
user_id: str = Field(..., description="ID del usuario")
company_id: int = Field(..., description="ID del companye")
tenant_id: Optional[int] = Field(
default=None,
description=(
"ID del tenant resuelto en backend para esta compañía. Permite "
"al frontend dejar de leer tenant_id desde claims del JWT."
),
)
permissions: List[str] = Field(
default_factory=list, description="Lista de códigos de permisos del usuario"
)
roles: List[str] = Field(
default_factory=list, description="Lista de nombres de roles del usuario"
)
allowed_systems: List[str] = Field(
default_factory=list,
description="Sistemas a los que el usuario tiene acceso: fixed_asset, inventory",
)
class UserCompanyRoleResponse(BaseModel):
"""Esquema de respuesta para la asignación de rol a usuario."""
id: int
user_id: str
company_id: int
company_role_id: int
is_active: bool
created_at: datetime
assigned_by: Optional[str] = None
company_role: Optional[CompanyRoleResponse] = Field(None, description="Información del rol asignado")
model_config = ConfigDict(from_attributes=True)
class UserCompanyPermissionResponse(BaseModel):
"""Esquema de respuesta para un permiso directo de usuario."""
id: int
user_id: str
company_id: int
permission_id: int
permission_code: Optional[str] = None
is_granted: bool = Field(
..., description="True si está concedido, False si está revocado"
)
is_active: bool
created_at: datetime
assigned_by: Optional[str] = None
expires_at: Optional[datetime] = Field(
None, description="Fecha de expiración del permiso"
)
model_config = ConfigDict(from_attributes=True)
# ============================================================================
# SCHEMAS DE REQUEST
# ============================================================================
class AssignRoleRequest(BaseModel):
"""Esquema de request para asignar un rol a un usuario."""
user_id: str = Field(..., description="ID del usuario al que se asignará el rol")
role_id: int = Field(..., description="ID del rol a asignar")
class RemoveRoleRequest(BaseModel):
"""Esquema de request para remover un rol de un usuario."""
user_id: str = Field(..., description="ID del usuario")
role_id: int = Field(..., description="ID del rol a remover")
class GrantPermissionRequest(BaseModel):
"""Esquema de request para conceder un permiso directo a un usuario."""
user_id: str = Field(..., description="ID del usuario")
permission_code: str = Field(
..., description="Código del permiso a conceder (ej: 'invoice.delete')"
)
expires_at: Optional[datetime] = Field(
None, description="Fecha de expiración del permiso (opcional)"
)
class RevokePermissionRequest(BaseModel):
"""Esquema de request para revocar un permiso directo."""
user_id: str = Field(..., description="ID del usuario")
permission_code: str = Field(..., description="Código del permiso a revocar")
class CreateRoleRequest(BaseModel):
"""Esquema de request para crear un rol personalizado."""
name: str = Field(..., min_length=1, max_length=100, description="Nombre del rol")
code: str = Field(
...,
min_length=1,
max_length=100,
description="Código único del rol (ej: 'custom_admin')",
)
description: Optional[str] = Field(
None, max_length=255, description="Descripción del rol"
)
permission_ids: List[int] = Field(
default_factory=list, description="IDs de permisos a asignar al rol"
)
class UpdateRoleRequest(BaseModel):
"""Esquema de request para actualizar un rol existente."""
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=255)
is_active: Optional[bool] = None
class AssignPermissionsToRoleRequest(BaseModel):
"""Esquema de request para asignar permisos a un rol."""
permission_ids: List[int] = Field(
..., description="Lista de IDs de permisos a asignar al rol"
)
replace_existing: bool = Field(
False,
description="Si True, reemplaza los permisos existentes. Si False, los agrega.",
)
class CreatePermissionRequest(BaseModel):
"""Esquema de request para crear un nuevo permiso (uso administrativo)."""
code: str = Field(
...,
min_length=1,
max_length=100,
description="Código único del permiso (ej: 'custom_module.action')",
)
description: Optional[str] = Field(None, max_length=255)
module: str = Field(
..., min_length=1, max_length=50, description="Módulo del permiso"
)
action: str = Field(
..., min_length=1, max_length=50, description="Acción del permiso"
)
# ============================================================================
# SCHEMAS DE RESPUESTA GENÉRICOS
# ============================================================================
class SuccessResponse(BaseModel):
"""Respuesta genérica de éxito."""
success: bool = True
message: str = Field(..., description="Mensaje descriptivo de la operación")
data: Optional[dict] = Field(None, description="Datos adicionales opcionales")
class ErrorResponse(BaseModel):
"""Respuesta genérica de error."""
success: bool = False
detail: str = Field(..., description="Descripción del error")
error_code: Optional[str] = Field(None, description="Código de error específico")
# ============================================================================
# SCHEMAS DE PAGINACIÓN
# ============================================================================
class PaginatedResponse(BaseModel):
"""Esquema genérico para respuestas paginadas."""
items: List[dict] = Field(default_factory=list)
total: int = Field(..., description="Total de items disponibles")
page: int = Field(..., description="Página actual")
page_size: int = Field(..., description="Tamaño de página")
total_pages: int = Field(..., description="Total de páginas disponibles")
class PermissionListResponse(BaseModel):
"""Lista paginada de permisos."""
items: List[PermissionResponse]
total: int
page: int = 1
page_size: int = 100
class RoleListResponse(BaseModel):
"""Lista paginada de roles."""
items: List[CompanyRoleResponse]
total: int
page: int = 1
page_size: int = 100
class UserRoleListResponse(BaseModel):
"""Lista paginada de asignaciones de roles a usuarios."""
items: List[UserCompanyRoleResponse]
total: int
page: int = 1
page_size: int = 100
class AssignUserRoleRequest(BaseModel):
"""Esquema de request para asignar un rol a un usuario."""
user_id: str = Field(..., description="ID del usuario")
company_role_id: int = Field(..., description="ID del rol a asignar")
# ============================================================================
# SCHEMAS PARA PERMISOS INDIVIDUALES DE USUARIO
# ============================================================================
class UserPermissionResponse(BaseModel):
"""Esquema de respuesta para un permiso individual de usuario."""
id: int
user_id: str
permission_id: int
company_id: int
tenant_id: int
is_granted: bool = Field(..., description="True = permiso concedido, False = permiso revocado")
is_active: bool
assigned_by: Optional[str] = None
expires_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
permission: Optional[PermissionResponse] = None
model_config = ConfigDict(from_attributes=True)
class AssignUserPermissionRequest(BaseModel):
"""Esquema de request para asignar un permiso individual a un usuario."""
permission_id: int = Field(..., description="ID del permiso")
is_granted: bool = Field(True, description="True para conceder, False para revocar")
expires_at: Optional[datetime] = Field(None, description="Fecha de expiración (opcional)")
class UserPermissionsListResponse(BaseModel):
"""Lista de permisos individuales de un usuario."""
items: List[UserPermissionResponse]
total: int
class EffectiveUserPermissionsResponse(BaseModel):
"""Permisos efectivos de un usuario (roles + individuales - revocados)."""
user_id: str
company_id: int
role_permissions: List[PermissionResponse] = Field(
default_factory=list, description="Permisos heredados de roles"
)
granted_permissions: List[PermissionResponse] = Field(
default_factory=list, description="Permisos individuales concedidos"
)
revoked_permissions: List[PermissionResponse] = Field(
default_factory=list, description="Permisos revocados explícitamente"
)
effective_permissions: List[PermissionResponse] = Field(
default_factory=list, description="Permisos finales efectivos"
)

View File

@@ -0,0 +1,585 @@
"""
Servicio de gestión de permisos multi-tenant.
Proporciona funciones para verificar y obtener permisos de usuarios por companye.
"""
import logging
from datetime import datetime
from typing import Set, Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from core.database import RLS_TENANT_KEY
from .cache import PermissionCache
from .models import (
Permission,
CompanyRole,
RolePermission,
UserCompanyRole,
UserCompanyPermission,
)
logger = logging.getLogger(__name__)
class PermissionService:
"""
Servicio para gestionar permisos de usuarios en contextos multi-tenant.
Combina permisos de roles y permisos directos del usuario.
"""
def __init__(self, db: Session):
self.db = db
self._cache = PermissionCache()
def _ensure_user_tenant_row_for_company(
self, user_id: str, company_id: int
) -> None:
"""STUB — implementa con el modelo de compañía de tu proyecto."""
def _resolve_tenant_id_for_company(self, company_id: int) -> Optional[int]:
"""
Resuelve tenant_id efectivo para una compañía usando primero el contexto RLS
de la sesión y, como fallback, la tabla de compañías.
"""
tenant_id = self.db.info.get(RLS_TENANT_KEY)
if tenant_id is not None:
try:
return int(tenant_id)
except (TypeError, ValueError):
return None
try:
# Sin modelo de compañía en la plantilla — implementa la consulta aquí.
pass
except Exception as exc:
logger.warning(
"resolve_tenant_id_for_company_failed",
extra={
"company_id": company_id,
"error": str(exc),
},
)
return None
def _get_user_permissions_uncached(
self, user_id: str, company_id: int
) -> Set[str]:
"""
Lógica de cálculo de permisos sin caché.
"""
role_permissions = self._get_permissions_from_roles(user_id, company_id)
direct_permissions = self._get_direct_permissions(user_id, company_id)
all_permissions = role_permissions.copy()
for perm_code, is_granted in direct_permissions.items():
if is_granted:
all_permissions.add(perm_code)
else:
all_permissions.discard(perm_code)
return all_permissions
def get_user_permissions(
self, user_id: str, company_id: int, use_cache: bool = False
) -> Set[str]:
"""
Obtiene todos los permisos de un usuario para un companye específico.
Puede usar caché de Valkey cuando use_cache=True.
"""
if not use_cache:
return self._get_user_permissions_uncached(user_id, company_id)
tenant_id = self._resolve_tenant_id_for_company(company_id)
cache_key = self._cache.build_permissions_key(tenant_id, company_id, user_id)
cached = self._cache.get_permissions(
cache_key,
user_id=user_id,
company_id=company_id,
tenant_id=tenant_id,
)
if cached is not None:
return cached
permissions = self._get_user_permissions_uncached(user_id, company_id)
self._cache.set_permissions(
cache_key,
permissions,
user_id=user_id,
company_id=company_id,
tenant_id=tenant_id,
)
return permissions
def _get_permissions_from_roles(self, user_id: str, company_id: int) -> Set[str]:
"""
Obtiene permisos derivados de los roles del usuario en el companye.
Realiza un JOIN eficiente para obtener todos los permisos de los roles activos.
"""
query = (
self.db.query(Permission.code)
.join(RolePermission, RolePermission.permission_id == Permission.id)
.join(CompanyRole, CompanyRole.id == RolePermission.company_role_id)
.join(UserCompanyRole, UserCompanyRole.company_role_id == CompanyRole.id)
.filter(
and_(
UserCompanyRole.user_id == user_id,
UserCompanyRole.company_id == company_id,
UserCompanyRole.is_active == True,
CompanyRole.is_active == True,
Permission.is_active == True,
)
)
)
results = query.all()
return {perm_code for (perm_code,) in results}
def _get_direct_permissions(self, user_id: str, company_id: int) -> dict:
"""
Obtiene permisos directos asignados al usuario.
Returns:
Dict con código de permiso como key y is_granted como value
{
"invoice.delete": True, # Permiso concedido
"user.delete": False # Permiso revocado explícitamente
}
"""
now = datetime.utcnow()
query = (
self.db.query(Permission.code, UserCompanyPermission.is_granted)
.join(
UserCompanyPermission,
UserCompanyPermission.permission_id == Permission.id,
)
.filter(
and_(
UserCompanyPermission.user_id == user_id,
UserCompanyPermission.company_id == company_id,
UserCompanyPermission.is_active == True,
Permission.is_active == True,
or_(
UserCompanyPermission.expires_at.is_(None),
UserCompanyPermission.expires_at > now,
),
)
)
)
results = query.all()
return {perm_code: is_granted for perm_code, is_granted in results}
def has_permission(
self, user_id: str, company_id: int, permission_code: str
) -> bool:
"""
Verifica si un usuario tiene un permiso específico en un companye.
Args:
user_id: ID del usuario
company_id: ID del companye/tenant
permission_code: Código del permiso (ej: "invoice.edit")
Returns:
True si el usuario tiene el permiso, False en caso contrario
"""
permissions = self.get_user_permissions(user_id, company_id, use_cache=True)
return permission_code in permissions
def has_any_permission(
self, user_id: str, company_id: int, permission_codes: List[str]
) -> bool:
"""
Verifica si el usuario tiene al menos uno de los permisos especificados.
"""
permissions = self.get_user_permissions(user_id, company_id, use_cache=True)
return any(perm in permissions for perm in permission_codes)
def has_all_permissions(
self, user_id: str, company_id: int, permission_codes: List[str]
) -> bool:
"""
Verifica si el usuario tiene todos los permisos especificados.
"""
permissions = self.get_user_permissions(user_id, company_id, use_cache=True)
return all(perm in permissions for perm in permission_codes)
def get_user_roles(self, user_id: str, company_id: int) -> List[CompanyRole]:
"""
Obtiene los roles activos de un usuario en un companye.
"""
query = (
self.db.query(CompanyRole)
.join(UserCompanyRole, UserCompanyRole.company_role_id == CompanyRole.id)
.filter(
and_(
UserCompanyRole.user_id == user_id,
UserCompanyRole.company_id == company_id,
UserCompanyRole.is_active == True,
CompanyRole.is_active == True,
)
)
)
return query.all()
def assign_role_to_user(
self,
user_id: str,
company_id: int,
role_id: int,
assigned_by: Optional[str] = None,
) -> UserCompanyRole:
"""
Asigna un rol a un usuario en un companye específico.
"""
# Verificar que el rol pertenece al companye
role = (
self.db.query(CompanyRole)
.filter(
and_(
CompanyRole.id == role_id,
CompanyRole.company_id == company_id,
CompanyRole.is_active == True,
)
)
.first()
)
if not role:
raise ValueError(f"Role {role_id} not found for company {company_id}")
# Verificar si ya existe la asignación
existing = (
self.db.query(UserCompanyRole)
.filter(
and_(
UserCompanyRole.user_id == user_id,
UserCompanyRole.company_id == company_id,
UserCompanyRole.company_role_id == role_id,
)
)
.first()
)
if existing:
if not existing.is_active:
existing.is_active = True
existing.assigned_at = datetime.utcnow()
existing.assigned_by = assigned_by
self.db.commit()
self._ensure_user_tenant_row_for_company(user_id, company_id)
# Invalidar caché del usuario tras reactivar el rol
try:
tenant_id = self._resolve_tenant_id_for_company(company_id)
self._cache.invalidate_user(tenant_id, company_id, user_id)
except Exception:
logger.warning(
"permission_cache_invalidate_user_after_assign_role_failed",
extra={"user_id": user_id, "company_id": company_id},
)
return existing
self._ensure_user_tenant_row_for_company(user_id, company_id)
return existing
# Crear nueva asignación
user_role = UserCompanyRole(
user_id=user_id,
company_id=company_id,
company_role_id=role_id,
assigned_by=assigned_by,
)
self.db.add(user_role)
self.db.commit()
self.db.refresh(user_role)
self._ensure_user_tenant_row_for_company(user_id, company_id)
# Invalidar caché del usuario tras nueva asignación de rol
try:
tenant_id = self._resolve_tenant_id_for_company(company_id)
self._cache.invalidate_user(tenant_id, company_id, user_id)
except Exception:
logger.warning(
"permission_cache_invalidate_user_after_assign_role_failed",
extra={"user_id": user_id, "company_id": company_id},
)
return user_role
def grant_direct_permission(
self,
user_id: str,
company_id: int,
permission_code: str,
assigned_by: Optional[str] = None,
expires_at: Optional[datetime] = None,
) -> UserCompanyPermission:
"""
Concede un permiso directo a un usuario en un companye.
"""
# Obtener el permiso por código
permission = (
self.db.query(Permission)
.filter(
and_(Permission.code == permission_code, Permission.is_active == True)
)
.first()
)
if not permission:
raise ValueError(f"Permission {permission_code} not found")
# Verificar si ya existe
existing = (
self.db.query(UserCompanyPermission)
.filter(
and_(
UserCompanyPermission.user_id == user_id,
UserCompanyPermission.company_id == company_id,
UserCompanyPermission.permission_id == permission.id,
)
)
.first()
)
if existing:
existing.is_granted = True
existing.is_active = True
existing.assigned_at = datetime.utcnow()
existing.assigned_by = assigned_by
existing.expires_at = expires_at
self.db.commit()
self._ensure_user_tenant_row_for_company(user_id, company_id)
# Invalidar caché del usuario tras mutación
try:
tenant_id = self._resolve_tenant_id_for_company(company_id)
self._cache.invalidate_user(tenant_id, company_id, user_id)
except Exception:
# La invalidación de caché nunca debe romper la operación principal
logger.warning(
"permission_cache_invalidate_user_after_grant_failed",
extra={"user_id": user_id, "company_id": company_id},
)
return existing
# Crear nuevo permiso directo
user_permission = UserCompanyPermission(
user_id=user_id,
company_id=company_id,
permission_id=permission.id,
is_granted=True,
assigned_by=assigned_by,
expires_at=expires_at,
)
self.db.add(user_permission)
self.db.commit()
self.db.refresh(user_permission)
self._ensure_user_tenant_row_for_company(user_id, company_id)
# Invalidar caché del usuario tras mutación
try:
tenant_id = self._resolve_tenant_id_for_company(company_id)
self._cache.invalidate_user(tenant_id, company_id, user_id)
except Exception:
logger.warning(
"permission_cache_invalidate_user_after_grant_failed",
extra={"user_id": user_id, "company_id": company_id},
)
return user_permission
def sync_permissions(self) -> dict:
"""
Sincroniza los permisos registrados en el registry con la base de datos.
Inserta nuevos permisos y actualiza los existentes.
"""
from .registry import registry
# IMPORTANTE: Importar seed_v2 para que se ejecute register_core_permissions()
from . import seed_v2
registered_permissions = registry.get_all()
synced_count = 0
updated_count = 0
for p_def in registered_permissions:
# Buscar permiso existente
db_permission = self.db.query(Permission).filter(Permission.code == p_def.code).first()
if db_permission:
# Actualizar si hay cambios
changed = False
if db_permission.description != p_def.description:
db_permission.description = p_def.description
changed = True
if db_permission.module != p_def.module:
db_permission.module = p_def.module
changed = True
if db_permission.action != p_def.action:
db_permission.action = p_def.action
changed = True
if db_permission.is_active != p_def.is_active:
db_permission.is_active = p_def.is_active
changed = True
if changed:
updated_count += 1
else:
# Crear nuevo
new_permission = Permission(
code=p_def.code,
description=p_def.description,
module=p_def.module,
action=p_def.action,
is_active=p_def.is_active
)
self.db.add(new_permission)
synced_count += 1
self.db.commit()
return {
"synced": synced_count,
"updated": updated_count,
"total_registered": len(registered_permissions)
}
def bootstrap_super_admin(self, user_id: str, company_id: int) -> bool:
"""
Crea un rol de Super Administrador con todos los permisos y se lo asigna al usuario.
Diseñado para el primer inicio de una compañía o para asegurar acceso a administradores.
"""
from .models import CompanyRole, RolePermission, UserCompanyRole, Permission
import logging
logger = logging.getLogger(__name__)
try:
# 0. Sincronizar permisos por si la tabla esta vacía
# Esto puebla la tabla 'permissions' desde el registry de código
logger.info("Bootstrap: Sincronizando catálogo de permisos desde el registry...")
from . import seed_v2
sync_res = self.sync_permissions()
logger.info(f"Bootstrap: Sincronización completa. {sync_res.get('synced', 0)} nuevos, {sync_res.get('total_registered', 0)} totales.")
# 1. Obtener el tenant_id (implementa con tu modelo de compañía)
tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1
# 2. Buscar si ya existe el rol "super_admin"
admin_role = self.db.query(CompanyRole).filter(
CompanyRole.company_id == company_id,
CompanyRole.code == "super_admin"
).first()
if not admin_role:
logger.info(f"Bootstrap: Creando Super Administrador para usuario {user_id} (Company: {company_id})")
# Crear el rol Super Administrador si no existe
admin_role = CompanyRole(
company_id=company_id,
tenant_id=tenant_id,
name="Super Administrador",
code="super_admin",
description="Rol con acceso total al sistema (generado automáticamente)",
is_active=True
)
self.db.add(admin_role)
self.db.flush()
# 4. Asignar TODOS los permisos activos al rol
all_perms = self.db.query(Permission).filter(Permission.is_active == True).all()
if not all_perms:
logger.warning("Bootstrap: ¡ALERTA! No se encontraron permisos en la DB ni tras la sincronización.")
for perm in all_perms:
role_perm = RolePermission(
company_role_id=admin_role.id,
permission_id=perm.id,
tenant_id=tenant_id,
company_id=company_id
)
self.db.add(role_perm)
else:
logger.info(f"Bootstrap: El rol super_admin ya existe para la compañía {company_id}. Sincronizando permisos nuevos si es necesario.")
# Asegurar que el rol tenga TODOS los permisos activos (incluidos los agregados después)
all_perms = self.db.query(Permission).filter(Permission.is_active == True).all()
existing_perm_ids = {
pid
for (pid,) in self.db.query(RolePermission.permission_id).filter(
RolePermission.company_role_id == admin_role.id
)
}
added = 0
for perm in all_perms:
if perm.id in existing_perm_ids:
continue
role_perm = RolePermission(
company_role_id=admin_role.id,
permission_id=perm.id,
tenant_id=tenant_id,
company_id=company_id,
)
self.db.add(role_perm)
added += 1
if added:
logger.info(
"Bootstrap: sincronicé %s permisos nuevos en super_admin company_id=%s",
added,
company_id,
)
# 5. Asegurar que el usuario tenga el rol asignado
user_has_role = self.db.query(UserCompanyRole).filter(
UserCompanyRole.user_id == user_id,
UserCompanyRole.company_id == company_id,
UserCompanyRole.company_role_id == admin_role.id
).first()
if not user_has_role:
logger.info(f"Bootstrap: Asignando rol Super Administrador al usuario {user_id}")
user_role = UserCompanyRole(
user_id=user_id,
company_id=company_id,
tenant_id=tenant_id,
company_role_id=admin_role.id,
is_active=True,
assigned_by="SYSTEM_BOOTSTRAP"
)
self.db.add(user_role)
self.db.commit()
# Invalida caché de permisos de la compañía y del usuario afectado
try:
self._cache.invalidate_company(company_id)
self._cache.invalidate_user(tenant_id, company_id, user_id)
except Exception:
logger.warning(
"permission_cache_invalidate_after_bootstrap_failed",
extra={"user_id": user_id, "company_id": company_id},
)
return True
else:
logger.info(f"Bootstrap: El usuario {user_id} ya tiene el rol asignado.")
self.db.commit()
# Invalida caché de permisos de la compañía para reflejar cambios de catálogo
try:
self._cache.invalidate_company(company_id)
except Exception:
logger.warning(
"permission_cache_invalidate_after_bootstrap_failed",
extra={"user_id": user_id, "company_id": company_id},
)
return False
except Exception as e:
self.db.rollback()
logger.error(f"Bootstrap: ERROR CRÍTICO - {str(e)}")
import traceback
logger.error(traceback.format_exc())
return False

View File

@@ -0,0 +1,50 @@
"""
Script CLI para sincronizar los permisos de la aplicación.
Útil para el bootstrap inicial cuando el endpoint /sync aún no es accesible
o cuando se desea forzar una actualización desde la consola/Docker.
Uso:
docker exec -it <container> python3 -m api.v1.modules.core.permissions.sync_cli
"""
import sys
import os
import logging
# Configurar logging básico para ver resultados en consola
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
# Asegurar que el backend esté en el path
sys.path.append(os.path.abspath("."))
sys.path.append(os.path.abspath("backend"))
from core.database import CoreSessionLocal
from api.v1.modules.core.permissions.service import PermissionService
def run_sync():
"""Ejecuta la lógica de sincronización modular."""
logger.info("Iniciando Sincronización Modular de Permisos...")
db = CoreSessionLocal()
try:
service = PermissionService(db)
result = service.sync_permissions()
logger.info("-" * 40)
logger.info(f"Sincronización Exitosa!")
logger.info(f" - Nuevos insertados: {result['synced']}")
logger.info(f" - Existentes actualizados: {result['updated']}")
logger.info(f" - Total en Registry: {result['total_registered']}")
logger.info("-" * 40)
except Exception as e:
logger.error(f"Error crítico durante la sincronización: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
db.close()
if __name__ == "__main__":
run_sync()

View File

@@ -0,0 +1,26 @@
from .auth.routes import router as auth_router
from .invite_codes.routes import router as invite_codes_router
from .invites.routes import router as invites_router
from .licenses.routes import router as licenses_router
from .permissions.routes import router as permissions_router
from .tenants.routes import router as tenants_router
from .user_tenant.routes import router as user_tenant_router
from .users.routes import router as users_router
from .dashboard.routes import router as dashboard_router
from .help_center.routes import router as help_center_router
from .tasks_tracking.routes import router as tasks_tracking_router
from fastapi import APIRouter
router = APIRouter()
router.include_router(auth_router)
router.include_router(invites_router, prefix="/core", tags=["core / invites"])
router.include_router(invite_codes_router, prefix="/core", tags=["core / invite-codes"])
router.include_router(tenants_router, prefix="/core", tags=["core / tenants"])
router.include_router(user_tenant_router, prefix="/core", tags=["core / user-tenants"])
router.include_router(users_router, prefix="/core", tags=["core / users"])
router.include_router(licenses_router, prefix="/core", tags=["core / licenses"])
router.include_router(permissions_router, prefix="/core", tags=["core / permissions"])
router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"])
router.include_router(help_center_router, prefix="/core", tags=["core / help-center"])
router.include_router(tasks_tracking_router, prefix="/core", tags=["core / tasks"])

View File

@@ -0,0 +1,4 @@
from .dispatch import track_and_dispatch
from .service import TaskTrackerService
__all__ = ["track_and_dispatch", "TaskTrackerService"]

View File

@@ -0,0 +1,75 @@
from typing import Any
import logging
from celery import Task
from sqlalchemy.orm import Session
from core.database import rls_company_var, rls_tenant_var
from .service import TaskTrackerService
logger = logging.getLogger(__name__)
def track_and_dispatch(
*,
db: Session,
task: Task,
tenant_id: int,
task_name: str,
task_group: str,
company_id: int | None = None,
requested_by_user: str | None = None,
task_origin: str | None = None,
args: list[Any] | None = None,
kwargs: dict[str, Any] | None = None,
task_id: str | None = None,
meta_payload: dict[str, Any] | None = None,
):
# Propaga contexto RLS vía Celery headers (leídos en task_prerun) y
# ContextVars (para modo eager, donde before_task_publish no dispara).
headers = {"rls_tenant_id": str(int(tenant_id))}
if company_id is not None:
headers["rls_company_id"] = str(int(company_id))
else:
logger.warning(
"Dispatching task without company_id in RLS headers task=%s tenant_id=%s",
getattr(task, "name", "<unknown>"),
tenant_id,
)
prev_tenant = rls_tenant_var.get()
prev_company = rls_company_var.get()
rls_tenant_var.set(int(tenant_id))
rls_company_var.set(int(company_id) if company_id is not None else None)
try:
logger.info(
"Dispatching Celery task task=%s task_id=%s tenant_id=%s company_id=%s headers=%s",
getattr(task, "name", "<unknown>"),
task_id,
tenant_id,
company_id,
headers,
)
celery_task = task.apply_async(
args=args or [],
kwargs=kwargs or {},
task_id=task_id,
headers=headers,
)
finally:
rls_tenant_var.set(prev_tenant)
rls_company_var.set(prev_company)
tracker = TaskTrackerService(db)
tracker.register_dispatch(
task_id=celery_task.id,
tenant_id=tenant_id,
company_id=company_id,
requested_by_user=requested_by_user,
task_name=task_name,
task_group=task_group,
task_origin=task_origin,
meta_payload=meta_payload,
)
return celery_task

View File

@@ -0,0 +1,62 @@
import enum
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class TaskStatus(str, enum.Enum):
PENDING = "pending"
ACTIVE = "active"
COMPLETED = "completed"
FAILED = "failed"
class TaskRun(Base):
__tablename__ = "task_runs"
__table_args__ = (
Index("ix_core_task_runs_task_id", "task_id", unique=True),
Index("ix_core_task_runs_tenant_updated", "tenant_id", "updated_at"),
Index("ix_core_task_runs_tenant_status_updated", "tenant_id", "status", "updated_at"),
Index("ix_core_task_runs_tenant_group_updated", "tenant_id", "task_group", "updated_at"),
Index("ix_core_task_runs_tenant_company_updated", "tenant_id", "company_id", "updated_at"),
{"schema": "core"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
task_id: Mapped[str] = mapped_column(String(255), nullable=False)
tenant_id: Mapped[int] = mapped_column(
Integer, ForeignKey("core.tenants.id"), nullable=False, index=True
)
company_id: Mapped[int | None] = mapped_column(
Integer, nullable=True, index=True
)
requested_by_user: Mapped[str | None] = mapped_column(String(255), nullable=True)
task_name: Mapped[str] = mapped_column(String(255), nullable=False)
task_group: Mapped[str] = mapped_column(String(100), nullable=False)
task_origin: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[TaskStatus] = mapped_column(
String(20), nullable=False, default=TaskStatus.PENDING.value
)
celery_state_raw: Mapped[str] = mapped_column(String(30), nullable=False, default="PENDING")
progress_current: Mapped[int | None] = mapped_column(Integer, nullable=True)
progress_total: Mapped[int | None] = mapped_column(Integer, nullable=True)
progress_percent: Mapped[float | None] = mapped_column(nullable=True)
progress_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
retries: Mapped[int | None] = mapped_column(Integer, nullable=True)
exception_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
exception_message: Mapped[str | None] = mapped_column(Text, nullable=True)
traceback_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
result_summary: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
meta_payload: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)

View File

@@ -0,0 +1,149 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user, resolve_tenant_id_required
from .models import TaskRun, TaskStatus
from .schemas import TaskCatalogsResponse, TaskRunDetail, TaskRunListItem, TaskRunsResponse, TaskSyncRequest
from .service import TaskTrackerService
router = APIRouter()
def _map_row(row: TaskRun) -> TaskRunListItem:
pct = row.progress_percent
if row.status == TaskStatus.COMPLETED.value and (pct is None or pct == 0):
pct = 100.0
return TaskRunListItem(
task_id=row.task_id,
task_name=row.task_name,
task_group=row.task_group,
task_origin=row.task_origin,
status=row.status,
celery_state_raw=row.celery_state_raw,
progress={
"current": row.progress_current,
"total": row.progress_total,
"percent": pct,
"message": row.progress_message,
},
retries=row.retries,
error=(
{"type": row.exception_type, "message": row.exception_message}
if row.exception_type or row.exception_message
else None
),
tenant_id=row.tenant_id,
requested_by_user=row.requested_by_user,
started_at=row.started_at,
finished_at=row.finished_at,
created_at=row.created_at,
updated_at=row.updated_at,
)
@router.get("/tasks", response_model=TaskRunsResponse)
def list_tasks(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
status: list[str] | None = Query(None),
task_group: list[str] | None = Query(None),
task_name: list[str] | None = Query(None),
company_id: int | None = Query(None),
search: str | None = Query(None),
sync_active: bool = Query(False),
current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = resolve_tenant_id_required(current_user)
tracker = TaskTrackerService(db)
if sync_active:
tracker.sync_active_tasks(tenant_id=tenant_id)
rows, total = tracker.list_tasks(
tenant_id=tenant_id,
page=page,
page_size=page_size,
status=status,
task_group=task_group,
task_name=task_name,
company_id=company_id,
search=search,
)
items = [_map_row(row) for row in rows]
return TaskRunsResponse(
items=items,
total=total,
page=page,
page_size=page_size,
has_next=(page * page_size) < total,
)
@router.get("/tasks/{task_id}", response_model=TaskRunDetail)
def get_task_detail(
task_id: str,
sync: bool = Query(True),
current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = resolve_tenant_id_required(current_user)
query = db.query(TaskRun).filter(TaskRun.task_id == task_id)
if tenant_id is not None:
query = query.filter(TaskRun.tenant_id == tenant_id)
row = query.first()
if not row:
raise HTTPException(status_code=404, detail="Task not found")
tracker = TaskTrackerService(db)
if sync:
row = tracker.sync_task(row)
item = _map_row(row)
return TaskRunDetail(
**item.model_dump(),
traceback_excerpt=row.traceback_excerpt,
result_summary=row.result_summary,
meta_payload=row.meta_payload,
)
@router.post("/tasks/sync")
def sync_tasks(
body: TaskSyncRequest,
current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = resolve_tenant_id_required(current_user)
tracker = TaskTrackerService(db)
updated = tracker.sync_active_tasks(tenant_id=tenant_id, task_ids=body.task_ids)
return {"updated": updated}
@router.get("/tasks/catalogs", response_model=TaskCatalogsResponse)
def get_catalogs(
current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = resolve_tenant_id_required(current_user)
groups_q = db.query(TaskRun.task_group)
names_q = db.query(TaskRun.task_name)
statuses_q = db.query(TaskRun.status)
if tenant_id is not None:
groups_q = groups_q.filter(TaskRun.tenant_id == tenant_id)
names_q = names_q.filter(TaskRun.tenant_id == tenant_id)
statuses_q = statuses_q.filter(TaskRun.tenant_id == tenant_id)
groups = groups_q.distinct().order_by(TaskRun.task_group).all()
names = names_q.distinct().order_by(TaskRun.task_name).all()
statuses = statuses_q.distinct().order_by(TaskRun.status).all()
return TaskCatalogsResponse(
task_groups=[g[0] for g in groups if g[0]],
task_names=[n[0] for n in names if n[0]],
statuses=[s[0] for s in statuses if s[0]],
)

View File

@@ -0,0 +1,58 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class TaskProgress(BaseModel):
current: int | None = None
total: int | None = None
percent: float | None = None
message: str | None = None
class TaskError(BaseModel):
type: str | None = None
message: str | None = None
class TaskRunListItem(BaseModel):
task_id: str
task_name: str
task_group: str
task_origin: str | None = None
status: str
celery_state_raw: str
progress: TaskProgress
retries: int | None = None
error: TaskError | None = None
tenant_id: int
requested_by_user: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
created_at: datetime
updated_at: datetime
class TaskRunDetail(TaskRunListItem):
traceback_excerpt: str | None = None
result_summary: dict[str, Any] | None = None
meta_payload: dict[str, Any] | None = None
class TaskRunsResponse(BaseModel):
items: list[TaskRunListItem]
total: int
page: int
page_size: int
has_next: bool
class TaskSyncRequest(BaseModel):
task_ids: list[str] | None = None
class TaskCatalogsResponse(BaseModel):
task_groups: list[str]
task_names: list[str]
statuses: list[str]

View File

@@ -0,0 +1,246 @@
from datetime import datetime, timezone
from typing import Any
from celery.result import AsyncResult
from sqlalchemy import asc, desc, func, or_
from sqlalchemy.orm import Session
from .models import TaskRun, TaskStatus
# No usar valid_rows/total_rows del resultado final: en SUCCESS distorsiona el % (errores de scan).
_CELERY_TERMINAL_RAW = frozenset({"SUCCESS", "FAILURE", "REVOKED", "REJECTED"})
def _progress_from_row_count_dict(d: dict[str, Any]) -> tuple[int, int] | None:
total = d.get("total_rows")
if not isinstance(total, (int, float)) or total <= 0:
return None
valid = d.get("valid_rows")
if isinstance(valid, (int, float)):
return int(valid), int(total)
processed = d.get("processed_rows")
if isinstance(processed, (int, float)):
return int(processed), int(total)
return None
def normalize_celery_state(state: str | None) -> TaskStatus:
raw = (state or "PENDING").upper()
if raw in {"STARTED", "PROGRESS", "PROCESSING", "RETRY"}:
return TaskStatus.ACTIVE
if raw == "SUCCESS":
return TaskStatus.COMPLETED
if raw in {"FAILURE", "REVOKED", "REJECTED"}:
return TaskStatus.FAILED
return TaskStatus.PENDING
def _extract_progress(result: AsyncResult, raw_state_upper: str) -> tuple[int | None, int | None, float | None, str | None]:
payload = result.info if isinstance(result.info, dict) else {}
current = payload.get("current")
total = payload.get("total")
message = payload.get("status")
if current is None and isinstance(result.result, dict):
current = result.result.get("current")
if total is None and isinstance(result.result, dict):
total = result.result.get("total")
if message is None and isinstance(result.result, dict):
message = result.result.get("status")
percent = None
if isinstance(current, (int, float)) and isinstance(total, (int, float)) and total > 0:
percent = min(100.0, max(0.0, (float(current) / float(total)) * 100.0))
raw = (raw_state_upper or "PENDING").upper()
if percent is None and raw not in _CELERY_TERMINAL_RAW:
for src in (payload, result.result if isinstance(result.result, dict) else {}):
if not isinstance(src, dict):
continue
pair = _progress_from_row_count_dict(src)
if pair is None:
continue
cur_i, tot_i = pair
current, total = cur_i, tot_i
percent = min(100.0, max(0.0, (float(cur_i) / float(tot_i)) * 100.0))
break
return (
int(current) if isinstance(current, (int, float)) else None,
int(total) if isinstance(total, (int, float)) else None,
percent,
str(message) if message is not None else None,
)
def _extract_failure(result: AsyncResult) -> tuple[str | None, str | None, str | None]:
exception_type = None
exception_message = None
traceback_excerpt = None
err = result.result
if isinstance(err, Exception):
exception_type = type(err).__name__
exception_message = str(err)
elif isinstance(err, dict):
exception_type = err.get("exc_type")
exception_message = err.get("exc_message") or err.get("error") or err.get("message")
elif err is not None:
exception_message = str(err)
tb = getattr(result, "traceback", None)
if isinstance(tb, str):
traceback_excerpt = tb[-4000:]
return exception_type, exception_message, traceback_excerpt
class TaskTrackerService:
def __init__(self, db: Session):
self.db = db
def register_dispatch(
self,
*,
task_id: str,
tenant_id: int,
task_name: str,
task_group: str,
company_id: int | None = None,
requested_by_user: str | None = None,
task_origin: str | None = None,
meta_payload: dict[str, Any] | None = None,
) -> TaskRun:
current = self.db.query(TaskRun).filter(TaskRun.task_id == task_id).first()
if current:
return current
row = TaskRun(
task_id=task_id,
tenant_id=tenant_id,
company_id=company_id,
requested_by_user=requested_by_user,
task_name=task_name,
task_group=task_group,
task_origin=task_origin,
status=TaskStatus.PENDING.value,
celery_state_raw="PENDING",
progress_current=0,
progress_total=100,
progress_percent=0.0,
progress_message="Queued",
retries=0,
meta_payload=meta_payload,
started_at=None,
finished_at=None,
)
self.db.add(row)
self.db.commit()
self.db.refresh(row)
return row
def sync_task(self, task_run: TaskRun) -> TaskRun:
from core.celery_app import celery_app
async_result = celery_app.AsyncResult(task_run.task_id)
raw_state = (async_result.state or "PENDING").upper()
normalized = normalize_celery_state(raw_state)
now = datetime.now(timezone.utc)
task_run.celery_state_raw = raw_state
task_run.status = normalized.value
task_run.retries = int(getattr(async_result, "retries", 0) or 0)
current, total, percent, message = _extract_progress(async_result, raw_state)
if current is not None:
task_run.progress_current = current
if total is not None:
task_run.progress_total = total
if percent is not None:
task_run.progress_percent = percent
if message:
task_run.progress_message = message
if normalized == TaskStatus.ACTIVE and task_run.started_at is None:
task_run.started_at = now
if normalized == TaskStatus.COMPLETED:
if task_run.started_at is None:
task_run.started_at = now
task_run.finished_at = now
task_run.exception_type = None
task_run.exception_message = None
task_run.traceback_excerpt = None
if isinstance(async_result.result, dict):
task_run.result_summary = async_result.result
else:
task_run.result_summary = {"result": str(async_result.result)}
# register_dispatch seeds progress_percent=0; Celery SUCCESS often has no current/total meta
if percent is None:
task_run.progress_percent = 100.0
if normalized == TaskStatus.FAILED:
if task_run.started_at is None:
task_run.started_at = now
task_run.finished_at = now
etype, emsg, tb = _extract_failure(async_result)
task_run.exception_type = etype
task_run.exception_message = emsg
task_run.traceback_excerpt = tb
self.db.add(task_run)
self.db.commit()
self.db.refresh(task_run)
return task_run
def sync_active_tasks(self, tenant_id: int | None, task_ids: list[str] | None = None) -> int:
query = self.db.query(TaskRun).filter(
TaskRun.status.in_([TaskStatus.PENDING.value, TaskStatus.ACTIVE.value])
)
if tenant_id is not None:
query = query.filter(TaskRun.tenant_id == tenant_id)
if task_ids:
query = query.filter(TaskRun.task_id.in_(task_ids))
rows = query.limit(200).all()
for row in rows:
self.sync_task(row)
return len(rows)
def list_tasks(
self,
*,
tenant_id: int | None,
page: int,
page_size: int,
status: list[str] | None = None,
task_group: list[str] | None = None,
task_name: list[str] | None = None,
company_id: int | None = None,
search: str | None = None,
order: str = "desc",
) -> tuple[list[TaskRun], int]:
query = self.db.query(TaskRun)
if tenant_id is not None:
query = query.filter(TaskRun.tenant_id == tenant_id)
if status:
query = query.filter(TaskRun.status.in_(status))
if task_group:
query = query.filter(TaskRun.task_group.in_(task_group))
if task_name:
query = query.filter(TaskRun.task_name.in_(task_name))
if company_id is not None:
# Tareas sin company_id (p. ej. reportes solo por tenant) deben seguir visibles
query = query.filter(or_(TaskRun.company_id == company_id, TaskRun.company_id.is_(None)))
if search:
term = f"%{search}%"
query = query.filter(
(TaskRun.task_id.ilike(term))
| (TaskRun.task_name.ilike(term))
| (TaskRun.task_origin.ilike(term))
| (TaskRun.exception_message.ilike(term))
)
total = query.with_entities(func.count(TaskRun.id)).scalar() or 0
order_expr = asc(TaskRun.updated_at) if order == "asc" else desc(TaskRun.updated_at)
items = query.order_by(order_expr).offset((page - 1) * page_size).limit(page_size).all()
return items, total

View File

@@ -0,0 +1,7 @@
"""
Módulo de Tenants
"""
from .routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,119 @@
"""
DTOs (Data Transfer Objects) para módulo de tenants
Reemplaza schemas.py siguiendo enfoque DDD y estilo NestJS
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class TenantTypeDTO(str, Enum):
"""Tipo de tenant"""
SHARED = "shared"
DEDICATED = "dedicated"
class TenantCreateDTO(BaseModel):
"""DTO para crear un nuevo tenant"""
name: str = Field(
..., min_length=3, max_length=255, description="Nombre del tenant"
)
slug: str = Field(
..., min_length=3, max_length=100, description="Identificador único del tenant"
)
keycloak_realm: str = Field(
..., min_length=3, max_length=255, description="Nombre del realm en Keycloak"
)
type: TenantTypeDTO = Field(
default=TenantTypeDTO.SHARED, description="Tipo de tenant"
)
contact_name: Optional[str] = Field(
None, max_length=255, description="Nombre de contacto"
)
contact_email: Optional[EmailStr] = Field(None, description="Email de contacto")
contact_phone: Optional[str] = Field(
None, max_length=50, description="Teléfono de contacto"
)
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Empresa ABC S.A. de C.V.",
"slug": "empresa-abc",
"keycloak_realm": "empresa-abc-realm",
"type": "shared",
"contact_name": "Juan Pérez",
"contact_email": "juan.perez@empresa-abc.com",
"contact_phone": "+52 55 1234 5678",
}
}
)
class TenantUpdateDTO(BaseModel):
"""DTO para actualizar un tenant"""
name: Optional[str] = Field(None, min_length=3, max_length=255)
contact_name: Optional[str] = Field(None, max_length=255)
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = Field(None, max_length=50)
is_active: Optional[bool] = None
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Empresa ABC S.A. de C.V. - Actualizado",
"contact_email": "nuevo@empresa-abc.com",
}
}
)
class TenantResponseDTO(BaseModel):
"""DTO para respuesta de tenant"""
id: int
name: str
slug: str
type: TenantTypeDTO
keycloak_realm: str
contact_name: Optional[str]
contact_email: Optional[str]
contact_phone: Optional[str]
is_active: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": 1,
"name": "Empresa ABC S.A. de C.V.",
"slug": "empresa-abc",
"type": "shared",
"keycloak_realm": "empresa-abc-realm",
"contact_name": "Juan Pérez",
"contact_email": "juan.perez@empresa-abc.com",
"contact_phone": "+52 55 1234 5678",
"is_active": True,
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z",
}
},
)
class TenantListResponseDTO(BaseModel):
"""DTO para lista de tenants"""
tenants: list[TenantResponseDTO]
total: int
page: int
page_size: int

View File

@@ -0,0 +1,66 @@
"""
Modelos ORM para gestión de tenants
"""
import enum
from typing import TYPE_CHECKING, List
from api.v1.common.base_models import TimestampMixin
from core.database import Base
from sqlalchemy import Boolean, Column
from sqlalchemy import Enum as SQLEnum
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, relationship
from api.v1.modules.core.user_tenant.models import UserTenant
class TenantType(enum.Enum):
"""Tipo de tenant según tamaño y necesidades"""
SHARED = "shared" # BD compartida
DEDICATED = "dedicated" # BD dedicada
class Tenant(Base, TimestampMixin):
"""
Modelo de Tenant - Cliente/Organización en el sistema
Cada tenant puede tener BD compartida o dedicada
"""
__tablename__ = "tenants"
__table_args__ = {"schema": "core", "extend_existing": True}
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False, index=True)
slug = Column(String(100), unique=True, nullable=False, index=True)
# Tipo de tenant (compartido o dedicado)
type = Column(
SQLEnum(TenantType),
default=TenantType.SHARED,
server_default="SHARED",
nullable=False,
)
# Keycloak realm asociado
keycloak_realm = Column(String(255), nullable=False)
# Configuración de BD dedicada (JSON string o NULL si usa BD compartida)
db_config = Column(Text, nullable=True) # JSON: {host, port, name, user, password}
# Información de contacto
contact_name = Column(String(255))
contact_email = Column(String(255))
contact_phone = Column(String(50))
# Estado
is_active = Column(Boolean, default=True, server_default="true", nullable=False)
# Relación con UserTenant
user_relations: Mapped[List["UserTenant"]] = relationship(
"UserTenant", back_populates="tenant"
)
def __repr__(self):
return f"<Tenant(id={self.id}, name={self.name}, type={self.type.value})>"

View File

@@ -0,0 +1,131 @@
"""
Endpoints API para gestión de tenants
"""
from core.database import get_core_db
from core.security import get_current_user, has_role
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from .dto import (
TenantCreateDTO,
TenantListResponseDTO,
TenantResponseDTO,
TenantUpdateDTO,
)
from .service import TenantService
router = APIRouter(prefix="/tenants")
@router.post("/", response_model=TenantResponseDTO, status_code=201)
async def create_tenant(
tenant_data: TenantCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin")),
):
"""
Crea un nuevo tenant en el sistema
Requiere rol: admin
"""
service = TenantService(db)
return service.create_tenant(tenant_data)
@router.get("/", response_model=TenantListResponseDTO)
async def list_tenants(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
active_only: bool = Query(False, description="Solo tenants activos"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin")),
):
"""
Lista todos los tenants
Requiere rol: admin
"""
service = TenantService(db)
skip = (page - 1) * page_size
tenants = service.list_tenants(skip=skip, limit=page_size, active_only=active_only)
# Contar total
from .models import Tenant
query = db.query(Tenant)
if active_only:
query = query.filter(Tenant.is_active)
total = query.count()
return TenantListResponseDTO(
tenants=tenants, total=total, page=page, page_size=page_size
)
@router.get("/{tenant_id}", response_model=TenantResponseDTO)
async def get_tenant(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene información de un tenant por ID
"""
service = TenantService(db)
tenant = service.get_tenant(tenant_id)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
return tenant
@router.put("/{tenant_id}", response_model=TenantResponseDTO)
async def update_tenant(
tenant_id: int,
tenant_data: TenantUpdateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin")),
):
"""
Actualiza un tenant
Requiere rol: admin
"""
service = TenantService(db)
tenant = service.update_tenant(tenant_id, tenant_data)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
return tenant
@router.delete("/{tenant_id}", status_code=204)
async def delete_tenant(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(has_role("admin")),
):
"""
Elimina (desactiva) un tenant
Requiere rol: admin
"""
service = TenantService(db)
if not service.delete_tenant(tenant_id):
raise HTTPException(status_code=404, detail="Tenant not found")
return None
@router.get("/slug/{slug}", response_model=TenantResponseDTO)
async def get_tenant_by_slug(
slug: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene un tenant por su slug
"""
service = TenantService(db)
tenant = service.get_tenant_by_slug(slug)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
return tenant

View File

@@ -0,0 +1,207 @@
"""
Capa de servicio para lógica de negocio de tenants
"""
import json
import logging
from typing import List, Optional
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import TenantCreateDTO, TenantResponseDTO, TenantUpdateDTO
from .models import Tenant, TenantType
logger = logging.getLogger(__name__)
class TenantService:
"""Servicio para gestión de tenants"""
def __init__(self, db: Session):
self.db = db
def create_tenant(self, tenant_data: TenantCreateDTO) -> TenantResponseDTO:
"""
Crea un nuevo tenant en el sistema
Args:
tenant_data: Datos del tenant a crear
Returns:
TenantResponseDTO con información del tenant creado
Raises:
HTTPException: Si el slug o realm ya existen
"""
try:
# Verificar que no exista el slug
existing = (
self.db.query(Tenant).filter(Tenant.slug == tenant_data.slug).first()
)
if existing:
raise HTTPException(
status_code=400,
detail=f"Tenant with slug '{tenant_data.slug}' already exists",
)
# Crear tenant
db_tenant = Tenant(
name=tenant_data.name,
slug=tenant_data.slug,
keycloak_realm=tenant_data.keycloak_realm,
type=TenantType(tenant_data.type.value),
contact_name=tenant_data.contact_name,
contact_email=tenant_data.contact_email,
contact_phone=tenant_data.contact_phone,
is_active=True,
)
self.db.add(db_tenant)
self.db.commit()
self.db.refresh(db_tenant)
return TenantResponseDTO.model_validate(db_tenant)
except IntegrityError as e:
self.db.rollback()
logger.error(f"IntegrityError creating tenant: {str(e)}")
raise HTTPException(
status_code=400, detail="Tenant with this slug or realm already exists"
)
except HTTPException:
raise
except Exception as e:
self.db.rollback()
logger.error(f"Error creating tenant: {str(e)}")
raise HTTPException(status_code=500, detail="Error creating tenant")
def get_tenant(self, tenant_id: int) -> Optional[TenantResponseDTO]:
"""
Obtiene un tenant por ID
Args:
tenant_id: ID del tenant
Returns:
TenantResponseDTO o None si no existe
"""
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
return None
return TenantResponseDTO.model_validate(tenant)
def get_tenant_by_slug(self, slug: str) -> Optional[TenantResponseDTO]:
"""Obtiene un tenant por slug"""
tenant = self.db.query(Tenant).filter(Tenant.slug == slug).first()
if not tenant:
return None
return TenantResponseDTO.model_validate(tenant)
def list_tenants(
self, skip: int = 0, limit: int = 100, active_only: bool = False
) -> List[TenantResponseDTO]:
"""
Lista todos los tenants
Args:
skip: Número de registros a omitir
limit: Número máximo de registros a retornar
active_only: Si True, solo retorna tenants activos
Returns:
Lista de TenantResponseDTO
"""
query = self.db.query(Tenant)
if active_only:
query = query.filter(Tenant.is_active)
tenants = query.offset(skip).limit(limit).all()
return [TenantResponseDTO.model_validate(t) for t in tenants]
def update_tenant(
self, tenant_id: int, tenant_data: TenantUpdateDTO
) -> Optional[TenantResponseDTO]:
"""
Actualiza un tenant
Args:
tenant_id: ID del tenant a actualizar
tenant_data: Datos a actualizar
Returns:
TenantResponseDTO actualizado o None si no existe
"""
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
return None
# Actualizar solo campos proporcionados
update_data = tenant_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tenant, field, value)
try:
self.db.commit()
self.db.refresh(tenant)
return TenantResponseDTO.model_validate(tenant)
except Exception as e:
self.db.rollback()
logger.error(f"Error updating tenant {tenant_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error updating tenant")
def delete_tenant(self, tenant_id: int) -> bool:
"""
Elimina (desactiva) un tenant
Args:
tenant_id: ID del tenant a eliminar
Returns:
True si se eliminó, False si no existe
"""
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
return False
# Soft delete: marcar como inactivo
tenant.is_active = False
try:
self.db.commit()
return True
except Exception as e:
self.db.rollback()
logger.error(f"Error deleting tenant {tenant_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error deleting tenant")
def upgrade_to_dedicated(
self, tenant_id: int, db_config: dict
) -> Optional[TenantResponseDTO]:
"""
Actualiza un tenant de BD compartida a BD dedicada
Args:
tenant_id: ID del tenant
db_config: Configuración de BD dedicada
Returns:
TenantResponseDTO actualizado
"""
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
return None
tenant.type = TenantType.DEDICATED
tenant.db_config = json.dumps(db_config)
try:
self.db.commit()
self.db.refresh(tenant)
return TenantResponseDTO.model_validate(tenant)
except Exception as e:
self.db.rollback()
logger.error(f"Error upgrading tenant {tenant_id}: {str(e)}")
raise HTTPException(status_code=500, detail="Error upgrading tenant")

View File

@@ -0,0 +1,67 @@
"""
DTOs para gestión de relaciones usuario-tenant
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class AddUserToTenantRequestDTO(BaseModel):
"""Request para agregar un usuario a un tenant"""
keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak")
tenant_id: int = Field(..., description="ID del tenant")
role: Optional[str] = Field(None, description="Rol del usuario en el tenant")
class RemoveUserFromTenantRequestDTO(BaseModel):
"""Request para eliminar un usuario de un tenant"""
keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak")
tenant_id: int = Field(..., description="ID del tenant")
soft_delete: bool = Field(True, description="Si True, desactiva. Si False, elimina")
class UpdateUserRoleRequestDTO(BaseModel):
"""Request para actualizar el rol de un usuario en un tenant"""
keycloak_user_id: str = Field(..., description="ID del usuario en Keycloak")
tenant_id: int = Field(..., description="ID del tenant")
role: str = Field(..., description="Nuevo rol del usuario")
class UserTenantResponseDTO(BaseModel):
"""Response con información de relación usuario-tenant"""
id: int
keycloak_user_id: str
tenant_id: int
is_active: bool
role: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TenantBasicInfoDTO(BaseModel):
"""Información básica de un tenant"""
id: int
name: str
slug: str
is_active: bool
keycloak_realm: str
class Config:
from_attributes = True
class UserTenantsResponseDTO(BaseModel):
"""Response con los tenants de un usuario"""
keycloak_user_id: str
tenants: list[TenantBasicInfoDTO]

View File

@@ -0,0 +1,91 @@
"""
Modelo de relación entre usuarios (Keycloak) y tenants
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import (
Boolean,
ForeignKeyConstraint,
JSON,
String,
Text,
UniqueConstraint,
DateTime,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
from api.v1.modules.core.tenants.models import Tenant
class UserTenant(Base, TenantScopedMixin, TimestampMixin):
"""
Relación muchos-a-muchos entre usuarios de Keycloak y tenants
Un usuario puede pertenecer a múltiples tenants
Un tenant puede tener múltiples usuarios
"""
__tablename__ = "user_tenants"
__table_args__ = (
UniqueConstraint(
"keycloak_user_id", "tenant_id", "company_id", name="uq_user_tenant"
),
{"schema": "core", "extend_existing": True},
)
# Primary Key
id: Mapped[int] = mapped_column(primary_key=True, index=True)
# ID del usuario en Keycloak (UUID string)
keycloak_user_id: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
# Estado de la relación
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="true", nullable=False
)
# Información adicional - Rol del usuario en este tenant (opcional)
role: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
# Campos de perfil de usuario
avatar_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="URL de la imagen de perfil"
)
workspace_user_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True, comment="User ID (sub) proveniente de Workspace"
)
workspace_avatar_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="Avatar URL sincronizado desde Workspace"
)
workspace_profile_synced_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Última sincronización de perfil con Workspace",
)
# Caché local de nombre/apellido (fuente de verdad = Keycloak vía Hub;
# se sincroniza al editar perfil desde Anexo76)
first_name: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True, comment="Nombre (caché local de Keycloak)"
)
last_name: Mapped[Optional[str]] = mapped_column(
String(100), nullable=True, comment="Apellido (caché local de Keycloak)"
)
phone: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True, comment="Teléfono del usuario"
)
bio: Mapped[Optional[str]] = mapped_column(
Text, nullable=True, comment="Biografía del usuario"
)
preferences: Mapped[Optional[dict]] = mapped_column(
JSON, nullable=True, comment="Preferencias del usuario (tema, idioma, etc.)"
)
# Relación con Tenant
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="user_relations")

View File

@@ -0,0 +1,141 @@
"""
Rutas para gestión de relaciones usuario-tenant
"""
from typing import List
from core.database import get_core_db
from core.security import get_current_user
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .dto import (
AddUserToTenantRequestDTO,
RemoveUserFromTenantRequestDTO,
TenantBasicInfoDTO,
UpdateUserRoleRequestDTO,
UserTenantResponseDTO,
UserTenantsResponseDTO,
)
from .service import UserTenantService
router = APIRouter(prefix="/user-tenants")
@router.post("/add", response_model=UserTenantResponseDTO)
def add_user_to_tenant(
data: AddUserToTenantRequestDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Agrega un usuario a un tenant
Requiere permisos de administrador
"""
service = UserTenantService(db)
result = service.add_user_to_tenant(
keycloak_user_id=data.keycloak_user_id, tenant_id=data.tenant_id, role=data.role
)
return result
@router.post("/remove")
def remove_user_from_tenant(
data: RemoveUserFromTenantRequestDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Elimina un usuario de un tenant
Requiere permisos de administrador
"""
service = UserTenantService(db)
service.remove_user_from_tenant(
keycloak_user_id=data.keycloak_user_id,
tenant_id=data.tenant_id,
soft_delete=data.soft_delete,
)
return {"message": "User removed from tenant successfully"}
@router.put("/update-role", response_model=UserTenantResponseDTO)
def update_user_role(
data: UpdateUserRoleRequestDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Actualiza el rol de un usuario en un tenant
Requiere permisos de administrador
"""
service = UserTenantService(db)
result = service.update_user_role_in_tenant(
keycloak_user_id=data.keycloak_user_id, tenant_id=data.tenant_id, role=data.role
)
return result
@router.get("/user/{keycloak_user_id}", response_model=UserTenantsResponseDTO)
def get_user_tenants(
keycloak_user_id: str,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene todos los tenants a los que tiene acceso un usuario
Los usuarios solo pueden ver sus propios tenants, a menos que sean admin
"""
# Verificar que el usuario solo pueda ver sus propios tenants (excepto admin)
if current_user.get("sub") != keycloak_user_id:
# TODO: Verificar si es admin
raise HTTPException(
status_code=403, detail="You can only view your own tenants"
)
service = UserTenantService(db)
tenants = service.get_user_tenants(keycloak_user_id)
return UserTenantsResponseDTO(
keycloak_user_id=keycloak_user_id,
tenants=[TenantBasicInfoDTO.model_validate(t) for t in tenants],
)
@router.get("/tenant/{tenant_id}", response_model=List[UserTenantResponseDTO])
def get_tenant_users(
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene todos los usuarios que tienen acceso a un tenant
Requiere permisos de administrador del tenant
"""
service = UserTenantService(db)
user_tenants = service.get_tenant_users(tenant_id)
return user_tenants
@router.get("/check-access/{keycloak_user_id}/{tenant_id}")
def check_user_access(
keycloak_user_id: str,
tenant_id: int,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Verifica si un usuario tiene acceso a un tenant
"""
service = UserTenantService(db)
has_access = service.user_has_access_to_tenant(keycloak_user_id, tenant_id)
return {
"keycloak_user_id": keycloak_user_id,
"tenant_id": tenant_id,
"has_access": has_access,
}

View File

@@ -0,0 +1,225 @@
"""
Servicio para gestionar relaciones entre usuarios y tenants
"""
import logging
from typing import List, Optional
from fastapi import HTTPException
from sqlalchemy import and_
from sqlalchemy.orm import Session
from ..tenants.models import Tenant
from .models import UserTenant
logger = logging.getLogger(__name__)
class UserTenantService:
"""Servicio para gestionar acceso de usuarios a tenants"""
def __init__(self, db: Session):
self.db = db
def add_user_to_tenant(
self, keycloak_user_id: str, tenant_id: int, role: Optional[str] = None
) -> UserTenant:
"""
Agrega un usuario a un tenant
Args:
keycloak_user_id: ID del usuario en Keycloak
tenant_id: ID del tenant
role: Rol opcional del usuario en este tenant
Returns:
UserTenant creado
"""
# Verificar que el tenant existe
tenant = self.db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
# Verificar si la relación ya existe
existing = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant_id,
)
)
.first()
)
if existing:
# Si existe pero está inactiva, reactivarla
if not existing.is_active:
existing.is_active = True
existing.role = role
self.db.commit()
self.db.refresh(existing)
return existing
else:
raise HTTPException(
status_code=409, detail="User already has access to this tenant"
)
# Crear nueva relación
user_tenant = UserTenant(
keycloak_user_id=keycloak_user_id,
tenant_id=tenant_id,
role=role,
is_active=True,
)
self.db.add(user_tenant)
self.db.commit()
self.db.refresh(user_tenant)
return user_tenant
def remove_user_from_tenant(
self, keycloak_user_id: str, tenant_id: int, soft_delete: bool = True
) -> bool:
"""
Elimina un usuario de un tenant
Args:
keycloak_user_id: ID del usuario en Keycloak
tenant_id: ID del tenant
soft_delete: Si True, solo marca como inactivo. Si False, elimina físicamente
Returns:
True si se eliminó correctamente
"""
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant_id,
)
)
.first()
)
if not user_tenant:
raise HTTPException(
status_code=404, detail="User-tenant relationship not found"
)
if soft_delete:
user_tenant.is_active = False
self.db.commit()
else:
self.db.delete(user_tenant)
self.db.commit()
return True
def get_user_tenants(self, keycloak_user_id: str) -> List[Tenant]:
"""
Obtiene todos los tenants a los que tiene acceso un usuario
Args:
keycloak_user_id: ID del usuario en Keycloak
Returns:
Lista de tenants
"""
user_tenants = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active,
)
)
.all()
)
tenant_ids = [ut.tenant_id for ut in user_tenants]
tenants = (
self.db.query(Tenant)
.filter(and_(Tenant.id.in_(tenant_ids), Tenant.is_active))
.all()
)
return tenants
def get_tenant_users(self, tenant_id: int) -> List[UserTenant]:
"""
Obtiene todos los usuarios que tienen acceso a un tenant
Args:
tenant_id: ID del tenant
Returns:
Lista de relaciones UserTenant
"""
return (
self.db.query(UserTenant)
.filter(and_(UserTenant.tenant_id == tenant_id, UserTenant.is_active))
.all()
)
def user_has_access_to_tenant(self, keycloak_user_id: str, tenant_id: int) -> bool:
"""
Verifica si un usuario tiene acceso a un tenant
Args:
keycloak_user_id: ID del usuario en Keycloak
tenant_id: ID del tenant
Returns:
True si tiene acceso, False en caso contrario
"""
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant_id,
UserTenant.is_active,
)
)
.first()
)
return user_tenant is not None
def update_user_role_in_tenant(
self, keycloak_user_id: str, tenant_id: int, role: str
) -> UserTenant:
"""
Actualiza el rol de un usuario en un tenant
Args:
keycloak_user_id: ID del usuario en Keycloak
tenant_id: ID del tenant
role: Nuevo rol
Returns:
UserTenant actualizado
"""
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant_id,
)
)
.first()
)
if not user_tenant:
raise HTTPException(
status_code=404, detail="User-tenant relationship not found"
)
user_tenant.role = role
self.db.commit()
self.db.refresh(user_tenant)
return user_tenant

View File

@@ -0,0 +1,3 @@
"""
Módulo de gestión de usuarios (Keycloak)
"""

View File

@@ -0,0 +1,105 @@
"""
DTOs para gestión de usuarios de Keycloak
"""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, EmailStr, Field, field_validator
class CreateUserRequestDTO(BaseModel):
"""Request para crear un nuevo usuario en Keycloak"""
email: EmailStr = Field(..., description="Email del usuario")
username: str = Field(
..., min_length=3, max_length=50, description="Nombre de usuario"
)
first_name: str = Field(..., min_length=1, max_length=100, description="Nombre")
last_name: str = Field(..., min_length=1, max_length=100, description="Apellido")
password: str = Field(..., min_length=8, description="Contraseña temporal")
role: Optional[str] = Field(None, description="Rol del usuario en el tenant")
enabled: bool = Field(True, description="Si el usuario está habilitado")
email_verified: bool = Field(False, description="Si el email está verificado")
class UpdateUserRequestDTO(BaseModel):
"""Request para actualizar un usuario en Keycloak"""
first_name: Optional[str] = Field(None, max_length=100)
last_name: Optional[str] = Field(None, max_length=100)
email: Optional[str] = Field(None, max_length=255)
enabled: Optional[bool] = None
email_verified: Optional[bool] = None
role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual")
# Campos de perfil
avatar_url: Optional[str] = Field(
None, max_length=500, description="URL del avatar"
)
phone: Optional[str] = Field(None, max_length=20, description="Teléfono")
bio: Optional[str] = Field(None, description="Biografía")
preferences: Optional[dict] = Field(None, description="Preferencias del usuario")
@field_validator("first_name", "last_name", "email")
@classmethod
def validate_non_empty_string(cls, v: Optional[str]) -> Optional[str]:
"""Valida que si el string está presente, no esté vacío"""
if v is not None and v.strip() == "":
return None # Convertir strings vacíos a None
return v
class UserResponseDTO(BaseModel):
"""Response con información de usuario de Keycloak"""
id: str = Field(..., description="ID de Keycloak del usuario")
username: str
email: str = Field(default="", description="Email del usuario")
first_name: str = Field(default="", description="Nombre del usuario")
last_name: str = Field(default="", description="Apellido del usuario")
enabled: bool
email_verified: bool
created_timestamp: Optional[int] = None
role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual")
# Campos de perfil
avatar_url: Optional[str] = Field(None, description="URL del avatar")
phone: Optional[str] = Field(None, description="Teléfono")
bio: Optional[str] = Field(None, description="Biografía")
preferences: Optional[dict] = Field(
default_factory=dict, description="Preferencias"
)
class Config:
from_attributes = True
class UserListResponseDTO(BaseModel):
"""Response con lista de usuarios"""
users: List[UserResponseDTO]
total: int
page: int
page_size: int
total_pages: int
class ChangePasswordRequestDTO(BaseModel):
"""Request para cambiar contraseña de un usuario"""
password: str = Field(..., min_length=8, description="Nueva contraseña")
temporary: bool = Field(
True, description="Si es temporal (usuario debe cambiarla al login)"
)
class UserStatsDTO(BaseModel):
"""Estadísticas de usuarios del tenant"""
total_users: int
active_users: int
inactive_users: int
max_users_allowed: int
users_available: int
usage_percentage: float

View File

@@ -0,0 +1,478 @@
"""
Rutas para gestión de usuarios de Keycloak
"""
import logging
import mimetypes
from typing import Optional
import os
import uuid
from pathlib import Path
from core.config import settings
from core.database import get_core_db
from core.s3_keys import public_user_avatar_api_path, user_avatar_key
from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes
from core.security import (
get_current_user,
is_hub_admin,
resolve_hub_tenant_id_for_api,
validate_access_to_resource,
)
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import Response
from sqlalchemy.orm import Session
from ..user_tenant.models import UserTenant
from .dto import (
ChangePasswordRequestDTO,
CreateUserRequestDTO,
UpdateUserRequestDTO,
UserListResponseDTO,
UserResponseDTO,
UserStatsDTO,
)
from .service import UserService
router = APIRouter(prefix="/users", tags=["Users"])
logger = logging.getLogger(__name__)
_AVATAR_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
@router.get("/stats", response_model=UserStatsDTO)
async def get_user_statistics(
request: Request,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene estadísticas de usuarios del tenant actual
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
auth_header = request.headers.get("Authorization") or ""
token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip()
)
hub_tid = resolve_hub_tenant_id_for_api(
tenant_id, request.headers.get("X-Tenant-Override")
)
return service.get_user_stats(
access_token=token or None,
hub_tenant_id=hub_tid,
x_tenant_override=request.headers.get("X-Tenant-Override"),
)
@router.get("/", response_model=UserListResponseDTO)
async def list_users(
request: Request,
company_id: int = Query(..., description="Company ID"),
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(20, ge=1, le=100, description="Tamaño de página"),
search: Optional[str] = Query(None, description="Término de búsqueda"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Lista todos los usuarios del tenant con paginación
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
auth_header = request.headers.get("Authorization") or ""
token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip()
)
hub_tid = resolve_hub_tenant_id_for_api(
tenant_id, request.headers.get("X-Tenant-Override")
)
result = await service.get_tenant_users(
page=page,
page_size=page_size,
search=search,
access_token=token,
hub_tenant_id=hub_tid,
x_tenant_override=request.headers.get("X-Tenant-Override"),
)
return result
@router.get("/avatar/{tenant_id}/{keycloak_user_id}")
def get_user_avatar_image(
tenant_id: int,
keycloak_user_id: str,
db: Session = Depends(get_core_db),
):
"""
Sirve la imagen de avatar (público para poder usarla en <img src> sin Bearer).
El almacenamiento interno puede ser clave S3 o ruta bajo uploads/.
"""
ut = (
db.query(UserTenant)
.filter(
UserTenant.tenant_id == tenant_id,
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
.first()
)
if not ut or not ut.avatar_url:
raise HTTPException(status_code=404, detail="Avatar not found")
raw = ut.avatar_url
if raw.startswith("tenants/"):
try:
data = get_object_bytes(raw)
except Exception:
raise HTTPException(status_code=404, detail="Avatar not found")
media = mimetypes.guess_type(raw)[0] or "image/jpeg"
return Response(content=data, media_type=media)
rel = raw.lstrip("/")
path = Path(rel)
if not path.is_file():
path = Path.cwd() / rel
if not path.is_file():
alt = Path("/app") / rel
if alt.is_file():
path = alt
if not path.is_file():
raise HTTPException(status_code=404, detail="Avatar file not found")
data = path.read_bytes()
media = mimetypes.guess_type(str(path))[0] or "image/jpeg"
return Response(content=data, media_type=media)
# === Endpoints de Perfil del Usuario Actual ===
@router.get("/me/profile", response_model=UserResponseDTO)
async def get_my_profile(
request: Request,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Obtiene el perfil completo del usuario actual
"""
keycloak_user_id = current_user.get("sub")
if not keycloak_user_id:
raise HTTPException(status_code=400, detail="User ID not found in token")
# Obtener user_tenant para crear servicio
user_tenant = (
db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
.first()
)
if not user_tenant:
raise HTTPException(
status_code=400, detail="User does not belong to any tenant"
)
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
auth_header = request.headers.get("Authorization") or ""
access_token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip() or None
)
return await service.get_current_user_profile(
keycloak_user_id,
current_user=current_user,
access_token=access_token,
)
@router.put("/me/profile", response_model=UserResponseDTO)
async def update_my_profile(
request: Request,
data: UpdateUserRequestDTO,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Actualiza el perfil del usuario actual.
Campos editables: first_name, last_name, phone.
Email, username y otros campos de identidad solo se cambian desde el Hub.
"""
keycloak_user_id = current_user.get("sub")
if not keycloak_user_id:
raise HTTPException(status_code=400, detail="User ID not found in token")
# Obtener user_tenant
user_tenant = (
db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
.first()
)
if not user_tenant:
raise HTTPException(
status_code=400, detail="User does not belong to any tenant"
)
# Para sesiones autenticadas vía Workspace/Hub, la foto de perfil viene del Hub
# y no debe mutarse localmente en Anexo76.
if current_user.get("sub"):
raise HTTPException(
status_code=409,
detail="Avatar is managed by Workspace for this user",
)
auth_header = request.headers.get("Authorization") or ""
access_token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip() or None
)
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
return await service.update_current_user_profile(
keycloak_user_id=keycloak_user_id,
current_user=current_user,
access_token=access_token,
first_name=data.first_name,
last_name=data.last_name,
phone=data.phone,
)
@router.post("/me/avatar")
async def upload_my_avatar(
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Sube un avatar para el usuario actual.
Con MinIO guarda en tenants/{tid}/users/{sub}/avatar.{ext} y persiste la clave en UserTenant.
Retorna URL pública para <img src> (GET /users/avatar/...).
"""
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="El archivo debe ser una imagen")
keycloak_user_id = current_user.get("sub")
if not keycloak_user_id:
raise HTTPException(status_code=400, detail="User ID not found in token")
user_tenant = (
db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
.first()
)
if not user_tenant:
raise HTTPException(
status_code=400, detail="User does not belong to any tenant"
)
ext = Path(file.filename or "image.jpg").suffix.lower() or ".jpg"
if ext not in _AVATAR_EXT:
raise HTTPException(
status_code=400,
detail=f"Extensión no permitida. Use: {', '.join(sorted(_AVATAR_EXT))}",
)
contents = await file.read()
if len(contents) > 2 * 1024 * 1024:
raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB")
tenant_id = user_tenant.tenant_id
try:
if settings.use_s3_object_storage:
if user_tenant.avatar_url and str(user_tenant.avatar_url).startswith(
"tenants/"
):
delete_object_if_exists(str(user_tenant.avatar_url))
key = user_avatar_key(tenant_id, keycloak_user_id, ext)
ct = file.content_type or mimetypes.guess_type(f"x{ext}")[0] or "image/jpeg"
put_object_bytes(key, contents, content_type=ct)
user_tenant.avatar_url = key
logger.info(
"User avatar stored in S3 key=%s bytes=%s", key, len(contents)
)
else:
upload_dir = Path("uploads/avatars")
upload_dir.mkdir(parents=True, exist_ok=True)
filename = f"{keycloak_user_id}{ext}"
file_path = upload_dir / filename
with open(file_path, "wb") as f:
f.write(contents)
user_tenant.avatar_url = f"/uploads/avatars/{filename}"
db.add(user_tenant)
db.commit()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
db.rollback()
raise HTTPException(
status_code=500, detail=f"Error al guardar el avatar: {str(e)}"
) from e
public_url = public_user_avatar_api_path(tenant_id, keycloak_user_id)
return {"avatar_url": public_url}
@router.get("/{user_id}", response_model=UserResponseDTO)
async def get_user_detail(
user_id: str,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Obtiene información detallada de un usuario específico
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
return await service.get_user(user_id)
@router.post("/", response_model=UserResponseDTO, status_code=201)
async def create_new_user(
data: CreateUserRequestDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Crea un nuevo usuario a través del Hub y lo asocia al tenant
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.create"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
user = await service.create_user(
email=data.email,
username=data.username,
first_name=data.first_name,
last_name=data.last_name,
password=data.password,
role=data.role,
enabled=data.enabled,
email_verified=data.email_verified,
)
return user
@router.put("/{user_id}", response_model=UserResponseDTO)
async def update_user_detail(
user_id: str,
data: UpdateUserRequestDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Actualiza información de un usuario
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
user = await service.update_user(
user_id=user_id,
first_name=data.first_name,
last_name=data.last_name,
email=data.email,
enabled=data.enabled,
email_verified=data.email_verified,
role=data.role,
avatar_url=data.avatar_url,
phone=data.phone,
bio=data.bio,
preferences=data.preferences,
)
return user
@router.get("/{user_id}/tenant-count")
async def get_user_tenant_count(
user_id: str,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Retorna en cuántos tenants está registrado el usuario.
"""
tenant_id = validate_access_to_resource(
db, company_id, current_user, required_permissions=["user.view"]
)
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
count = service.get_user_tenant_count(user_id)
return {"tenant_count": count}
@router.delete("/{user_id}")
async def delete_user_route(
request: Request,
user_id: str,
company_id: int = Query(..., description="Company ID"),
soft_delete: bool = Query(
True,
description="Si es True, solo desactiva. Si es False, elimina permanentemente",
),
scope: str = Query(
"current",
description="'current' para borrar solo del tenant activo, 'all' para borrar de todos los tenants",
),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Elimina un usuario del tenant.
scope='current' (default): solo del tenant activo.
scope='all': de todos los tenants en los que aparece el usuario.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.delete"])
auth_header = request.headers.get("Authorization") or ""
token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip()
)
hub_tid = resolve_hub_tenant_id_for_api(
tenant_id, request.headers.get("X-Tenant-Override")
)
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
await service.delete_user(
user_id,
soft_delete=soft_delete,
scope=scope,
access_token=token or None,
hub_tenant_id=hub_tid,
)
return {"message": "User deleted successfully"}
@router.post("/{user_id}/change-password")
async def change_user_password(
user_id: str,
data: ChangePasswordRequestDTO,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Cambia la contraseña de un usuario a través del Hub
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.update"])
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
await service.change_password(user_id, data.password, data.temporary)
return {"message": "Password changed successfully"}

View File

@@ -0,0 +1,808 @@
import logging
import httpx
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from fastapi import HTTPException
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from core.config import settings
from ..licenses.models import License, LicenseStatus
from ..user_tenant.models import UserTenant
logger = logging.getLogger(__name__)
def _is_valid_http_url(url: Optional[str]) -> bool:
if not url or not isinstance(url, str):
return False
parsed = urlparse(url.strip())
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _legacy_avatar_public_url(user_tenant: Optional[Any]) -> Optional[str]:
if not user_tenant or not user_tenant.avatar_url:
return None
avatar_out = str(user_tenant.avatar_url)
if avatar_out.startswith("http://") or avatar_out.startswith("https://"):
return avatar_out if _is_valid_http_url(avatar_out) else None
from core.s3_keys import public_user_avatar_api_path
# Entregamos siempre el endpoint público del backend para assets locales/S3.
return public_user_avatar_api_path(
user_tenant.tenant_id,
user_tenant.keycloak_user_id,
)
def _normalize_user(
user_data: Dict[str, Any],
role: Optional[str] = None,
user_tenant: Optional[Any] = None,
) -> Dict[str, Any]:
"""
Normaliza los datos de usuario al formato esperado por el DTO.
Prioridad para nombre/apellido: caché local (user_tenant) > JWT claims > campo 'name'.
"""
name_parts = (user_data.get("name") or "").split(" ", 1)
# Caché local tiene prioridad — se actualiza al guardar perfil desde Anexo76
local_first = getattr(user_tenant, "first_name", None) if user_tenant else None
local_last = getattr(user_tenant, "last_name", None) if user_tenant else None
normalized = {
"id": user_data.get("id") or user_data.get("sub"),
"username": user_data.get("username") or user_data.get("preferred_username", ""),
"email": user_data.get("email", ""),
"first_name": local_first or user_data.get("firstName") or user_data.get("given_name") or (name_parts[0] if name_parts else ""),
"last_name": local_last or user_data.get("lastName") or user_data.get("family_name") or (name_parts[1] if len(name_parts) > 1 else ""),
"enabled": user_data.get("enabled", True),
"email_verified": user_data.get("emailVerified") or user_data.get("email_verified", False),
"created_timestamp": user_data.get("createdTimestamp"),
"role": role,
}
# Agregar campos de perfil si user_tenant está disponible
if user_tenant:
workspace_avatar = (
user_tenant.workspace_avatar_url
if _is_valid_http_url(user_tenant.workspace_avatar_url)
else None
)
legacy_avatar = _legacy_avatar_public_url(user_tenant)
avatar_out = workspace_avatar or legacy_avatar
normalized.update(
{
"avatar_url": avatar_out,
"workspace_avatar_url": workspace_avatar,
"legacy_avatar_url": legacy_avatar,
"workspace_user_id": user_tenant.workspace_user_id,
"phone": user_tenant.phone,
"bio": user_tenant.bio,
"preferences": user_tenant.preferences or {},
}
)
return normalized
class UserService:
"""Servicio para gestionar usuarios vía Hub"""
def __init__(self, db: Session, tenant_id: int = None, company_id: int = None, *, is_hub_admin: bool = False):
self.db = db
self.tenant_id = tenant_id
self.company_id = company_id
self.is_hub_admin = is_hub_admin
def _get_license(self) -> License:
"""Obtiene la licencia del tenant actual"""
license = (
self.db.query(License).filter(License.tenant_id == self.tenant_id).first()
)
if not license:
raise HTTPException(
status_code=404, detail="License not found for this tenant"
)
if license.status != LicenseStatus.ACTIVE:
raise HTTPException(
status_code=403,
detail=f"License is not active. Current status: {license.status.value}",
)
# Verificar si la licencia está vigente
now = datetime.now(license.expires_at.tzinfo)
if license.expires_at < now:
raise HTTPException(status_code=403, detail="License has expired")
return license
def _check_user_limit(self) -> None:
"""Verifica si se puede crear un nuevo usuario según la licencia"""
if self.is_hub_admin:
return
license = self._get_license()
# Contar usuarios activos del tenant
active_users = (
self.db.query(func.count(UserTenant.id))
.filter(
and_(
UserTenant.tenant_id == self.tenant_id,
UserTenant.is_active == True,
)
)
.scalar()
)
# max_users=NULL en BD indica licencia sin cuota (ilimitada).
# Comparar con None lanzaría TypeError — salida temprana explícita.
if license.max_users is None:
return
if active_users >= license.max_users:
raise HTTPException(
status_code=403,
detail=f"User limit reached. Your license allows {license.max_users} users. "
f"Currently active: {active_users}. Please upgrade your license.",
)
async def create_user(
self,
email: str,
username: str,
first_name: str,
last_name: str,
password: str,
role: Optional[str] = None,
enabled: bool = True,
email_verified: bool = False,
) -> Dict[str, Any]:
"""
Crea un nuevo usuario a través del Hub y lo asocia localmente
"""
# Verificar límite de usuarios
self._check_user_limit()
try:
# Mandar al Hub para creación en Keycloak
async with httpx.AsyncClient(timeout=10.0) as client:
hub_response = await client.post(
f"{settings.HUB_URL}api/v1/auth/register",
json={
"email": email,
"username": username,
"first_name": first_name,
"last_name": last_name,
"password": password,
"tenant_slug": "default", # TODO: Get real slug if needed
}
)
if hub_response.status_code != 201:
logger.error(f"Hub registration failed: {hub_response.text}")
raise HTTPException(status_code=hub_response.status_code, detail="Failed to create user in Hub")
user_data = hub_response.json()
user_id = user_data.get("user_id")
# Obtener company_id — implementa con tu modelo de compañía si company_id es None.
if not self.company_id:
raise HTTPException(status_code=400, detail="company_id requerido")
company_id = self.company_id
# Crear relación local
user_tenant = UserTenant(
keycloak_user_id=user_id,
tenant_id=self.tenant_id,
company_id=company_id,
role=role,
is_active=True,
)
self.db.add(user_tenant)
self.db.commit()
return _normalize_user(user_data, role, user_tenant)
except Exception as e:
logger.error(f"Error creating user: {str(e)}")
self.db.rollback()
if isinstance(e, HTTPException):
raise e
raise HTTPException(status_code=500, detail=str(e))
async def _fetch_hub_tenant_users_with_info(
self,
access_token: str,
hub_tenant_id: int,
x_tenant_override: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Lista usuarios del tenant desde Aduanasoft Hub (Keycloak + user_tenants)."""
base = (settings.HUB_URL or "").rstrip("/")
url = f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info"
headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"}
if x_tenant_override and str(x_tenant_override).strip():
headers["X-Tenant-Override"] = str(x_tenant_override).strip()
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
if response.status_code == 401:
raise HTTPException(status_code=401, detail="No autorizado en el Hub")
if response.status_code == 403:
raise HTTPException(
status_code=403, detail="Sin permiso para listar usuarios del tenant en el Hub"
)
if response.status_code >= 400:
logger.error(
"Hub users-with-info error status=%s body=%s",
response.status_code,
response.text[:500],
)
raise HTTPException(
status_code=502,
detail="No se pudo obtener el catálogo de usuarios desde el Hub",
)
data = response.json()
if not isinstance(data, list):
raise HTTPException(
status_code=502, detail="Respuesta inválida del Hub al listar usuarios"
)
return data
async def get_tenant_users(
self,
page: int = 1,
page_size: int = 20,
search: Optional[str] = None,
*,
access_token: str,
hub_tenant_id: int,
x_tenant_override: Optional[str] = None,
) -> Dict[str, Any]:
"""
Usuarios del tenant: fuente de verdad Aduanasoft Hub; roles de compañía y
perfil extendido desde BD local (user_tenants / user_company_roles).
"""
try:
from ..permissions.models import UserCompanyRole
from sqlalchemy.orm import joinedload
if not access_token or not hub_tenant_id:
raise HTTPException(
status_code=400,
detail="Token o tenant Hub requerido para listar usuarios",
)
hub_rows = await self._fetch_hub_tenant_users_with_info(
access_token, hub_tenant_id, x_tenant_override
)
user_roles_query = (
self.db.query(UserCompanyRole)
.options(joinedload(UserCompanyRole.company_role))
.filter(
and_(
UserCompanyRole.company_id == self.company_id,
UserCompanyRole.tenant_id == self.tenant_id,
UserCompanyRole.is_active == True,
)
)
)
user_roles_map: Dict[str, List[str]] = {}
for user_role in user_roles_query.all():
uid = user_role.user_id
if uid not in user_roles_map:
user_roles_map[uid] = []
user_roles_map[uid].append(user_role.company_role.name)
local_by_kc = {
ut.keycloak_user_id: ut
for ut in self.db.query(UserTenant)
.filter(
and_(
UserTenant.tenant_id == self.tenant_id,
UserTenant.company_id == self.company_id,
)
)
.all()
}
needle = (search or "").strip().lower()
filtered: List[Dict[str, Any]] = []
for u in hub_rows:
if not u.get("is_active", True):
continue
kc = u.get("keycloak_user_id")
if not kc:
continue
# Filtrar usuarios soft-deleted localmente (is_active=False en user_tenants local)
local_ut_check = local_by_kc.get(kc)
if local_ut_check is not None and not local_ut_check.is_active:
continue
if needle:
blob = " ".join(
[
str(u.get("email") or ""),
str(u.get("username") or ""),
str(u.get("first_name") or ""),
str(u.get("last_name") or ""),
]
).lower()
ut_loc = local_by_kc.get(kc)
if ut_loc:
blob += f" {ut_loc.phone or ''} {ut_loc.bio or ''}".lower()
if needle not in blob:
continue
filtered.append(u)
total = len(filtered)
offset = (page - 1) * page_size
page_rows = filtered[offset : offset + page_size]
users: List[Dict[str, Any]] = []
for u in page_rows:
kc = u["keycloak_user_id"]
role_names = user_roles_map.get(kc, [])
role_str = ", ".join(role_names) if role_names else u.get("role")
local_ut = local_by_kc.get(kc)
normalized_user = _normalize_user(
{
"id": kc,
"username": u.get("username") or "",
"email": u.get("email") or "",
"firstName": u.get("first_name") or "",
"lastName": u.get("last_name") or "",
"enabled": u.get("is_active", True),
"emailVerified": False,
},
role_str,
local_ut,
)
users.append(normalized_user)
total_pages = max(1, (total + page_size - 1) // page_size) if total else 1
return {
"users": users,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting tenant users: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error getting users: {str(e)}"
) from e
async def get_user(self, user_id: str) -> Dict[str, Any]:
"""Obtiene un usuario específico"""
from ..permissions.models import UserCompanyRole
from sqlalchemy.orm import joinedload
user_tenant = self.db.query(UserTenant).filter(
and_(
UserTenant.keycloak_user_id == user_id,
UserTenant.tenant_id == self.tenant_id,
UserTenant.is_active == True,
)
).first()
if not user_tenant:
raise HTTPException(status_code=404, detail="User not found")
# Roles locales
user_roles = self.db.query(UserCompanyRole).options(joinedload(UserCompanyRole.company_role)).filter(
and_(
UserCompanyRole.user_id == user_id,
UserCompanyRole.company_id == self.company_id,
UserCompanyRole.tenant_id == self.tenant_id,
UserCompanyRole.is_active == True
)
).all()
roles = [ur.company_role.name for ur in user_roles]
role_str = ", ".join(roles) if roles else None
# TODO: Call Hub if more info is needed
return _normalize_user({"id": user_id}, role_str, user_tenant)
async def update_user(self, user_id: str, **kwargs) -> Dict[str, Any]:
"""Actualiza información local del usuario (e identidad vía Hub si se implementa)"""
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id)
).first()
if not user_tenant:
raise HTTPException(status_code=404, detail="User not found")
# Actualizar campos locales
for field in ["role", "avatar_url", "phone", "bio", "preferences"]:
if field in kwargs and kwargs[field] is not None:
setattr(user_tenant, field, kwargs[field])
self.db.commit()
self.db.refresh(user_tenant)
return _normalize_user({"id": user_id}, user_tenant.role, user_tenant)
def get_user_tenant_count(self, user_id: str) -> int:
"""Cuenta en cuántos tenants activos está registrado el usuario."""
return (
self.db.query(func.count(UserTenant.id))
.filter(
UserTenant.keycloak_user_id == user_id,
UserTenant.is_active == True,
)
.scalar()
or 0
)
async def delete_user(
self,
user_id: str,
soft_delete: bool = True,
scope: str = "current",
access_token: Optional[str] = None,
hub_tenant_id: Optional[int] = None,
) -> None:
"""
Elimina/Desactiva usuario.
scope='current': solo del tenant activo.
scope='all': de todos los tenants (útil cuando el usuario pertenece a múltiples tenants).
"""
if scope == "all":
rows = (
self.db.query(UserTenant)
.filter(UserTenant.keycloak_user_id == user_id)
.all()
)
if not rows:
raise HTTPException(status_code=404, detail="User not found")
now = datetime.utcnow()
# Collect unique hub_tenant_ids to notify Hub for each tenant
hub_tenant_ids = {row.tenant_id for row in rows}
for row in rows:
row.is_active = False
if not soft_delete:
row.deleted_at = now
self.db.commit()
# Propagate to Hub for every tenant the user belonged to
if access_token:
for tid in hub_tenant_ids:
await self._hub_remove_user(user_id, tid, soft_delete, access_token)
return
# scope == "current" (default)
user_tenant = self.db.query(UserTenant).filter(
and_(
UserTenant.keycloak_user_id == user_id,
UserTenant.tenant_id == self.tenant_id,
UserTenant.company_id == self.company_id,
)
).first()
if not user_tenant:
# No local record — user exists in Hub but not synced locally yet.
# Create tombstone so user is filtered from future listings.
user_tenant = UserTenant(
keycloak_user_id=user_id,
tenant_id=self.tenant_id,
company_id=self.company_id,
is_active=False,
deleted_at=None if soft_delete else datetime.utcnow(),
)
self.db.add(user_tenant)
self.db.commit()
else:
user_tenant.is_active = False
if not soft_delete:
user_tenant.deleted_at = datetime.utcnow()
self.db.commit()
# Propagate to Hub
if access_token and hub_tenant_id:
await self._hub_remove_user(user_id, hub_tenant_id, soft_delete, access_token)
async def _hub_remove_user(
self,
user_id: str,
hub_tenant_id: int,
soft_delete: bool,
access_token: str,
) -> None:
"""Calls Hub POST /api/v1/hub/user-tenants/remove to sync the deletion."""
base = (settings.HUB_URL or "").rstrip("/")
url = f"{base}/api/v1/hub/user-tenants/remove"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
url,
json={
"keycloak_user_id": user_id,
"tenant_id": hub_tenant_id,
"soft_delete": soft_delete,
},
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code >= 400:
logger.warning(
"Hub remove user-tenant returned %s for user %s tenant %s: %s",
resp.status_code, user_id, hub_tenant_id, resp.text[:200],
)
except Exception as exc:
logger.error("Error calling Hub remove user-tenant: %s", exc)
# Do not raise — local deletion already committed; Hub sync is best-effort.
async def change_password(self, user_id: str, password: str, temporary: bool = True) -> None:
"""Cambia contraseña vía Hub"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await client.post(
f"{settings.HUB_URL}api/v1/auth/change-password",
json={"user_id": user_id, "password": password, "temporary": temporary}
)
except Exception as e:
logger.error(f"Error changing password: {e}")
raise HTTPException(status_code=500, detail="Error changing password")
def _count_active_user_tenants_local(self) -> int:
return (
self.db.query(func.count(UserTenant.id))
.filter(
and_(
UserTenant.tenant_id == self.tenant_id,
UserTenant.is_active == True,
)
)
.scalar()
or 0
)
def get_user_stats(
self,
access_token: Optional[str] = None,
hub_tenant_id: Optional[int] = None,
x_tenant_override: Optional[str] = None,
) -> Dict[str, Any]:
"""
Estadísticas de usuarios: cupo según licencia efectiva del Hub (verify-license)
con ``X-Tenant-Override``; activos desde users-with-info del Hub si hay token;
inactivos y fallback de conteos en BD local.
"""
max_users_allowed: Optional[int] = None # None = sin cuota (hub_admin ilimitado)
hub_max_ok = False
active_users = 0
active_from_hub = False
if access_token and hub_tenant_id:
base = (settings.HUB_URL or "").rstrip("/")
headers: Dict[str, Any] = {"Authorization": f"Bearer {access_token}"}
if x_tenant_override and str(x_tenant_override).strip():
headers["X-Tenant-Override"] = str(x_tenant_override).strip()
try:
with httpx.Client(timeout=30.0) as client:
lic_resp = client.get(
f"{base}/api/v1/auth/verify-license",
headers=headers,
)
if lic_resp.status_code == 200:
lic_body = lic_resp.json()
if lic_body.get("valid"):
raw_max = lic_body.get("max_users")
# max_users=null → hub_admin sin cuota; None indica ilimitado
max_users_allowed = int(raw_max) if raw_max is not None else None
hub_max_ok = True
users_resp = client.get(
f"{base}/api/v1/hub/user-tenants/tenant/{hub_tenant_id}/users-with-info",
headers=headers,
)
if users_resp.status_code == 200:
payload = users_resp.json()
if isinstance(payload, list):
active_users = sum(
1 for row in payload if row.get("is_active", True)
)
active_from_hub = True
else:
logger.warning(
"Hub users-with-info stats: respuesta no lista"
)
else:
logger.warning(
"Hub users-with-info stats status=%s",
users_resp.status_code,
)
except Exception as e:
logger.warning("Hub stats (verify-license / users-with-info): %s", e)
if not hub_max_ok:
license = self._get_license()
max_users_allowed = license.max_users
if not active_from_hub:
active_users = self._count_active_user_tenants_local()
inactive_users = (
self.db.query(func.count(UserTenant.id))
.filter(
and_(
UserTenant.tenant_id == self.tenant_id,
UserTenant.is_active == False,
)
)
.scalar()
or 0
)
total_users = active_users + inactive_users
# Cuando max_users_allowed es None la cuota es ilimitada (hub_admin)
users_available = (
max(0, max_users_allowed - active_users)
if max_users_allowed is not None
else None
)
usage_percentage = (
(active_users / max_users_allowed * 100) if max_users_allowed else 0.0
)
return {
"total_users": total_users,
"active_users": active_users,
"inactive_users": inactive_users,
"max_users_allowed": max_users_allowed,
"users_available": users_available,
"usage_percentage": round(usage_percentage, 2),
}
async def get_current_user_profile(
self,
keycloak_user_id: str,
current_user: Dict[str, Any] = None,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""Obtiene el perfil completo del usuario actual"""
# Use the already-verified JWT claims dict — do NOT call verify_token(uuid)
user_info = current_user or {"id": keycloak_user_id}
# Perfil "me": sincronización con cache corto (5 min).
if access_token:
from core.workspace_profile_sync import sync_workspace_profile_for_user
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=keycloak_user_id,
tenant_id=self.tenant_id,
)
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
).first()
return _normalize_user(user_info, user_tenant.role if user_tenant else None, user_tenant)
async def update_current_user_profile(
self,
keycloak_user_id: str,
current_user: Dict[str, Any] = None,
access_token: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
"""
Actualiza el perfil del usuario actual.
- first_name / last_name: persiste localmente en UserTenant Y sincroniza con Keycloak vía Hub.
- phone: persiste solo localmente.
"""
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.tenant_id == self.tenant_id)
).first()
if not user_tenant:
raise HTTPException(status_code=404, detail="User not found")
# Campos locales — incluye first_name/last_name como caché
for field in ["role", "avatar_url", "phone", "bio", "preferences", "first_name", "last_name"]:
if field in kwargs and kwargs[field] is not None:
setattr(user_tenant, field, kwargs[field])
self.db.commit()
self.db.refresh(user_tenant)
# Sincronizar nombre/apellido con Keycloak vía Hub (best-effort)
first_name = kwargs.get("first_name")
last_name = kwargs.get("last_name")
if access_token and (first_name or last_name):
await self._hub_update_user_profile(keycloak_user_id, first_name, last_name, access_token)
user_info = current_user or {"id": keycloak_user_id}
return _normalize_user(user_info, user_tenant.role, user_tenant)
async def _hub_get_service_token(self) -> Optional[str]:
"""Obtiene un token de la cuenta de servicio Hub para operaciones admin."""
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
return None
base = (settings.HUB_URL or "").rstrip("/")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{base}/api/v1/auth/login",
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
)
if resp.status_code == 200:
data = resp.json()
return data.get("access_token")
logger.warning("Hub service account login failed status=%s", resp.status_code)
except Exception as exc:
logger.warning("Hub service account login error: %s", exc)
return None
async def _hub_update_user_profile(
self,
user_id: str,
first_name: Optional[str],
last_name: Optional[str],
access_token: str,
) -> None:
"""Sincroniza nombre/apellido con Keycloak a través del Hub (best-effort, no bloquea).
Intenta primero con el token del usuario. Si el Hub devuelve 403 (el usuario no
tiene rol de Hub-admin), reintenta usando la cuenta de servicio configurada en
HUB_ADMIN_EMAIL / HUB_ADMIN_PASSWORD.
"""
base = (settings.HUB_URL or "").rstrip("/")
url = f"{base}/api/v1/hub/admins/{user_id}"
payload: Dict[str, Any] = {}
if first_name:
payload["first_name"] = first_name
if last_name:
payload["last_name"] = last_name
if not payload:
return
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.patch(
url,
json=payload,
headers={"Authorization": f"Bearer {access_token}"},
)
if resp.status_code == 403:
# El usuario no es Hub admin — reintentar con cuenta de servicio
logger.info("Hub profile sync: user token got 403, trying service account for user_id=%s", user_id)
service_token = await self._hub_get_service_token()
if service_token:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.patch(
url,
json=payload,
headers={"Authorization": f"Bearer {service_token}"},
)
else:
logger.warning(
"Hub profile sync skipped: no service account configured (HUB_ADMIN_EMAIL/HUB_ADMIN_PASSWORD)"
)
return
if resp.status_code not in (200, 204):
logger.warning(
"Hub profile sync failed status=%s body=%s",
resp.status_code,
resp.text[:300],
)
else:
logger.info("Hub profile sync OK user_id=%s", user_id)
except Exception as exc:
logger.warning("Hub profile sync error user_id=%s: %s", user_id, exc)