From 882b409d1c4de83af18d6ed044e32e4d81cdeb68 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 8 May 2026 15:19:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20PROJECT-LEVEL=20=D0=94=D0=9E=D0=A1?= =?UTF-8?q?=D0=A2=D0=A3=D0=9F=D0=AB=20OPERATIONAL=20CORE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/nodedc_workspace_adapter.py | 227 +++++++++++++++++- plane-src/apps/api/plane/urls.py | 12 + 2 files changed, 235 insertions(+), 4 deletions(-) diff --git a/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py b/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py index 0067490..a0e5541 100644 --- a/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py +++ b/plane-src/apps/api/plane/authentication/views/nodedc_workspace_adapter.py @@ -9,7 +9,7 @@ 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.db.models import ExternalIdentityLink, Profile, ProjectMember, User, Workspace, WorkspaceMember +from plane.db.models import ExternalIdentityLink, Profile, Project, ProjectMember, User, Workspace, WorkspaceMember OIDC_PROVIDER = "authentik" @@ -24,6 +24,11 @@ ROLE_VALUES = { 15: 15, ADMIN_ROLE: ADMIN_ROLE, } +PROJECT_ROLE_LABELS = { + 5: "guest", + 15: "member", + ADMIN_ROLE: "admin", +} def internal_unauthorized_response(): @@ -56,6 +61,21 @@ def resolve_workspace(payload): return None +def resolve_project(payload, workspace=None): + project_id = payload.get("projectId") or payload.get("project_id") + project_identifier = payload.get("projectIdentifier") or payload.get("project_identifier") or payload.get("identifier") + + queryset = Project.objects.filter(deleted_at__isnull=True).select_related("workspace") + if workspace is not None: + queryset = queryset.filter(workspace=workspace) + + if project_id: + return queryset.filter(id=project_id).first() + if project_identifier and workspace is not None: + return queryset.filter(identifier__iexact=project_identifier).first() + return None + + def resolve_user(payload): plane_user_id = payload.get("planeUserId") or payload.get("plane_user_id") subject = payload.get("subject") @@ -92,6 +112,10 @@ def normalize_role(value): return ROLE_VALUES.get(value, 15) +def project_role_slug(value): + return PROJECT_ROLE_LABELS.get(value, "member") + + def first_payload_string(payload, *keys): for key in keys: value = payload.get(key) @@ -147,7 +171,22 @@ def sync_user_avatar_from_payload(user, payload): user.save(update_fields=["avatar", "updated_at"]) -def serialize_workspace(workspace): +def serialize_project(project): + return { + "id": str(project.id), + "workspaceSlug": project.workspace.slug, + "name": project.name, + "identifier": project.identifier, + "memberCount": ProjectMember.objects.filter( + project=project, + deleted_at__isnull=True, + is_active=True, + member__is_bot=False, + ).count(), + } + + +def serialize_workspace(workspace, projects=None): return { "id": str(workspace.id), "slug": workspace.slug, @@ -159,6 +198,7 @@ def serialize_workspace(workspace): is_active=True, member__is_bot=False, ).count(), + "projects": [serialize_project(project) for project in projects] if projects is not None else [], } @@ -177,6 +217,22 @@ def serialize_membership(membership, created): } +def serialize_project_membership(project_member, created): + return { + "created": created, + "workspace": serialize_workspace(project_member.workspace), + "project": serialize_project(project_member.project), + "member": { + "id": str(project_member.member.id), + "email": project_member.member.email, + "displayName": project_member.member.display_name, + }, + "role": project_member.role, + "roleSlug": project_role_slug(project_member.role), + "isActive": project_member.is_active, + } + + def restore_admin_project_memberships(workspace, user): restored = 0 for project_member in ProjectMember.objects.filter( @@ -204,8 +260,22 @@ class NodeDCInternalWorkspaceListEndpoint(View): if not is_internal_logout_request_authorized(request): return internal_unauthorized_response() - workspaces = Workspace.objects.filter(deleted_at__isnull=True).select_related("owner").order_by("name") - return JsonResponse({"ok": True, "workspaces": [serialize_workspace(workspace) for workspace in workspaces]}) + workspaces = list(Workspace.objects.filter(deleted_at__isnull=True).select_related("owner").order_by("name")) + projects_by_workspace = {workspace.id: [] for workspace in workspaces} + for project in Project.objects.filter( + workspace__in=workspaces, + deleted_at__isnull=True, + ).select_related("workspace").order_by("workspace_id", "name"): + projects_by_workspace.setdefault(project.workspace_id, []).append(project) + + return JsonResponse( + { + "ok": True, + "workspaces": [ + serialize_workspace(workspace, projects_by_workspace.get(workspace.id, [])) for workspace in workspaces + ], + } + ) @method_decorator(csrf_exempt, name="dispatch") @@ -329,3 +399,152 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View): }, } ) + + +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCInternalProjectMembershipEnsureEndpoint(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) + + workspace = resolve_workspace(payload) + project = resolve_project(payload, workspace) + if project is None: + return JsonResponse({"ok": False, "error": "project_not_found"}, status=404) + if workspace is None: + workspace = project.workspace + if project.workspace_id != workspace.id: + return JsonResponse({"ok": False, "error": "project_workspace_mismatch"}, status=400) + + user = resolve_user(payload) + if user is None: + return JsonResponse({"ok": False, "error": "user_not_found"}, status=404) + + role = normalize_role(payload.get("role")) + fallback_workspace_role = 5 if role == 5 else 15 + + with transaction.atomic(): + sync_user_avatar_from_payload(user, payload) + + workspace_membership = WorkspaceMember.objects.filter( + workspace=workspace, + member=user, + deleted_at__isnull=True, + ).first() + if workspace_membership is None: + WorkspaceMember.objects.create( + workspace=workspace, + member=user, + role=fallback_workspace_role, + company_role=None, + is_active=True, + is_banned=False, + ) + else: + update_fields = [] + if not workspace_membership.is_active: + workspace_membership.is_active = True + update_fields.append("is_active") + if workspace_membership.is_banned: + workspace_membership.is_banned = False + workspace_membership.banned_at = None + workspace_membership.banned_until = None + update_fields.extend(["is_banned", "banned_at", "banned_until"]) + if workspace_membership.role < fallback_workspace_role: + workspace_membership.role = fallback_workspace_role + update_fields.append("role") + if update_fields: + update_fields.append("updated_at") + workspace_membership.save(update_fields=update_fields) + + project_member = ProjectMember.objects.filter( + project=project, + member=user, + deleted_at__isnull=True, + ).first() + created = project_member is None + if project_member is None: + project_member = ProjectMember.objects.create( + project=project, + workspace=workspace, + member=user, + role=role, + is_active=True, + ) + else: + project_member.role = role + project_member.is_active = True + project_member.save(update_fields=["role", "is_active", "updated_at"]) + + if payload.get("setLastWorkspace", False) is True: + profile, _ = Profile.objects.get_or_create(user=user) + profile.last_workspace_id = workspace.id + profile.save(update_fields=["last_workspace_id", "updated_at"]) + + return JsonResponse({"ok": True, "membership": serialize_project_membership(project_member, created)}) + + +@method_decorator(csrf_exempt, name="dispatch") +class NodeDCInternalProjectMembershipRemoveEndpoint(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) + + workspace = resolve_workspace(payload) + project = resolve_project(payload, workspace) + if project is None: + return JsonResponse({"ok": False, "error": "project_not_found"}, status=404) + if workspace is None: + workspace = project.workspace + if project.workspace_id != workspace.id: + return JsonResponse({"ok": False, "error": "project_workspace_mismatch"}, status=400) + + user = resolve_user(payload) + if user is None: + return JsonResponse({"ok": False, "error": "user_not_found"}, status=404) + + project_member = ProjectMember.objects.filter( + project=project, + member=user, + deleted_at__isnull=True, + ).first() + + if project_member is None or not project_member.is_active: + return JsonResponse( + { + "ok": True, + "removed": False, + "workspace": serialize_workspace(workspace), + "project": serialize_project(project), + "member": { + "id": str(user.id), + "email": user.email, + "displayName": user.display_name, + }, + } + ) + + project_member.is_active = False + project_member.save(update_fields=["is_active", "updated_at"]) + + return JsonResponse( + { + "ok": True, + "removed": True, + "workspace": serialize_workspace(workspace), + "project": serialize_project(project), + "member": { + "id": str(user.id), + "email": user.email, + "displayName": user.display_name, + }, + } + ) diff --git a/plane-src/apps/api/plane/urls.py b/plane-src/apps/api/plane/urls.py index 83ac333..16eb9f7 100644 --- a/plane-src/apps/api/plane/urls.py +++ b/plane-src/apps/api/plane/urls.py @@ -16,6 +16,8 @@ from plane.authentication.views.nodedc_logout import ( NodeDCInternalSessionLogoutEndpoint, ) from plane.authentication.views.nodedc_workspace_adapter import ( + NodeDCInternalProjectMembershipEnsureEndpoint, + NodeDCInternalProjectMembershipRemoveEndpoint, NodeDCInternalWorkspaceListEndpoint, NodeDCInternalWorkspaceMembershipEnsureEndpoint, NodeDCInternalWorkspaceMembershipRemoveEndpoint, @@ -44,6 +46,16 @@ urlpatterns = [ NodeDCInternalWorkspaceMembershipRemoveEndpoint.as_view(), name="nodedc-internal-workspace-membership-remove", ), + path( + "api/internal/nodedc/project-memberships/ensure/", + NodeDCInternalProjectMembershipEnsureEndpoint.as_view(), + name="nodedc-internal-project-membership-ensure", + ), + path( + "api/internal/nodedc/project-memberships/remove/", + NodeDCInternalProjectMembershipRemoveEndpoint.as_view(), + name="nodedc-internal-project-membership-remove", + ), path("api/", include("plane.app.urls")), path("api/public/", include("plane.space.urls")), path("api/instances/", include("plane.license.urls")),