Skip to content

Commit d118da7

Browse files
authored
feat(experimentation): add Experiment base model and CRUD endpoints (#7591)
1 parent cc96859 commit d118da7

12 files changed

Lines changed: 1013 additions & 12 deletions

File tree

api/audit/related_object_type.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ class RelatedObjectType(enum.Enum):
1313
FEATURE_HEALTH = "Feature health status"
1414
RELEASE_PIPELINE = "Release pipeline"
1515
WAREHOUSE_CONNECTION = "Warehouse connection"
16+
EXPERIMENT = "Experiment"

api/environments/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,8 @@
177177
"<str:environment_api_key>/warehouse-connections/",
178178
include("experimentation.urls"),
179179
),
180+
path(
181+
"<str:environment_api_key>/experiments/",
182+
include("experimentation.experiment_urls"),
183+
),
180184
]

api/experimentation/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
WAREHOUSE_CONNECTION_FLAG = "experimentation_warehouse_connection"
2+
EXPERIMENT_FLAG = "experimental_flags"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from rest_framework.routers import DefaultRouter
2+
3+
from experimentation.views import ExperimentViewSet
4+
5+
app_name = "experiments"
6+
7+
router = DefaultRouter()
8+
router.register(r"", ExperimentViewSet, basename="experiments")
9+
10+
urlpatterns = router.urls
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Generated by Django 5.2.14 on 2026-05-25 08:45
2+
3+
import django.db.models.deletion
4+
import django_lifecycle.mixins # type: ignore[import-untyped]
5+
import uuid
6+
from django.db import migrations, models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("environments", "0037_add_uuid_field"),
13+
("experimentation", "0003_unique_connection_per_environment"),
14+
("features", "0066_constrain_feature_type"),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name="Experiment",
20+
fields=[
21+
(
22+
"id",
23+
models.AutoField(
24+
auto_created=True,
25+
primary_key=True,
26+
serialize=False,
27+
verbose_name="ID",
28+
),
29+
),
30+
(
31+
"deleted_at",
32+
models.DateTimeField(
33+
blank=True,
34+
db_index=True,
35+
default=None,
36+
editable=False,
37+
null=True,
38+
),
39+
),
40+
(
41+
"uuid",
42+
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
43+
),
44+
("name", models.CharField(max_length=255)),
45+
("hypothesis", models.TextField()),
46+
(
47+
"status",
48+
models.CharField(
49+
choices=[
50+
("created", "Created"),
51+
("running", "Running"),
52+
("paused", "Paused"),
53+
("completed", "Completed"),
54+
],
55+
default="created",
56+
max_length=50,
57+
),
58+
),
59+
("created_at", models.DateTimeField(auto_now_add=True)),
60+
("updated_at", models.DateTimeField(auto_now=True)),
61+
("started_at", models.DateTimeField(blank=True, null=True)),
62+
("ended_at", models.DateTimeField(blank=True, null=True)),
63+
(
64+
"environment",
65+
models.ForeignKey(
66+
on_delete=django.db.models.deletion.CASCADE,
67+
related_name="experiments",
68+
to="environments.environment",
69+
),
70+
),
71+
(
72+
"feature",
73+
models.ForeignKey(
74+
on_delete=django.db.models.deletion.CASCADE,
75+
related_name="experiments",
76+
to="features.feature",
77+
),
78+
),
79+
],
80+
options={
81+
"constraints": [
82+
models.UniqueConstraint(
83+
condition=models.Q(
84+
("deleted_at__isnull", True),
85+
models.Q(("status", "completed"), _negated=True),
86+
),
87+
fields=("feature", "environment"),
88+
name="unique_active_experiment_per_feature_env",
89+
)
90+
],
91+
},
92+
bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model),
93+
),
94+
]

api/experimentation/models.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.db import models
2+
from django.db.models import Q
23
from django_lifecycle import ( # type: ignore[import-untyped]
34
AFTER_CREATE,
45
AFTER_DELETE,
@@ -68,3 +69,51 @@ def sync_to_ingestion_on_delete(self) -> None:
6869
delete_environment_key_from_ingestion.delay(
6970
kwargs={"environment_api_key": self.environment.api_key},
7071
)
72+
73+
74+
class ExperimentStatus(models.TextChoices):
75+
CREATED = "created", "Created"
76+
RUNNING = "running", "Running"
77+
PAUSED = "paused", "Paused"
78+
COMPLETED = "completed", "Completed"
79+
80+
81+
VALID_STATUS_TRANSITIONS: dict[str, set[str]] = {
82+
ExperimentStatus.CREATED: {ExperimentStatus.RUNNING},
83+
ExperimentStatus.RUNNING: {ExperimentStatus.PAUSED, ExperimentStatus.COMPLETED},
84+
ExperimentStatus.PAUSED: {ExperimentStatus.RUNNING, ExperimentStatus.COMPLETED},
85+
ExperimentStatus.COMPLETED: set(),
86+
}
87+
88+
89+
class Experiment(LifecycleModelMixin, SoftDeleteExportableModel): # type: ignore[misc]
90+
environment = models.ForeignKey(
91+
Environment,
92+
on_delete=models.CASCADE,
93+
related_name="experiments",
94+
)
95+
feature = models.ForeignKey(
96+
"features.Feature",
97+
on_delete=models.CASCADE,
98+
related_name="experiments",
99+
)
100+
name = models.CharField(max_length=255)
101+
hypothesis = models.TextField()
102+
status = models.CharField(
103+
max_length=50,
104+
choices=ExperimentStatus.choices,
105+
default=ExperimentStatus.CREATED,
106+
)
107+
created_at = models.DateTimeField(auto_now_add=True)
108+
updated_at = models.DateTimeField(auto_now=True)
109+
started_at = models.DateTimeField(null=True, blank=True)
110+
ended_at = models.DateTimeField(null=True, blank=True)
111+
112+
class Meta:
113+
constraints = [
114+
models.UniqueConstraint(
115+
fields=["feature", "environment"],
116+
condition=Q(deleted_at__isnull=True) & ~Q(status="completed"),
117+
name="unique_active_experiment_per_feature_env",
118+
),
119+
]

api/experimentation/permissions.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
from rest_framework.views import APIView
44

55
from environments.models import Environment
6-
from experimentation.services import is_warehouse_feature_enabled
6+
from experimentation.services import (
7+
is_experiment_feature_enabled,
8+
is_warehouse_feature_enabled,
9+
)
710
from users.models import FFAdminUser
811

912

@@ -21,3 +24,19 @@ def has_permission(self, request: Request, view: APIView) -> bool:
2124

2225
user: FFAdminUser = request.user # type: ignore[assignment]
2326
return user.is_environment_admin(environment)
27+
28+
29+
class ExperimentPermission(BasePermission):
30+
def has_permission(self, request: Request, view: APIView) -> bool:
31+
try:
32+
environment = Environment.objects.get(
33+
api_key=view.kwargs.get("environment_api_key")
34+
)
35+
except Environment.DoesNotExist:
36+
return False
37+
38+
if not is_experiment_feature_enabled(environment.project.organisation):
39+
return False
40+
41+
user: FFAdminUser = request.user # type: ignore[assignment]
42+
return user.is_environment_admin(environment)

api/experimentation/serializers.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
from environments.models import Environment
66
from experimentation.models import (
7+
Experiment,
78
WarehouseConnection,
89
WarehouseType,
910
)
1011
from experimentation.types import SNOWFLAKE_DEFAULTS, SnowflakeConfig
12+
from features.feature_types import MULTIVARIATE
1113

1214

1315
class WarehouseConnectionSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
@@ -70,3 +72,46 @@ def _validate_snowflake_config(config: dict[str, Any]) -> SnowflakeConfig:
7072
**config, # type: ignore[typeddict-item]
7173
}
7274
return merged
75+
76+
77+
class ExperimentSerializer(serializers.ModelSerializer): # type: ignore[type-arg]
78+
class Meta:
79+
model = Experiment
80+
fields = (
81+
"id",
82+
"feature",
83+
"name",
84+
"hypothesis",
85+
"status",
86+
"created_at",
87+
"updated_at",
88+
"started_at",
89+
"ended_at",
90+
)
91+
read_only_fields = (
92+
"id",
93+
"status",
94+
"created_at",
95+
"updated_at",
96+
"started_at",
97+
"ended_at",
98+
)
99+
100+
def validate_feature(self, feature: Any) -> Any:
101+
if feature.type != MULTIVARIATE:
102+
raise serializers.ValidationError(
103+
"Experiments can only be created for multivariate flags."
104+
)
105+
environment: Environment | None = self.context.get("environment")
106+
if environment and feature.project_id != environment.project_id:
107+
raise serializers.ValidationError(
108+
"Feature does not belong to this environment's project."
109+
)
110+
return feature
111+
112+
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
113+
if self.instance is not None and "feature" in attrs:
114+
raise serializers.ValidationError(
115+
{"feature": "Cannot change the feature of an existing experiment."}
116+
)
117+
return attrs

api/experimentation/services.py

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
import typing
44

5+
from django.utils import timezone
6+
57
from audit.models import AuditLog
68
from audit.related_object_type import RelatedObjectType
7-
from experimentation.constants import WAREHOUSE_CONNECTION_FLAG
9+
from experimentation.constants import EXPERIMENT_FLAG, WAREHOUSE_CONNECTION_FLAG
810
from integrations.flagsmith.client import get_openfeature_client
911

1012
if typing.TYPE_CHECKING:
11-
from experimentation.models import WarehouseConnection
13+
from experimentation.models import Experiment, WarehouseConnection
1214
from organisations.models import Organisation
1315
from users.models import FFAdminUser
1416

@@ -21,6 +23,22 @@ def is_warehouse_feature_enabled(organisation: Organisation) -> bool:
2123
)
2224

2325

26+
def is_experiment_feature_enabled(organisation: Organisation) -> bool:
27+
return get_openfeature_client().get_boolean_value(
28+
EXPERIMENT_FLAG,
29+
default_value=False,
30+
evaluation_context=organisation.openfeature_evaluation_context,
31+
)
32+
33+
34+
def _resolve_audit_log_author(
35+
user: FFAdminUser,
36+
) -> dict[str, int | None]:
37+
if getattr(user, "is_master_api_key_user", False):
38+
return {"author_id": None, "master_api_key_id": user.key.id}
39+
return {"author_id": user.pk, "master_api_key_id": None}
40+
41+
2442
def create_warehouse_audit_log(
2543
connection: WarehouseConnection,
2644
user: FFAdminUser,
@@ -30,11 +48,55 @@ def create_warehouse_audit_log(
3048
AuditLog.objects.create(
3149
environment=connection.environment,
3250
project=connection.environment.project,
33-
author=user,
51+
**_resolve_audit_log_author(user),
3452
related_object_id=connection.id,
3553
related_object_type=RelatedObjectType.WAREHOUSE_CONNECTION.name,
3654
log=(
3755
f"Warehouse connection {action} for environment "
3856
f"{connection.environment.name}"
3957
),
4058
)
59+
60+
61+
def create_experiment_audit_log(
62+
experiment: Experiment,
63+
user: FFAdminUser,
64+
*,
65+
action: str,
66+
) -> None:
67+
AuditLog.objects.create(
68+
environment=experiment.environment,
69+
project=experiment.environment.project,
70+
**_resolve_audit_log_author(user),
71+
related_object_id=experiment.id,
72+
related_object_type=RelatedObjectType.EXPERIMENT.name,
73+
log=(
74+
f"Experiment '{experiment.name}' {action} for environment "
75+
f"{experiment.environment.name}"
76+
),
77+
)
78+
79+
80+
def transition_experiment_status(
81+
experiment: Experiment,
82+
target_status: str,
83+
user: FFAdminUser,
84+
) -> Experiment:
85+
from experimentation.models import VALID_STATUS_TRANSITIONS, ExperimentStatus
86+
87+
valid_targets = VALID_STATUS_TRANSITIONS.get(experiment.status, set())
88+
if target_status not in valid_targets:
89+
raise ValueError(
90+
f"Cannot transition from '{experiment.status}' to '{target_status}'."
91+
)
92+
93+
experiment.status = target_status
94+
95+
if target_status == ExperimentStatus.RUNNING and not experiment.started_at:
96+
experiment.started_at = timezone.now()
97+
elif target_status == ExperimentStatus.COMPLETED:
98+
experiment.ended_at = timezone.now()
99+
100+
experiment.save()
101+
create_experiment_audit_log(experiment, user, action=target_status)
102+
return experiment

0 commit comments

Comments
 (0)