АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: каркас Voice Tasker settings
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user