Merge pull request 'feature/users-delete' (#364) from feature/users-delete into development

Reviewed-on: ADUANASOFT/anexo76#364
This commit is contained in:
2026-05-06 13:43:46 +00:00
7 changed files with 278 additions and 33 deletions

View File

@@ -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"}

View File

@@ -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: