ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: PROJECT-LEVEL ДОСТУПЫ OPERATIONAL CORE

This commit is contained in:
DCCONSTRUCTIONS 2026-05-08 15:19:38 +03:00
parent 3afa15d326
commit 882b409d1c
2 changed files with 235 additions and 4 deletions

View File

@ -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,
},
}
)

View File

@ -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")),