# Copyright (c) 2023-present Plane Software, Inc. and contributors # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. import json from openai import OpenAI from rest_framework import status from rest_framework.parsers import FormParser, MultiPartParser 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, WorkspaceMember from plane.license.utils.encryption import decrypt_data from plane.utils.exception_logger import log_exception from .base import BaseAPIView VOICE_TASK_ACCEPTED_AUDIO_TYPES = ["audio/webm", "audio/mp4", "audio/mpeg", "audio/wav"] def get_voice_task_preflight(workspace, user): ai_settings = WorkspaceAISettings.objects.filter(workspace=workspace).first() workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user, is_active=True).first() response = { "available": False, "reason": "not_configured", "max_audio_duration_seconds": 120, "accepted_mime_types": VOICE_TASK_ACCEPTED_AUDIO_TYPES, "access_mode": "all_workspace_members", } if not ai_settings: return response response["max_audio_duration_seconds"] = ai_settings.max_audio_duration_seconds response["access_mode"] = ai_settings.access_mode if not ai_settings.voice_tasker_enabled: response["reason"] = "disabled" return response credential = WorkspaceAICredential.objects.filter( workspace=workspace, provider=ai_settings.provider, is_active=True, ).first() if not credential or not credential.encrypted_api_key: response["reason"] = "missing_api_key" return response if ai_settings.access_mode == WorkspaceAISettings.AccessMode.ADMINS_ONLY: if not workspace_member or workspace_member.role != ROLE.ADMIN.value: response["reason"] = "role_denied" return response response["available"] = True response["reason"] = None return response 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, ) class VoiceTaskPreflightEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def get(self, request, slug): workspace = Workspace.objects.get(slug=slug) return Response(get_voice_task_preflight(workspace, request.user), status=status.HTTP_200_OK) class VoiceTaskParseEndpoint(BaseAPIView): parser_classes = (MultiPartParser, FormParser) @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def post(self, request, slug): workspace = Workspace.objects.get(slug=slug) preflight = get_voice_task_preflight(workspace, request.user) if not preflight["available"]: response_status = status.HTTP_403_FORBIDDEN if preflight["reason"] == "role_denied" else status.HTTP_400_BAD_REQUEST return Response( { "ok": False, "code": preflight["reason"], "error": "Voice Tasker is not available for this workspace.", }, status=response_status, ) audio = request.FILES.get("audio") if not audio: return Response( {"ok": False, "code": "missing_audio", "error": "Audio file is required."}, status=status.HTTP_400_BAD_REQUEST, ) if audio.content_type not in VOICE_TASK_ACCEPTED_AUDIO_TYPES: return Response( {"ok": False, "code": "unsupported_audio_type", "error": "Unsupported audio file type."}, status=status.HTTP_400_BAD_REQUEST, ) try: duration_seconds = float(request.data.get("duration_seconds", 0)) except (TypeError, ValueError): duration_seconds = 0 if duration_seconds <= 0: return Response( {"ok": False, "code": "invalid_duration", "error": "Audio duration is required."}, status=status.HTTP_400_BAD_REQUEST, ) if duration_seconds > preflight["max_audio_duration_seconds"]: return Response( {"ok": False, "code": "audio_too_long", "error": "Audio duration exceeds workspace limit."}, status=status.HTTP_400_BAD_REQUEST, ) client_context_raw = request.data.get("client_context") or "{}" try: client_context = json.loads(client_context_raw) except (TypeError, json.JSONDecodeError): client_context = {} return Response( { "ok": True, "status": "uploaded", "pipeline_status": "pending_openai_pipeline", "audio": { "content_type": audio.content_type, "duration_seconds": duration_seconds, "size": audio.size, }, "client_context": client_context, }, status=status.HTTP_202_ACCEPTED, )