feat: plantilla base workspace SaaS
This commit is contained in:
3
backend/api/v1/modules/core/users/__init__.py
Normal file
3
backend/api/v1/modules/core/users/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Módulo de gestión de usuarios (Keycloak)
|
||||
"""
|
||||
105
backend/api/v1/modules/core/users/dto.py
Normal file
105
backend/api/v1/modules/core/users/dto.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
DTOs para gestión de usuarios de Keycloak
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
|
||||
class CreateUserRequestDTO(BaseModel):
|
||||
"""Request para crear un nuevo usuario en Keycloak"""
|
||||
|
||||
email: EmailStr = Field(..., description="Email del usuario")
|
||||
username: str = Field(
|
||||
..., min_length=3, max_length=50, description="Nombre de usuario"
|
||||
)
|
||||
first_name: str = Field(..., min_length=1, max_length=100, description="Nombre")
|
||||
last_name: str = Field(..., min_length=1, max_length=100, description="Apellido")
|
||||
password: str = Field(..., min_length=8, description="Contraseña temporal")
|
||||
role: Optional[str] = Field(None, description="Rol del usuario en el tenant")
|
||||
enabled: bool = Field(True, description="Si el usuario está habilitado")
|
||||
email_verified: bool = Field(False, description="Si el email está verificado")
|
||||
|
||||
|
||||
class UpdateUserRequestDTO(BaseModel):
|
||||
"""Request para actualizar un usuario en Keycloak"""
|
||||
|
||||
first_name: Optional[str] = Field(None, max_length=100)
|
||||
last_name: Optional[str] = Field(None, max_length=100)
|
||||
email: Optional[str] = Field(None, max_length=255)
|
||||
enabled: Optional[bool] = None
|
||||
email_verified: Optional[bool] = None
|
||||
role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual")
|
||||
|
||||
# Campos de perfil
|
||||
avatar_url: Optional[str] = Field(
|
||||
None, max_length=500, description="URL del avatar"
|
||||
)
|
||||
phone: Optional[str] = Field(None, max_length=20, description="Teléfono")
|
||||
bio: Optional[str] = Field(None, description="Biografía")
|
||||
preferences: Optional[dict] = Field(None, description="Preferencias del usuario")
|
||||
|
||||
@field_validator("first_name", "last_name", "email")
|
||||
@classmethod
|
||||
def validate_non_empty_string(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Valida que si el string está presente, no esté vacío"""
|
||||
if v is not None and v.strip() == "":
|
||||
return None # Convertir strings vacíos a None
|
||||
return v
|
||||
|
||||
|
||||
class UserResponseDTO(BaseModel):
|
||||
"""Response con información de usuario de Keycloak"""
|
||||
|
||||
id: str = Field(..., description="ID de Keycloak del usuario")
|
||||
username: str
|
||||
email: str = Field(default="", description="Email del usuario")
|
||||
first_name: str = Field(default="", description="Nombre del usuario")
|
||||
last_name: str = Field(default="", description="Apellido del usuario")
|
||||
enabled: bool
|
||||
email_verified: bool
|
||||
created_timestamp: Optional[int] = None
|
||||
role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual")
|
||||
|
||||
# Campos de perfil
|
||||
avatar_url: Optional[str] = Field(None, description="URL del avatar")
|
||||
phone: Optional[str] = Field(None, description="Teléfono")
|
||||
bio: Optional[str] = Field(None, description="Biografía")
|
||||
preferences: Optional[dict] = Field(
|
||||
default_factory=dict, description="Preferencias"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserListResponseDTO(BaseModel):
|
||||
"""Response con lista de usuarios"""
|
||||
|
||||
users: List[UserResponseDTO]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
class ChangePasswordRequestDTO(BaseModel):
|
||||
"""Request para cambiar contraseña de un usuario"""
|
||||
|
||||
password: str = Field(..., min_length=8, description="Nueva contraseña")
|
||||
temporary: bool = Field(
|
||||
True, description="Si es temporal (usuario debe cambiarla al login)"
|
||||
)
|
||||
|
||||
|
||||
class UserStatsDTO(BaseModel):
|
||||
"""Estadísticas de usuarios del tenant"""
|
||||
|
||||
total_users: int
|
||||
active_users: int
|
||||
inactive_users: int
|
||||
max_users_allowed: int
|
||||
users_available: int
|
||||
usage_percentage: float
|
||||
478
backend/api/v1/modules/core/users/routes.py
Normal file
478
backend/api/v1/modules/core/users/routes.py
Normal file
@@ -0,0 +1,478 @@
|
||||
"""
|
||||
Rutas para gestión de usuarios de Keycloak
|
||||
"""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from typing import Optional
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
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,
|
||||
is_hub_admin,
|
||||
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
|
||||
|
||||
from ..user_tenant.models import UserTenant
|
||||
from .dto import (
|
||||
ChangePasswordRequestDTO,
|
||||
CreateUserRequestDTO,
|
||||
UpdateUserRequestDTO,
|
||||
UserListResponseDTO,
|
||||
UserResponseDTO,
|
||||
UserStatsDTO,
|
||||
)
|
||||
from .service import UserService
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["Users"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_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),
|
||||
):
|
||||
"""
|
||||
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, is_hub_admin=is_hub_admin(current_user))
|
||||
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"),
|
||||
search: Optional[str] = Query(None, description="Término de búsqueda"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
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, is_hub_admin=is_hub_admin(current_user))
|
||||
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
|
||||
|
||||
|
||||
@router.get("/avatar/{tenant_id}/{keycloak_user_id}")
|
||||
def get_user_avatar_image(
|
||||
tenant_id: int,
|
||||
keycloak_user_id: str,
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Sirve la imagen de avatar (público para poder usarla en <img src> sin Bearer).
|
||||
El almacenamiento interno puede ser clave S3 o ruta bajo uploads/.
|
||||
"""
|
||||
ut = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.tenant_id == tenant_id,
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not ut or not ut.avatar_url:
|
||||
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||
|
||||
raw = ut.avatar_url
|
||||
if raw.startswith("tenants/"):
|
||||
try:
|
||||
data = get_object_bytes(raw)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Avatar not found")
|
||||
media = mimetypes.guess_type(raw)[0] or "image/jpeg"
|
||||
return Response(content=data, media_type=media)
|
||||
|
||||
rel = raw.lstrip("/")
|
||||
path = Path(rel)
|
||||
if not path.is_file():
|
||||
path = Path.cwd() / rel
|
||||
if not path.is_file():
|
||||
alt = Path("/app") / rel
|
||||
if alt.is_file():
|
||||
path = alt
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Avatar file not found")
|
||||
data = path.read_bytes()
|
||||
media = mimetypes.guess_type(str(path))[0] or "image/jpeg"
|
||||
return Response(content=data, media_type=media)
|
||||
|
||||
|
||||
# === Endpoints de Perfil del Usuario Actual ===
|
||||
|
||||
|
||||
@router.get("/me/profile", response_model=UserResponseDTO)
|
||||
async def get_my_profile(
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Obtiene el perfil completo del usuario actual
|
||||
"""
|
||||
keycloak_user_id = current_user.get("sub")
|
||||
if not keycloak_user_id:
|
||||
raise HTTPException(status_code=400, detail="User ID not found in token")
|
||||
|
||||
# Obtener user_tenant para crear servicio
|
||||
user_tenant = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="User does not belong to any tenant"
|
||||
)
|
||||
|
||||
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
access_token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip() or None
|
||||
)
|
||||
return await service.get_current_user_profile(
|
||||
keycloak_user_id,
|
||||
current_user=current_user,
|
||||
access_token=access_token,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/me/profile", response_model=UserResponseDTO)
|
||||
async def update_my_profile(
|
||||
request: Request,
|
||||
data: UpdateUserRequestDTO,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Actualiza el perfil del usuario actual.
|
||||
Campos editables: first_name, last_name, phone.
|
||||
Email, username y otros campos de identidad solo se cambian desde el Hub.
|
||||
"""
|
||||
keycloak_user_id = current_user.get("sub")
|
||||
if not keycloak_user_id:
|
||||
raise HTTPException(status_code=400, detail="User ID not found in token")
|
||||
|
||||
# Obtener user_tenant
|
||||
user_tenant = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="User does not belong to any tenant"
|
||||
)
|
||||
|
||||
# Para sesiones autenticadas vía Workspace/Hub, la foto de perfil viene del Hub
|
||||
# y no debe mutarse localmente en Anexo76.
|
||||
if current_user.get("sub"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Avatar is managed by Workspace for this user",
|
||||
)
|
||||
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
access_token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip() or None
|
||||
)
|
||||
|
||||
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
|
||||
return await service.update_current_user_profile(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
current_user=current_user,
|
||||
access_token=access_token,
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
phone=data.phone,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/avatar")
|
||||
async def upload_my_avatar(
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Sube un avatar para el usuario actual.
|
||||
Con MinIO guarda en tenants/{tid}/users/{sub}/avatar.{ext} y persiste la clave en UserTenant.
|
||||
Retorna URL pública para <img src> (GET /users/avatar/...).
|
||||
"""
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="El archivo debe ser una imagen")
|
||||
|
||||
keycloak_user_id = current_user.get("sub")
|
||||
if not keycloak_user_id:
|
||||
raise HTTPException(status_code=400, detail="User ID not found in token")
|
||||
|
||||
user_tenant = (
|
||||
db.query(UserTenant)
|
||||
.filter(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not user_tenant:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="User does not belong to any tenant"
|
||||
)
|
||||
|
||||
ext = Path(file.filename or "image.jpg").suffix.lower() or ".jpg"
|
||||
if ext not in _AVATAR_EXT:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Extensión no permitida. Use: {', '.join(sorted(_AVATAR_EXT))}",
|
||||
)
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) > 2 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB")
|
||||
|
||||
tenant_id = user_tenant.tenant_id
|
||||
|
||||
try:
|
||||
if settings.use_s3_object_storage:
|
||||
if user_tenant.avatar_url and str(user_tenant.avatar_url).startswith(
|
||||
"tenants/"
|
||||
):
|
||||
delete_object_if_exists(str(user_tenant.avatar_url))
|
||||
key = user_avatar_key(tenant_id, keycloak_user_id, ext)
|
||||
ct = file.content_type or mimetypes.guess_type(f"x{ext}")[0] or "image/jpeg"
|
||||
put_object_bytes(key, contents, content_type=ct)
|
||||
user_tenant.avatar_url = key
|
||||
logger.info(
|
||||
"User avatar stored in S3 key=%s bytes=%s", key, len(contents)
|
||||
)
|
||||
else:
|
||||
upload_dir = Path("uploads/avatars")
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{keycloak_user_id}{ext}"
|
||||
file_path = upload_dir / filename
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(contents)
|
||||
user_tenant.avatar_url = f"/uploads/avatars/{filename}"
|
||||
|
||||
db.add(user_tenant)
|
||||
db.commit()
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error al guardar el avatar: {str(e)}"
|
||||
) from e
|
||||
|
||||
public_url = public_user_avatar_api_path(tenant_id, keycloak_user_id)
|
||||
return {"avatar_url": public_url}
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponseDTO)
|
||||
async def get_user_detail(
|
||||
user_id: str,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
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, is_hub_admin=is_hub_admin(current_user))
|
||||
return await service.get_user(user_id)
|
||||
|
||||
|
||||
@router.post("/", response_model=UserResponseDTO, status_code=201)
|
||||
async def create_new_user(
|
||||
data: CreateUserRequestDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_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, is_hub_admin=is_hub_admin(current_user))
|
||||
user = await service.create_user(
|
||||
email=data.email,
|
||||
username=data.username,
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
password=data.password,
|
||||
role=data.role,
|
||||
enabled=data.enabled,
|
||||
email_verified=data.email_verified,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponseDTO)
|
||||
async def update_user_detail(
|
||||
user_id: str,
|
||||
data: UpdateUserRequestDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
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, is_hub_admin=is_hub_admin(current_user))
|
||||
user = await service.update_user(
|
||||
user_id=user_id,
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
email=data.email,
|
||||
enabled=data.enabled,
|
||||
email_verified=data.email_verified,
|
||||
role=data.role,
|
||||
avatar_url=data.avatar_url,
|
||||
phone=data.phone,
|
||||
bio=data.bio,
|
||||
preferences=data.preferences,
|
||||
)
|
||||
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, is_hub_admin=is_hub_admin(current_user))
|
||||
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.
|
||||
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, is_hub_admin=is_hub_admin(current_user))
|
||||
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"}
|
||||
|
||||
|
||||
@router.post("/{user_id}/change-password")
|
||||
async def change_user_password(
|
||||
user_id: str,
|
||||
data: ChangePasswordRequestDTO,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
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, is_hub_admin=is_hub_admin(current_user))
|
||||
await service.change_password(user_id, data.password, data.temporary)
|
||||
return {"message": "Password changed successfully"}
|
||||
808
backend/api/v1/modules/core/users/service.py
Normal file
808
backend/api/v1/modules/core/users/service.py
Normal file
@@ -0,0 +1,808 @@
|
||||
import logging
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
|
||||
from ..licenses.models import License, LicenseStatus
|
||||
from ..user_tenant.models import UserTenant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_valid_http_url(url: Optional[str]) -> bool:
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
parsed = urlparse(url.strip())
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
def _legacy_avatar_public_url(user_tenant: Optional[Any]) -> Optional[str]:
|
||||
if not user_tenant or not user_tenant.avatar_url:
|
||||
return None
|
||||
avatar_out = str(user_tenant.avatar_url)
|
||||
|
||||
if avatar_out.startswith("http://") or avatar_out.startswith("https://"):
|
||||
return avatar_out if _is_valid_http_url(avatar_out) else None
|
||||
|
||||
from core.s3_keys import public_user_avatar_api_path
|
||||
|
||||
# Entregamos siempre el endpoint público del backend para assets locales/S3.
|
||||
return public_user_avatar_api_path(
|
||||
user_tenant.tenant_id,
|
||||
user_tenant.keycloak_user_id,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_user(
|
||||
user_data: Dict[str, Any],
|
||||
role: Optional[str] = None,
|
||||
user_tenant: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Normaliza los datos de usuario al formato esperado por el DTO.
|
||||
Prioridad para nombre/apellido: caché local (user_tenant) > JWT claims > campo 'name'.
|
||||
"""
|
||||
name_parts = (user_data.get("name") or "").split(" ", 1)
|
||||
# Caché local tiene prioridad — se actualiza al guardar perfil desde Anexo76
|
||||
local_first = getattr(user_tenant, "first_name", None) if user_tenant else None
|
||||
local_last = getattr(user_tenant, "last_name", None) if user_tenant else None
|
||||
|
||||
normalized = {
|
||||
"id": user_data.get("id") or user_data.get("sub"),
|
||||
"username": user_data.get("username") or user_data.get("preferred_username", ""),
|
||||
"email": user_data.get("email", ""),
|
||||
"first_name": local_first or user_data.get("firstName") or user_data.get("given_name") or (name_parts[0] if name_parts else ""),
|
||||
"last_name": local_last or user_data.get("lastName") or user_data.get("family_name") or (name_parts[1] if len(name_parts) > 1 else ""),
|
||||
"enabled": user_data.get("enabled", True),
|
||||
"email_verified": user_data.get("emailVerified") or user_data.get("email_verified", False),
|
||||
"created_timestamp": user_data.get("createdTimestamp"),
|
||||
"role": role,
|
||||
}
|
||||
|
||||
# Agregar campos de perfil si user_tenant está disponible
|
||||
if user_tenant:
|
||||
workspace_avatar = (
|
||||
user_tenant.workspace_avatar_url
|
||||
if _is_valid_http_url(user_tenant.workspace_avatar_url)
|
||||
else None
|
||||
)
|
||||
legacy_avatar = _legacy_avatar_public_url(user_tenant)
|
||||
avatar_out = workspace_avatar or legacy_avatar
|
||||
|
||||
normalized.update(
|
||||
{
|
||||
"avatar_url": avatar_out,
|
||||
"workspace_avatar_url": workspace_avatar,
|
||||
"legacy_avatar_url": legacy_avatar,
|
||||
"workspace_user_id": user_tenant.workspace_user_id,
|
||||
"phone": user_tenant.phone,
|
||||
"bio": user_tenant.bio,
|
||||
"preferences": user_tenant.preferences or {},
|
||||
}
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Servicio para gestionar usuarios vía Hub"""
|
||||
|
||||
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"""
|
||||
license = (
|
||||
self.db.query(License).filter(License.tenant_id == self.tenant_id).first()
|
||||
)
|
||||
|
||||
if not license:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="License not found for this tenant"
|
||||
)
|
||||
|
||||
if license.status != LicenseStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"License is not active. Current status: {license.status.value}",
|
||||
)
|
||||
|
||||
# Verificar si la licencia está vigente
|
||||
now = datetime.now(license.expires_at.tzinfo)
|
||||
if license.expires_at < now:
|
||||
raise HTTPException(status_code=403, detail="License has expired")
|
||||
|
||||
return license
|
||||
|
||||
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
|
||||
active_users = (
|
||||
self.db.query(func.count(UserTenant.id))
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
)
|
||||
.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,
|
||||
detail=f"User limit reached. Your license allows {license.max_users} users. "
|
||||
f"Currently active: {active_users}. Please upgrade your license.",
|
||||
)
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
email: str,
|
||||
username: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
password: str,
|
||||
role: Optional[str] = None,
|
||||
enabled: bool = True,
|
||||
email_verified: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Crea un nuevo usuario a través del Hub y lo asocia localmente
|
||||
"""
|
||||
# Verificar límite de usuarios
|
||||
self._check_user_limit()
|
||||
|
||||
try:
|
||||
# Mandar al Hub para creación en Keycloak
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
hub_response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/register",
|
||||
json={
|
||||
"email": email,
|
||||
"username": username,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"password": password,
|
||||
"tenant_slug": "default", # TODO: Get real slug if needed
|
||||
}
|
||||
)
|
||||
|
||||
if hub_response.status_code != 201:
|
||||
logger.error(f"Hub registration failed: {hub_response.text}")
|
||||
raise HTTPException(status_code=hub_response.status_code, detail="Failed to create user in Hub")
|
||||
|
||||
user_data = hub_response.json()
|
||||
user_id = user_data.get("user_id")
|
||||
|
||||
# Obtener company_id — implementa con tu modelo de compañía si company_id es None.
|
||||
if not self.company_id:
|
||||
raise HTTPException(status_code=400, detail="company_id requerido")
|
||||
company_id = self.company_id
|
||||
|
||||
# Crear relación local
|
||||
user_tenant = UserTenant(
|
||||
keycloak_user_id=user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
company_id=company_id,
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
self.db.add(user_tenant)
|
||||
self.db.commit()
|
||||
|
||||
return _normalize_user(user_data, role, user_tenant)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating user: {str(e)}")
|
||||
self.db.rollback()
|
||||
if isinstance(e, HTTPException):
|
||||
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,
|
||||
*,
|
||||
access_token: str,
|
||||
hub_tenant_id: int,
|
||||
x_tenant_override: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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_map: Dict[str, List[str]] = {}
|
||||
for user_role in user_roles_query.all():
|
||||
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)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
# 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(
|
||||
[
|
||||
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 = 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": 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"""
|
||||
from ..permissions.models import UserCompanyRole
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == user_id,
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Roles locales
|
||||
user_roles = self.db.query(UserCompanyRole).options(joinedload(UserCompanyRole.company_role)).filter(
|
||||
and_(
|
||||
UserCompanyRole.user_id == user_id,
|
||||
UserCompanyRole.company_id == self.company_id,
|
||||
UserCompanyRole.tenant_id == self.tenant_id,
|
||||
UserCompanyRole.is_active == True
|
||||
)
|
||||
).all()
|
||||
roles = [ur.company_role.name for ur in user_roles]
|
||||
role_str = ", ".join(roles) if roles else None
|
||||
|
||||
# TODO: Call Hub if more info is needed
|
||||
return _normalize_user({"id": user_id}, role_str, user_tenant)
|
||||
|
||||
async def update_user(self, user_id: str, **kwargs) -> Dict[str, Any]:
|
||||
"""Actualiza información local del usuario (e identidad vía Hub si se implementa)"""
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Actualizar campos locales
|
||||
for field in ["role", "avatar_url", "phone", "bio", "preferences"]:
|
||||
if field in kwargs and kwargs[field] is not None:
|
||||
setattr(user_tenant, field, kwargs[field])
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(user_tenant)
|
||||
return _normalize_user({"id": user_id}, user_tenant.role, user_tenant)
|
||||
|
||||
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,
|
||||
UserTenant.company_id == self.company_id,
|
||||
)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
# 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:
|
||||
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:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/change-password",
|
||||
json={"user_id": user_id, "password": password, "temporary": temporary}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error changing password: {e}")
|
||||
raise HTTPException(status_code=500, detail="Error changing password")
|
||||
|
||||
def _count_active_user_tenants_local(self) -> int:
|
||||
return (
|
||||
self.db.query(func.count(UserTenant.id))
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
)
|
||||
.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: Optional[int] = None # None = sin cuota (hub_admin ilimitado)
|
||||
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"):
|
||||
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(
|
||||
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(
|
||||
and_(
|
||||
UserTenant.tenant_id == self.tenant_id,
|
||||
UserTenant.is_active == False,
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
total_users = active_users + inactive_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 else 0.0
|
||||
)
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"inactive_users": inactive_users,
|
||||
"max_users_allowed": max_users_allowed,
|
||||
"users_available": users_available,
|
||||
"usage_percentage": round(usage_percentage, 2),
|
||||
}
|
||||
|
||||
async def get_current_user_profile(
|
||||
self,
|
||||
keycloak_user_id: str,
|
||||
current_user: Dict[str, Any] = None,
|
||||
access_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Obtiene el perfil completo del usuario actual"""
|
||||
# Use the already-verified JWT claims dict — do NOT call verify_token(uuid)
|
||||
user_info = current_user or {"id": keycloak_user_id}
|
||||
|
||||
# Perfil "me": sincronización con cache corto (5 min).
|
||||
if access_token:
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=access_token,
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
|
||||
).first()
|
||||
|
||||
return _normalize_user(user_info, user_tenant.role if user_tenant else None, user_tenant)
|
||||
|
||||
async def update_current_user_profile(
|
||||
self,
|
||||
keycloak_user_id: str,
|
||||
current_user: Dict[str, Any] = None,
|
||||
access_token: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Actualiza el perfil del usuario actual.
|
||||
- first_name / last_name: persiste localmente en UserTenant Y sincroniza con Keycloak vía Hub.
|
||||
- phone: persiste solo localmente.
|
||||
"""
|
||||
user_tenant = self.db.query(UserTenant).filter(
|
||||
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.tenant_id == self.tenant_id)
|
||||
).first()
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Campos locales — incluye first_name/last_name como caché
|
||||
for field in ["role", "avatar_url", "phone", "bio", "preferences", "first_name", "last_name"]:
|
||||
if field in kwargs and kwargs[field] is not None:
|
||||
setattr(user_tenant, field, kwargs[field])
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(user_tenant)
|
||||
|
||||
# Sincronizar nombre/apellido con Keycloak vía Hub (best-effort)
|
||||
first_name = kwargs.get("first_name")
|
||||
last_name = kwargs.get("last_name")
|
||||
if access_token and (first_name or last_name):
|
||||
await self._hub_update_user_profile(keycloak_user_id, first_name, last_name, access_token)
|
||||
|
||||
user_info = current_user or {"id": keycloak_user_id}
|
||||
return _normalize_user(user_info, user_tenant.role, user_tenant)
|
||||
|
||||
async def _hub_get_service_token(self) -> Optional[str]:
|
||||
"""Obtiene un token de la cuenta de servicio Hub para operaciones admin."""
|
||||
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
|
||||
return None
|
||||
base = (settings.HUB_URL or "").rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{base}/api/v1/auth/login",
|
||||
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return data.get("access_token")
|
||||
logger.warning("Hub service account login failed status=%s", resp.status_code)
|
||||
except Exception as exc:
|
||||
logger.warning("Hub service account login error: %s", exc)
|
||||
return None
|
||||
|
||||
async def _hub_update_user_profile(
|
||||
self,
|
||||
user_id: str,
|
||||
first_name: Optional[str],
|
||||
last_name: Optional[str],
|
||||
access_token: str,
|
||||
) -> None:
|
||||
"""Sincroniza nombre/apellido con Keycloak a través del Hub (best-effort, no bloquea).
|
||||
|
||||
Intenta primero con el token del usuario. Si el Hub devuelve 403 (el usuario no
|
||||
tiene rol de Hub-admin), reintenta usando la cuenta de servicio configurada en
|
||||
HUB_ADMIN_EMAIL / HUB_ADMIN_PASSWORD.
|
||||
"""
|
||||
base = (settings.HUB_URL or "").rstrip("/")
|
||||
url = f"{base}/api/v1/hub/admins/{user_id}"
|
||||
payload: Dict[str, Any] = {}
|
||||
if first_name:
|
||||
payload["first_name"] = first_name
|
||||
if last_name:
|
||||
payload["last_name"] = last_name
|
||||
if not payload:
|
||||
return
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.patch(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
|
||||
if resp.status_code == 403:
|
||||
# El usuario no es Hub admin — reintentar con cuenta de servicio
|
||||
logger.info("Hub profile sync: user token got 403, trying service account for user_id=%s", user_id)
|
||||
service_token = await self._hub_get_service_token()
|
||||
if service_token:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.patch(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {service_token}"},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Hub profile sync skipped: no service account configured (HUB_ADMIN_EMAIL/HUB_ADMIN_PASSWORD)"
|
||||
)
|
||||
return
|
||||
|
||||
if resp.status_code not in (200, 204):
|
||||
logger.warning(
|
||||
"Hub profile sync failed status=%s body=%s",
|
||||
resp.status_code,
|
||||
resp.text[:300],
|
||||
)
|
||||
else:
|
||||
logger.info("Hub profile sync OK user_id=%s", user_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Hub profile sync error user_id=%s: %s", user_id, exc)
|
||||
|
||||
Reference in New Issue
Block a user