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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user