Files
plantillas-proyectos/backend/core/workspace_profile_sync.py
Galindo97 1dbc75107f feat: enhance user avatar handling and synchronization with Workspace
- Updated sidebar component to merge user data from Keycloak and Workspace, including avatar URLs.
- Refactored nav-user component to utilize new avatar resolution logic and display user information more effectively.
- Introduced a new utility function to resolve user avatar URLs, prioritizing Workspace avatars.
- Implemented backend changes to support synchronization of user profile data, including avatar URLs from Workspace.
- Added database migration to include new fields for Workspace profile synchronization in user_tenants.
- Created a new client for fetching user profiles from Workspace.
- Updated dashboard components to reflect changes in user data structure and avatar handling.
- Removed avatar upload functionality from the profile update process, relying on Workspace for avatar management.
- Added tests for avatar resolution logic to ensure correct prioritization of avatar sources.
2026-05-08 17:03:29 -05:00

115 lines
3.7 KiB
Python

import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from urllib.parse import urlparse
from sqlalchemy.orm import Session
from api.v1.modules.core.user_tenant.models import UserTenant
from core.workspace_profile_client import WorkspaceProfileClient
logger = logging.getLogger(__name__)
SYNC_TTL_SECONDS = 300
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 _is_fresh(ts: Optional[datetime], ttl_seconds: int = SYNC_TTL_SECONDS) -> bool:
if not ts:
return False
now = datetime.now(timezone.utc)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
return ts >= (now - timedelta(seconds=ttl_seconds))
async def sync_workspace_profile_for_user(
db: Session,
*,
access_token: Optional[str],
keycloak_user_id: Optional[str],
tenant_id: Optional[int] = None,
company_id: Optional[int] = None,
workspace_profile: Optional[dict[str, Any]] = None,
force: bool = False,
) -> None:
"""
Sincroniza sub/avatar_url desde Workspace hacia core.user_tenants.
Nunca lanza excepción para no bloquear login ni requests autenticados.
"""
if not access_token or not keycloak_user_id:
return
try:
query = db.query(UserTenant).filter(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
if tenant_id is not None:
query = query.filter(UserTenant.tenant_id == int(tenant_id))
if company_id is not None:
query = query.filter(UserTenant.company_id == int(company_id))
target = query.first()
if not target:
return
if not force and _is_fresh(target.workspace_profile_synced_at):
return
payload = workspace_profile
if payload is None:
client = WorkspaceProfileClient()
payload = await client.get_me(access_token)
workspace_sub = payload.get("sub")
if not workspace_sub:
logger.warning(
"workspace_profile_sync_warning",
extra={
"event": "workspace_profile_sync_warning",
"reason": "missing_sub",
"keycloak_user_id": keycloak_user_id,
"tenant_id": target.tenant_id,
},
)
return
avatar_url = payload.get("avatar_url")
sanitized_avatar = avatar_url.strip() if isinstance(avatar_url, str) else None
if sanitized_avatar and not _is_valid_http_url(sanitized_avatar):
logger.warning(
"workspace_profile_sync_warning",
extra={
"event": "workspace_profile_sync_warning",
"reason": "invalid_avatar_url",
"keycloak_user_id": keycloak_user_id,
"tenant_id": target.tenant_id,
},
)
sanitized_avatar = None
target.workspace_user_id = str(workspace_sub)
target.workspace_avatar_url = sanitized_avatar
target.workspace_profile_synced_at = datetime.now(timezone.utc)
db.add(target)
db.commit()
except Exception as exc:
db.rollback()
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"error": str(exc),
"keycloak_user_id": keycloak_user_id,
"tenant_id": tenant_id,
"company_id": company_id,
},
)