ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: администрирование воркспейсов и feature-gates в God Mode

This commit is contained in:
DCCONSTRUCTIONS
2026-04-29 00:38:11 +03:00
parent 7f47b85c36
commit 7bf416ec1f
15 changed files with 923 additions and 40 deletions
@@ -25,4 +25,6 @@ from .admin import (
from .workspace import (
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
InstanceWorkSpaceFeatureEndpoint,
InstanceWorkSpaceMemberEndpoint,
)
@@ -6,16 +6,109 @@
from rest_framework.response import Response
from rest_framework import status
from django.db import IntegrityError
from django.db.models import OuterRef, Func, F
from django.db.models import Count, OuterRef, Func, F, Q
from django.utils import timezone
# Module imports
from plane.app.views.base import BaseAPIView
from plane.license.api.permissions import InstanceAdminPermission
from plane.db.models import Workspace, WorkspaceMember, Project
from plane.db.models import (
Project,
ProjectMember,
Workspace,
WorkspaceAICredential,
WorkspaceAISettings,
WorkspaceFeatureEntitlement,
WorkspaceMember,
)
from plane.license.api.serializers import WorkspaceSerializer
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
WORKSPACE_ROLES = {5, 15, 20}
VOICE_TASKER_FEATURE = WorkspaceFeatureEntitlement.FeatureKey.VOICE_TASKER
def serialize_instance_workspace_member(workspace_member):
member = workspace_member.member
return {
"id": str(workspace_member.id),
"workspace": str(workspace_member.workspace_id),
"member": {
"id": str(member.id),
"email": member.email,
"display_name": member.display_name,
"first_name": member.first_name,
"last_name": member.last_name,
"avatar_url": member.avatar_url,
"is_bot": member.is_bot,
"is_active": member.is_active,
},
"role": workspace_member.role,
"is_active": workspace_member.is_active,
"created_at": workspace_member.created_at,
"active_project_count": getattr(workspace_member, "active_project_count", 0),
"admin_project_count": getattr(workspace_member, "admin_project_count", 0),
}
def has_other_workspace_admin(workspace_member):
return WorkspaceMember.objects.filter(
workspace=workspace_member.workspace,
role=20,
is_active=True,
member__is_bot=False,
).exclude(pk=workspace_member.pk).exists()
def is_only_project_admin(workspace_member):
return (
Project.objects.filter(
workspace=workspace_member.workspace,
archived_at__isnull=True,
project_projectmember__member_id=workspace_member.member_id,
project_projectmember__role=20,
project_projectmember__is_active=True,
)
.annotate(
other_admin_count=Count(
"project_projectmember",
filter=(
Q(project_projectmember__role=20)
& Q(project_projectmember__is_active=True)
& ~Q(project_projectmember__member_id=workspace_member.member_id)
),
distinct=True,
)
)
.filter(other_admin_count=0)
.exists()
)
def get_workspace_member_queryset(workspace_id):
return (
WorkspaceMember.objects.filter(workspace_id=workspace_id, member__is_bot=False, is_active=True)
.select_related("workspace", "member", "member__avatar_asset")
.annotate(
active_project_count=Count(
"member__member_project",
filter=Q(member__member_project__workspace_id=workspace_id)
& Q(member__member_project__is_active=True),
distinct=True,
),
admin_project_count=Count(
"member__member_project",
filter=Q(member__member_project__workspace_id=workspace_id)
& Q(member__member_project__is_active=True)
& Q(member__member_project__role=20),
distinct=True,
),
)
.order_by("member__display_name", "member__email")
)
class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
@@ -108,3 +201,131 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
{"slug": "The workspace with the slug already exists"},
status=status.HTTP_409_CONFLICT,
)
class InstanceWorkSpaceMemberEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
def get(self, request, workspace_id):
if not Workspace.objects.filter(id=workspace_id).exists():
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
workspace_members = get_workspace_member_queryset(workspace_id)
return Response(
[serialize_instance_workspace_member(workspace_member) for workspace_member in workspace_members],
status=status.HTTP_200_OK,
)
def patch(self, request, workspace_id, member_id):
workspace_member = get_workspace_member_queryset(workspace_id).filter(pk=member_id).first()
if not workspace_member:
return Response({"error": "Workspace member not found"}, status=status.HTTP_404_NOT_FOUND)
try:
role = int(request.data.get("role"))
except (TypeError, ValueError):
return Response({"error": "Role must be one of 5, 15, 20"}, status=status.HTTP_400_BAD_REQUEST)
if role not in WORKSPACE_ROLES:
return Response({"error": "Role must be one of 5, 15, 20"}, status=status.HTTP_400_BAD_REQUEST)
if workspace_member.role == 20 and role != 20 and not has_other_workspace_admin(workspace_member):
return Response(
{"error": "Cannot demote the only workspace admin"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace_member.role = role
workspace_member.save()
if role == 5:
ProjectMember.objects.filter(
workspace_id=workspace_id,
member_id=workspace_member.member_id,
is_active=True,
).update(role=5, updated_at=timezone.now())
workspace_member = get_workspace_member_queryset(workspace_id).get(pk=member_id)
return Response(serialize_instance_workspace_member(workspace_member), status=status.HTTP_200_OK)
def delete(self, request, workspace_id, member_id):
workspace_member = get_workspace_member_queryset(workspace_id).filter(pk=member_id).first()
if not workspace_member:
return Response({"error": "Workspace member not found"}, status=status.HTTP_404_NOT_FOUND)
if workspace_member.role == 20 and not has_other_workspace_admin(workspace_member):
return Response(
{"error": "Cannot remove the only workspace admin"},
status=status.HTTP_400_BAD_REQUEST,
)
if is_only_project_admin(workspace_member):
return Response(
{"error": "Cannot remove a member who is the only admin in one or more projects"},
status=status.HTTP_400_BAD_REQUEST,
)
ProjectMember.objects.filter(
workspace_id=workspace_id,
member_id=workspace_member.member_id,
is_active=True,
).update(is_active=False, updated_at=timezone.now())
workspace_member.is_active = False
workspace_member.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class InstanceWorkSpaceFeatureEndpoint(BaseAPIView):
permission_classes = [InstanceAdminPermission]
def serialize_voice_tasker_feature(self, workspace):
entitlement = WorkspaceFeatureEntitlement.objects.filter(
workspace=workspace,
feature_key=VOICE_TASKER_FEATURE,
).first()
ai_settings = WorkspaceAISettings.objects.filter(workspace=workspace).first()
credential = WorkspaceAICredential.objects.filter(
workspace=workspace,
provider=WorkspaceAICredential.Provider.OPENAI,
is_active=True,
).first()
return {
"key": VOICE_TASKER_FEATURE,
"title": "AI / Voice Tasker",
"description": "Голосовая постановка задач и встроенные AI-сценарии воркспейса.",
"is_enabled": bool(entitlement and entitlement.is_enabled),
"workspace_setting_enabled": bool(ai_settings and ai_settings.voice_tasker_enabled),
"access_mode": ai_settings.access_mode if ai_settings else WorkspaceAISettings.AccessMode.ALL_WORKSPACE_MEMBERS,
"has_workspace_key": bool(credential and credential.encrypted_api_key),
}
def get(self, request, workspace_id):
workspace = Workspace.objects.filter(id=workspace_id).first()
if not workspace:
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
return Response({"features": [self.serialize_voice_tasker_feature(workspace)]}, status=status.HTTP_200_OK)
def patch(self, request, workspace_id):
workspace = Workspace.objects.filter(id=workspace_id).first()
if not workspace:
return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND)
feature_key = request.data.get("feature_key")
if feature_key != VOICE_TASKER_FEATURE:
return Response({"error": "Unsupported feature key"}, status=status.HTTP_400_BAD_REQUEST)
is_enabled = request.data.get("is_enabled")
if not isinstance(is_enabled, bool):
return Response({"error": "is_enabled must be a boolean"}, status=status.HTTP_400_BAD_REQUEST)
entitlement, _ = WorkspaceFeatureEntitlement.objects.get_or_create(
workspace=workspace,
feature_key=feature_key,
)
entitlement.is_enabled = is_enabled
entitlement.save()
return Response({"features": [self.serialize_voice_tasker_feature(workspace)]}, status=status.HTTP_200_OK)
+17
View File
@@ -18,6 +18,8 @@ from plane.license.api.views import (
InstanceAdminUserSessionEndpoint,
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
InstanceWorkSpaceFeatureEndpoint,
InstanceWorkSpaceMemberEndpoint,
)
urlpatterns = [
@@ -71,4 +73,19 @@ urlpatterns = [
name="instance-workspace-availability",
),
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
path(
"workspaces/<uuid:workspace_id>/members/",
InstanceWorkSpaceMemberEndpoint.as_view(),
name="instance-workspace-members",
),
path(
"workspaces/<uuid:workspace_id>/members/<uuid:member_id>/",
InstanceWorkSpaceMemberEndpoint.as_view(),
name="instance-workspace-member",
),
path(
"workspaces/<uuid:workspace_id>/features/",
InstanceWorkSpaceFeatureEndpoint.as_view(),
name="instance-workspace-features",
),
]