diff --git a/api/audit/related_object_type.py b/api/audit/related_object_type.py index d43c849e574b..853011eb38f4 100644 --- a/api/audit/related_object_type.py +++ b/api/audit/related_object_type.py @@ -13,3 +13,4 @@ class RelatedObjectType(enum.Enum): FEATURE_HEALTH = "Feature health status" RELEASE_PIPELINE = "Release pipeline" WAREHOUSE_CONNECTION = "Warehouse connection" + EXPERIMENT = "Experiment" diff --git a/api/environments/urls.py b/api/environments/urls.py index 2642af9242a5..348594936a45 100644 --- a/api/environments/urls.py +++ b/api/environments/urls.py @@ -177,4 +177,8 @@ "/warehouse-connections/", include("experimentation.urls"), ), + path( + "/experiments/", + include("experimentation.experiment_urls"), + ), ] diff --git a/api/experimentation/constants.py b/api/experimentation/constants.py index a1e79a0fca8e..94f9870f881d 100644 --- a/api/experimentation/constants.py +++ b/api/experimentation/constants.py @@ -1 +1,2 @@ WAREHOUSE_CONNECTION_FLAG = "experimentation_warehouse_connection" +EXPERIMENT_FLAG = "experimental_flags" diff --git a/api/experimentation/experiment_urls.py b/api/experimentation/experiment_urls.py new file mode 100644 index 000000000000..0022909c55dd --- /dev/null +++ b/api/experimentation/experiment_urls.py @@ -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 diff --git a/api/experimentation/migrations/0004_experiment.py b/api/experimentation/migrations/0004_experiment.py new file mode 100644 index 000000000000..f87c9016f289 --- /dev/null +++ b/api/experimentation/migrations/0004_experiment.py @@ -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), + ), + ] diff --git a/api/experimentation/models.py b/api/experimentation/models.py index 043abf5ab2be..c2c1f9712b1e 100644 --- a/api/experimentation/models.py +++ b/api/experimentation/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models import Q from django_lifecycle import ( # type: ignore[import-untyped] AFTER_CREATE, AFTER_DELETE, @@ -68,3 +69,51 @@ def sync_to_ingestion_on_delete(self) -> None: delete_environment_key_from_ingestion.delay( kwargs={"environment_api_key": self.environment.api_key}, ) + + +class ExperimentStatus(models.TextChoices): + CREATED = "created", "Created" + RUNNING = "running", "Running" + PAUSED = "paused", "Paused" + COMPLETED = "completed", "Completed" + + +VALID_STATUS_TRANSITIONS: dict[str, set[str]] = { + ExperimentStatus.CREATED: {ExperimentStatus.RUNNING}, + ExperimentStatus.RUNNING: {ExperimentStatus.PAUSED, ExperimentStatus.COMPLETED}, + ExperimentStatus.PAUSED: {ExperimentStatus.RUNNING, ExperimentStatus.COMPLETED}, + ExperimentStatus.COMPLETED: set(), +} + + +class Experiment(LifecycleModelMixin, SoftDeleteExportableModel): # type: ignore[misc] + environment = models.ForeignKey( + Environment, + on_delete=models.CASCADE, + related_name="experiments", + ) + feature = models.ForeignKey( + "features.Feature", + on_delete=models.CASCADE, + related_name="experiments", + ) + name = models.CharField(max_length=255) + hypothesis = models.TextField() + status = models.CharField( + max_length=50, + choices=ExperimentStatus.choices, + default=ExperimentStatus.CREATED, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + started_at = models.DateTimeField(null=True, blank=True) + ended_at = models.DateTimeField(null=True, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["feature", "environment"], + condition=Q(deleted_at__isnull=True) & ~Q(status="completed"), + name="unique_active_experiment_per_feature_env", + ), + ] diff --git a/api/experimentation/permissions.py b/api/experimentation/permissions.py index 83e1463d1a5c..e1fdc8035101 100644 --- a/api/experimentation/permissions.py +++ b/api/experimentation/permissions.py @@ -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") + ) + 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) diff --git a/api/experimentation/serializers.py b/api/experimentation/serializers.py index bfece9cc5242..2380b8c33871 100644 --- a/api/experimentation/serializers.py +++ b/api/experimentation/serializers.py @@ -4,11 +4,13 @@ from environments.models import Environment from experimentation.models import ( + Experiment, WarehouseConnection, WarehouseConnectionStatus, WarehouseType, ) from experimentation.types import SNOWFLAKE_DEFAULTS, SnowflakeConfig +from features.feature_types import MULTIVARIATE class WarehouseConnectionSerializer(serializers.ModelSerializer): # type: ignore[type-arg] @@ -88,3 +90,46 @@ def _validate_snowflake_config(config: dict[str, Any]) -> SnowflakeConfig: **config, # type: ignore[typeddict-item] } return merged + + +class ExperimentSerializer(serializers.ModelSerializer): # type: ignore[type-arg] + class Meta: + model = Experiment + fields = ( + "id", + "feature", + "name", + "hypothesis", + "status", + "created_at", + "updated_at", + "started_at", + "ended_at", + ) + read_only_fields = ( + "id", + "status", + "created_at", + "updated_at", + "started_at", + "ended_at", + ) + + def validate_feature(self, feature: Any) -> Any: + if feature.type != MULTIVARIATE: + raise serializers.ValidationError( + "Experiments can only be created for multivariate flags." + ) + environment: Environment | None = self.context.get("environment") + if environment and feature.project_id != environment.project_id: + raise serializers.ValidationError( + "Feature does not belong to this environment's project." + ) + return feature + + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + if self.instance is not None and "feature" in attrs: + raise serializers.ValidationError( + {"feature": "Cannot change the feature of an existing experiment."} + ) + return attrs diff --git a/api/experimentation/services.py b/api/experimentation/services.py index fc44b501db1e..13b5e312a59b 100644 --- a/api/experimentation/services.py +++ b/api/experimentation/services.py @@ -2,13 +2,15 @@ import typing +from django.utils import timezone + from audit.models import AuditLog from audit.related_object_type import RelatedObjectType -from experimentation.constants import WAREHOUSE_CONNECTION_FLAG +from experimentation.constants import EXPERIMENT_FLAG, WAREHOUSE_CONNECTION_FLAG from integrations.flagsmith.client import get_openfeature_client if typing.TYPE_CHECKING: - from experimentation.models import WarehouseConnection + from experimentation.models import Experiment, WarehouseConnection from organisations.models import Organisation from users.models import FFAdminUser @@ -21,6 +23,22 @@ def is_warehouse_feature_enabled(organisation: Organisation) -> bool: ) +def is_experiment_feature_enabled(organisation: Organisation) -> bool: + return get_openfeature_client().get_boolean_value( + EXPERIMENT_FLAG, + default_value=False, + evaluation_context=organisation.openfeature_evaluation_context, + ) + + +def _resolve_audit_log_author( + user: FFAdminUser, +) -> dict[str, int | None]: + if getattr(user, "is_master_api_key_user", False): + return {"author_id": None, "master_api_key_id": user.key.id} + return {"author_id": user.pk, "master_api_key_id": None} + + def create_warehouse_audit_log( connection: WarehouseConnection, user: FFAdminUser, @@ -30,7 +48,7 @@ def create_warehouse_audit_log( AuditLog.objects.create( environment=connection.environment, project=connection.environment.project, - author=user, + **_resolve_audit_log_author(user), related_object_id=connection.id, related_object_type=RelatedObjectType.WAREHOUSE_CONNECTION.name, log=( @@ -38,3 +56,47 @@ def create_warehouse_audit_log( f"{connection.environment.name}" ), ) + + +def create_experiment_audit_log( + experiment: Experiment, + user: FFAdminUser, + *, + action: str, +) -> None: + AuditLog.objects.create( + environment=experiment.environment, + project=experiment.environment.project, + **_resolve_audit_log_author(user), + related_object_id=experiment.id, + related_object_type=RelatedObjectType.EXPERIMENT.name, + log=( + f"Experiment '{experiment.name}' {action} for environment " + f"{experiment.environment.name}" + ), + ) + + +def transition_experiment_status( + experiment: Experiment, + target_status: str, + user: FFAdminUser, +) -> Experiment: + from experimentation.models import VALID_STATUS_TRANSITIONS, ExperimentStatus + + valid_targets = VALID_STATUS_TRANSITIONS.get(experiment.status, set()) + if target_status not in valid_targets: + raise ValueError( + f"Cannot transition from '{experiment.status}' to '{target_status}'." + ) + + experiment.status = target_status + + if target_status == ExperimentStatus.RUNNING and not experiment.started_at: + experiment.started_at = timezone.now() + elif target_status == ExperimentStatus.COMPLETED: + experiment.ended_at = timezone.now() + + experiment.save() + create_experiment_audit_log(experiment, user, action=target_status) + return experiment diff --git a/api/experimentation/views.py b/api/experimentation/views.py index 5f2968c25b02..ef5718b0a6b0 100644 --- a/api/experimentation/views.py +++ b/api/experimentation/views.py @@ -1,16 +1,37 @@ -from rest_framework import mixins +import logging +from typing import Any + +from django.db.models import QuerySet +from rest_framework import mixins, status +from rest_framework.decorators import action 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 experimentation.models import ( + Experiment, + ExperimentStatus, + WarehouseConnection, +) +from experimentation.permissions import ( + ExperimentPermission, + WarehouseConnectionPermission, +) +from experimentation.serializers import ( + ExperimentSerializer, + WarehouseConnectionSerializer, +) +from experimentation.services import ( + create_experiment_audit_log, + create_warehouse_audit_log, + transition_experiment_status, +) from users.models import FFAdminUser +logger = logging.getLogger(__name__) + class WarehouseConnectionViewSet( NestedEnvironmentViewSet[WarehouseConnection], @@ -59,11 +80,116 @@ def create(self, request: Request, *args: object, **kwargs: object) -> Response: { "detail": "This environment already has an active warehouse connection." }, - status=409, + status=status.HTTP_409_CONFLICT, ) self.perform_create(serializer) - return Response(serializer.data, status=201) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @staticmethod + def _get_user(request: Request) -> FFAdminUser: + return request.user # type: ignore[return-value] + + +class ExperimentViewSet( + NestedEnvironmentViewSet[Experiment], + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, +): + serializer_class = ExperimentSerializer + pagination_class = None + permission_classes = [IsAuthenticated, ExperimentPermission] + model_class = Experiment + lookup_field = "id" + lookup_url_kwarg = "experiment_id" + + def get_serializer_context(self) -> dict[str, Any]: + context = super().get_serializer_context() + context["environment"] = self._get_environment() + return context + + def get_queryset(self) -> "QuerySet[Experiment]": + qs = super().get_queryset() + status_filter = self.request.query_params.get("status") + if status_filter: + qs = qs.filter(status=status_filter) + return qs + + def create(self, request: Request, *args: object, **kwargs: object) -> Response: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + feature = serializer.validated_data["feature"] + environment = self._get_environment() + if ( + Experiment.objects.filter( + feature=feature, + environment=environment, + ) + .exclude(status=ExperimentStatus.COMPLETED) + .exists() + ): + return Response( + {"detail": "An active experiment already exists for this feature."}, + status=status.HTTP_409_CONFLICT, + ) + + self.perform_create(serializer) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def perform_create(self, serializer: BaseSerializer[Experiment]) -> None: + experiment: Experiment = serializer.save(environment=self._get_environment()) + create_experiment_audit_log( + experiment, self._get_user(self.request), action="created" + ) + + def perform_update(self, serializer: BaseSerializer[Experiment]) -> None: + experiment: Experiment = serializer.save() + create_experiment_audit_log( + experiment, self._get_user(self.request), action="updated" + ) + + def perform_destroy(self, instance: Experiment) -> None: + create_experiment_audit_log( + instance, self._get_user(self.request), action="deleted" + ) + instance.delete() + + @action(detail=True, methods=["post"]) + def start(self, request: Request, **kwargs: object) -> Response: + return self._transition_status(ExperimentStatus.RUNNING) + + @action(detail=True, methods=["post"]) + def pause(self, request: Request, **kwargs: object) -> Response: + return self._transition_status(ExperimentStatus.PAUSED) + + @action(detail=True, methods=["post"]) + def complete(self, request: Request, **kwargs: object) -> Response: + return self._transition_status(ExperimentStatus.COMPLETED) + + def _transition_status(self, target_status: str) -> Response: + experiment: Experiment = self.get_object() + try: + experiment = transition_experiment_status( + experiment, target_status, self._get_user(self.request) + ) + except ValueError: + logger.warning( + "Invalid experiment status transition for " + "experiment_id=%s to status=%s", + experiment.id, + target_status, + exc_info=True, + ) + return Response( + {"detail": "Unable to transition experiment status."}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer = self.get_serializer(experiment) + return Response(serializer.data) @staticmethod def _get_user(request: Request) -> FFAdminUser: diff --git a/api/tests/unit/experimentation/conftest.py b/api/tests/unit/experimentation/conftest.py index e42b4882456b..fbc21d8f6974 100644 --- a/api/tests/unit/experimentation/conftest.py +++ b/api/tests/unit/experimentation/conftest.py @@ -4,7 +4,13 @@ from environments.models import Environment from experimentation import ingestion_sync_service -from experimentation.models import WarehouseConnection, WarehouseType +from experimentation.models import ( + Experiment, + ExperimentStatus, + WarehouseConnection, + WarehouseType, +) +from features.models import Feature @pytest.fixture(autouse=True) @@ -29,3 +35,18 @@ def warehouse_connection_url(environment: Environment) -> str: "api-v1:environments:experimentation:warehouse-connections-list", args=[environment.api_key], ) + + +@pytest.fixture() +def experiment( + environment: Environment, + multivariate_feature: Feature, +) -> Experiment: + experiment: Experiment = Experiment.objects.create( + environment=environment, + feature=multivariate_feature, + name="Test Experiment", + hypothesis="Test hypothesis", + status=ExperimentStatus.CREATED, + ) + return experiment diff --git a/api/tests/unit/experimentation/test_experiment_views.py b/api/tests/unit/experimentation/test_experiment_views.py new file mode 100644 index 000000000000..4dbc47684be7 --- /dev/null +++ b/api/tests/unit/experimentation/test_experiment_views.py @@ -0,0 +1,569 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from audit.models import AuditLog +from audit.related_object_type import RelatedObjectType +from environments.models import Environment +from experimentation.constants import EXPERIMENT_FLAG +from experimentation.models import Experiment, ExperimentStatus +from features.feature_types import MULTIVARIATE +from features.models import Feature +from tests.types import EnableFeaturesFixture + +if TYPE_CHECKING: + from projects.models import Project + +pytestmark = pytest.mark.django_db + + +def _list_url(environment: Environment) -> str: + return reverse( + "api-v1:environments:experiments:experiments-list", + args=[environment.api_key], + ) + + +def _detail_url(environment: Environment, experiment: Experiment) -> str: + return reverse( + "api-v1:environments:experiments:experiments-detail", + args=[environment.api_key, experiment.id], + ) + + +def _action_url(environment: Environment, experiment: Experiment, action: str) -> str: + return reverse( + f"api-v1:environments:experiments:experiments-{action}", + args=[environment.api_key, experiment.id], + ) + + +def test_post__valid_multivariate_feature__returns_201( + admin_client_new: APIClient, + environment: Environment, + multivariate_feature: Feature, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + response = admin_client_new.post( + _list_url(environment), + data={ + "feature": multivariate_feature.id, + "name": "My experiment", + "hypothesis": "It will work", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["feature"] == multivariate_feature.id + assert data["name"] == "My experiment" + assert data["status"] == "created" + assert data["started_at"] is None + assert data["ended_at"] is None + + +def test_post__non_multivariate_feature__returns_400( + admin_client_new: APIClient, + environment: Environment, + feature: Feature, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + response = admin_client_new.post( + _list_url(environment), + data={ + "feature": feature.id, + "name": "Bad experiment", + "hypothesis": "Nope", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_post__feature_from_different_project__returns_400( + admin_client_new: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, + organisation_one_project_one: Project, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + other_feature = Feature.objects.create( + project=organisation_one_project_one, + name="other_mv_feature", + type=MULTIVARIATE, + initial_value="control", + ) + + # When + response = admin_client_new.post( + _list_url(environment), + data={ + "feature": other_feature.id, + "name": "Wrong project", + "hypothesis": "Nope", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_post__active_experiment_exists__returns_409( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + multivariate_feature: Feature, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + response = admin_client_new.post( + _list_url(environment), + data={ + "feature": multivariate_feature.id, + "name": "Duplicate", + "hypothesis": "Should fail", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_409_CONFLICT + + +def test_post__completed_experiment_exists__returns_201( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + multivariate_feature: Feature, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + experiment.status = ExperimentStatus.COMPLETED + experiment.save() + + # When + response = admin_client_new.post( + _list_url(environment), + data={ + "feature": multivariate_feature.id, + "name": "New experiment", + "hypothesis": "Should work", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_201_CREATED + + +def test_post__staff_user_with_flag__returns_403( + staff_client: APIClient, + environment: Environment, + multivariate_feature: Feature, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + response = staff_client.post( + _list_url(environment), + data={ + "feature": multivariate_feature.id, + "name": "No access", + "hypothesis": "Nope", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_post__admin_without_flag__returns_403( + admin_client_new: APIClient, + environment: Environment, + multivariate_feature: Feature, +) -> None: + # Given — feature flag not enabled + + # When + response = admin_client_new.post( + _list_url(environment), + data={ + "feature": multivariate_feature.id, + "name": "No access", + "hypothesis": "Nope", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_post__nonexistent_environment__returns_403( + admin_client_new: APIClient, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + url = reverse( + "api-v1:environments:experiments:experiments-list", + args=["bad_api_key"], + ) + + # When + response = admin_client_new.post( + url, + data={ + "feature": 999, + "name": "Bad env", + "hypothesis": "Nope", + }, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_get_list__with_experiments__returns_all( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + response = admin_client_new.get(_list_url(environment)) + + # Then + assert response.status_code == status.HTTP_200_OK + assert len(response.json()) == 1 + assert response.json()[0]["id"] == experiment.id + + +def test_get_list__empty__returns_200( + admin_client_new: APIClient, + environment: Environment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + response = admin_client_new.get(_list_url(environment)) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json() == [] + + +@pytest.mark.parametrize( + "filter_status, expected_count", + [ + ("created", 1), + ("running", 0), + ("paused", 0), + ("completed", 0), + ], +) +def test_get_list__filter_by_status__returns_filtered( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + enable_features: EnableFeaturesFixture, + filter_status: str, + expected_count: int, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + response = admin_client_new.get(_list_url(environment), {"status": filter_status}) + + # Then + assert response.status_code == status.HTTP_200_OK + assert len(response.json()) == expected_count + + +def test_get_detail__exists__returns_200( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + response = admin_client_new.get(_detail_url(environment, experiment)) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["id"] == experiment.id + + +@pytest.mark.parametrize( + "field, value", + [ + ("name", "Updated name"), + ("hypothesis", "Updated hypothesis"), + ], +) +def test_patch__update_field__returns_200( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + enable_features: EnableFeaturesFixture, + field: str, + value: str, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + response = admin_client_new.patch( + _detail_url(environment, experiment), + data={field: value}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()[field] == value + + +def test_patch__change_feature__returns_400( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + project: Project, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + other_feature = Feature.objects.create( + project=project, + name="other_mv_feature", + type=MULTIVARIATE, + initial_value="control", + ) + + # When + response = admin_client_new.patch( + _detail_url(environment, experiment), + data={"feature": other_feature.id}, + format="json", + ) + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.parametrize( + "from_status, action_name, expected_status_code", + [ + (ExperimentStatus.CREATED, "start", 200), + (ExperimentStatus.RUNNING, "pause", 200), + (ExperimentStatus.RUNNING, "complete", 200), + (ExperimentStatus.PAUSED, "start", 200), + (ExperimentStatus.PAUSED, "complete", 200), + (ExperimentStatus.CREATED, "pause", 400), + (ExperimentStatus.CREATED, "complete", 400), + (ExperimentStatus.COMPLETED, "start", 400), + (ExperimentStatus.COMPLETED, "pause", 400), + (ExperimentStatus.RUNNING, "start", 400), + ], +) +def test_action__status_transition__returns_expected( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + enable_features: EnableFeaturesFixture, + from_status: str, + action_name: str, + expected_status_code: int, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + experiment.status = from_status + experiment.save() + + # When + response = admin_client_new.post(_action_url(environment, experiment, action_name)) + + # Then + assert response.status_code == expected_status_code + + +def test_action__start__sets_started_at( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + response = admin_client_new.post(_action_url(environment, experiment, "start")) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["started_at"] is not None + + +def test_action__complete__sets_ended_at( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + experiment.status = ExperimentStatus.RUNNING + experiment.save() + + # When + response = admin_client_new.post(_action_url(environment, experiment, "complete")) + + # Then + assert response.status_code == status.HTTP_200_OK + assert response.json()["ended_at"] is not None + + +def test_delete__exists__returns_204_and_soft_deletes( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + response = admin_client_new.delete(_detail_url(environment, experiment)) + + # Then + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Experiment.objects.filter(id=experiment.id).exists() + assert Experiment.objects.all_with_deleted().filter(id=experiment.id).exists() + + +def test_post__valid_create__creates_audit_log( + admin_client_new: APIClient, + environment: Environment, + multivariate_feature: Feature, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + admin_client_new.post( + _list_url(environment), + data={ + "feature": multivariate_feature.id, + "name": "Audit test", + "hypothesis": "Check audit", + }, + format="json", + ) + + # Then + audit = AuditLog.objects.filter( + related_object_type=RelatedObjectType.EXPERIMENT.name + ).last() + assert audit is not None + assert "created" in audit.log + + +def test_patch__valid_update__creates_audit_log( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + admin_client_new.patch( + _detail_url(environment, experiment), + data={"name": "Renamed"}, + format="json", + ) + + # Then + audit = AuditLog.objects.filter( + related_object_type=RelatedObjectType.EXPERIMENT.name + ).last() + assert audit is not None + assert "updated" in audit.log + + +def test_action__start__creates_audit_log( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + admin_client_new.post(_action_url(environment, experiment, "start")) + + # Then + audit = AuditLog.objects.filter( + related_object_type=RelatedObjectType.EXPERIMENT.name + ).last() + assert audit is not None + assert "running" in audit.log + + +def test_delete__valid_delete__creates_audit_log( + admin_client_new: APIClient, + environment: Environment, + experiment: Experiment, + enable_features: EnableFeaturesFixture, +) -> None: + # Given + enable_features(EXPERIMENT_FLAG) + + # When + admin_client_new.delete(_detail_url(environment, experiment)) + + # Then + audit = AuditLog.objects.filter( + related_object_type=RelatedObjectType.EXPERIMENT.name + ).last() + assert audit is not None + assert "deleted" in audit.log