From 325f0089842fc46ab4ca269854ef87a6b53a8f8f Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 19 May 2026 15:44:37 +0200 Subject: [PATCH 1/8] feat: add-experimentation-app-with-warehouse-connection-model-and-endpoints --- api/app/settings/common.py | 1 + api/audit/related_object_type.py | 1 + api/environments/urls.py | 4 + api/experimentation/__init__.py | 0 api/experimentation/apps.py | 5 ++ api/experimentation/constants.py | 1 + .../0001_introduce_warehouse_connection.py | 89 +++++++++++++++++++ api/experimentation/migrations/__init__.py | 0 api/experimentation/models.py | 45 ++++++++++ api/experimentation/permissions.py | 23 +++++ api/experimentation/serializers.py | 48 ++++++++++ api/experimentation/services.py | 40 +++++++++ api/experimentation/urls.py | 13 +++ api/experimentation/views.py | 69 ++++++++++++++ 14 files changed, 339 insertions(+) create mode 100644 api/experimentation/__init__.py create mode 100644 api/experimentation/apps.py create mode 100644 api/experimentation/constants.py create mode 100644 api/experimentation/migrations/0001_introduce_warehouse_connection.py create mode 100644 api/experimentation/migrations/__init__.py create mode 100644 api/experimentation/models.py create mode 100644 api/experimentation/permissions.py create mode 100644 api/experimentation/serializers.py create mode 100644 api/experimentation/services.py create mode 100644 api/experimentation/urls.py create mode 100644 api/experimentation/views.py 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..abe1bd47ad98 100644 --- a/api/environments/urls.py +++ b/api/environments/urls.py @@ -173,4 +173,8 @@ get_experiment_results, name="experiment-results", ), + path( + "/warehouse-connection/", + 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..afdb40fd8c5e --- /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 +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..8e4724387000 --- /dev/null +++ b/api/experimentation/serializers.py @@ -0,0 +1,48 @@ +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..5577e9df966f --- /dev/null +++ b/api/experimentation/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from experimentation.views import WarehouseConnectionView + +app_name = "experimentation" + +urlpatterns = [ + path( + "", + WarehouseConnectionView.as_view(), + name="warehouse-connection", + ), +] diff --git a/api/experimentation/views.py b/api/experimentation/views.py new file mode 100644 index 000000000000..0489a71780e7 --- /dev/null +++ b/api/experimentation/views.py @@ -0,0 +1,69 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from environments.models import Environment +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 WarehouseConnectionView(APIView): + permission_classes = [IsAuthenticated, WarehouseConnectionPermission] + + def _get_user(self, request: Request) -> FFAdminUser: + return request.user # type: ignore[return-value] + + def get(self, request: Request, environment_api_key: str) -> Response: + environment = get_object_or_404(Environment, api_key=environment_api_key) + connection = WarehouseConnection.objects.filter( + environment=environment, + ).first() + if connection is None: + return Response( + {"detail": "Not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response(WarehouseConnectionSerializer(connection).data) + + def post(self, request: Request, environment_api_key: str) -> Response: + environment = get_object_or_404(Environment, api_key=environment_api_key) + + serializer = WarehouseConnectionSerializer(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=status.HTTP_409_CONFLICT, + ) + + connection = serializer.save(environment=environment) + create_warehouse_audit_log(connection, self._get_user(request), action="created") + return Response( + WarehouseConnectionSerializer(connection).data, + status=status.HTTP_201_CREATED, + ) + + def delete(self, request: Request, environment_api_key: str) -> Response: + environment = get_object_or_404(Environment, api_key=environment_api_key) + connection = WarehouseConnection.objects.filter( + environment=environment, + ).first() + if connection is None: + return Response( + {"detail": "Not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + create_warehouse_audit_log(connection, self._get_user(request), action="deleted") + connection.delete() + return Response(status=status.HTTP_204_NO_CONTENT) From ed14ce231d03e6ef0bcb6d0f9c20bacf847a02ce Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 19 May 2026 15:45:00 +0200 Subject: [PATCH 2/8] test: add-warehouse-connection-view-serializer-and-permission-tests --- api/tests/unit/experimentation/__init__.py | 0 api/tests/unit/experimentation/conftest.py | 27 ++ .../unit/experimentation/test_permissions.py | 82 ++++++ .../unit/experimentation/test_serializers.py | 74 +++++ api/tests/unit/experimentation/test_views.py | 254 ++++++++++++++++++ 5 files changed, 437 insertions(+) create mode 100644 api/tests/unit/experimentation/__init__.py create mode 100644 api/tests/unit/experimentation/conftest.py create mode 100644 api/tests/unit/experimentation/test_permissions.py create mode 100644 api/tests/unit/experimentation/test_serializers.py create mode 100644 api/tests/unit/experimentation/test_views.py 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..07ab853acd65 --- /dev/null +++ b/api/tests/unit/experimentation/conftest.py @@ -0,0 +1,27 @@ +import pytest + +from environments.models import Environment +from experimentation.models import WarehouseConnection, WarehouseType + + +@pytest.fixture() +def warehouse_connection(environment: Environment) -> WarehouseConnection: + return WarehouseConnection.objects.create( + environment=environment, + warehouse_type=WarehouseType.FLAGSMITH, + name=f"Flagsmith Warehouse - {environment.name}", + ) + + +@pytest.fixture() +def warehouse_connection_url(environment: Environment) -> str: + return reverse_warehouse_connection_url(environment.api_key) + + +def reverse_warehouse_connection_url(environment_api_key: str) -> str: + from django.urls import reverse + + return reverse( + "api-v1:environments:experimentation:warehouse-connection", + 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..d8f699cfd518 --- /dev/null +++ b/api/tests/unit/experimentation/test_permissions.py @@ -0,0 +1,82 @@ +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 + + +class TestWarehouseConnectionPermission: + def _make_request( + self, + 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( + self, + admin_user: FFAdminUser, + environment: Environment, + enable_features: EnableFeaturesFixture, + ) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + result = self._make_request(admin_user, environment.api_key) + + # Then + assert result is True + + def test_has_permission__flag_disabled__returns_false( + self, + admin_user: FFAdminUser, + environment: Environment, + ) -> None: + # Given - no enable_features call + + # When + result = self._make_request(admin_user, environment.api_key) + + # Then + assert result is False + + def test_has_permission__flag_enabled_not_admin__returns_false( + self, + staff_user: FFAdminUser, + environment: Environment, + enable_features: EnableFeaturesFixture, + ) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + result = self._make_request(staff_user, environment.api_key) + + # Then + assert result is False + + def test_has_permission__environment_not_found__returns_false( + self, + admin_user: FFAdminUser, + enable_features: EnableFeaturesFixture, + ) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + result = self._make_request(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..f1b5d3b5a195 --- /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__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..a13b79a5c8fc --- /dev/null +++ b/api/tests/unit/experimentation/test_views.py @@ -0,0 +1,254 @@ +import pytest +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 +from tests.unit.experimentation.conftest import reverse_warehouse_connection_url + +pytestmark = pytest.mark.django_db + + +class TestWarehouseConnectionViewPost: + def test_post__valid_data__returns_201_and_creates_connection( + self, + 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( + self, + 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( + self, + 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__feature_flag_disabled__returns_403( + self, + admin_client: APIClient, + warehouse_connection_url: str, + ) -> None: + # Given - no enable_features call + + # When + response = admin_client.post( + warehouse_connection_url, + data={"warehouse_type": "flagsmith"}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_post__non_admin__returns_403( + self, + staff_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + ) -> None: + # Given + enable_features("experimentation_warehouse_connection") + url = reverse_warehouse_connection_url(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__creates_audit_log( + self, + 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 + + +class TestWarehouseConnectionViewGet: + def test_get__exists__returns_200( + self, + 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 data["warehouse_type"] == "flagsmith" + assert data["status"] == "pending_connection" + assert data["name"] == f"Flagsmith Warehouse - {environment.name}" + assert data["uuid"] == str(warehouse_connection.uuid) + assert "created_at" in data + + def test_get__not_exists__returns_404( + self, + 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_404_NOT_FOUND + + +class TestWarehouseConnectionViewDelete: + def test_delete__exists__returns_204( + self, + 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.delete(warehouse_connection_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( + self, + admin_client: APIClient, + enable_features: EnableFeaturesFixture, + warehouse_connection_url: str, + ) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + response = admin_client.delete(warehouse_connection_url) + + # Then + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete__creates_audit_log( + self, + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + warehouse_connection: WarehouseConnection, + warehouse_connection_url: str, + ) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + admin_client.delete(warehouse_connection_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 From 9257a5a981f206262b3deb25dcde2cdd788671df Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 19 May 2026 17:38:42 +0200 Subject: [PATCH 3/8] feat: rename warehouse endpoint to plural and return list response --- api/environments/urls.py | 2 +- api/experimentation/urls.py | 2 +- api/experimentation/views.py | 11 +- .../flagsmith/data/environment.json | 15 +- api/tests/unit/experimentation/conftest.py | 2 +- .../unit/experimentation/test_permissions.py | 137 +++--- api/tests/unit/experimentation/test_views.py | 460 +++++++++--------- 7 files changed, 305 insertions(+), 324 deletions(-) diff --git a/api/environments/urls.py b/api/environments/urls.py index abe1bd47ad98..2642af9242a5 100644 --- a/api/environments/urls.py +++ b/api/environments/urls.py @@ -174,7 +174,7 @@ name="experiment-results", ), path( - "/warehouse-connection/", + "/warehouse-connections/", include("experimentation.urls"), ), ] diff --git a/api/experimentation/urls.py b/api/experimentation/urls.py index 5577e9df966f..5de1b924a279 100644 --- a/api/experimentation/urls.py +++ b/api/experimentation/urls.py @@ -8,6 +8,6 @@ path( "", WarehouseConnectionView.as_view(), - name="warehouse-connection", + name="warehouse-connections", ), ] diff --git a/api/experimentation/views.py b/api/experimentation/views.py index 0489a71780e7..cdf4ccd5cb0c 100644 --- a/api/experimentation/views.py +++ b/api/experimentation/views.py @@ -21,15 +21,8 @@ def _get_user(self, request: Request) -> FFAdminUser: def get(self, request: Request, environment_api_key: str) -> Response: environment = get_object_or_404(Environment, api_key=environment_api_key) - connection = WarehouseConnection.objects.filter( - environment=environment, - ).first() - if connection is None: - return Response( - {"detail": "Not found."}, - status=status.HTTP_404_NOT_FOUND, - ) - return Response(WarehouseConnectionSerializer(connection).data) + connections = WarehouseConnection.objects.filter(environment=environment) + return Response(WarehouseConnectionSerializer(connections, many=True).data) def post(self, request: Request, environment_api_key: str) -> Response: environment = get_object_or_404(Environment, api_key=environment_api_key) 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/conftest.py b/api/tests/unit/experimentation/conftest.py index 07ab853acd65..f9e24b9361e1 100644 --- a/api/tests/unit/experimentation/conftest.py +++ b/api/tests/unit/experimentation/conftest.py @@ -22,6 +22,6 @@ def reverse_warehouse_connection_url(environment_api_key: str) -> str: from django.urls import reverse return reverse( - "api-v1:environments:experimentation:warehouse-connection", + "api-v1:environments:experimentation:warehouse-connections", args=[environment_api_key], ) diff --git a/api/tests/unit/experimentation/test_permissions.py b/api/tests/unit/experimentation/test_permissions.py index d8f699cfd518..5cc06102a231 100644 --- a/api/tests/unit/experimentation/test_permissions.py +++ b/api/tests/unit/experimentation/test_permissions.py @@ -9,74 +9,69 @@ pytestmark = pytest.mark.django_db -class TestWarehouseConnectionPermission: - def _make_request( - self, - 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( - self, - admin_user: FFAdminUser, - environment: Environment, - enable_features: EnableFeaturesFixture, - ) -> None: - # Given - enable_features("experimentation_warehouse_connection") - - # When - result = self._make_request(admin_user, environment.api_key) - - # Then - assert result is True - - def test_has_permission__flag_disabled__returns_false( - self, - admin_user: FFAdminUser, - environment: Environment, - ) -> None: - # Given - no enable_features call - - # When - result = self._make_request(admin_user, environment.api_key) - - # Then - assert result is False - - def test_has_permission__flag_enabled_not_admin__returns_false( - self, - staff_user: FFAdminUser, - environment: Environment, - enable_features: EnableFeaturesFixture, - ) -> None: - # Given - enable_features("experimentation_warehouse_connection") - - # When - result = self._make_request(staff_user, environment.api_key) - - # Then - assert result is False - - def test_has_permission__environment_not_found__returns_false( - self, - admin_user: FFAdminUser, - enable_features: EnableFeaturesFixture, - ) -> None: - # Given - enable_features("experimentation_warehouse_connection") - - # When - result = self._make_request(admin_user, "nonexistent-key") - - # Then - assert result is False +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_views.py b/api/tests/unit/experimentation/test_views.py index a13b79a5c8fc..5090b9430179 100644 --- a/api/tests/unit/experimentation/test_views.py +++ b/api/tests/unit/experimentation/test_views.py @@ -12,243 +12,223 @@ pytestmark = pytest.mark.django_db -class TestWarehouseConnectionViewPost: - def test_post__valid_data__returns_201_and_creates_connection( - self, - 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( - self, - 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( - self, - 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__feature_flag_disabled__returns_403( - self, - admin_client: APIClient, - warehouse_connection_url: str, - ) -> None: - # Given - no enable_features call - - # When - response = admin_client.post( - warehouse_connection_url, - data={"warehouse_type": "flagsmith"}, - format="json", - ) - - # Then - assert response.status_code == status.HTTP_403_FORBIDDEN - - def test_post__non_admin__returns_403( - self, - staff_client: APIClient, - environment: Environment, - enable_features: EnableFeaturesFixture, - ) -> None: - # Given - enable_features("experimentation_warehouse_connection") - url = reverse_warehouse_connection_url(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__creates_audit_log( - self, - 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 - - -class TestWarehouseConnectionViewGet: - def test_get__exists__returns_200( - self, - 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 data["warehouse_type"] == "flagsmith" - assert data["status"] == "pending_connection" - assert data["name"] == f"Flagsmith Warehouse - {environment.name}" - assert data["uuid"] == str(warehouse_connection.uuid) - assert "created_at" in data - - def test_get__not_exists__returns_404( - self, - 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_404_NOT_FOUND - - -class TestWarehouseConnectionViewDelete: - def test_delete__exists__returns_204( - self, - 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.delete(warehouse_connection_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( - self, - admin_client: APIClient, - enable_features: EnableFeaturesFixture, - warehouse_connection_url: str, - ) -> None: - # Given - enable_features("experimentation_warehouse_connection") - - # When - response = admin_client.delete(warehouse_connection_url) - - # Then - assert response.status_code == status.HTTP_404_NOT_FOUND - - def test_delete__creates_audit_log( - self, - admin_client: APIClient, - environment: Environment, - enable_features: EnableFeaturesFixture, - warehouse_connection: WarehouseConnection, - warehouse_connection_url: str, - ) -> None: - # Given - enable_features("experimentation_warehouse_connection") - - # When - admin_client.delete(warehouse_connection_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_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_warehouse_connection_url(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__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, + warehouse_connection_url: str, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + response = admin_client.delete(warehouse_connection_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, + enable_features: EnableFeaturesFixture, + warehouse_connection_url: str, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + response = admin_client.delete(warehouse_connection_url) + + # Then + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_delete__creates_audit_log( + admin_client: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + warehouse_connection: WarehouseConnection, + warehouse_connection_url: str, +) -> None: + # Given + enable_features("experimentation_warehouse_connection") + + # When + admin_client.delete(warehouse_connection_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 From 8db7e03091b64f0578329cd7d379360664e1223b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 16:03:00 +0000 Subject: [PATCH 4/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/experimentation/serializers.py | 3 ++- api/experimentation/views.py | 8 ++++++-- api/tests/unit/experimentation/test_views.py | 19 ++++++++++++------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/api/experimentation/serializers.py b/api/experimentation/serializers.py index 8e4724387000..dc195170bc26 100644 --- a/api/experimentation/serializers.py +++ b/api/experimentation/serializers.py @@ -17,7 +17,8 @@ class Meta: read_only_fields = ("uuid", "status", "name", "created_at") def create( - self, validated_data: dict[str, Any], + self, + validated_data: dict[str, Any], ) -> WarehouseConnection: environment: Environment = validated_data["environment"] warehouse_type: str = validated_data["warehouse_type"] diff --git a/api/experimentation/views.py b/api/experimentation/views.py index cdf4ccd5cb0c..777429139d9e 100644 --- a/api/experimentation/views.py +++ b/api/experimentation/views.py @@ -41,7 +41,9 @@ def post(self, request: Request, environment_api_key: str) -> Response: ) connection = serializer.save(environment=environment) - create_warehouse_audit_log(connection, self._get_user(request), action="created") + create_warehouse_audit_log( + connection, self._get_user(request), action="created" + ) return Response( WarehouseConnectionSerializer(connection).data, status=status.HTTP_201_CREATED, @@ -57,6 +59,8 @@ def delete(self, request: Request, environment_api_key: str) -> Response: {"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND, ) - create_warehouse_audit_log(connection, self._get_user(request), action="deleted") + create_warehouse_audit_log( + connection, self._get_user(request), action="deleted" + ) connection.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/api/tests/unit/experimentation/test_views.py b/api/tests/unit/experimentation/test_views.py index 5090b9430179..47abce4234ab 100644 --- a/api/tests/unit/experimentation/test_views.py +++ b/api/tests/unit/experimentation/test_views.py @@ -85,7 +85,6 @@ def test_post__soft_deleted_exists__resurrects_and_returns_201( assert response.json()["status"] == "pending_connection" - def test_post__non_admin__returns_403( staff_client: APIClient, environment: Environment, @@ -123,9 +122,12 @@ def test_post__creates_audit_log( ) # Then - assert AuditLog.objects.filter( - related_object_type=RelatedObjectType.WAREHOUSE_CONNECTION.name, - ).count() == 1 + 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, ) @@ -224,9 +226,12 @@ def test_delete__creates_audit_log( admin_client.delete(warehouse_connection_url) # Then - assert AuditLog.objects.filter( - related_object_type=RelatedObjectType.WAREHOUSE_CONNECTION.name, - ).count() == 1 + 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, ) From 2dee61df6580cb4c27abbaa6eb53e4ba340f357a Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 19 May 2026 18:05:16 +0200 Subject: [PATCH 5/8] feat: lint tests --- api/tests/unit/experimentation/test_serializers.py | 2 +- api/tests/unit/experimentation/test_views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/tests/unit/experimentation/test_serializers.py b/api/tests/unit/experimentation/test_serializers.py index f1b5d3b5a195..637d3524da8c 100644 --- a/api/tests/unit/experimentation/test_serializers.py +++ b/api/tests/unit/experimentation/test_serializers.py @@ -57,7 +57,7 @@ def test_create__soft_deleted_exists__resurrects_record( assert connection.name == f"Flagsmith Warehouse - {environment.name}" -def test_create__name_uses_warehouse_type_label( +def test_create__valid_data__name_uses_warehouse_type_label( environment: Environment, ) -> None: # Given diff --git a/api/tests/unit/experimentation/test_views.py b/api/tests/unit/experimentation/test_views.py index 5090b9430179..9305f41992cd 100644 --- a/api/tests/unit/experimentation/test_views.py +++ b/api/tests/unit/experimentation/test_views.py @@ -106,7 +106,7 @@ def test_post__non_admin__returns_403( assert response.status_code == status.HTTP_403_FORBIDDEN -def test_post__creates_audit_log( +def test_post__valid_data__creates_audit_log( admin_client: APIClient, environment: Environment, enable_features: EnableFeaturesFixture, @@ -210,7 +210,7 @@ def test_delete__not_exists__returns_404( assert response.status_code == status.HTTP_404_NOT_FOUND -def test_delete__creates_audit_log( +def test_delete__exists__creates_audit_log( admin_client: APIClient, environment: Environment, enable_features: EnableFeaturesFixture, From 3e78fabeb290cadf8c063afb015d0c32b4815d5d Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 19 May 2026 18:08:34 +0200 Subject: [PATCH 6/8] feat: type --- .../migrations/0001_introduce_warehouse_connection.py | 2 +- api/tests/unit/experimentation/conftest.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/experimentation/migrations/0001_introduce_warehouse_connection.py b/api/experimentation/migrations/0001_introduce_warehouse_connection.py index afdb40fd8c5e..57e60ca08fc8 100644 --- a/api/experimentation/migrations/0001_introduce_warehouse_connection.py +++ b/api/experimentation/migrations/0001_introduce_warehouse_connection.py @@ -1,7 +1,7 @@ # Generated by Django 5.2.14 on 2026-05-19 11:05 import django.db.models.deletion -import django_lifecycle.mixins +import django_lifecycle.mixins # type: ignore[import-untyped] import uuid from django.db import migrations, models diff --git a/api/tests/unit/experimentation/conftest.py b/api/tests/unit/experimentation/conftest.py index f9e24b9361e1..b61bf5c8538c 100644 --- a/api/tests/unit/experimentation/conftest.py +++ b/api/tests/unit/experimentation/conftest.py @@ -6,11 +6,12 @@ @pytest.fixture() def warehouse_connection(environment: Environment) -> WarehouseConnection: - return WarehouseConnection.objects.create( + connection: WarehouseConnection = WarehouseConnection.objects.create( environment=environment, warehouse_type=WarehouseType.FLAGSMITH, name=f"Flagsmith Warehouse - {environment.name}", ) + return connection @pytest.fixture() From dbf0c1551ca36775c483b8dc627327368de8ea9b Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 20 May 2026 10:42:26 +0200 Subject: [PATCH 7/8] feat: switched to viewset and added get single connection endpoint --- api/experimentation/urls.py | 15 ++-- api/experimentation/views.py | 74 +++++++++----------- api/tests/unit/experimentation/conftest.py | 11 +-- api/tests/unit/experimentation/test_views.py | 74 +++++++++++++++++--- 4 files changed, 110 insertions(+), 64 deletions(-) diff --git a/api/experimentation/urls.py b/api/experimentation/urls.py index 5de1b924a279..99f7ec28f266 100644 --- a/api/experimentation/urls.py +++ b/api/experimentation/urls.py @@ -1,13 +1,10 @@ -from django.urls import path +from rest_framework.routers import DefaultRouter -from experimentation.views import WarehouseConnectionView +from experimentation.views import WarehouseConnectionViewSet app_name = "experimentation" -urlpatterns = [ - path( - "", - WarehouseConnectionView.as_view(), - name="warehouse-connections", - ), -] +router = DefaultRouter() +router.register(r"", WarehouseConnectionViewSet, basename="warehouse-connections") + +urlpatterns = router.urls diff --git a/api/experimentation/views.py b/api/experimentation/views.py index 777429139d9e..1c2ccf1f329b 100644 --- a/api/experimentation/views.py +++ b/api/experimentation/views.py @@ -1,11 +1,9 @@ -from django.shortcuts import get_object_or_404 -from rest_framework import status +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.views import APIView -from environments.models import Environment +from environments.views import NestedEnvironmentViewSet from experimentation.models import WarehouseConnection from experimentation.permissions import WarehouseConnectionPermission from experimentation.serializers import WarehouseConnectionSerializer @@ -13,21 +11,37 @@ from users.models import FFAdminUser -class WarehouseConnectionView(APIView): +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 _get_user(self, request: Request) -> FFAdminUser: - return request.user # type: ignore[return-value] - - def get(self, request: Request, environment_api_key: str) -> Response: - environment = get_object_or_404(Environment, api_key=environment_api_key) - connections = WarehouseConnection.objects.filter(environment=environment) - return Response(WarehouseConnectionSerializer(connections, many=True).data) + def perform_create(self, serializer: WarehouseConnectionSerializer) -> None: + connection: WarehouseConnection = serializer.save( + environment=self._get_environment() + ) + create_warehouse_audit_log( + connection, self._get_user(self.request), action="created" + ) - def post(self, request: Request, environment_api_key: str) -> Response: - environment = get_object_or_404(Environment, api_key=environment_api_key) + def perform_destroy(self, instance: WarehouseConnection) -> None: + create_warehouse_audit_log( + instance, self._get_user(self.request), action="deleted" + ) + instance.delete() - serializer = WarehouseConnectionSerializer(data=request.data) + 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"] @@ -37,30 +51,12 @@ def post(self, request: Request, environment_api_key: str) -> Response: ).exists(): return Response( {"detail": "Warehouse connection already exists."}, - status=status.HTTP_409_CONFLICT, + status=409, ) - connection = serializer.save(environment=environment) - create_warehouse_audit_log( - connection, self._get_user(request), action="created" - ) - return Response( - WarehouseConnectionSerializer(connection).data, - status=status.HTTP_201_CREATED, - ) + self.perform_create(serializer) + return Response(serializer.data, status=201) - def delete(self, request: Request, environment_api_key: str) -> Response: - environment = get_object_or_404(Environment, api_key=environment_api_key) - connection = WarehouseConnection.objects.filter( - environment=environment, - ).first() - if connection is None: - return Response( - {"detail": "Not found."}, - status=status.HTTP_404_NOT_FOUND, - ) - create_warehouse_audit_log( - connection, self._get_user(request), action="deleted" - ) - connection.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + @staticmethod + def _get_user(request: Request) -> FFAdminUser: + return request.user # type: ignore[return-value] diff --git a/api/tests/unit/experimentation/conftest.py b/api/tests/unit/experimentation/conftest.py index b61bf5c8538c..127286d3016f 100644 --- a/api/tests/unit/experimentation/conftest.py +++ b/api/tests/unit/experimentation/conftest.py @@ -1,4 +1,5 @@ import pytest +from django.urls import reverse from environments.models import Environment from experimentation.models import WarehouseConnection, WarehouseType @@ -16,13 +17,7 @@ def warehouse_connection(environment: Environment) -> WarehouseConnection: @pytest.fixture() def warehouse_connection_url(environment: Environment) -> str: - return reverse_warehouse_connection_url(environment.api_key) - - -def reverse_warehouse_connection_url(environment_api_key: str) -> str: - from django.urls import reverse - return reverse( - "api-v1:environments:experimentation:warehouse-connections", - args=[environment_api_key], + "api-v1:environments:experimentation:warehouse-connections-list", + args=[environment.api_key], ) diff --git a/api/tests/unit/experimentation/test_views.py b/api/tests/unit/experimentation/test_views.py index 14166be84f9a..f93b109a1e68 100644 --- a/api/tests/unit/experimentation/test_views.py +++ b/api/tests/unit/experimentation/test_views.py @@ -1,4 +1,5 @@ import pytest +from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient @@ -7,7 +8,6 @@ from environments.models import Environment from experimentation.models import WarehouseConnection from tests.types import EnableFeaturesFixture -from tests.unit.experimentation.conftest import reverse_warehouse_connection_url pytestmark = pytest.mark.django_db @@ -92,7 +92,10 @@ def test_post__non_admin__returns_403( ) -> None: # Given enable_features("experimentation_warehouse_connection") - url = reverse_warehouse_connection_url(environment.api_key) + url = reverse( + "api-v1:environments:experimentation:warehouse-connections-list", + args=[environment.api_key], + ) # When response = staff_client.post( @@ -180,13 +183,16 @@ def test_delete__exists__returns_204( environment: Environment, enable_features: EnableFeaturesFixture, warehouse_connection: WarehouseConnection, - warehouse_connection_url: str, ) -> 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(warehouse_connection_url) + response = admin_client.delete(url) # Then assert response.status_code == status.HTTP_204_NO_CONTENT @@ -199,14 +205,18 @@ def test_delete__exists__returns_204( def test_delete__not_exists__returns_404( admin_client: APIClient, + environment: Environment, enable_features: EnableFeaturesFixture, - warehouse_connection_url: str, ) -> 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(warehouse_connection_url) + response = admin_client.delete(url) # Then assert response.status_code == status.HTTP_404_NOT_FOUND @@ -217,13 +227,16 @@ def test_delete__exists__creates_audit_log( environment: Environment, enable_features: EnableFeaturesFixture, warehouse_connection: WarehouseConnection, - warehouse_connection_url: str, ) -> 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(warehouse_connection_url) + admin_client.delete(url) # Then assert ( @@ -237,3 +250,48 @@ def test_delete__exists__creates_audit_log( ) 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 From 307d545ee9f48b870be6cc7e42f6241cadab00eb Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 20 May 2026 10:46:32 +0200 Subject: [PATCH 8/8] feat: fixed types --- api/experimentation/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/experimentation/views.py b/api/experimentation/views.py index 1c2ccf1f329b..38176023a273 100644 --- a/api/experimentation/views.py +++ b/api/experimentation/views.py @@ -2,6 +2,7 @@ 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 @@ -25,7 +26,7 @@ class WarehouseConnectionViewSet( lookup_field = "uuid" lookup_url_kwarg = "connection_id" - def perform_create(self, serializer: WarehouseConnectionSerializer) -> None: + def perform_create(self, serializer: BaseSerializer[WarehouseConnection]) -> None: connection: WarehouseConnection = serializer.save( environment=self._get_environment() )