АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: каркас Voice Tasker settings

This commit is contained in:
DCCONSTRUCTIONS
2026-04-24 16:47:16 +03:00
parent b3c6b37399
commit 237c7964cd
20 changed files with 2009 additions and 3 deletions
@@ -125,6 +125,7 @@ from .notification import NotificationSerializer, UserNotificationPreferenceSeri
from .exporter import ExporterHistorySerializer
from .webhook import WebhookSerializer, WebhookLogSerializer
from .voice_tasker import WorkspaceAISettingsSerializer
from .favorite import UserFavoriteSerializer
@@ -0,0 +1,95 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from rest_framework import serializers
from plane.db.models import Project, WorkspaceAICredential, WorkspaceAISettings
from plane.license.utils.encryption import encrypt_data
from .base import BaseSerializer
class WorkspaceAISettingsSerializer(BaseSerializer):
default_project_id = serializers.UUIDField(required=False, allow_null=True)
openai_api_key = serializers.CharField(required=False, allow_blank=True, write_only=True, trim_whitespace=False)
credential = serializers.SerializerMethodField(read_only=True)
class Meta:
model = WorkspaceAISettings
fields = [
"id",
"workspace_id",
"voice_tasker_enabled",
"provider",
"transcription_model",
"structuring_model",
"default_project_id",
"access_mode",
"max_audio_duration_seconds",
"per_user_hourly_limit",
"workspace_hourly_limit",
"credential",
"openai_api_key",
"created_at",
"updated_at",
]
read_only_fields = ["id", "workspace_id", "provider", "created_at", "updated_at", "credential"]
def get_credential(self, obj):
credential = WorkspaceAICredential.objects.filter(workspace=obj.workspace, provider=obj.provider).first()
return {
"provider": obj.provider,
"has_key": bool(credential and credential.encrypted_api_key and credential.is_active),
"key_last4": credential.key_last4 if credential else "",
"updated_at": credential.updated_at if credential else None,
}
def validate_default_project_id(self, value):
if value is None:
return None
workspace = self.context["workspace"]
if not Project.objects.filter(workspace=workspace, id=value, archived_at__isnull=True).exists():
raise serializers.ValidationError("Default project must belong to this workspace.")
return value
def validate_max_audio_duration_seconds(self, value):
if value < 10 or value > 600:
raise serializers.ValidationError("Max audio duration must be between 10 and 600 seconds.")
return value
def validate_per_user_hourly_limit(self, value):
if value < 1 or value > 1000:
raise serializers.ValidationError("Per-user hourly limit must be between 1 and 1000.")
return value
def validate_workspace_hourly_limit(self, value):
if value < 1 or value > 10000:
raise serializers.ValidationError("Workspace hourly limit must be between 1 and 10000.")
return value
def update(self, instance, validated_data):
api_key = validated_data.pop("openai_api_key", None)
default_project_id = validated_data.pop("default_project_id", serializers.empty)
if default_project_id is not serializers.empty:
instance.default_project_id = default_project_id
for key, value in validated_data.items():
setattr(instance, key, value)
instance.save()
if api_key:
cleaned_api_key = api_key.strip()
credential, _ = WorkspaceAICredential.objects.get_or_create(
workspace=instance.workspace,
provider=instance.provider,
)
credential.encrypted_api_key = encrypt_data(cleaned_api_key)
credential.key_last4 = cleaned_api_key[-4:] if len(cleaned_api_key) >= 4 else cleaned_api_key
credential.is_active = True
credential.save()
return instance
@@ -23,6 +23,7 @@ from .webhook import urlpatterns as webhook_urls
from .workspace import urlpatterns as workspace_urls
from .timezone import urlpatterns as timezone_urls
from .exporter import urlpatterns as exporter_urls
from .voice_tasker import urlpatterns as voice_tasker_urls
urlpatterns = [
*analytic_urls,
@@ -46,4 +47,5 @@ urlpatterns = [
*webhook_urls,
*timezone_urls,
*exporter_urls,
*voice_tasker_urls,
]
@@ -0,0 +1,24 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.app.views import (
WorkspaceAISettingsEndpoint,
WorkspaceAISettingsTestConnectionEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/voice-tasker/settings/",
WorkspaceAISettingsEndpoint.as_view(),
name="voice-tasker-settings",
),
path(
"workspaces/<str:slug>/voice-tasker/settings/test-connection/",
WorkspaceAISettingsTestConnectionEndpoint.as_view(),
name="voice-tasker-settings-test-connection",
),
]
@@ -243,6 +243,11 @@ from .webhook.base import (
WebhookSecretRegenerateEndpoint,
)
from .voice_tasker import (
WorkspaceAISettingsEndpoint,
WorkspaceAISettingsTestConnectionEndpoint,
)
from .error_404 import custom_404_view
from .notification.base import MarkAllReadNotificationViewSet
@@ -0,0 +1,107 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from openai import OpenAI
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import ROLE, allow_permission
from plane.app.serializers import WorkspaceAISettingsSerializer
from plane.db.models import Workspace, WorkspaceAICredential, WorkspaceAISettings
from plane.license.utils.encryption import decrypt_data
from plane.utils.exception_logger import log_exception
from .base import BaseAPIView
class WorkspaceAISettingsEndpoint(BaseAPIView):
def get_settings(self, slug):
workspace = Workspace.objects.get(slug=slug)
ai_settings, _ = WorkspaceAISettings.objects.get_or_create(workspace=workspace)
return workspace, ai_settings
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
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)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def patch(self, request, slug):
workspace, ai_settings = self.get_settings(slug)
serializer = WorkspaceAISettingsSerializer(
ai_settings,
data=request.data,
partial=True,
context={"workspace": workspace},
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class WorkspaceAISettingsTestConnectionEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
ai_settings, _ = WorkspaceAISettings.objects.get_or_create(workspace=workspace)
credential = WorkspaceAICredential.objects.filter(
workspace=workspace,
provider=ai_settings.provider,
is_active=True,
).first()
if not credential or not credential.encrypted_api_key:
return Response(
{
"ok": False,
"code": "missing_api_key",
"error": "OpenAI API key is not configured for this workspace.",
},
status=status.HTTP_400_BAD_REQUEST,
)
api_key = decrypt_data(credential.encrypted_api_key)
if not api_key:
return Response(
{
"ok": False,
"code": "invalid_encrypted_key",
"error": "OpenAI API key could not be decrypted.",
},
status=status.HTTP_400_BAD_REQUEST,
)
try:
client = OpenAI(api_key=api_key)
client.models.retrieve(ai_settings.structuring_model)
return Response(
{
"ok": True,
"provider": ai_settings.provider,
"model": ai_settings.structuring_model,
},
status=status.HTTP_200_OK,
)
except Exception as exc:
log_exception(exc)
error_type = exc.__class__.__name__
status_code = status.HTTP_400_BAD_REQUEST
error_code = "openai_connection_failed"
if error_type == "AuthenticationError":
error_code = "invalid_api_key"
elif error_type == "RateLimitError":
error_code = "rate_limited"
status_code = status.HTTP_429_TOO_MANY_REQUESTS
return Response(
{
"ok": False,
"code": error_code,
"error": "OpenAI connection check failed.",
},
status=status_code,
)
@@ -0,0 +1,187 @@
# Generated by Codex on 2026-04-24
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", "0123_force_profile_language_ru"),
]
operations = [
migrations.CreateModel(
name="WorkspaceAICredential",
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,
),
),
(
"provider",
models.CharField(
choices=[("openai", "OpenAI")],
default="openai",
max_length=32,
),
),
("encrypted_api_key", models.TextField(blank=True)),
("key_last4", models.CharField(blank=True, max_length=4)),
("is_active", models.BooleanField(default=True)),
(
"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.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="ai_credential",
to="db.workspace",
),
),
],
options={
"verbose_name": "Workspace AI Credential",
"verbose_name_plural": "Workspace AI Credentials",
"db_table": "workspace_ai_credentials",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="WorkspaceAISettings",
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,
),
),
("voice_tasker_enabled", models.BooleanField(default=False)),
(
"provider",
models.CharField(
choices=[("openai", "OpenAI")],
default="openai",
max_length=32,
),
),
(
"transcription_model",
models.CharField(default="gpt-4o-mini-transcribe", max_length=80),
),
(
"structuring_model",
models.CharField(default="gpt-4o-mini", max_length=80),
),
(
"access_mode",
models.CharField(
choices=[
("all_workspace_members", "All workspace members"),
("admins_only", "Admins only"),
],
default="all_workspace_members",
max_length=40,
),
),
("max_audio_duration_seconds", models.PositiveIntegerField(default=120)),
("per_user_hourly_limit", models.PositiveIntegerField(default=30)),
("workspace_hourly_limit", models.PositiveIntegerField(default=300)),
(
"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",
),
),
(
"default_project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="workspace_ai_default_project",
to="db.project",
),
),
(
"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.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="ai_settings",
to="db.workspace",
),
),
],
options={
"verbose_name": "Workspace AI Settings",
"verbose_name_plural": "Workspace AI Settings",
"db_table": "workspace_ai_settings",
"ordering": ("-created_at",),
},
),
]
@@ -65,6 +65,7 @@ from .state import State, StateGroup, DEFAULT_STATES
from .user import Account, Profile, User, BotTypeEnum
from .view import IssueView
from .webhook import Webhook, WebhookLog
from .voice_tasker import WorkspaceAICredential, WorkspaceAISettings
from .workspace import (
Workspace,
WorkspaceBaseModel,
@@ -0,0 +1,74 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.db import models
from .base import BaseModel
class WorkspaceAISettings(BaseModel):
class Provider(models.TextChoices):
OPENAI = "openai", "OpenAI"
class AccessMode(models.TextChoices):
ALL_WORKSPACE_MEMBERS = "all_workspace_members", "All workspace members"
ADMINS_ONLY = "admins_only", "Admins only"
workspace = models.OneToOneField(
"db.Workspace",
on_delete=models.CASCADE,
related_name="ai_settings",
)
voice_tasker_enabled = models.BooleanField(default=False)
provider = models.CharField(max_length=32, choices=Provider.choices, default=Provider.OPENAI)
transcription_model = models.CharField(max_length=80, default="gpt-4o-mini-transcribe")
structuring_model = models.CharField(max_length=80, default="gpt-4o-mini")
default_project = models.ForeignKey(
"db.Project",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="workspace_ai_default_project",
)
access_mode = models.CharField(
max_length=40,
choices=AccessMode.choices,
default=AccessMode.ALL_WORKSPACE_MEMBERS,
)
max_audio_duration_seconds = models.PositiveIntegerField(default=120)
per_user_hourly_limit = models.PositiveIntegerField(default=30)
workspace_hourly_limit = models.PositiveIntegerField(default=300)
class Meta:
verbose_name = "Workspace AI Settings"
verbose_name_plural = "Workspace AI Settings"
db_table = "workspace_ai_settings"
ordering = ("-created_at",)
def __str__(self):
return f"{self.workspace.slug} AI settings"
class WorkspaceAICredential(BaseModel):
class Provider(models.TextChoices):
OPENAI = "openai", "OpenAI"
workspace = models.OneToOneField(
"db.Workspace",
on_delete=models.CASCADE,
related_name="ai_credential",
)
provider = models.CharField(max_length=32, choices=Provider.choices, default=Provider.OPENAI)
encrypted_api_key = models.TextField(blank=True)
key_last4 = models.CharField(max_length=4, blank=True)
is_active = models.BooleanField(default=True)
class Meta:
verbose_name = "Workspace AI Credential"
verbose_name_plural = "Workspace AI Credentials"
db_table = "workspace_ai_credentials"
ordering = ("-created_at",)
def __str__(self):
return f"{self.workspace.slug} {self.provider} credential"