diff --git a/api/app/settings/common.py b/api/app/settings/common.py index 027d029f085d..a46cbd07d461 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -167,6 +167,7 @@ "softdelete", "metadata", "app_analytics", + "experimentation", "oauth2_metadata", ] diff --git a/api/audit/related_object_type.py b/api/audit/related_object_type.py index 884bf3cc8fe9..d43c849e574b 100644 --- a/api/audit/related_object_type.py +++ b/api/audit/related_object_type.py @@ -12,3 +12,4 @@ class RelatedObjectType(enum.Enum): EF_VERSION = "Environment feature version" FEATURE_HEALTH = "Feature health status" RELEASE_PIPELINE = "Release pipeline" + WAREHOUSE_CONNECTION = "Warehouse connection" diff --git a/api/environments/urls.py b/api/environments/urls.py index 01dd68c4f88b..2642af9242a5 100644 --- a/api/environments/urls.py +++ b/api/environments/urls.py @@ -173,4 +173,8 @@ get_experiment_results, name="experiment-results", ), + path( + "/warehouse-connections/", + include("experimentation.urls"), + ), ] diff --git a/api/experimentation/__init__.py b/api/experimentation/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/experimentation/apps.py b/api/experimentation/apps.py new file mode 100644 index 000000000000..27787df60280 --- /dev/null +++ b/api/experimentation/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ExperimentationConfig(AppConfig): + name = "experimentation" diff --git a/api/experimentation/constants.py b/api/experimentation/constants.py new file mode 100644 index 000000000000..a1e79a0fca8e --- /dev/null +++ b/api/experimentation/constants.py @@ -0,0 +1 @@ +WAREHOUSE_CONNECTION_FLAG = "experimentation_warehouse_connection" diff --git a/api/experimentation/migrations/0001_introduce_warehouse_connection.py b/api/experimentation/migrations/0001_introduce_warehouse_connection.py new file mode 100644 index 000000000000..57e60ca08fc8 --- /dev/null +++ b/api/experimentation/migrations/0001_introduce_warehouse_connection.py @@ -0,0 +1,89 @@ +# Generated by Django 5.2.14 on 2026-05-19 11:05 + +import django.db.models.deletion +import django_lifecycle.mixins # type: ignore[import-untyped] +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("environments", "0037_add_uuid_field"), + ] + + operations = [ + migrations.CreateModel( + name="WarehouseConnection", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, + db_index=True, + default=None, + editable=False, + null=True, + ), + ), + ( + "uuid", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ( + "warehouse_type", + models.CharField( + choices=[ + ("flagsmith", "Flagsmith"), + ("snowflake", "Snowflake"), + ("clickhouse", "ClickHouse"), + ], + max_length=50, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("pending_connection", "Pending Connection"), + ("connected", "Connected"), + ("errored", "Errored"), + ], + default="pending_connection", + max_length=50, + ), + ), + ("name", models.CharField(max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "environment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="warehouse_connections", + to="environments.environment", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("warehouse_type", "environment"), + name="unique_active_warehouse_per_type_and_env", + ) + ], + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + ] diff --git a/api/experimentation/migrations/__init__.py b/api/experimentation/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/experimentation/models.py b/api/experimentation/models.py new file mode 100644 index 000000000000..632f0e5722a7 --- /dev/null +++ b/api/experimentation/models.py @@ -0,0 +1,45 @@ +from django.db import models +from django_lifecycle import LifecycleModelMixin # type: ignore[import-untyped] + +from core.models import SoftDeleteExportableModel +from environments.models import Environment + + +class WarehouseType(models.TextChoices): + FLAGSMITH = "flagsmith", "Flagsmith" + SNOWFLAKE = "snowflake", "Snowflake" + CLICKHOUSE = "clickhouse", "ClickHouse" + + +class WarehouseConnectionStatus(models.TextChoices): + PENDING_CONNECTION = "pending_connection", "Pending Connection" + CONNECTED = "connected", "Connected" + ERRORED = "errored", "Errored" + + +class WarehouseConnection(LifecycleModelMixin, SoftDeleteExportableModel): # type: ignore[misc] + environment = models.ForeignKey( + Environment, + on_delete=models.CASCADE, + related_name="warehouse_connections", + ) + warehouse_type = models.CharField( + max_length=50, + choices=WarehouseType.choices, + ) + status = models.CharField( + max_length=50, + choices=WarehouseConnectionStatus.choices, + default=WarehouseConnectionStatus.PENDING_CONNECTION, + ) + name = models.CharField(max_length=255) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["warehouse_type", "environment"], + condition=models.Q(deleted_at__isnull=True), + name="unique_active_warehouse_per_type_and_env", + ), + ] diff --git a/api/experimentation/permissions.py b/api/experimentation/permissions.py new file mode 100644 index 000000000000..83e1463d1a5c --- /dev/null +++ b/api/experimentation/permissions.py @@ -0,0 +1,23 @@ +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.views import APIView + +from environments.models import Environment +from experimentation.services import is_warehouse_feature_enabled +from users.models import FFAdminUser + + +class WarehouseConnectionPermission(BasePermission): + def has_permission(self, request: Request, view: APIView) -> bool: + try: + environment = Environment.objects.get( + api_key=view.kwargs.get("environment_api_key") + ) + except Environment.DoesNotExist: + return False + + if not is_warehouse_feature_enabled(environment.project.organisation): + return False + + user: FFAdminUser = request.user # type: ignore[assignment] + return user.is_environment_admin(environment) diff --git a/api/experimentation/serializers.py b/api/experimentation/serializers.py new file mode 100644 index 000000000000..dc195170bc26 --- /dev/null +++ b/api/experimentation/serializers.py @@ -0,0 +1,49 @@ +from typing import Any + +from rest_framework import serializers + +from environments.models import Environment +from experimentation.models import ( + WarehouseConnection, + WarehouseConnectionStatus, + WarehouseType, +) + + +class WarehouseConnectionSerializer(serializers.ModelSerializer): # type: ignore[type-arg] + class Meta: + model = WarehouseConnection + fields = ("uuid", "warehouse_type", "status", "name", "created_at") + read_only_fields = ("uuid", "status", "name", "created_at") + + def create( + self, + validated_data: dict[str, Any], + ) -> WarehouseConnection: + environment: Environment = validated_data["environment"] + warehouse_type: str = validated_data["warehouse_type"] + + existing: WarehouseConnection | None = ( + WarehouseConnection.objects.all_with_deleted() + .filter( + environment=environment, + warehouse_type=warehouse_type, + deleted_at__isnull=False, + ) + .first() + ) + if existing: + existing.deleted_at = None + existing.status = WarehouseConnectionStatus.PENDING_CONNECTION + existing.name = self._generate_name(warehouse_type, environment) + existing.save() + return existing + + validated_data["name"] = self._generate_name(warehouse_type, environment) + result: WarehouseConnection = super().create(validated_data) + return result + + @staticmethod + def _generate_name(warehouse_type: str, environment: Environment) -> str: + label = WarehouseType(warehouse_type).label + return f"{label} Warehouse - {environment.name}" diff --git a/api/experimentation/services.py b/api/experimentation/services.py new file mode 100644 index 000000000000..fc44b501db1e --- /dev/null +++ b/api/experimentation/services.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import typing + +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType +from experimentation.constants import WAREHOUSE_CONNECTION_FLAG +from integrations.flagsmith.client import get_openfeature_client + +if typing.TYPE_CHECKING: + from experimentation.models import WarehouseConnection + from organisations.models import Organisation + from users.models import FFAdminUser + + +def is_warehouse_feature_enabled(organisation: Organisation) -> bool: + return get_openfeature_client().get_boolean_value( + WAREHOUSE_CONNECTION_FLAG, + default_value=False, + evaluation_context=organisation.openfeature_evaluation_context, + ) + + +def create_warehouse_audit_log( + connection: WarehouseConnection, + user: FFAdminUser, + *, + action: str, +) -> None: + AuditLog.objects.create( + environment=connection.environment, + project=connection.environment.project, + author=user, + related_object_id=connection.id, + related_object_type=RelatedObjectType.WAREHOUSE_CONNECTION.name, + log=( + f"Warehouse connection {action} for environment " + f"{connection.environment.name}" + ), + ) diff --git a/api/experimentation/urls.py b/api/experimentation/urls.py new file mode 100644 index 000000000000..99f7ec28f266 --- /dev/null +++ b/api/experimentation/urls.py @@ -0,0 +1,10 @@ +from rest_framework.routers import DefaultRouter + +from experimentation.views import WarehouseConnectionViewSet + +app_name = "experimentation" + +router = DefaultRouter() +router.register(r"", WarehouseConnectionViewSet, basename="warehouse-connections") + +urlpatterns = router.urls diff --git a/api/experimentation/views.py b/api/experimentation/views.py new file mode 100644 index 000000000000..38176023a273 --- /dev/null +++ b/api/experimentation/views.py @@ -0,0 +1,63 @@ +from rest_framework import mixins +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import BaseSerializer + +from environments.views import NestedEnvironmentViewSet +from experimentation.models import WarehouseConnection +from experimentation.permissions import WarehouseConnectionPermission +from experimentation.serializers import WarehouseConnectionSerializer +from experimentation.services import create_warehouse_audit_log +from users.models import FFAdminUser + + +class WarehouseConnectionViewSet( + NestedEnvironmentViewSet[WarehouseConnection], + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, +): + serializer_class = WarehouseConnectionSerializer + pagination_class = None + permission_classes = [IsAuthenticated, WarehouseConnectionPermission] + model_class = WarehouseConnection + lookup_field = "uuid" + lookup_url_kwarg = "connection_id" + + def perform_create(self, serializer: BaseSerializer[WarehouseConnection]) -> None: + connection: WarehouseConnection = serializer.save( + environment=self._get_environment() + ) + create_warehouse_audit_log( + connection, self._get_user(self.request), action="created" + ) + + def perform_destroy(self, instance: WarehouseConnection) -> None: + create_warehouse_audit_log( + instance, self._get_user(self.request), action="deleted" + ) + instance.delete() + + def create(self, request: Request, *args: object, **kwargs: object) -> Response: + environment = self._get_environment() + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + warehouse_type = serializer.validated_data["warehouse_type"] + if WarehouseConnection.objects.filter( + environment=environment, + warehouse_type=warehouse_type, + ).exists(): + return Response( + {"detail": "Warehouse connection already exists."}, + status=409, + ) + + self.perform_create(serializer) + return Response(serializer.data, status=201) + + @staticmethod + def _get_user(request: Request) -> FFAdminUser: + return request.user # type: ignore[return-value] diff --git a/api/integrations/flagsmith/data/environment.json b/api/integrations/flagsmith/data/environment.json index 23c4763d8c06..9760f2c1f850 100644 --- a/api/integrations/flagsmith/data/environment.json +++ b/api/integrations/flagsmith/data/environment.json @@ -117,6 +117,19 @@ "feature_state_value": null, "featurestate_uuid": "e7ed0d54-b17c-4df1-ab98-5f8dc9597127", "multivariate_feature_state_values": [] + }, + { + "django_id": 1229328, + "enabled": true, + "feature": { + "id": 195394, + "name": "experimentation_warehouse_connection", + "type": "STANDARD" + }, + "feature_segment": null, + "feature_state_value": null, + "featurestate_uuid": "fe8067b9-efef-4f02-a6d9-0d1ed901f7fa", + "multivariate_feature_state_values": [] } ], "id": 0, @@ -135,4 +148,4 @@ "segments": [] }, "use_identity_composite_key_for_hashing": true -} \ No newline at end of file +} diff --git a/api/tests/unit/experimentation/__init__.py b/api/tests/unit/experimentation/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/tests/unit/experimentation/conftest.py b/api/tests/unit/experimentation/conftest.py new file mode 100644 index 000000000000..127286d3016f --- /dev/null +++ b/api/tests/unit/experimentation/conftest.py @@ -0,0 +1,23 @@ +import pytest +from django.urls import reverse + +from environments.models import Environment +from experimentation.models import WarehouseConnection, WarehouseType + + +@pytest.fixture() +def warehouse_connection(environment: Environment) -> WarehouseConnection: + connection: WarehouseConnection = WarehouseConnection.objects.create( + environment=environment, + warehouse_type=WarehouseType.FLAGSMITH, + name=f"Flagsmith Warehouse - {environment.name}", + ) + return connection + + +@pytest.fixture() +def warehouse_connection_url(environment: Environment) -> str: + return reverse( + "api-v1:environments:experimentation:warehouse-connections-list", + args=[environment.api_key], + ) diff --git a/api/tests/unit/experimentation/test_permissions.py b/api/tests/unit/experimentation/test_permissions.py new file mode 100644 index 000000000000..5cc06102a231 --- /dev/null +++ b/api/tests/unit/experimentation/test_permissions.py @@ -0,0 +1,77 @@ +import pytest +from rest_framework.test import APIRequestFactory + +from environments.models import Environment +from experimentation.permissions import WarehouseConnectionPermission +from tests.types import EnableFeaturesFixture +from users.models import FFAdminUser + +pytestmark = pytest.mark.django_db + + +def _check_permission(user: FFAdminUser, environment_api_key: str) -> bool: + factory = APIRequestFactory() + request = factory.get("/") + request.user = user + + class FakeView: + kwargs = {"environment_api_key": environment_api_key} + + return WarehouseConnectionPermission().has_permission(request, FakeView()) # type: ignore[arg-type] + + +def test_has_permission__flag_enabled_and_admin__returns_true( + admin_user: FFAdminUser, + environment: Environment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + result = _check_permission(admin_user, environment.api_key) + + # Then + assert result is True + + +def test_has_permission__flag_disabled__returns_false( + admin_user: FFAdminUser, + environment: Environment, +) -> None: + # Given - no enable_features call + + # When + result = _check_permission(admin_user, environment.api_key) + + # Then + assert result is False + + +def test_has_permission__flag_enabled_not_admin__returns_false( + staff_user: FFAdminUser, + environment: Environment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + result = _check_permission(staff_user, environment.api_key) + + # Then + assert result is False + + +def test_has_permission__environment_not_found__returns_false( + admin_user: FFAdminUser, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + result = _check_permission(admin_user, "nonexistent-key") + + # Then + assert result is False diff --git a/api/tests/unit/experimentation/test_serializers.py b/api/tests/unit/experimentation/test_serializers.py new file mode 100644 index 000000000000..637d3524da8c --- /dev/null +++ b/api/tests/unit/experimentation/test_serializers.py @@ -0,0 +1,74 @@ +import pytest + +from environments.models import Environment +from experimentation.models import ( + WarehouseConnection, + WarehouseConnectionStatus, + WarehouseType, +) +from experimentation.serializers import WarehouseConnectionSerializer + +pytestmark = pytest.mark.django_db + + +def test_create__no_existing__creates_new_connection( + environment: Environment, +) -> None: + # Given + serializer = WarehouseConnectionSerializer( + data={"warehouse_type": "flagsmith"}, + ) + serializer.is_valid(raise_exception=True) + + # When + connection = serializer.save(environment=environment) + + # Then + assert connection.pk is not None + assert connection.warehouse_type == WarehouseType.FLAGSMITH + assert connection.status == WarehouseConnectionStatus.PENDING_CONNECTION + assert connection.name == f"Flagsmith Warehouse - {environment.name}" + + +def test_create__soft_deleted_exists__resurrects_record( + environment: Environment, +) -> None: + # Given + original = WarehouseConnection.objects.create( + environment=environment, + warehouse_type=WarehouseType.FLAGSMITH, + name="Old Name", + ) + original_pk = original.pk + original.delete() + + serializer = WarehouseConnectionSerializer( + data={"warehouse_type": "flagsmith"}, + ) + serializer.is_valid(raise_exception=True) + + # When + connection = serializer.save(environment=environment) + + # Then + assert connection.pk == original_pk + assert connection.deleted_at is None + assert connection.status == WarehouseConnectionStatus.PENDING_CONNECTION + assert connection.name == f"Flagsmith Warehouse - {environment.name}" + + +def test_create__valid_data__name_uses_warehouse_type_label( + environment: Environment, +) -> None: + # Given + serializer = WarehouseConnectionSerializer( + data={"warehouse_type": "flagsmith"}, + ) + serializer.is_valid(raise_exception=True) + + # When + connection = serializer.save(environment=environment) + + # Then + assert connection.name.startswith("Flagsmith Warehouse") + assert environment.name in connection.name diff --git a/api/tests/unit/experimentation/test_views.py b/api/tests/unit/experimentation/test_views.py new file mode 100644 index 000000000000..f93b109a1e68 --- /dev/null +++ b/api/tests/unit/experimentation/test_views.py @@ -0,0 +1,297 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType +from environments.models import Environment +from experimentation.models import WarehouseConnection +from tests.types import EnableFeaturesFixture + +pytestmark = pytest.mark.django_db + + +def test_post__valid_data__returns_201_and_creates_connection( + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + warehouse_connection_url: str, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + response = admin_client.post( + warehouse_connection_url, + data={"warehouse_type": "flagsmith"}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["warehouse_type"] == "flagsmith" + assert response.json()["status"] == "pending_connection" + assert response.json()["name"] == f"Flagsmith Warehouse - {environment.name}" + assert "uuid" in response.json() + assert "created_at" in response.json() + assert WarehouseConnection.objects.filter(environment=environment).count() == 1 + + +def test_post__already_exists__returns_409( + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + warehouse_connection: WarehouseConnection, + warehouse_connection_url: str, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + response = admin_client.post( + warehouse_connection_url, + data={"warehouse_type": "flagsmith"}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_409_CONFLICT + assert response.json()["detail"] == "Warehouse connection already exists." + + +def test_post__soft_deleted_exists__resurrects_and_returns_201( + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + warehouse_connection: WarehouseConnection, + warehouse_connection_url: str, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + original_uuid = str(warehouse_connection.uuid) + warehouse_connection.delete() + + # When + response = admin_client.post( + warehouse_connection_url, + data={"warehouse_type": "flagsmith"}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["uuid"] == original_uuid + assert response.json()["status"] == "pending_connection" + + +def test_post__non_admin__returns_403( + staff_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + url = reverse( + "api-v1:environments:experimentation:warehouse-connections-list", + args=[environment.api_key], + ) + + # When + response = staff_client.post( + url, + data={"warehouse_type": "flagsmith"}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_post__valid_data__creates_audit_log( + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + warehouse_connection_url: str, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + admin_client.post( + warehouse_connection_url, + data={"warehouse_type": "flagsmith"}, + format="json", + ) + + # Then + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.WAREHOUSE_CONNECTION.name, + ).count() + == 1 + ) + audit_log = AuditLog.objects.get( + related_object_type=RelatedObjectType.WAREHOUSE_CONNECTION.name, + ) + assert "created" in audit_log.log + assert environment.name in audit_log.log + + +def test_get__exists__returns_200_with_list( + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + warehouse_connection: WarehouseConnection, + warehouse_connection_url: str, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + response = admin_client.get(warehouse_connection_url) + + # Then + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) == 1 + assert data[0]["warehouse_type"] == "flagsmith" + assert data[0]["status"] == "pending_connection" + assert data[0]["name"] == f"Flagsmith Warehouse - {environment.name}" + assert data[0]["uuid"] == str(warehouse_connection.uuid) + assert "created_at" in data[0] + + +def test_get__not_exists__returns_200_with_empty_list( + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + warehouse_connection_url: str, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + response = admin_client.get(warehouse_connection_url) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json() == [] + + +def test_delete__exists__returns_204( + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + warehouse_connection: WarehouseConnection, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + url = reverse( + "api-v1:environments:experimentation:warehouse-connections-detail", + args=[environment.api_key, str(warehouse_connection.uuid)], + ) + + # When + response = admin_client.delete(url) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert WarehouseConnection.objects.filter(environment=environment).count() == 0 + deleted = WarehouseConnection.objects.all_with_deleted().get( + pk=warehouse_connection.pk, + ) + assert deleted.deleted_at is not None + + +def test_delete__not_exists__returns_404( + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + url = reverse( + "api-v1:environments:experimentation:warehouse-connections-detail", + args=[environment.api_key, "00000000-0000-0000-0000-000000000000"], + ) + + # When + response = admin_client.delete(url) + + # Then + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_delete__exists__creates_audit_log( + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + warehouse_connection: WarehouseConnection, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + url = reverse( + "api-v1:environments:experimentation:warehouse-connections-detail", + args=[environment.api_key, str(warehouse_connection.uuid)], + ) + + # When + admin_client.delete(url) + + # Then + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.WAREHOUSE_CONNECTION.name, + ).count() + == 1 + ) + audit_log = AuditLog.objects.get( + related_object_type=RelatedObjectType.WAREHOUSE_CONNECTION.name, + ) + assert "deleted" in audit_log.log + assert environment.name in audit_log.log + + +def test_get_detail__exists__returns_200( + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + warehouse_connection: WarehouseConnection, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + url = reverse( + "api-v1:environments:experimentation:warehouse-connections-detail", + args=[environment.api_key, str(warehouse_connection.uuid)], + ) + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["uuid"] == str(warehouse_connection.uuid) + assert data["warehouse_type"] == "flagsmith" + assert data["status"] == "pending_connection" + assert data["name"] == f"Flagsmith Warehouse - {environment.name}" + assert "created_at" in data + + +def test_get_detail__not_exists__returns_404( + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + url = reverse( + "api-v1:environments:experimentation:warehouse-connections-detail", + args=[environment.api_key, "00000000-0000-0000-0000-000000000000"], + ) + + # When + response = admin_client.get(url) + + # Then + assert response.status_code == status.HTTP_404_NOT_FOUND