diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index c3cd30d932..6a69a88394 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -1983,3 +1983,362 @@ class Processor(RowLevelSecurityProtectedModel): class JSONAPIMeta: resource_name = "processors" + + +class LighthouseProviderConfiguration(RowLevelSecurityProtectedModel): + """ + Per-tenant configuration for an LLM provider (credentials, base URL, activation). + + One configuration per provider type per tenant. + """ + + class ProviderChoices(models.TextChoices): + OPENAI = "openai", _("OpenAI") + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + + provider_type = models.CharField( + max_length=50, + choices=ProviderChoices.choices, + help_text="LLM provider name", + ) + + # For OpenAI-compatible providers + base_url = models.URLField(blank=True, null=True) + + # Encrypted JSON for provider-specific auth + credentials = models.BinaryField( + help_text="Encrypted JSON credentials for the provider" + ) + + is_active = models.BooleanField(default=True) + + def __str__(self): + return f"{self.get_provider_type_display()} ({self.tenant_id})" + + def clean(self): + super().clean() + + @property + def credentials_decoded(self): + if not self.credentials: + return None + try: + decrypted_data = fernet.decrypt(bytes(self.credentials)) + return json.loads(decrypted_data.decode()) + except (InvalidToken, json.JSONDecodeError) as e: + logger.warning("Failed to decrypt provider credentials: %s", e) + return None + except Exception as e: + logger.exception( + "Unexpected error while decrypting provider credentials: %s", e + ) + return None + + @credentials_decoded.setter + def credentials_decoded(self, value): + if not value: + raise ModelValidationError( + detail="Credentials are required", + code="invalid_credentials", + pointer="/data/attributes/credentials", + ) + + self._validate_credentials_for_provider(self.provider_type, value) + self.credentials = fernet.encrypt(json.dumps(value).encode()) + + def _validate_credentials_for_provider(self, provider_type: str, credentials: dict): + if provider_type == self.ProviderChoices.OPENAI: + api_key = credentials.get("api_key") + if not isinstance(api_key, str) or not api_key: + raise ModelValidationError( + detail="OpenAI credentials must include 'api_key'", + code="invalid_openai_credentials", + pointer="/data/attributes/credentials/api_key", + ) + + def delete(self, *args, **kwargs): + # Cleanup tenant defaults that reference this provider + try: + tenant_cfg = LighthouseTenantConfiguration.objects.get( + tenant_id=self.tenant_id + ) + except LighthouseTenantConfiguration.DoesNotExist: + tenant_cfg = None + + if tenant_cfg: + updated = False + defaults = tenant_cfg.default_models or {} + if self.provider_type in defaults: + defaults.pop(self.provider_type, None) + tenant_cfg.default_models = defaults + updated = True + + if tenant_cfg.default_provider == self.provider_type: + tenant_cfg.default_provider = "" + updated = True + + if updated: + tenant_cfg.save() + + return super().delete(*args, **kwargs) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "lighthouse_provider_configurations" + + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + models.UniqueConstraint( + fields=["tenant_id", "provider_type"], + name="unique_provider_config_per_tenant", + ), + ] + + indexes = [ + models.Index( + fields=["tenant_id", "provider_type"], + name="lh_pc_tenant_type_idx", + ), + ] + + class JSONAPIMeta: + resource_name = "lighthouse-providers" + + +class LighthouseTenantConfiguration(RowLevelSecurityProtectedModel): + """ + Tenant-level Lighthouse settings (business context and defaults). + One record per tenant. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + + business_context = models.TextField(blank=True, default="") + + # Preferred provider key (e.g., "openai", "bedrock", "openai_compatible") + default_provider = models.CharField(max_length=50, blank=True) + + # Mapping of provider -> model id, e.g., {"openai": "gpt-4o", "bedrock": "anthropic.claude-v2"} + default_models = models.JSONField(default=dict) + + def __str__(self): + return f"Lighthouse Tenant Config for {self.tenant_id}" + + def clean(self): + super().clean() + + # Validate default_provider is configured and active (if provided) + if self.default_provider: + if not LighthouseProviderConfiguration.objects.filter( + tenant_id=self.tenant_id, + provider_type=self.default_provider, + is_active=True, + ).exists(): + raise ModelValidationError( + detail=f"No active configuration found for provider '{self.default_provider}'", + code="invalid_default_provider", + pointer="/data/attributes/default_provider", + ) + + # Validate default_models mapping + if self.default_models is not None and not isinstance( + self.default_models, dict + ): + raise ModelValidationError( + detail="default_models must be an object mapping provider->model", + code="invalid_default_models", + pointer="/data/attributes/default_models", + ) + + for provider_type, model_id in (self.default_models or {}).items(): + # Provider must exist and be active + provider_cfg = LighthouseProviderConfiguration.objects.filter( + tenant_id=self.tenant_id, + provider_type=provider_type, + is_active=True, + ).first() + + if not provider_cfg: + raise ModelValidationError( + detail=f"No active configuration found for provider '{provider_type}'", + code="invalid_default_models_provider", + pointer="/data/attributes/default_models", + ) + + # Model must exist under that provider configuration + if not LighthouseProviderModels.objects.filter( + tenant_id=self.tenant_id, + provider_configuration=provider_cfg, + model_id=model_id, + ).exists(): + raise ModelValidationError( + detail=f"Invalid model '{model_id}' for provider '{provider_type}'", + code="invalid_default_models_model", + pointer="/data/attributes/default_models", + ) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "lighthouse_tenant_config" + + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + models.UniqueConstraint( + fields=["tenant_id"], name="unique_tenant_lighthouse_config" + ), + ] + + class JSONAPIMeta: + resource_name = "lighthouse-config" + + +class LighthouseProviderModels(RowLevelSecurityProtectedModel): + """ + Per-tenant, per-provider configuration list of available LLM models. + RLS-protected; populated via provider API using tenant-scoped credentials. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + + # Scope to a specific provider configuration within a tenant + provider_configuration = models.ForeignKey( + LighthouseProviderConfiguration, + on_delete=models.CASCADE, + related_name="available_models", + ) + model_id = models.CharField(max_length=100) + + # Model-specific default parameters (e.g., temperature, max_tokens) + default_parameters = models.JSONField(default=dict, blank=True) + + def __str__(self): + return f"{self.provider_configuration.provider_type}:{self.model_id} ({self.tenant_id})" + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "lighthouse_provider_models" + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + models.UniqueConstraint( + fields=["tenant_id", "provider_configuration", "model_id"], + name="unique_provider_model_per_configuration", + ), + ] + indexes = [ + models.Index( + fields=["tenant_id", "provider_configuration"], + name="lh_prov_models_cfg_idx", + ), + ] + + class JSONAPIMeta: + resource_name = "lighthouse-models" + + +class LighthouseSettings(RowLevelSecurityProtectedModel): + """ + Stores tenant-level Lighthouse settings including business context and defaults. + """ + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + + business_context = models.TextField( + blank=True, + default="", + help_text="Business context for all AI model configurations in this tenant", + ) + + default_provider = models.CharField( + max_length=50, + blank=True, + null=True, + help_text="Default LLM provider for this tenant", + ) + + default_model = models.CharField( + max_length=100, + blank=True, + null=True, + help_text="Default model name for the selected provider", + ) + + def clean(self): + super().clean() + + # Normalize empty strings to None for consistency + if self.default_provider == "": + self.default_provider = None + if self.default_model == "": + self.default_model = None + + # Cross-field validation: default_model requires default_provider + if self.default_model and not self.default_provider: + raise ModelValidationError( + detail="Default model cannot be set without specifying a default provider", + code="model_requires_provider", + pointer="/data/attributes/default_model", + ) + + # Validate that default_provider exists in LighthouseConfiguration if set + if self.default_provider: + if not LighthouseConfiguration.objects.filter( + tenant_id=self.tenant_id, + model_provider=self.default_provider, + is_active=True, + ).exists(): + raise ModelValidationError( + detail=f"No active configuration found for provider '{self.default_provider}'", + code="invalid_default_provider", + pointer="/data/attributes/default_provider", + ) + + # Validate that default_model is valid for the provider if both are set + if self.default_provider and self.default_model: + from api.v1.serializers import PROVIDER_ALLOWED_MODELS + + allowed_models = PROVIDER_ALLOWED_MODELS.get(self.default_provider, []) + if self.default_model not in allowed_models: + raise ModelValidationError( + detail=f"Invalid model '{self.default_model}' for provider '{self.default_provider}'. " + f"Allowed models: {', '.join(allowed_models)}", + code="invalid_model_for_provider", + pointer="/data/attributes/default_model", + ) + + def __str__(self): + return f"Lighthouse Settings for {self.tenant_id}" + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "lighthouse_settings" + + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + models.UniqueConstraint( + fields=["tenant_id"], name="unique_lighthouse_settings_per_tenant" + ), + ] + + class JSONAPIMeta: + resource_name = "lighthouse-settings" diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 8456c5a081..6241eea5e0 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -25,6 +25,9 @@ from api.models import ( Invitation, InvitationRoleRelationship, LighthouseConfiguration, + LighthouseProviderConfiguration, + LighthouseProviderModels, + LighthouseTenantConfiguration, Membership, Processor, Provider, @@ -2931,3 +2934,257 @@ class TenantApiKeyUpdateSerializer(RLSSerializer, BaseWriteSerializer): ): raise ValidationError("An API key with this name already exists.") return value + +# Lighthouse: Provider configurations + + +class LighthouseProviderConfigSerializer(RLSSerializer): + """ + Read serializer for LighthouseProviderConfiguration. + """ + + # Decrypted credentials are only returned in to_representation when requested + credentials = serializers.JSONField(required=False, read_only=True) + + class Meta: + model = LighthouseProviderConfiguration + fields = [ + "id", + "inserted_at", + "updated_at", + "provider_type", + "base_url", + "is_active", + "credentials", + "url", + ] + extra_kwargs = { + "id": {"read_only": True}, + "inserted_at": {"read_only": True}, + "updated_at": {"read_only": True}, + "is_active": {"read_only": True}, + "url": {"read_only": True}, + } + + def to_representation(self, instance): + data = super().to_representation(instance) + # Support JSON:API fields filter: fields[lighthouse-providers]=credentials + fields_param = self.context.get("request", None) and self.context[ + "request" + ].query_params.get("fields[lighthouse-providers]", "") + + creds = instance.credentials_decoded + + if fields_param == "credentials": + # Return full decrypted credentials JSON + data["credentials"] = creds + else: + # Return masked credentials by default + def mask_value(value): + if isinstance(value, str): + return "*" * len(value) + if isinstance(value, dict): + return {k: mask_value(v) for k, v in value.items()} + if isinstance(value, list): + return [mask_value(v) for v in value] + return value + + data["credentials"] = mask_value(creds) if creds is not None else None + + return data + + +class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerializer): + """ + Create serializer for LighthouseProviderConfiguration. + Accepts credentials as JSON; stored encrypted via credentials_decoded. + """ + + credentials = serializers.JSONField(write_only=True) + + class Meta: + model = LighthouseProviderConfiguration + fields = [ + "provider_type", + "base_url", + "credentials", + "is_active", + ] + extra_kwargs = { + "is_active": {"required": False}, + "base_url": {"required": False, "allow_null": True}, + } + + def validate(self, attrs): + tenant_id = self.context.get("tenant_id") + provider_type = attrs.get("provider_type") + if LighthouseProviderConfiguration.objects.filter( + tenant_id=tenant_id, provider_type=provider_type + ).exists(): + raise ValidationError( + { + "provider_type": "Configuration for this provider already exists for the tenant.", + } + ) + return super().validate(attrs) + + def create(self, validated_data): + credentials = validated_data.pop("credentials") + instance = super().create(validated_data) + instance.credentials_decoded = credentials + instance.save() + return instance + + +class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer): + """ + Update serializer for LighthouseProviderConfiguration. + """ + + credentials = serializers.JSONField(write_only=True, required=False) + + class Meta: + model = LighthouseProviderConfiguration + fields = [ + "id", + "provider_type", + "base_url", + "credentials", + "is_active", + ] + extra_kwargs = { + "id": {"read_only": True}, + "provider_type": {"read_only": True}, + "base_url": {"required": False, "allow_null": True}, + "is_active": {"required": False}, + } + + def update(self, instance, validated_data): + credentials = validated_data.pop("credentials", None) + instance = super().update(instance, validated_data) + if credentials is not None: + instance.credentials_decoded = credentials + instance.save() + return instance + + +# Lighthouse: Tenant configuration + + +class LighthouseTenantConfigSerializer(RLSSerializer): + """ + Read serializer for LighthouseTenantConfiguration. + """ + + class Meta: + model = LighthouseTenantConfiguration + fields = [ + "id", + "inserted_at", + "updated_at", + "business_context", + "default_provider", + "default_models", + "url", + ] + extra_kwargs = { + "id": {"read_only": True}, + "inserted_at": {"read_only": True}, + "updated_at": {"read_only": True}, + "url": {"read_only": True}, + } + + +class LighthouseTenantConfigCreateSerializer(RLSSerializer, BaseWriteSerializer): + """ + Create serializer for LighthouseTenantConfiguration. + Ensures one record per tenant. + """ + + class Meta: + model = LighthouseTenantConfiguration + fields = [ + "business_context", + "default_provider", + "default_models", + ] + + def validate(self, attrs): + tenant_id = self.context.get("tenant_id") + if LighthouseTenantConfiguration.objects.filter(tenant_id=tenant_id).exists(): + raise ValidationError( + {"tenant_id": "Tenant Lighthouse configuration already exists."} + ) + return super().validate(attrs) + + +class LighthouseTenantConfigUpdateSerializer(BaseWriteSerializer): + class Meta: + model = LighthouseTenantConfiguration + fields = [ + "id", + "business_context", + "default_provider", + "default_models", + ] + extra_kwargs = { + "id": {"read_only": True}, + } + + +# Lighthouse: Provider models + + +class LighthouseProviderModelsSerializer(RLSSerializer): + """ + Read serializer for LighthouseProviderModels. + """ + + provider_configuration = serializers.ResourceRelatedField(read_only=True) + + class Meta: + model = LighthouseProviderModels + fields = [ + "id", + "inserted_at", + "updated_at", + "provider_configuration", + "model_id", + "default_parameters", + "url", + ] + extra_kwargs = { + "id": {"read_only": True}, + "inserted_at": {"read_only": True}, + "updated_at": {"read_only": True}, + "url": {"read_only": True}, + } + + +class LighthouseProviderModelsCreateSerializer(RLSSerializer, BaseWriteSerializer): + provider_configuration = serializers.ResourceRelatedField( + queryset=LighthouseProviderConfiguration.objects.all() + ) + + class Meta: + model = LighthouseProviderModels + fields = [ + "provider_configuration", + "model_id", + "default_parameters", + ] + extra_kwargs = { + "default_parameters": {"required": False}, + } + + +class LighthouseProviderModelsUpdateSerializer(BaseWriteSerializer): + class Meta: + model = LighthouseProviderModels + fields = [ + "id", + "default_parameters", + ] + extra_kwargs = { + "id": {"read_only": True}, + } diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py index 1e8694ef23..baa6dba61e 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -17,6 +17,9 @@ from api.v1.views import ( InvitationAcceptViewSet, InvitationViewSet, LighthouseConfigViewSet, + LighthouseProviderConfigViewSet, + LighthouseProviderModelsViewSet, + LighthouseTenantConfigViewSet, MembershipViewSet, OverviewViewSet, ProcessorViewSet, @@ -67,6 +70,16 @@ router.register( basename="lighthouseconfiguration", ) router.register(r"api-keys", TenantApiKeyViewSet, basename="api-key") +router.register( + r"lighthouse/providers", + LighthouseProviderConfigViewSet, + basename="lighthouse-providers", +) +router.register( + r"lighthouse/models", + LighthouseProviderModelsViewSet, + basename="lighthouse-models", +) tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant") tenants_router.register( @@ -137,6 +150,14 @@ urlpatterns = [ ), name="provider_group-providers-relationship", ), + # Lighthouse tenant config as singleton endpoint + path( + "lighthouse/config", + LighthouseTenantConfigViewSet.as_view( + {"get": "retrieve", "post": "create", "patch": "partial_update"} + ), + name="lighthouse-config", + ), # API endpoint to start SAML SSO flow path( "auth/saml/initiate/", SAMLInitiateAPIView.as_view(), name="api_saml_initiate" diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 6e561ff969..b0292062f5 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -105,6 +105,9 @@ from api.models import ( Integration, Invitation, LighthouseConfiguration, + LighthouseProviderConfiguration, + LighthouseProviderModels, + LighthouseTenantConfiguration, Membership, Processor, Provider, @@ -159,6 +162,13 @@ from api.v1.serializers import ( LighthouseConfigCreateSerializer, LighthouseConfigSerializer, LighthouseConfigUpdateSerializer, + LighthouseProviderConfigCreateSerializer, + LighthouseProviderConfigSerializer, + LighthouseProviderConfigUpdateSerializer, + LighthouseProviderModelsSerializer, + LighthouseTenantConfigCreateSerializer, + LighthouseTenantConfigSerializer, + LighthouseTenantConfigUpdateSerializer, MembershipSerializer, OverviewFindingSerializer, OverviewProviderSerializer, @@ -4176,6 +4186,167 @@ class LighthouseConfigViewSet(BaseRLSViewSet): ) +@extend_schema_view( + list=extend_schema( + tags=["Lighthouse AI"], + summary="List provider configs", + description="Retrieve all LLM provider configurations for the current tenant", + ), + retrieve=extend_schema( + tags=["Lighthouse AI"], + summary="Get provider config", + description="Get details for a specific provider configuration in the current tenant.", + ), + create=extend_schema( + tags=["Lighthouse AI"], + summary="Create provider config", + description="Create a per-tenant configuration for an LLM provider. Only one configuration per provider type is allowed per tenant.", + ), + partial_update=extend_schema( + tags=["Lighthouse AI"], + summary="Update provider config", + description="Partially update a provider configuration (e.g., base_url, is_active).", + ), + destroy=extend_schema( + tags=["Lighthouse AI"], + summary="Delete provider config", + description="Delete a provider configuration. Any tenant defaults that reference this provider are cleared during deletion.", + ), +) +class LighthouseProviderConfigViewSet(BaseRLSViewSet): + queryset = LighthouseProviderConfiguration.objects.all() + serializer_class = LighthouseProviderConfigSerializer + http_method_names = ["get", "post", "patch", "delete"] + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return LighthouseProviderConfiguration.objects.none() + return LighthouseProviderConfiguration.objects.filter( + tenant_id=self.request.tenant_id + ) + + def get_serializer_class(self): + if self.action == "create": + return LighthouseProviderConfigCreateSerializer + elif self.action == "partial_update": + return LighthouseProviderConfigUpdateSerializer + return super().get_serializer_class() + + +@extend_schema_view( + list=extend_schema( + tags=["Lighthouse AI"], + summary="List tenant config", + description="List Lighthouse AI tenant-level settings for the current tenant.", + ), + retrieve=extend_schema( + tags=["Lighthouse AI"], + summary="Get tenant config", + description="Retrieve tenant Lighthouse AI settings by ID.", + ), + create=extend_schema( + tags=["Lighthouse AI"], + summary="Create tenant config", + description="Create Lighthouse AI tenant-level settings. Only one configuration is allowed per tenant.", + ), + partial_update=extend_schema( + tags=["Lighthouse AI"], + summary="Update tenant config", + description="Update tenant-level settings. Validates that the default provider is configured and active and that default model IDs exist for the chosen providers.", + ), + destroy=extend_schema( + tags=["Lighthouse AI"], + summary="Delete tenant config", + description="Delete tenant Lighthouse AI settings for the current tenant.", + ), +) +class LighthouseTenantConfigViewSet(BaseRLSViewSet): + queryset = LighthouseTenantConfiguration.objects.all() + serializer_class = LighthouseTenantConfigSerializer + http_method_names = ["get", "post", "patch", "delete"] + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return LighthouseTenantConfiguration.objects.none() + return LighthouseTenantConfiguration.objects.filter( + tenant_id=self.request.tenant_id + ) + + def get_serializer_class(self): + if self.action == "create": + return LighthouseTenantConfigCreateSerializer + elif self.action == "partial_update": + return LighthouseTenantConfigUpdateSerializer + return super().get_serializer_class() + + def get_object(self): + obj = LighthouseTenantConfiguration.objects.filter( + tenant_id=self.request.tenant_id + ).first() + if obj is None: + raise NotFound("Tenant Lighthouse configuration not found") + self.check_object_permissions(self.request, obj) + return obj + + +@extend_schema_view( + list=extend_schema( + tags=["Lighthouse AI"], + summary="List provider models", + description="List available LLM models per configured provider for the current tenant.", + ), + retrieve=extend_schema( + tags=["Lighthouse AI"], + summary="Get provider model", + description="Retrieve details for a specific provider model.", + ), + create=extend_schema( + tags=["Lighthouse AI"], + summary="Create provider model", + description="Create a provider model entry for a given provider configuration.", + ), + partial_update=extend_schema( + tags=["Lighthouse AI"], + summary="Update provider model", + description="Partially update a provider model entry (e.g., default parameters).", + ), + destroy=extend_schema( + tags=["Lighthouse AI"], + summary="Delete provider model", + description="Delete a provider model entry.", + ), +) +class LighthouseProviderModelsViewSet(BaseRLSViewSet): + queryset = LighthouseProviderModels.objects.all() + serializer_class = LighthouseProviderModelsSerializer + # Expose as read-only catalog collection + http_method_names = ["get"] + + def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return LighthouseProviderModels.objects.none() + return LighthouseProviderModels.objects.filter(tenant_id=self.request.tenant_id) + + def get_serializer_class(self): + return super().get_serializer_class() + + @extend_schema(exclude=True) + def retrieve(self, request, *args, **kwargs): + raise MethodNotAllowed(method="GET") + + @extend_schema(exclude=True) + def create(self, request, *args, **kwargs): + raise MethodNotAllowed(method="POST") + + @extend_schema(exclude=True) + def partial_update(self, request, *args, **kwargs): + raise MethodNotAllowed(method="PATCH") + + @extend_schema(exclude=True) + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(method="DELETE") + + @extend_schema_view( list=extend_schema( tags=["Processor"],