ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: self-service workspace invite approval

This commit is contained in:
DCCONSTRUCTIONS
2026-05-09 22:57:07 +03:00
parent 42bc0fb0e6
commit 0be8f01283
22 changed files with 707 additions and 77 deletions
@@ -17,6 +17,10 @@ from django.views import View
from plane.authentication.utils.host import base_host
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.nodedc_workspace_join import (
NodeDCWorkspaceInviteJoinError,
accept_nodedc_workspace_invite_request_for_user,
)
from plane.authentication.views.nodedc_logout import get_nodedc_internal_token
from plane.db.models import ExternalIdentityLink, Profile, User
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
@@ -24,6 +28,7 @@ from plane.utils.path_validator import get_safe_redirect_url, validate_next_path
OIDC_SESSION_KEY = "nodedc_oidc"
OIDC_PROVIDER = "authentik"
NODEDC_WORKSPACE_INVITE_ACCEPT_PREFIX = "/auth/nodedc/workspace-invite/accept/"
DEFAULT_REQUIRED_GROUPS = "nodedc:superadmin,nodedc:taskmanager:admin,nodedc:taskmanager:user"
logger = logging.getLogger(__name__)
@@ -161,10 +166,45 @@ class NodeDCHandoffEndpoint(View):
user_login(request=request, user=user, is_app=True)
workspace_invite_redirect = resolve_nodedc_workspace_invite_accept_redirect(base_url, next_path, user)
if workspace_invite_redirect is not None:
return workspace_invite_redirect
path = next_path or get_redirection_path(user=user)
return HttpResponseRedirect(get_safe_redirect_url(base_url=base_url, next_path=path, params={}))
def resolve_nodedc_workspace_invite_accept_redirect(base_url, next_path, user):
if not next_path.startswith(NODEDC_WORKSPACE_INVITE_ACCEPT_PREFIX):
return None
request_id = next_path.removeprefix(NODEDC_WORKSPACE_INVITE_ACCEPT_PREFIX).strip("/")
try:
result = accept_nodedc_workspace_invite_request_for_user(request_id, user)
except NodeDCWorkspaceInviteJoinError as error:
logger.warning(
"NODEDC managed workspace invite accept failed: code=%s user_id=%s request_id=%s",
error.code,
user.id,
request_id,
)
return oidc_error_redirect(base_url, "", error.code)
except Exception:
logger.exception(
"NODEDC managed workspace invite accept crashed: user_id=%s request_id=%s",
user.id,
request_id,
)
return oidc_error_redirect(base_url, "", "workspace_invite_accept_failed")
workspace_slug = result.get("workspaceSlug")
if not workspace_slug:
return oidc_error_redirect(base_url, "", "workspace_invite_redirect_missing")
return HttpResponseRedirect(f"{base_url.rstrip('/')}/{workspace_slug}/")
def get_oidc_config():
issuer = os.environ.get("PLANE_OIDC_ISSUER", "").strip()
client_id = os.environ.get("PLANE_OIDC_CLIENT_ID", "").strip()
@@ -4,11 +4,13 @@ from urllib.parse import urlparse
from django.db import transaction
from django.http import JsonResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
from plane.utils.host import base_host
from plane.db.models import (
ExternalIdentityLink,
IssueAssignee,
@@ -18,6 +20,7 @@ from plane.db.models import (
User,
Workspace,
WorkspaceMember,
WorkspaceMemberInvite,
)
@@ -226,6 +229,44 @@ def serialize_membership(membership, created):
}
def resolve_workspace_invite(payload):
invite_id = payload.get("taskerInviteId") or payload.get("tasker_invite_id") or payload.get("inviteId")
request_id = payload.get("requestId") or payload.get("request_id")
queryset = WorkspaceMemberInvite.objects.filter(deleted_at__isnull=True).select_related("workspace")
if request_id:
queryset = queryset.filter(nodedc_approval_request_id=request_id)
if invite_id:
return queryset.filter(id=invite_id).first()
return None
def build_workspace_invite_link(request, invitation):
invite_path = f"/workspace-invitations/?invitation_id={invitation.id}&slug={invitation.workspace.slug}&token={invitation.token}"
return f"{base_host(request=request, is_app=True).rstrip('/')}{invite_path}"
def serialize_workspace_invite(request, invitation):
tasker_invite_link = build_workspace_invite_link(request, invitation)
return {
"id": str(invitation.id),
"email": invitation.email,
"status": invitation.nodedc_approval_status,
"workspace": serialize_workspace(invitation.workspace),
"platformInviteLink": invitation.nodedc_platform_invite_link,
"taskerInviteLink": (
tasker_invite_link
if invitation.nodedc_approval_status == WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED
else None
),
"inviteLink": (
invitation.nodedc_platform_invite_link or tasker_invite_link
if invitation.nodedc_approval_status == WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED
else None
),
}
def serialize_project_membership(project_member, created):
return {
"created": created,
@@ -414,6 +455,68 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
)
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalWorkspaceInviteApproveEndpoint(View):
def post(self, request):
if not is_internal_logout_request_authorized(request):
return internal_unauthorized_response()
payload = parse_json_body(request)
if payload is None:
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
invitation = resolve_workspace_invite(payload)
if invitation is None:
return JsonResponse({"ok": False, "error": "workspace_invite_not_found"}, status=404)
invitation.nodedc_approval_status = WorkspaceMemberInvite.NODEDC_APPROVAL_APPROVED
invitation.nodedc_approval_request_id = payload.get("requestId") or invitation.nodedc_approval_request_id
invitation.nodedc_approval_decided_at = timezone.now()
invitation.nodedc_platform_invite_link = payload.get("platformInviteLink") or invitation.nodedc_platform_invite_link
invitation.save(
update_fields=[
"nodedc_approval_status",
"nodedc_approval_request_id",
"nodedc_approval_decided_at",
"nodedc_platform_invite_link",
"updated_at",
]
)
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalWorkspaceInviteRejectEndpoint(View):
def post(self, request):
if not is_internal_logout_request_authorized(request):
return internal_unauthorized_response()
payload = parse_json_body(request)
if payload is None:
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
invitation = resolve_workspace_invite(payload)
if invitation is None:
return JsonResponse({"ok": False, "error": "workspace_invite_not_found"}, status=404)
invitation.nodedc_approval_status = WorkspaceMemberInvite.NODEDC_APPROVAL_REJECTED
invitation.nodedc_approval_request_id = payload.get("requestId") or invitation.nodedc_approval_request_id
invitation.nodedc_approval_decided_at = timezone.now()
invitation.message = payload.get("comment") or invitation.message
invitation.save(
update_fields=[
"nodedc_approval_status",
"nodedc_approval_request_id",
"nodedc_approval_decided_at",
"message",
"updated_at",
]
)
return JsonResponse({"ok": True, "invite": serialize_workspace_invite(request, invitation)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCInternalProjectMembershipEnsureEndpoint(View):
def post(self, request):