import logging import os from urllib.parse import urlparse import requests from django.db import transaction from plane.authentication.views.nodedc_logout import get_nodedc_internal_token from plane.db.models import ExternalIdentityLink, User logger = logging.getLogger("plane") OIDC_PROVIDER = "authentik" def get_nodedc_profile_sync_url(): launcher_base_url = ( os.environ.get("PLANE_NODEDC_LAUNCHER_URL", "").strip() or os.environ.get("PLANE_NODEDC_LAUNCHER_PUBLIC_URL", "").strip() or "http://launcher.local.nodedc" ).rstrip("/") return ( os.environ.get("PLANE_NODEDC_PROFILE_SYNC_URL", "").strip() or f"{launcher_base_url}/api/internal/tasker/profile-sync" ) def get_tasker_public_origin(): explicit_origin = os.environ.get("PLANE_NODEDC_TASK_PUBLIC_URL", "").strip() if explicit_origin: return explicit_origin.rstrip("/") configured_url = os.environ.get("WEB_URL", "").strip() if configured_url: parsed_url = urlparse(configured_url) if parsed_url.scheme and parsed_url.netloc: return f"{parsed_url.scheme}://{parsed_url.netloc}" task_domain = os.environ.get("TASK_DOMAIN", "").strip() if task_domain: return f"http://{task_domain}" return "" def normalize_tasker_avatar_url(value): if not isinstance(value, str): return None avatar_url = value.strip() if not avatar_url: return None if avatar_url.startswith(("http://", "https://", "data:")): return avatar_url if avatar_url.startswith("/"): tasker_origin = get_tasker_public_origin() return f"{tasker_origin}{avatar_url}" if tasker_origin else avatar_url return avatar_url def get_user_full_name(user): return " ".join( value for value in [getattr(user, "first_name", ""), getattr(user, "last_name", "")] if value ).strip() def get_user_display_name(user, changed_fields=None): changed_fields = set(changed_fields or []) full_name = get_user_full_name(user) display_name = getattr(user, "display_name", "") if {"first_name", "last_name"} & changed_fields and "display_name" not in changed_fields and full_name: return full_name if display_name: return display_name return full_name or user.email def get_nodedc_subject(user): link = ExternalIdentityLink.objects.filter(provider=OIDC_PROVIDER, user=user, status="active").first() return link.subject if link else None def build_nodedc_profile_payload(user, changed_fields=None): changed_fields = sorted(set(changed_fields or [])) display_name = get_user_display_name(user, changed_fields=changed_fields) return { "source": "tasker", "planeUserId": str(user.id), "subject": get_nodedc_subject(user), "email": user.email, "name": display_name, "displayName": display_name, "firstName": user.first_name, "lastName": user.last_name, "avatarUrl": normalize_tasker_avatar_url(user.avatar_url), "changedFields": changed_fields, } def push_nodedc_user_profile_update(user, changed_fields=None): request_url = get_nodedc_profile_sync_url() token = get_nodedc_internal_token() if not request_url or not token: logger.warning("NODE.DC profile sync is not configured") return None response = requests.post( request_url, json=build_nodedc_profile_payload(user, changed_fields=changed_fields), headers={ "Authorization": f"Bearer {token}", "Accept": "application/json", }, timeout=float(os.environ.get("PLANE_NODEDC_PROFILE_SYNC_TIMEOUT_SECONDS", "3") or "3"), ) response.raise_for_status() return response.json() def push_nodedc_user_profile_update_on_commit(user, changed_fields=None): user_id = user.id changed_fields = sorted(set(changed_fields or [])) def _push(): fresh_user = User.objects.filter(id=user_id, is_bot=False).first() if fresh_user is None: return try: push_nodedc_user_profile_update(fresh_user, changed_fields=changed_fields) except Exception: logger.exception("Failed to push NODE.DC profile update to Launcher") transaction.on_commit(_push)