ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: администрирование воркспейсов и feature-gates в God Mode
This commit is contained in:
@@ -51,6 +51,7 @@ from plane.db.models import (
|
||||
Workspace,
|
||||
WorkspaceAICredential,
|
||||
WorkspaceAISettings,
|
||||
WorkspaceFeatureEntitlement,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.license.utils.encryption import decrypt_data
|
||||
@@ -288,10 +289,12 @@ def get_request_project_id(request):
|
||||
def get_voice_task_preflight(workspace, user, project_id=None):
|
||||
ai_settings = WorkspaceAISettings.objects.filter(workspace=workspace).first()
|
||||
workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user, is_active=True).first()
|
||||
entitlement_enabled = is_voice_tasker_entitled(workspace)
|
||||
|
||||
response = {
|
||||
"available": False,
|
||||
"reason": "not_configured",
|
||||
"entitlement_enabled": entitlement_enabled,
|
||||
"max_audio_duration_seconds": 120,
|
||||
"accepted_mime_types": VOICE_TASK_ACCEPTED_AUDIO_TYPES,
|
||||
"access_mode": "all_workspace_members",
|
||||
@@ -299,6 +302,10 @@ def get_voice_task_preflight(workspace, user, project_id=None):
|
||||
"enabled_member_ids": [],
|
||||
}
|
||||
|
||||
if not entitlement_enabled:
|
||||
response["reason"] = "disabled"
|
||||
return response
|
||||
|
||||
if not ai_settings:
|
||||
return response
|
||||
|
||||
@@ -343,6 +350,14 @@ def get_voice_task_preflight(workspace, user, project_id=None):
|
||||
return response
|
||||
|
||||
|
||||
def is_voice_tasker_entitled(workspace):
|
||||
return WorkspaceFeatureEntitlement.objects.filter(
|
||||
workspace=workspace,
|
||||
feature_key=WorkspaceFeatureEntitlement.FeatureKey.VOICE_TASKER,
|
||||
is_enabled=True,
|
||||
).exists()
|
||||
|
||||
|
||||
def get_voice_task_quota_project(workspace, project_id=None, ai_settings=None):
|
||||
if project_id:
|
||||
project = Project.objects.filter(
|
||||
@@ -3390,11 +3405,23 @@ class WorkspaceAISettingsEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
workspace, ai_settings = self.get_settings(slug)
|
||||
serializer = WorkspaceAISettingsSerializer(ai_settings, context={"workspace": workspace})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
data = serializer.data
|
||||
data["feature_entitlement_enabled"] = is_voice_tasker_entitled(workspace)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def patch(self, request, slug):
|
||||
workspace, ai_settings = self.get_settings(slug)
|
||||
requested_enabled = request.data.get("voice_tasker_enabled")
|
||||
if requested_enabled in [True, "true", "True", "1", 1] and not is_voice_tasker_entitled(workspace):
|
||||
return Response(
|
||||
{
|
||||
"error": "Voice Tasker is not enabled for this workspace by the instance administrator.",
|
||||
"code": "feature_not_entitled",
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = WorkspaceAISettingsSerializer(
|
||||
ai_settings,
|
||||
data=request.data,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Generated by Codex on 2026-04-28
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("db", "0134_voice_tasker_retention"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WorkspaceFeatureEntitlement",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
|
||||
),
|
||||
("deleted_at", models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"feature_key",
|
||||
models.CharField(
|
||||
choices=[("voice_tasker", "AI / Voice Tasker")],
|
||||
max_length=80,
|
||||
),
|
||||
),
|
||||
("is_enabled", models.BooleanField(default=False)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="feature_entitlements",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Workspace Feature Entitlement",
|
||||
"verbose_name_plural": "Workspace Feature Entitlements",
|
||||
"db_table": "workspace_feature_entitlements",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="workspacefeatureentitlement",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True)),
|
||||
fields=("workspace", "feature_key"),
|
||||
name="workspace_feature_entitlement_unique_active_feature",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -69,6 +69,7 @@ from .voice_tasker import VoiceTaskSession, WorkspaceAICredential, WorkspaceAISe
|
||||
from .workspace import (
|
||||
Workspace,
|
||||
WorkspaceBaseModel,
|
||||
WorkspaceFeatureEntitlement,
|
||||
WorkspaceMember,
|
||||
WorkspaceMemberInvite,
|
||||
WorkspaceTheme,
|
||||
|
||||
@@ -260,6 +260,36 @@ class WorkspaceMemberInvite(BaseModel):
|
||||
return f"{self.workspace.name} {self.email} {self.accepted}"
|
||||
|
||||
|
||||
class WorkspaceFeatureEntitlement(BaseModel):
|
||||
class FeatureKey(models.TextChoices):
|
||||
VOICE_TASKER = "voice_tasker", "AI / Voice Tasker"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="feature_entitlements",
|
||||
)
|
||||
feature_key = models.CharField(max_length=80, choices=FeatureKey.choices)
|
||||
is_enabled = models.BooleanField(default=False)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "feature_key"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="workspace_feature_entitlement_unique_active_feature",
|
||||
)
|
||||
]
|
||||
verbose_name = "Workspace Feature Entitlement"
|
||||
verbose_name_plural = "Workspace Feature Entitlements"
|
||||
db_table = "workspace_feature_entitlements"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.slug} <{self.feature_key}>"
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
name = models.CharField(max_length=255, verbose_name="Team Name")
|
||||
description = models.TextField(verbose_name="Team Description", blank=True)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user