Skip to content
1 change: 1 addition & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@
"softdelete",
"metadata",
"app_analytics",
"experimentation",
"oauth2_metadata",
]

Expand Down
1 change: 1 addition & 0 deletions api/audit/related_object_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 4 additions & 0 deletions api/environments/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,8 @@
get_experiment_results,
name="experiment-results",
),
path(
"<str:environment_api_key>/warehouse-connections/",
include("experimentation.urls"),
),
]
Empty file.
5 changes: 5 additions & 0 deletions api/experimentation/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class ExperimentationConfig(AppConfig):
name = "experimentation"
1 change: 1 addition & 0 deletions api/experimentation/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
WAREHOUSE_CONNECTION_FLAG = "experimentation_warehouse_connection"
Original file line number Diff line number Diff line change
@@ -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),
Comment on lines +41 to +42
Copy link
Copy Markdown
Contributor Author

@Zaimwa9 Zaimwa9 May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hesitated between UUID and integerID, let me know what you think

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer int because uuid takes a lot more space, but either is fine here, doesn't make much of a difference.

),
(
"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),
),
]
Empty file.
45 changes: 45 additions & 0 deletions api/experimentation/models.py
Original file line number Diff line number Diff line change
@@ -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",
),
]
23 changes: 23 additions & 0 deletions api/experimentation/permissions.py
Original file line number Diff line number Diff line change
@@ -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)
49 changes: 49 additions & 0 deletions api/experimentation/serializers.py
Original file line number Diff line number Diff line change
@@ -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}"
40 changes: 40 additions & 0 deletions api/experimentation/services.py
Original file line number Diff line number Diff line change
@@ -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}"
),
)
10 changes: 10 additions & 0 deletions api/experimentation/urls.py
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions api/experimentation/views.py
Original file line number Diff line number Diff line change
@@ -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]
15 changes: 14 additions & 1 deletion api/integrations/flagsmith/data/environment.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -135,4 +148,4 @@
"segments": []
},
"use_identity_composite_key_for_hashing": true
}
}
Empty file.
Loading
Loading