Merge pull request 'feat: add first_name and last_name fields to UserTenant model and sync with Keycloak' (#366) from fix/user-profile-edit-keycloak-sync into development
Reviewed-on: ADUANASOFT/anexo76#366
This commit is contained in:
@@ -56,6 +56,14 @@ class UserTenant(Base, TenantScopedMixin, TimestampMixin):
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(500), nullable=True, comment="URL de la imagen de perfil"
|
||||
)
|
||||
# Caché local de nombre/apellido (fuente de verdad = Keycloak vía Hub;
|
||||
# se sincroniza al editar perfil desde Anexo76)
|
||||
first_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(100), nullable=True, comment="Nombre (caché local de Keycloak)"
|
||||
)
|
||||
last_name: Mapped[Optional[str]] = mapped_column(
|
||||
String(100), nullable=True, comment="Apellido (caché local de Keycloak)"
|
||||
)
|
||||
phone: Mapped[Optional[str]] = mapped_column(
|
||||
String(20), nullable=True, comment="Teléfono del usuario"
|
||||
)
|
||||
|
||||
@@ -149,6 +149,169 @@ def get_user_avatar_image(
|
||||
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(
|
||||
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)
|
||||
return await service.get_current_user_profile(keycloak_user_id, current_user=current_user)
|
||||
|
||||
|
||||
@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"
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -293,158 +456,3 @@ async def change_user_password(
|
||||
service = UserService(db, tenant_id, company_id)
|
||||
await service.change_password(user_id, data.password, data.temporary)
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
|
||||
# === Endpoints de Perfil del Usuario Actual ===
|
||||
|
||||
|
||||
@router.get("/me/profile", response_model=UserResponseDTO)
|
||||
async def get_my_profile(
|
||||
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)
|
||||
return await service.get_current_user_profile(keycloak_user_id)
|
||||
|
||||
|
||||
@router.put("/me/profile", response_model=UserResponseDTO)
|
||||
async def update_my_profile(
|
||||
data: UpdateUserRequestDTO,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Actualiza el perfil 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
|
||||
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)
|
||||
return await service.update_current_user_profile(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
email=data.email,
|
||||
avatar_url=data.avatar_url,
|
||||
phone=data.phone,
|
||||
bio=data.bio,
|
||||
preferences=data.preferences,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/avatar")
|
||||
async def upload_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}
|
||||
|
||||
@@ -21,14 +21,20 @@ def _normalize_user(
|
||||
user_tenant: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Normaliza los datos de usuario al formato esperado por el DTO
|
||||
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": user_data.get("firstName") or user_data.get("name", "").split(" ")[0],
|
||||
"last_name": user_data.get("lastName") or (" ".join(user_data.get("name", "").split(" ")[1:]) if " " in user_data.get("name", "") else ""),
|
||||
"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"),
|
||||
@@ -38,9 +44,11 @@ def _normalize_user(
|
||||
# Agregar campos de perfil si user_tenant está disponible
|
||||
if user_tenant:
|
||||
avatar_out = user_tenant.avatar_url
|
||||
if avatar_out and str(avatar_out).startswith("tenants/"):
|
||||
if avatar_out:
|
||||
from core.s3_keys import public_user_avatar_api_path
|
||||
|
||||
# Siempre devolver la URL pública del endpoint de servicio de imágenes,
|
||||
# independientemente de si es clave S3 (tenants/...) o ruta local (/uploads/...).
|
||||
avatar_out = public_user_avatar_api_path(
|
||||
user_tenant.tenant_id, user_tenant.keycloak_user_id
|
||||
)
|
||||
@@ -621,19 +629,127 @@ class UserService:
|
||||
"usage_percentage": round(usage_percentage, 2),
|
||||
}
|
||||
|
||||
async def get_current_user_profile(self, keycloak_user_id: str) -> Dict[str, Any]:
|
||||
async def get_current_user_profile(self, keycloak_user_id: str, current_user: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""Obtiene el perfil completo del usuario actual"""
|
||||
# Reutilizamos verify_token para obtener info del Hub
|
||||
from core.security import verify_token
|
||||
user_info = await verify_token(keycloak_user_id) # keycloak_user_id es el token en este contexto, o el ID
|
||||
# Nota: en routes.py se pasa el ID. Si necesitamos info real, pedimos al Hub.
|
||||
|
||||
# Use the already-verified JWT claims dict — do NOT call verify_token(uuid)
|
||||
user_info = current_user or {"id": keycloak_user_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, **kwargs) -> Dict[str, Any]:
|
||||
"""Actualiza el perfil del usuario actual"""
|
||||
return await self.update_user(keycloak_user_id, **kwargs)
|
||||
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