Merge pull request 'feature/hub-admin-global-access' (#410) from feature/hub-admin-global-access into development

Reviewed-on: ADUANASOFT/anexo76#410
This commit is contained in:
2026-05-20 16:31:09 +00:00
15 changed files with 251 additions and 174 deletions

View File

@@ -17,7 +17,7 @@ from core.s3_keys import (
customs_broker_vu_doda_private_key_key,
customs_broker_vu_private_key_key,
)
from core.security import get_current_user, get_tenant_from_token, validate_access_to_resource
from core.security import get_current_user, get_tenant_from_token, resolve_tenant_id_required, validate_access_to_resource
from core.storage_s3 import delete_object_if_exists, put_object_bytes
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
@@ -32,25 +32,13 @@ MAX_VU_CER_KEY_BYTES = 5 * 1024 * 1024 # 5 MB
MAX_COVE_BYTES = 15 * 1024 * 1024 # 15 MB (xml/zip)
def _resolve_tenant_id_int(current_user: dict) -> int:
tid = get_tenant_from_token(current_user)
if tid is not None:
return int(tid)
raw = current_user.get("tenant_id")
if isinstance(raw, list) and raw:
raw = raw[0]
if raw is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
try:
return int(raw)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant ID in token",
)
def _resolve_tenant_id_int(
current_user: dict,
db=None,
company_id: int = None,
):
"""Delega a resolve_tenant_id_required. Hub admin resuelve tenant desde la empresa."""
return resolve_tenant_id_required(current_user, db=db, company_id=company_id)
def _remove_stored_vu_path(ref: Optional[str]) -> None:
@@ -198,7 +186,7 @@ async def upload_customs_broker_vu_file(
- DODA: doda_certificate_path, doda_key_path, doda_xml_files_path
"""
validate_access_to_resource(db, company_id, current_user, ["customs_brokers.create"])
tenant_id = _resolve_tenant_id_int(current_user)
tenant_id = _resolve_tenant_id_int(current_user, db=db, company_id=company_id)
broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id)
if not broker:

View File

@@ -14,7 +14,7 @@ from core.config import settings
from core.database import get_core_db
from core.exceptions import ValidationException
from core.s3_keys import cove_acuse_pdf_key
from core.security import get_current_user, get_tenant_from_token, validate_access_to_resource
from core.security import get_current_user, get_tenant_from_token, resolve_tenant_id_required, validate_access_to_resource
from api.v1.modules.core.tasks_tracking import track_and_dispatch
@@ -147,14 +147,10 @@ def check_cove_eligibility(
Evalúa si la factura tiene todos los datos necesarios (VU, factura, partidas)
para poder generar un COVE. No dispara la tarea Celery.
"""
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
tenant_id_int = int(tenant_id)
tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
service = FacturaCoveDomainService(db)
eligibility = service.check_eligibility(invoice_id=invoice_id, tenant_id=tenant_id_int, company_id=company_id)
eligibility = service.check_eligibility(invoice_id=invoice_id, tenant_id=tenant_id, company_id=company_id)
return eligibility

View File

@@ -12,6 +12,9 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
class CompanyCreateDTO(BaseModel):
"""DTO para crear una empresa"""
# Solo requerido cuando hub_admin crea una empresa (no tiene tenant_id en token)
tenant_id: Optional[int] = Field(None, description="Tenant ID (requerido para hub_admin)")
name: Optional[str] = Field(None, max_length=255, description="Company name")
rfc: Optional[str] = Field(None, max_length=30, description="Company RFC")
main_activity: Optional[str] = Field(

View File

@@ -10,7 +10,7 @@ from typing import List, Optional
from pathlib import Path
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile
from fastapi import APIRouter, Depends, HTTPException, Request, status, File, UploadFile
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
@@ -23,7 +23,9 @@ from core.security import (
collect_user_role_names,
get_current_user,
get_tenant_from_token,
is_hub_admin,
resolve_effective_tenant_id_from_user,
resolve_tenant_id_required,
validate_access_to_resource,
)
from .....common.tenant_crud_routes import TenantCRUDRoutes
@@ -40,7 +42,7 @@ logger = logging.getLogger(__name__)
def _user_is_admin(current_user: dict) -> bool:
return "admin" in collect_user_role_names(current_user)
return "admin" in collect_user_role_names(current_user) or is_hub_admin(current_user)
def _assert_permission_any_company(
@@ -81,26 +83,13 @@ def _assert_permission_for_company(
return validate_access_to_resource(db, company_id, current_user, [permission_code])
def _resolve_tenant_id_int(current_user: dict) -> int:
"""Misma lógica que validate_access_to_resource: entero estable para BD y claves S3."""
tid = get_tenant_from_token(current_user)
if tid is not None:
return int(tid)
raw = current_user.get("tenant_id")
if isinstance(raw, list) and raw:
raw = raw[0]
if raw is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
try:
return int(raw)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant ID in token",
)
def _resolve_tenant_id_int(
current_user: dict,
db: Session = None,
company_id: int = None,
) -> Optional[int]:
"""Delega a resolve_tenant_id_required. Hub admin resuelve tenant desde la empresa."""
return resolve_tenant_id_required(current_user, db=db, company_id=company_id)
def _is_s3_object_key(ref: Optional[str]) -> bool:
@@ -127,18 +116,27 @@ router = APIRouter(prefix="/company")
summary="Create a new company",
)
async def create_company(
request: Request,
data: CompanyCreateDTO,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
_assert_permission_any_company(db, current_user, "cat_company.create")
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
tenant_id = resolve_tenant_id_required(current_user, db=db)
if tenant_id is None:
# Prioridad: X-Tenant-Override del header (tenant seleccionado en el sidebar)
override = request.headers.get("X-Tenant-Override")
if override and str(override).strip().isdigit():
tenant_id = int(override)
# Fallback: campo tenant_id en el body (enviado por el frontend como respaldo)
elif data.tenant_id:
tenant_id = data.tenant_id
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Hub admin: selecciona un tenant en el sidebar antes de crear una empresa",
)
service = CompanyService(db)
new_company = service.create_company_manually(data, tenant_id=tenant_id)
@@ -151,6 +149,7 @@ async def create_company(
summary="Get companies with pagination",
)
async def list_companies(
request: Request,
page: int = 1,
page_size: int = 50,
name: Optional[str] = None,
@@ -161,12 +160,12 @@ async def list_companies(
"""Get paginated list of companies for current tenant with optional filters"""
_assert_permission_any_company(db, current_user, "cat_company.view")
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
tenant_id = resolve_tenant_id_required(current_user, db=db)
# Hub admin: si hay X-Tenant-Override y aún no se resolvió tenant, filtra por ese tenant
if tenant_id is None and is_hub_admin(current_user):
override = request.headers.get("X-Tenant-Override")
if override and str(override).strip().isdigit():
tenant_id = int(override)
skip = (page - 1) * page_size
filters = {}
@@ -205,15 +204,32 @@ async def list_companies(
summary="Get all companies for current tenant",
)
async def get_my_companies(
request: Request,
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Compañías accesibles: tenant del token/Hub (si existe) unión con membership en BD
(``user_company_roles``, ``user_company_permissions``, ``user_tenants``).
Un usuario solo con roles de app y sin ``tenant_id`` en /auth/me sigue pudiendo
listar sus compañías asignadas.
Hub admin: si hay X-Tenant-Override muestra solo ese tenant; si no, muestra todos.
"""
from core.security import collect_user_role_names
user_roles = collect_user_role_names(current_user)
# Hub admin: respeta X-Tenant-Override para filtrar por tenant seleccionado;
# sin override devuelve todo (visibilidad global).
if "hub_admin" in user_roles:
service = CompanyService(db)
override = request.headers.get("X-Tenant-Override")
query = db.query(Company).filter(Company.deleted_at.is_(None))
if override and str(override).strip().isdigit():
query = query.filter(Company.tenant_id == int(override))
all_companies = query.order_by(Company.name).all()
return [
CompanyResponseDTO.model_validate(service.flatten_company_dto(c))
for c in all_companies
]
_assert_permission_any_company(db, current_user, "cat_company.view")
user_id = current_user.get("sub") or current_user.get("id")
@@ -245,12 +261,7 @@ async def get_company(
"""Get a specific company by ID"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.view")
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
service = CompanyService(db)
company = CompanyService.get_by_id(db, company_id, tenant_id, 0)
@@ -277,12 +288,7 @@ async def update_company(
"""Update a company"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.edit")
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
service = CompanyService(db)
updated_company = service.update(db, company_id, tenant_id, 0, data)
@@ -354,12 +360,7 @@ async def delete_company(
"""Delete a company"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.delete")
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
tenant_id = resolve_tenant_id_required(current_user, db=db, company_id=company_id)
success = CompanyService.delete(db, company_id, tenant_id, 0)
if not success:
@@ -385,7 +386,7 @@ async def upload_company_logo(
"""Upload a logo for a company"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.edit")
tenant_id = _resolve_tenant_id_int(current_user)
tenant_id = _resolve_tenant_id_int(current_user, db=db, company_id=company_id)
# Validar que la empresa existe
company = CompanyService.get_by_id(db, company_id, tenant_id, 0)
@@ -468,7 +469,7 @@ async def upload_company_certificate(
"""
_assert_permission_for_company(db, company_id, current_user, "cat_company.edit")
tenant_id = _resolve_tenant_id_int(current_user)
tenant_id = _resolve_tenant_id_int(current_user, db=db, company_id=company_id)
# Validar que la empresa existe
service = CompanyService(db)

View File

@@ -44,7 +44,9 @@ class CompanyService:
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Company], int]:
"""Get all companies for a tenant with pagination"""
query = db.query(Company).filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
query = db.query(Company).filter(Company.deleted_at.is_(None))
if tenant_id is not None:
query = query.filter(Company.tenant_id == tenant_id)
# Apply filters if provided
if filters:
@@ -67,15 +69,10 @@ class CompanyService:
db: Session, company_id: int, tenant_id: int, company_id_unused: int
) -> Optional[Company]:
"""Get company by ID"""
return (
db.query(Company)
.filter(
Company.id == company_id,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None)
)
.first()
)
query = db.query(Company).filter(Company.id == company_id, Company.deleted_at.is_(None))
if tenant_id is not None:
query = query.filter(Company.tenant_id == tenant_id)
return query.first()
# ESTE ES EL MÉTODO VIEJO QUE CAUSABA PROBLEMAS (Lo dejamos por si acaso)
@staticmethod

View File

@@ -721,7 +721,6 @@ async def get_all_movements(
logger.warning(f"Email sending failed: {str(email_error)} - continuing with report generation")
return movements
return movements
except ValueError as e:
logger.warning(f"Validation error fetching all movements: {str(e)}")
raise HTTPException(
@@ -761,23 +760,22 @@ def generate_invoice_report_async(
# validate_access_to_resource returns the integer tenant_id from DB
tenant_id = validate_access_to_resource(db, company_id, current_user, ["report.process"])
# Guardia explícita: tenant_id debe ser un entero positivo antes del dispatch a Celery.
# Un valor inválido aquí generaría un reporte sin filtro de tenant o un crash en la tarea.
if not isinstance(tenant_id, int) or tenant_id <= 0:
raise HTTPException(
status_code=400,
detail="No se pudo determinar el tenant para esta empresa. Verifica que la empresa exista.",
)
# Serialize filters to dict for Celery
filter_data = filters.model_dump()
user_email = current_user.get('email')
# Trigger task
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in token",
)
task = track_and_dispatch(
db=db,
task=generate_invoice_movements_async,
tenant_id=int(tenant_id),
tenant_id=tenant_id,
company_id=company_id,
requested_by_user=current_user.get("preferred_username") or current_user.get("email") or current_user.get("sub"),
task_name="generate_invoice_movements_async",

View File

@@ -4,7 +4,7 @@ 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, get_tenant_from_token
from core.security import get_current_user, resolve_tenant_id_required
from .models import TaskRun, TaskStatus
from .schemas import TaskCatalogsResponse, TaskRunDetail, TaskRunListItem, TaskRunsResponse, TaskSyncRequest
@@ -58,9 +58,7 @@ def list_tasks(
current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
tenant_id = resolve_tenant_id_required(current_user)
tracker = TaskTrackerService(db)
if sync_active:
@@ -93,11 +91,12 @@ def get_task_detail(
current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
tenant_id = resolve_tenant_id_required(current_user)
row = db.query(TaskRun).filter(TaskRun.task_id == task_id, TaskRun.tenant_id == tenant_id).first()
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")
@@ -120,9 +119,7 @@ def sync_tasks(
current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
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}
@@ -133,15 +130,18 @@ def get_catalogs(
current_user: dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
tenant_id = resolve_tenant_id_required(current_user)
groups = (
db.query(TaskRun.task_group).filter(TaskRun.tenant_id == tenant_id).distinct().order_by(TaskRun.task_group).all()
)
names = db.query(TaskRun.task_name).filter(TaskRun.tenant_id == tenant_id).distinct().order_by(TaskRun.task_name).all()
statuses = db.query(TaskRun.status).filter(TaskRun.tenant_id == tenant_id).distinct().order_by(TaskRun.status).all()
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]],

View File

@@ -193,10 +193,12 @@ class TaskTrackerService:
self.db.refresh(task_run)
return task_run
def sync_active_tasks(self, tenant_id: int, task_ids: list[str] | None = None) -> int:
def sync_active_tasks(self, tenant_id: int | None, task_ids: list[str] | None = None) -> int:
query = self.db.query(TaskRun).filter(
TaskRun.tenant_id == tenant_id, TaskRun.status.in_([TaskStatus.PENDING.value, TaskStatus.ACTIVE.value])
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()
@@ -207,7 +209,7 @@ class TaskTrackerService:
def list_tasks(
self,
*,
tenant_id: int,
tenant_id: int | None,
page: int,
page_size: int,
status: list[str] | None = None,
@@ -217,7 +219,9 @@ class TaskTrackerService:
search: str | None = None,
order: str = "desc",
) -> tuple[list[TaskRun], int]:
query = self.db.query(TaskRun).filter(TaskRun.tenant_id == tenant_id)
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:

View File

@@ -15,6 +15,7 @@ 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,
)
@@ -51,7 +52,7 @@ async def get_user_statistics(
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)
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()
@@ -82,7 +83,7 @@ async def list_users(
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)
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()
@@ -342,7 +343,7 @@ async def get_user_detail(
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)
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
return await service.get_user(user_id)
@@ -357,7 +358,7 @@ async def create_new_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)
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,
@@ -383,7 +384,7 @@ async def update_user_detail(
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)
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,
@@ -413,7 +414,7 @@ async def get_user_tenant_count(
tenant_id = validate_access_to_resource(
db, company_id, current_user, required_permissions=["user.view"]
)
service = UserService(db, tenant_id, company_id)
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}
@@ -449,7 +450,7 @@ async def delete_user_route(
hub_tid = resolve_hub_tenant_id_for_api(
tenant_id, request.headers.get("X-Tenant-Override")
)
service = UserService(db, tenant_id, company_id)
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,
@@ -472,6 +473,6 @@ async def change_user_password(
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)
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

@@ -94,10 +94,11 @@ def _normalize_user(
class UserService:
"""Servicio para gestionar usuarios vía Hub"""
def __init__(self, db: Session, tenant_id: int = None, company_id: int = None):
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"""
@@ -125,6 +126,8 @@ class UserService:
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
@@ -139,6 +142,11 @@ class UserService:
.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,
@@ -577,7 +585,7 @@ class UserService:
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
max_users_allowed: Optional[int] = None # None = sin cuota (hub_admin ilimitado)
hub_max_ok = False
active_users = 0
active_from_hub = False
@@ -595,8 +603,10 @@ class UserService:
)
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"])
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(
@@ -642,9 +652,14 @@ class UserService:
)
total_users = active_users + inactive_users
users_available = max(0, max_users_allowed - active_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 > 0 else 0
(active_users / max_users_allowed * 100) if max_users_allowed else 0.0
)
return {