Merge branch 'development' into feature/permisos-reference-data
This commit is contained in:
@@ -218,23 +218,63 @@ async def update_user_detail(
|
||||
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)
|
||||
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
|
||||
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)
|
||||
await service.delete_user(user_id, soft_delete=soft_delete)
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
@@ -286,6 +286,10 @@ class UserService:
|
||||
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(
|
||||
[
|
||||
@@ -394,23 +398,114 @@ class UserService:
|
||||
self.db.refresh(user_tenant)
|
||||
return _normalize_user({"id": user_id}, user_tenant.role, user_tenant)
|
||||
|
||||
async def delete_user(self, user_id: str, soft_delete: bool = True) -> None:
|
||||
"""Elimina/Desactiva usuario"""
|
||||
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)
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.company_id == self.company_id,
|
||||
)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if soft_delete:
|
||||
user_tenant.is_active = False
|
||||
# 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:
|
||||
# TODO: Call Hub to delete from Keycloak
|
||||
self.db.delete(user_tenant)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user