feat: enhance user statistics and tenant management

- Updated user statistics endpoint to include access token and hub tenant ID for improved data retrieval.
- Refactored user listing functionality to support access token and hub tenant ID, ensuring accurate user data from the Hub.
- Introduced new methods in UserService for fetching users with additional information from the Hub.
- Enhanced security functions to ensure user-tenant relationships are maintained and synchronized with the Hub.
- Improved frontend logic to handle company initialization and user authentication more effectively.
This commit is contained in:
2026-05-01 23:32:48 -05:00
parent d24bb89294
commit 2ce8a9047c
6 changed files with 436 additions and 90 deletions

View File

@@ -13,8 +13,12 @@ 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, validate_access_to_resource
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from core.security import (
get_current_user,
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
@@ -38,6 +42,7 @@ _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),
@@ -47,12 +52,25 @@ async def get_user_statistics(
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id)
return service.get_user_stats() # Este no es async en service.py
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"),
@@ -65,7 +83,23 @@ async def list_users(
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
service = UserService(db, tenant_id, company_id)
result = await service.get_tenant_users(page=page, page_size=page_size, search=search)
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

View File

@@ -180,85 +180,170 @@ class UserService:
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
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]:
"""
Obtiene todos los usuarios del tenant con paginación
Args:
page: Número de página (1-indexed)
page_size: Tamaño de página
search: Término de búsqueda (busca en username, email, nombre, teléfono y bio)
Returns:
Dict con usuarios y metadatos de paginación
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, CompanyRole
from ..permissions.models import UserCompanyRole
from sqlalchemy.orm import joinedload
# Obtener relaciones usuario-tenant
query = self.db.query(UserTenant).filter(
and_(
UserTenant.tenant_id == self.tenant_id,
UserTenant.is_active == True,
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
)
total = query.count()
# Calcular offset
offset = (page - 1) * page_size
user_tenants = query.offset(offset).limit(page_size).all()
# Obtener roles de usuarios en la compañía actual
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_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,
)
)
)
# Crear un mapa de user_id -> lista de roles
user_roles_map = {}
user_roles_map: Dict[str, List[str]] = {}
for user_role in user_roles_query.all():
if user_role.user_id not in user_roles_map:
user_roles_map[user_role.user_id] = []
user_roles_map[user_role.user_id].append(user_role.company_role.name)
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)
try:
# En lugar de consultar Keycloak uno a uno (lento y sin API directa ahora),
# devolvemos la info local mínima o consultamos un endpoint de "buscar varios" en el Hub si existiera.
# Por ahora, minimizamos el impacto devolviendo lo que tenemos local.
normalized_user = _normalize_user({
"id": ut.keycloak_user_id,
"username": "User", # Placeholder si no tenemos el dato local
}, role_str, ut)
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()
}
users.append(normalized_user)
except Exception as e:
logger.warning(f"Error processing user {ut.keycloak_user_id}: {e}")
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
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_pages = (total + page_size - 1) // page_size
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": len(users) if search else total,
"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"""
@@ -338,12 +423,8 @@ class UserService:
logger.error(f"Error changing password: {e}")
raise HTTPException(status_code=500, detail="Error changing password")
def get_user_stats(self) -> Dict[str, Any]:
"""Obtiene estadísticas de usuarios del tenant"""
license = self._get_license()
# Contar usuarios activos e inactivos
active_users = (
def _count_active_user_tenants_local(self) -> int:
return (
self.db.query(func.count(UserTenant.id))
.filter(
and_(
@@ -352,8 +433,72 @@ class UserService:
)
)
.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 = 0
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") and lic_body.get("max_users") is not None:
max_users_allowed = int(lic_body["max_users"])
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(
@@ -363,19 +508,20 @@ class UserService:
)
)
.scalar()
or 0
)
total_users = active_users + inactive_users
users_available = max(0, license.max_users - active_users)
users_available = max(0, max_users_allowed - active_users)
usage_percentage = (
(active_users / license.max_users * 100) if license.max_users > 0 else 0
(active_users / max_users_allowed * 100) if max_users_allowed > 0 else 0
)
return {
"total_users": total_users,
"active_users": active_users,
"inactive_users": inactive_users,
"max_users_allowed": license.max_users,
"max_users_allowed": max_users_allowed,
"users_available": users_available,
"usage_percentage": round(usage_percentage, 2),
}

View File

@@ -27,6 +27,8 @@ _synced_tenant_ids: Set[int] = set()
# Alias Hub tenant_id -> tenant_id local cuando existe drift histórico de IDs
# (mismo slug, diferente id).
_tenant_id_aliases: Dict[int, int] = {}
# Inverso: id local core.tenants -> id tenant en Hub (JWT / client_tenants) para llamadas al Hub.
_tenant_id_hub_by_local: Dict[int, int] = {}
# Security scheme
security = HTTPBearer()
@@ -68,36 +70,173 @@ async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str,
raise HTTPException(status_code=401, detail="Authentication error")
def _ensure_company_exists(db: Session, tenant_id: int, tenant_name: str) -> None:
def _ensure_user_tenant_for_company(
db: Session, keycloak_user_id: str, tenant_id: int, company_id: int
) -> None:
"""Garantiza fila core.user_tenants (usuario ↔ compañía ↔ tenant)."""
from api.v1.modules.core.user_tenant.models import UserTenant
existing = (
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
db.commit()
return
db.add(
UserTenant(
keycloak_user_id=keycloak_user_id,
tenant_id=tenant_id,
company_id=company_id,
is_active=True,
)
)
db.commit()
def _ensure_company_exists(
db: Session,
tenant_id: int,
tenant_name: str,
hub_user: Optional[Dict[str, Any]] = None,
) -> None:
"""
Garantiza que exista al menos una empresa en a76.company para el tenant.
El tenant IS la empresa — se crea automáticamente al primer login.
Garantiza al menos una empresa en a76.company para el tenant.
Primera vez: CompanyService.create_company_manually (seed catálogos por compañía),
relación user_tenants y bootstrap_super_admin para el usuario del token.
"""
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.a76.general_catalogs.company.dto import CompanyCreateDTO
from api.v1.modules.a76.general_catalogs.company.service import CompanyService
from api.v1.modules.core.permissions.service import PermissionService
from api.v1.modules.core.user_tenant.models import UserTenant
kc = hub_user.get("sub") if hub_user else None
try:
from api.v1.modules.a76.general_catalogs.company.models import Company
exists = db.query(Company).filter(Company.tenant_id == tenant_id).first()
if not exists:
company = Company(tenant_id=tenant_id, name=tenant_name)
db.add(company)
company = db.query(Company).filter(Company.tenant_id == tenant_id).first()
if not company:
svc = CompanyService(db)
username = "System"
if hub_user:
username = (
hub_user.get("preferred_username")
or hub_user.get("email")
or hub_user.get("name")
or "System"
)
company = svc.create_company_manually(
CompanyCreateDTO(name=tenant_name),
tenant_id=tenant_id,
username=username,
)
logger.info(
"Empresa creada vía CompanyService para tenant id=%s name=%r company_id=%s",
tenant_id,
tenant_name,
company.id,
)
if not company:
logger.warning(
"No hay empresa para tenant %s tras intento de creación automática", tenant_id
)
return
if company.name != tenant_name:
company.name = tenant_name
db.commit()
logger.info(f"Empresa creada automáticamente para tenant id={tenant_id}: '{tenant_name}'")
elif exists.name != tenant_name:
exists.name = tenant_name
db.commit()
logger.info(f"Empresa actualizada para tenant id={tenant_id}: '{tenant_name}'")
logger.info("Empresa actualizada para tenant id=%s: '%s'", tenant_id, tenant_name)
if kc:
ut = (
db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == kc,
UserTenant.tenant_id == tenant_id,
UserTenant.company_id == company.id,
)
.first()
)
if not ut:
_ensure_user_tenant_for_company(db, kc, tenant_id, company.id)
PermissionService(db).bootstrap_super_admin(kc, company.id)
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.warning(f"No se pudo crear empresa automática para tenant {tenant_id}: {e}")
logger.warning(
"No se pudo asegurar empresa/usuario para tenant %s: %s", tenant_id, e
)
def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
def _repair_user_company_link_if_needed(
db: Session,
tenant_id_effective: int,
hub_user: Optional[Dict[str, Any]],
) -> None:
"""Si ya hay empresa pero el usuario no tiene user_tenants, enlaza y hace bootstrap."""
if not hub_user or not hub_user.get("sub"):
return
from api.v1.modules.a76.general_catalogs.company.models import Company
from api.v1.modules.core.permissions.service import PermissionService
from api.v1.modules.core.user_tenant.models import UserTenant
kc = hub_user["sub"]
company = (
db.query(Company)
.filter(Company.tenant_id == tenant_id_effective)
.first()
)
if not company:
return
ut = (
db.query(UserTenant)
.filter(
UserTenant.keycloak_user_id == kc,
UserTenant.tenant_id == tenant_id_effective,
UserTenant.company_id == company.id,
)
.first()
)
if ut:
return
try:
_ensure_user_tenant_for_company(db, kc, tenant_id_effective, company.id)
PermissionService(db).bootstrap_super_admin(kc, company.id)
except Exception as e:
logger.warning(
"No se pudo reparar enlace usuario-compañía tenant=%s: %s",
tenant_id_effective,
e,
)
def _ensure_tenant_synced(
db: Session,
tenant_id: int,
tenant_slug: str,
hub_user: Optional[Dict[str, Any]] = None,
) -> int:
"""
Garantiza que el tenant del Hub exista en core.tenants local.
Se ejecuta una sola vez por tenant_id por ciclo de vida del proceso.
El Hub es la fuente de verdad — este método solo sincroniza en una dirección.
"""
if tenant_id in _synced_tenant_ids:
return _tenant_id_aliases.get(tenant_id, tenant_id)
effective = int(_tenant_id_aliases.get(tenant_id, tenant_id))
_repair_user_company_link_if_needed(db, effective, hub_user)
return effective
try:
# Importación local para evitar imports circulares
@@ -116,7 +255,7 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
logger.info(f"Tenant id={tenant_id} actualizado: slug='{tenant_slug}'")
_synced_tenant_ids.add(tenant_id)
# Garantizar empresa aunque el tenant ya existiera
_ensure_company_exists(db, tenant_id, name)
_ensure_company_exists(db, tenant_id, name, hub_user)
return tenant_id
# Crear el tenant local con los datos disponibles del token.
@@ -134,7 +273,7 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
_synced_tenant_ids.add(tenant_id)
logger.info(f"Tenant '{tenant_slug}' (id={tenant_id}) sincronizado desde Hub a core.tenants")
# Crear la empresa correspondiente al tenant recién sincronizado
_ensure_company_exists(db, tenant_id, name)
_ensure_company_exists(db, tenant_id, name, hub_user)
return tenant_id
except IntegrityError:
@@ -154,8 +293,9 @@ def _ensure_tenant_synced(db: Session, tenant_id: int, tenant_slug: str) -> int:
# Auto-heal en runtime: mapear temporalmente al tenant local existente por slug
# para evitar dejar al usuario sin compañías y evitar este conflicto en cada request.
_tenant_id_aliases[tenant_id] = int(stale.id)
_tenant_id_hub_by_local[int(stale.id)] = int(tenant_id)
_synced_tenant_ids.add(tenant_id)
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug)
_ensure_company_exists(db, int(stale.id), stale.name or tenant_slug, hub_user)
return int(stale.id)
else:
_synced_tenant_ids.add(tenant_id)
@@ -195,7 +335,9 @@ async def get_current_user(
tenant_id = user_info.get("tenant_id")
tenant_slug = user_info.get("tenant_slug")
if tenant_id and tenant_slug:
effective_tenant_id = _ensure_tenant_synced(db, int(tenant_id), str(tenant_slug))
effective_tenant_id = _ensure_tenant_synced(
db, int(tenant_id), str(tenant_slug), hub_user=user_info
)
if effective_tenant_id != int(tenant_id):
logger.warning(
f"[get_current_user] tenant_id ajustado por alias: hub={tenant_id} local={effective_tenant_id} slug={tenant_slug}"
@@ -268,6 +410,23 @@ def get_tenant_from_token(user_info: Dict[str, Any]) -> Optional[int]:
return None
def resolve_hub_tenant_id_for_api(
local_tenant_id: Optional[int], x_tenant_override: Optional[str]
) -> int:
"""
ID de tenant en Hub (client_tenants) para llamadas a la API del Hub.
Prioriza X-Tenant-Override (cookie SSO). Si hubo drift id Hub↔local,
usa el mapeo inverso registrado en _ensure_tenant_synced.
"""
if x_tenant_override and str(x_tenant_override).strip().isdigit():
return int(str(x_tenant_override).strip())
if local_tenant_id is None:
return 0
lid = int(local_tenant_id)
return int(_tenant_id_hub_by_local.get(lid, lid))
def collect_user_role_names(current_user: Dict[str, Any]) -> Set[str]:
"""
Roles del usuario: primero la lista ``roles`` del Hub (GET /api/v1/auth/me