-
Notifications
You must be signed in to change notification settings - Fork 523
feat(experimentation): add Experiment base model and CRUD endpoints #7591
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
05c82fd
005ae23
b27797d
43b0897
5d55695
4269a8a
52fa070
683fc69
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| WAREHOUSE_CONNECTION_FLAG = "experimentation_warehouse_connection" | ||
| EXPERIMENT_FLAG = "experimental_flags" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| from rest_framework.routers import DefaultRouter | ||
|
|
||
| from experimentation.views import ExperimentViewSet | ||
|
|
||
| app_name = "experiments" | ||
|
|
||
| router = DefaultRouter() | ||
| router.register(r"", ExperimentViewSet, basename="experiments") | ||
|
|
||
| urlpatterns = router.urls |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| # Generated by Django 5.2.14 on 2026-05-25 08:45 | ||
|
|
||
| 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): | ||
|
|
||
| dependencies = [ | ||
| ("environments", "0037_add_uuid_field"), | ||
| ("experimentation", "0003_unique_connection_per_environment"), | ||
| ("features", "0066_constrain_feature_type"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.CreateModel( | ||
| name="Experiment", | ||
| 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), | ||
| ), | ||
| ("name", models.CharField(max_length=255)), | ||
| ("hypothesis", models.TextField()), | ||
| ( | ||
| "status", | ||
| models.CharField( | ||
| choices=[ | ||
| ("created", "Created"), | ||
| ("running", "Running"), | ||
| ("paused", "Paused"), | ||
| ("completed", "Completed"), | ||
| ], | ||
| default="created", | ||
| max_length=50, | ||
| ), | ||
| ), | ||
| ("created_at", models.DateTimeField(auto_now_add=True)), | ||
| ("updated_at", models.DateTimeField(auto_now=True)), | ||
| ("started_at", models.DateTimeField(blank=True, null=True)), | ||
| ("ended_at", models.DateTimeField(blank=True, null=True)), | ||
| ( | ||
| "environment", | ||
| models.ForeignKey( | ||
| on_delete=django.db.models.deletion.CASCADE, | ||
| related_name="experiments", | ||
| to="environments.environment", | ||
| ), | ||
| ), | ||
| ( | ||
| "feature", | ||
| models.ForeignKey( | ||
| on_delete=django.db.models.deletion.CASCADE, | ||
| related_name="experiments", | ||
| to="features.feature", | ||
| ), | ||
| ), | ||
| ], | ||
| options={ | ||
| "constraints": [ | ||
| models.UniqueConstraint( | ||
| condition=models.Q( | ||
| ("deleted_at__isnull", True), | ||
| models.Q(("status", "completed"), _negated=True), | ||
| ), | ||
| fields=("feature", "environment"), | ||
| name="unique_active_experiment_per_feature_env", | ||
| ) | ||
| ], | ||
| }, | ||
| bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,10 @@ | |
| from rest_framework.views import APIView | ||
|
|
||
| from environments.models import Environment | ||
| from experimentation.services import is_warehouse_feature_enabled | ||
| from experimentation.services import ( | ||
| is_experiment_feature_enabled, | ||
| is_warehouse_feature_enabled, | ||
| ) | ||
| from users.models import FFAdminUser | ||
|
|
||
|
|
||
|
|
@@ -21,3 +24,19 @@ def has_permission(self, request: Request, view: APIView) -> bool: | |
|
|
||
| user: FFAdminUser = request.user # type: ignore[assignment] | ||
| return user.is_environment_admin(environment) | ||
|
|
||
|
|
||
| class ExperimentPermission(BasePermission): | ||
| def has_permission(self, request: Request, view: APIView) -> bool: | ||
| try: | ||
| environment = Environment.objects.get( | ||
| api_key=view.kwargs.get("environment_api_key") | ||
| ) | ||
|
Comment on lines
+31
to
+34
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This permission class performs a redundant database query to fetch the environment. Since try:
environment = view._get_environment()
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bad bot! Don't access private method |
||
| except Environment.DoesNotExist: | ||
| return False | ||
|
|
||
| if not is_experiment_feature_enabled(environment.project.organisation): | ||
| return False | ||
|
|
||
| user: FFAdminUser = request.user # type: ignore[assignment] | ||
| return user.is_environment_admin(environment) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic for automatically setting
started_atandended_attimestamps based on status transitions is currently implemented in the serializer'supdatemethod. This logic is better suited for the model layer usingdjango-lifecyclehooks, which are already being used in this model. This ensures data consistency regardless of how the model is updated (e.g., via the Django shell, admin, or background tasks).