diff --git a/src/backend/api/v1/experiments/__init__.py b/src/backend/api/v1/experiments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/v1/experiments/apps.py b/src/backend/api/v1/experiments/apps.py new file mode 100644 index 0000000..112ec7d --- /dev/null +++ b/src/backend/api/v1/experiments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ExperimentsApiConfig(AppConfig): + name = "api.v1.experiments" + label = "api_v1_experiments" diff --git a/src/backend/api/v1/experiments/endpoints.py b/src/backend/api/v1/experiments/endpoints.py new file mode 100644 index 0000000..16adac4 --- /dev/null +++ b/src/backend/api/v1/experiments/endpoints.py @@ -0,0 +1,523 @@ +from http import HTTPStatus +from uuid import UUID + +from django.http import Http404, HttpRequest +from django.shortcuts import get_object_or_404 +from ninja import Router +from ninja.errors import AuthenticationError + +from api.v1.experiments.schemas import ( + ApprovalOut, + ApproveIn, + CompleteIn, + ExperimentCreateIn, + ExperimentListOut, + ExperimentOut, + ExperimentUpdateIn, + LogOut, + OutcomeOut, + PauseIn, + RejectIn, + RequestChangesIn, + VariantCreateIn, + VariantOut, + VariantUpdateIn, +) +from apps.experiments.models import ( + Experiment, + ExperimentOutcome, + ExperimentStatus, + Variant, +) +from apps.experiments.selectors import ( + experiment_approvals, + experiment_get, + experiment_list, + experiment_logs, + variant_list, +) +from apps.experiments.services import ( + experiment_approve, + experiment_archive, + experiment_complete, + experiment_create, + experiment_pause, + experiment_reject, + experiment_reopen, + experiment_request_changes, + experiment_resume, + experiment_start, + experiment_submit_for_review, + experiment_update, + variant_create, + variant_delete, + variant_update, +) +from apps.flags.models import FeatureFlag +from apps.users.auth.bearer import ( + jwt_bearer, + require_admin_or_approver, + require_admin_or_experimenter, +) +from apps.users.models import User + +router = Router(tags=["experiments"], auth=jwt_bearer) + + +def _get_user(request: HttpRequest) -> User: + user = getattr(request, "auth", None) + if not isinstance(user, User): + raise AuthenticationError + return user + + +@router.get( + "", + response={HTTPStatus.OK: ExperimentListOut}, + summary="List experiments", +) +def list_experiments( + request: HttpRequest, + status: ExperimentStatus | None = None, + flag_id: UUID | None = None, + search: str | None = None, + limit: int = 50, + offset: int = 0, +) -> tuple[HTTPStatus, ExperimentListOut]: + qs = experiment_list( + status=status, + flag_id=flag_id, + search=search, + ) + total = qs.count() + items = [ + ExperimentOut.from_experiment(exp) + for exp in qs[offset : offset + limit] + ] + return HTTPStatus.OK, ExperimentListOut(count=total, items=items) + + +@router.post( + "", + response={HTTPStatus.CREATED: ExperimentOut}, + summary="Create experiment", +) +@require_admin_or_experimenter +def create_experiment( + request: HttpRequest, + payload: ExperimentCreateIn, +) -> tuple[HTTPStatus, ExperimentOut]: + user = _get_user(request) + flag = get_object_or_404(FeatureFlag, pk=payload.flag_id) + exp = experiment_create( + flag=flag, + name=payload.name, + owner=user, + description=payload.description, + hypothesis=payload.hypothesis, + traffic_allocation=payload.traffic_allocation, + targeting_rules=payload.targeting_rules, + ) + exp = experiment_get(exp.pk) + return HTTPStatus.CREATED, ExperimentOut.from_experiment(exp) + + +@router.get( + "/{experiment_id}", + response={HTTPStatus.OK: ExperimentOut}, + summary="Get experiment", +) +def get_experiment( + request: HttpRequest, + experiment_id: UUID, +) -> tuple[HTTPStatus, ExperimentOut]: + exp = experiment_get(experiment_id) + if not exp: + raise Http404 + return HTTPStatus.OK, ExperimentOut.from_experiment(exp) + + +@router.patch( + "/{experiment_id}", + response={HTTPStatus.OK: ExperimentOut}, + summary="Update experiment", +) +@require_admin_or_experimenter +def update_experiment( + request: HttpRequest, + experiment_id: UUID, + payload: ExperimentUpdateIn, +) -> tuple[HTTPStatus, ExperimentOut]: + user = _get_user(request) + exp = get_object_or_404( + Experiment.objects.select_related("flag", "owner"), + pk=experiment_id, + ) + fields = payload.model_dump(exclude_none=True) + exp = experiment_update(experiment=exp, user=user, **fields) + exp = experiment_get(exp.pk) + return HTTPStatus.OK, ExperimentOut.from_experiment(exp) + + +@router.get( + "/{experiment_id}/variants", + response={HTTPStatus.OK: list[VariantOut]}, + summary="List variants", +) +def list_variants( + request: HttpRequest, + experiment_id: UUID, +) -> tuple[HTTPStatus, list[VariantOut]]: + get_object_or_404(Experiment, pk=experiment_id) + variants = variant_list(experiment_id) + return HTTPStatus.OK, [VariantOut.model_validate(v) for v in variants] + + +@router.post( + "/{experiment_id}/variants", + response={HTTPStatus.CREATED: VariantOut}, + summary="Create variant", +) +@require_admin_or_experimenter +def create_variant( + request: HttpRequest, + experiment_id: UUID, + payload: VariantCreateIn, +) -> tuple[HTTPStatus, VariantOut]: + exp = get_object_or_404( + Experiment.objects.select_related("flag"), + pk=experiment_id, + ) + v = variant_create( + experiment=exp, + name=payload.name, + value=payload.value, + weight=payload.weight, + is_control=payload.is_control, + ) + return HTTPStatus.CREATED, VariantOut.model_validate(v) + + +@router.patch( + "/{experiment_id}/variants/{variant_id}", + response={HTTPStatus.OK: VariantOut}, + summary="Update variant", +) +@require_admin_or_experimenter +def update_variant( + request: HttpRequest, + experiment_id: UUID, + variant_id: UUID, + payload: VariantUpdateIn, +) -> tuple[HTTPStatus, VariantOut]: + v = get_object_or_404( + Variant.objects.select_related("experiment__flag"), + pk=variant_id, + experiment_id=experiment_id, + ) + v = variant_update( + variant=v, + **payload.model_dump(exclude_none=True), + ) + return HTTPStatus.OK, VariantOut.model_validate(v) + + +@router.delete( + "/{experiment_id}/variants/{variant_id}", + response={HTTPStatus.NO_CONTENT: None}, + summary="Delete variant", +) +@require_admin_or_experimenter +def delete_variant( + request: HttpRequest, + experiment_id: UUID, + variant_id: UUID, +) -> tuple[HTTPStatus, None]: + v = get_object_or_404( + Variant, + pk=variant_id, + experiment_id=experiment_id, + ) + variant_delete(variant=v) + return HTTPStatus.NO_CONTENT, None + + +@router.post( + "/{experiment_id}/submit-for-review", + response={HTTPStatus.OK: ExperimentOut}, + summary="Submit experiment for review", +) +@require_admin_or_experimenter +def submit_for_review( + request: HttpRequest, + experiment_id: UUID, +) -> tuple[HTTPStatus, ExperimentOut]: + user = _get_user(request) + exp = get_object_or_404( + Experiment.objects.select_related("flag", "owner"), + pk=experiment_id, + ) + exp = experiment_submit_for_review(experiment=exp, user=user) + exp = experiment_get(exp.pk) + return HTTPStatus.OK, ExperimentOut.from_experiment(exp) + + +@router.post( + "/{experiment_id}/approve", + response={HTTPStatus.OK: ExperimentOut}, + summary="Approve experiment", +) +@require_admin_or_approver +def approve( + request: HttpRequest, + experiment_id: UUID, + payload: ApproveIn, +) -> tuple[HTTPStatus, ExperimentOut]: + user = _get_user(request) + exp = get_object_or_404( + Experiment.objects.select_related("flag", "owner"), + pk=experiment_id, + ) + exp = experiment_approve( + experiment=exp, + approver=user, + comment=payload.comment, + ) + exp = experiment_get(exp.pk) + return HTTPStatus.OK, ExperimentOut.from_experiment(exp) + + +@router.post( + "/{experiment_id}/reject", + response={HTTPStatus.OK: ExperimentOut}, + summary="Reject experiment", +) +@require_admin_or_approver +def reject( + request: HttpRequest, + experiment_id: UUID, + payload: RejectIn, +) -> tuple[HTTPStatus, ExperimentOut]: + user = _get_user(request) + exp = get_object_or_404( + Experiment.objects.select_related("flag", "owner"), + pk=experiment_id, + ) + exp = experiment_reject( + experiment=exp, + user=user, + comment=payload.comment, + ) + exp = experiment_get(exp.pk) + return HTTPStatus.OK, ExperimentOut.from_experiment(exp) + + +@router.post( + "/{experiment_id}/request-changes", + response={HTTPStatus.OK: ExperimentOut}, + summary="Request changes (return to draft)", +) +@require_admin_or_approver +def request_changes( + request: HttpRequest, + experiment_id: UUID, + payload: RequestChangesIn, +) -> tuple[HTTPStatus, ExperimentOut]: + user = _get_user(request) + exp = get_object_or_404( + Experiment.objects.select_related("flag", "owner"), + pk=experiment_id, + ) + exp = experiment_request_changes( + experiment=exp, + user=user, + comment=payload.comment, + ) + exp = experiment_get(exp.pk) + return HTTPStatus.OK, ExperimentOut.from_experiment(exp) + + +@router.post( + "/{experiment_id}/start", + response={HTTPStatus.OK: ExperimentOut}, + summary="Start experiment", +) +@require_admin_or_experimenter +def start( + request: HttpRequest, + experiment_id: UUID, +) -> tuple[HTTPStatus, ExperimentOut]: + user = _get_user(request) + exp = get_object_or_404( + Experiment.objects.select_related("flag", "owner"), + pk=experiment_id, + ) + exp = experiment_start(experiment=exp, user=user) + exp = experiment_get(exp.pk) + return HTTPStatus.OK, ExperimentOut.from_experiment(exp) + + +@router.post( + "/{experiment_id}/pause", + response={HTTPStatus.OK: ExperimentOut}, + summary="Pause experiment", +) +@require_admin_or_experimenter +def pause( + request: HttpRequest, + experiment_id: UUID, + payload: PauseIn, +) -> tuple[HTTPStatus, ExperimentOut]: + user = _get_user(request) + exp = get_object_or_404( + Experiment.objects.select_related("flag", "owner"), + pk=experiment_id, + ) + exp = experiment_pause( + experiment=exp, + user=user, + comment=payload.comment, + ) + exp = experiment_get(exp.pk) + return HTTPStatus.OK, ExperimentOut.from_experiment(exp) + + +@router.post( + "/{experiment_id}/resume", + response={HTTPStatus.OK: ExperimentOut}, + summary="Resume experiment", +) +@require_admin_or_experimenter +def resume( + request: HttpRequest, + experiment_id: UUID, +) -> tuple[HTTPStatus, ExperimentOut]: + user = _get_user(request) + exp = get_object_or_404( + Experiment.objects.select_related("flag", "owner"), + pk=experiment_id, + ) + exp = experiment_resume(experiment=exp, user=user) + exp = experiment_get(exp.pk) + return HTTPStatus.OK, ExperimentOut.from_experiment(exp) + + +@router.post( + "/{experiment_id}/complete", + response={HTTPStatus.OK: ExperimentOut}, + summary="Complete experiment with outcome", +) +@require_admin_or_experimenter +def complete( + request: HttpRequest, + experiment_id: UUID, + payload: CompleteIn, +) -> tuple[HTTPStatus, ExperimentOut]: + user = _get_user(request) + exp = get_object_or_404( + Experiment.objects.select_related("flag", "owner"), + pk=experiment_id, + ) + exp = experiment_complete( + experiment=exp, + user=user, + outcome=payload.outcome, + rationale=payload.rationale, + winning_variant_id=( + str(payload.winning_variant_id) + if payload.winning_variant_id + else None + ), + ) + exp = experiment_get(exp.pk) + return HTTPStatus.OK, ExperimentOut.from_experiment(exp) + + +@router.post( + "/{experiment_id}/archive", + response={HTTPStatus.OK: ExperimentOut}, + summary="Archive experiment", +) +@require_admin_or_experimenter +def archive( + request: HttpRequest, + experiment_id: UUID, +) -> tuple[HTTPStatus, ExperimentOut]: + user = _get_user(request) + exp = get_object_or_404( + Experiment.objects.select_related("flag", "owner"), + pk=experiment_id, + ) + exp = experiment_archive(experiment=exp, user=user) + exp = experiment_get(exp.pk) + return HTTPStatus.OK, ExperimentOut.from_experiment(exp) + + +@router.post( + "/{experiment_id}/reopen", + response={HTTPStatus.OK: ExperimentOut}, + summary="Reopen rejected experiment", +) +@require_admin_or_experimenter +def reopen( + request: HttpRequest, + experiment_id: UUID, +) -> tuple[HTTPStatus, ExperimentOut]: + user = _get_user(request) + exp = get_object_or_404( + Experiment.objects.select_related("flag", "owner"), + pk=experiment_id, + ) + exp = experiment_reopen(experiment=exp, user=user) + exp = experiment_get(exp.pk) + return HTTPStatus.OK, ExperimentOut.from_experiment(exp) + + +@router.get( + "/{experiment_id}/logs", + response={HTTPStatus.OK: list[LogOut]}, + summary="Get experiment audit logs", +) +def get_logs( + request: HttpRequest, + experiment_id: UUID, +) -> tuple[HTTPStatus, list[LogOut]]: + get_object_or_404(Experiment, pk=experiment_id) + logs = experiment_logs(experiment_id) + return HTTPStatus.OK, [LogOut.from_log(log) for log in logs] + + +@router.get( + "/{experiment_id}/approvals", + response={HTTPStatus.OK: list[ApprovalOut]}, + summary="Get experiment approvals", +) +def get_approvals( + request: HttpRequest, + experiment_id: UUID, +) -> tuple[HTTPStatus, list[ApprovalOut]]: + get_object_or_404(Experiment, pk=experiment_id) + approvals = experiment_approvals(experiment_id) + return HTTPStatus.OK, [ApprovalOut.from_approval(a) for a in approvals] + + +@router.get( + "/{experiment_id}/outcome", + response={HTTPStatus.OK: OutcomeOut}, + summary="Get experiment outcome", +) +def get_outcome( + request: HttpRequest, + experiment_id: UUID, +) -> tuple[HTTPStatus, OutcomeOut]: + exp = get_object_or_404(Experiment, pk=experiment_id) + outcome = ( + ExperimentOutcome.objects.filter( + experiment=exp, + ) + .select_related("winning_variant", "decided_by") + .first() + ) + if not outcome: + raise Http404 + return HTTPStatus.OK, OutcomeOut.from_outcome(outcome) diff --git a/src/backend/api/v1/experiments/schemas.py b/src/backend/api/v1/experiments/schemas.py new file mode 100644 index 0000000..3f8e66e --- /dev/null +++ b/src/backend/api/v1/experiments/schemas.py @@ -0,0 +1,239 @@ +from datetime import datetime +from decimal import Decimal +from typing import ClassVar +from uuid import UUID + +from ninja import Field, ModelSchema, Schema + +from apps.experiments.models import ( + Approval, + Experiment, + ExperimentLog, + ExperimentOutcome, + ExperimentStatus, + OutcomeType, + Variant, +) +from apps.flags.models import FeatureFlagType + + +class VariantOut(ModelSchema): + class Meta: + model = Variant + fields: ClassVar[tuple[str, ...]] = ( + Variant.id.field.name, + Variant.name.field.name, + Variant.value.field.name, + Variant.weight.field.name, + Variant.is_control.field.name, + Variant.created_at.field.name, + Variant.updated_at.field.name, + ) + + +class VariantCreateIn(Schema): + name: str = Field(..., max_length=100) + value: str = Field(..., max_length=500) + weight: Decimal = Field(..., ge=0, le=100) + is_control: bool = False + + +class VariantUpdateIn(Schema): + name: str | None = None + value: str | None = None + weight: Decimal | None = Field(None, ge=0, le=100) + is_control: bool | None = None + + +class FlagBriefOut(Schema): + id: UUID + key: str + name: str + value_type: FeatureFlagType + + +class OwnerBriefOut(Schema): + id: UUID + username: str + + +class ExperimentOut(Schema): + id: UUID + flag: FlagBriefOut + name: str + description: str + hypothesis: str + status: ExperimentStatus + version: int + previous_version_id: UUID | None = None + traffic_allocation: Decimal + targeting_rules: str + owner: OwnerBriefOut + variants: list[VariantOut] + created_at: datetime + updated_at: datetime + + @classmethod + def from_experiment(cls, experiment: "Experiment") -> "ExperimentOut": + return cls( + id=experiment.pk, + flag=FlagBriefOut( + id=experiment.flag.pk, + key=experiment.flag.key, + name=experiment.flag.name, + value_type=experiment.flag.value_type, + ), + name=experiment.name, + description=experiment.description, + hypothesis=experiment.hypothesis, + status=experiment.status, + version=experiment.version, + previous_version_id=(experiment.previous_version_id or None), + traffic_allocation=experiment.traffic_allocation, + targeting_rules=experiment.targeting_rules, + owner=OwnerBriefOut( + id=experiment.owner.pk, + username=experiment.owner.username, + ), + variants=[ + VariantOut.model_validate(v) for v in experiment.variants.all() + ], + created_at=experiment.created_at, + updated_at=experiment.updated_at, + ) + + +class ExperimentCreateIn(Schema): + flag_id: UUID + name: str = Field(..., max_length=200) + description: str = "" + hypothesis: str = "" + traffic_allocation: Decimal = Field( + Decimal("100.00"), ge=Decimal("0.01"), le=Decimal("100.00") + ) + targeting_rules: str = "" + + +class ExperimentUpdateIn(Schema): + name: str | None = None + description: str | None = None + hypothesis: str | None = None + traffic_allocation: Decimal | None = Field( + None, ge=Decimal("0.01"), le=Decimal("100.00") + ) + targeting_rules: str | None = None + + +class ExperimentListOut(Schema): + count: int + items: list[ExperimentOut] + + +class ApprovalOut(ModelSchema): + approver: OwnerBriefOut + + class Meta: + model = Approval + fields: ClassVar[tuple[str, ...]] = ( + Approval.id.field.name, + Approval.comment.field.name, + Approval.created_at.field.name, + ) + + @classmethod + def from_approval(cls, approval: "Approval") -> "ApprovalOut": + return cls( + id=approval.pk, + approver=OwnerBriefOut( + id=approval.approver.pk, + username=approval.approver.username, + ), + comment=approval.comment, + created_at=approval.created_at, + ) + + +class LogOut(ModelSchema): + user: OwnerBriefOut | None = None + + class Meta: + model = ExperimentLog + fields: ClassVar[tuple[str, ...]] = ( + ExperimentLog.id.field.name, + ExperimentLog.log_type.field.name, + ExperimentLog.comment.field.name, + ExperimentLog.metadata.field.name, + ExperimentLog.created_at.field.name, + ) + + @classmethod + def from_log(cls, log: "ExperimentLog") -> "LogOut": + return cls( + id=log.pk, + log_type=log.log_type, + user=( + OwnerBriefOut(id=log.user.pk, username=log.user.username) + if log.user + else None + ), + comment=log.comment, + metadata=log.metadata, + created_at=log.created_at, + ) + + +class ApproveIn(Schema): + comment: str = "" + + +class RejectIn(Schema): + comment: str = "" + + +class RequestChangesIn(Schema): + comment: str = "" + + +class CompleteIn(Schema): + outcome: OutcomeType + rationale: str + winning_variant_id: UUID | None = None + + +class OutcomeOut(ModelSchema): + winning_variant: VariantOut | None = None + decided_by: OwnerBriefOut | None = None + + class Meta: + model = ExperimentOutcome + fields: ClassVar[tuple[str, ...]] = ( + ExperimentOutcome.id.field.name, + ExperimentOutcome.outcome.field.name, + ExperimentOutcome.rationale.field.name, + ExperimentOutcome.created_at.field.name, + ) + + @classmethod + def from_outcome(cls, o: "ExperimentOutcome") -> "OutcomeOut": + return cls( + id=o.pk, + outcome=o.outcome, + rationale=o.rationale, + winning_variant=( + VariantOut.model_validate(o.winning_variant) + if o.winning_variant + else None + ), + decided_by=( + OwnerBriefOut( + id=o.decided_by.pk, username=o.decided_by.username + ) + if o.decided_by + else None + ), + created_at=o.created_at, + ) + + +class PauseIn(Schema): + comment: str = "" diff --git a/src/backend/api/v1/experiments/tests/__init__.py b/src/backend/api/v1/experiments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/v1/experiments/tests/test_experiments_api.py b/src/backend/api/v1/experiments/tests/test_experiments_api.py new file mode 100644 index 0000000..85e1b0f --- /dev/null +++ b/src/backend/api/v1/experiments/tests/test_experiments_api.py @@ -0,0 +1,366 @@ +import json +import uuid +from typing import override + +from django.test import Client, TestCase +from django.urls import reverse + +from apps.experiments.services import ( + experiment_approve, + experiment_complete, + experiment_start, + experiment_submit_for_review, +) +from apps.experiments.tests.helpers import ( + add_two_variants, + make_experiment, + make_flag, +) +from apps.reviews.services import review_settings_update +from apps.reviews.tests.helpers import ( + make_admin, + make_approver, + make_experimenter, + make_viewer, +) +from apps.users.tests.helpers import auth_header + + +class ExperimentCrudAPITest(TestCase): + @override + def setUp(self) -> None: + self.client = Client() + self.admin = make_admin("_ec") + self.experimenter = make_experimenter("_ec") + self.viewer = make_viewer("_ec") + self.admin_auth = auth_header(self.admin) + self.exp_auth = auth_header(self.experimenter) + self.viewer_auth = auth_header(self.viewer) + self.flag = make_flag(suffix="_ec") + + def _create(self, data, auth=None): + return self.client.post( + reverse("api-1:create_experiment"), + data=json.dumps(data), + content_type="application/json", + **({"HTTP_AUTHORIZATION": auth} if auth else {}), + ) + + def test_create_experiment_admin(self) -> None: + resp = self._create( + { + "flag_id": str(self.flag.pk), + "name": "Test Exp", + }, + self.admin_auth, + ) + self.assertEqual(resp.status_code, 201) + data = resp.json() + self.assertEqual(data["name"], "Test Exp") + self.assertEqual(data["status"], "draft") + + def test_create_experiment_experimenter(self) -> None: + resp = self._create( + { + "flag_id": str(self.flag.pk), + "name": "Exp Test", + }, + self.exp_auth, + ) + self.assertEqual(resp.status_code, 201) + + def test_create_experiment_viewer_denied(self) -> None: + resp = self._create( + { + "flag_id": str(self.flag.pk), + "name": "Blocked", + }, + self.viewer_auth, + ) + self.assertEqual(resp.status_code, 403) + + def test_create_experiment_unauthenticated(self) -> None: + resp = self._create( + { + "flag_id": str(self.flag.pk), + "name": "Anon", + }, + ) + self.assertEqual(resp.status_code, 401) + + def test_list_experiments(self) -> None: + make_experiment( + flag=self.flag, + owner=self.admin, + suffix="_list", + name="Listed", + ) + resp = self.client.get( + reverse("api-1:list_experiments"), + HTTP_AUTHORIZATION=self.admin_auth, + ) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertGreaterEqual(data["count"], 1) + + def test_get_experiment(self) -> None: + exp = make_experiment( + flag=self.flag, + owner=self.admin, + suffix="_get", + ) + resp = self.client.get( + reverse("api-1:get_experiment", args=[exp.pk]), + HTTP_AUTHORIZATION=self.admin_auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["id"], str(exp.pk)) + + def test_get_experiment_not_found(self) -> None: + resp = self.client.get( + reverse("api-1:get_experiment", args=[uuid.uuid4()]), + HTTP_AUTHORIZATION=self.admin_auth, + ) + self.assertEqual(resp.status_code, 404) + + def test_update_experiment(self) -> None: + exp = make_experiment( + flag=self.flag, + owner=self.admin, + suffix="_upd", + ) + resp = self.client.patch( + reverse("api-1:update_experiment", args=[exp.pk]), + data=json.dumps({"name": "Renamed"}), + content_type="application/json", + HTTP_AUTHORIZATION=self.admin_auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["name"], "Renamed") + + +class VariantCrudAPITest(TestCase): + @override + def setUp(self) -> None: + self.client = Client() + self.admin = make_admin("_vc_api") + self.admin_auth = auth_header(self.admin) + self.flag = make_flag(suffix="_vc_api") + self.exp = make_experiment( + flag=self.flag, + owner=self.admin, + suffix="_vc_api", + ) + + def test_create_variant(self) -> None: + resp = self.client.post( + reverse("api-1:create_variant", args=[self.exp.pk]), + data=json.dumps( + { + "name": "control", + "value": "a", + "weight": "50.00", + "is_control": True, + } + ), + content_type="application/json", + HTTP_AUTHORIZATION=self.admin_auth, + ) + self.assertEqual(resp.status_code, 201) + data = resp.json() + self.assertEqual(data["name"], "control") + self.assertTrue(data["is_control"]) + + def test_list_variants(self) -> None: + add_two_variants(self.exp) + resp = self.client.get( + reverse("api-1:list_variants", args=[self.exp.pk]), + HTTP_AUTHORIZATION=self.admin_auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(resp.json()), 2) + + def test_delete_variant(self) -> None: + _vc, vt = add_two_variants(self.exp) + resp = self.client.delete( + reverse( + "api-1:delete_variant", + args=[self.exp.pk, vt.pk], + ), + HTTP_AUTHORIZATION=self.admin_auth, + ) + self.assertEqual(resp.status_code, 204) + self.assertEqual(self.exp.variants.count(), 1) + + +class LifecycleAPITest(TestCase): + @override + def setUp(self) -> None: + self.client = Client() + self.experimenter = make_experimenter("_lf_api") + self.approver = make_approver("_lf_api") + self.admin = make_admin("_lf_api") + self.exp_auth = auth_header(self.experimenter) + self.appr_auth = auth_header(self.approver) + self.admin_auth = auth_header(self.admin) + self.flag = make_flag(suffix="_lf_api") + review_settings_update( + default_min_approvals=1, allow_any_approver=True + ) + self.exp = make_experiment( + flag=self.flag, + owner=self.experimenter, + suffix="_lf_api", + ) + add_two_variants(self.exp) + + def test_submit_for_review(self) -> None: + resp = self.client.post( + reverse("api-1:submit_for_review", args=[self.exp.pk]), + HTTP_AUTHORIZATION=self.exp_auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["status"], "in_review") + + def test_approve(self) -> None: + experiment_submit_for_review( + experiment=self.exp, user=self.experimenter + ) + resp = self.client.post( + reverse("api-1:approve", args=[self.exp.pk]), + data=json.dumps({"comment": "ok"}), + content_type="application/json", + HTTP_AUTHORIZATION=self.appr_auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["status"], "approved") + + def test_reject(self) -> None: + experiment_submit_for_review( + experiment=self.exp, user=self.experimenter + ) + resp = self.client.post( + reverse("api-1:reject", args=[self.exp.pk]), + data=json.dumps({"comment": "needs work"}), + content_type="application/json", + HTTP_AUTHORIZATION=self.appr_auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["status"], "rejected") + + def test_start(self) -> None: + experiment_submit_for_review( + experiment=self.exp, user=self.experimenter + ) + experiment_approve(experiment=self.exp, approver=self.approver) + self.exp.refresh_from_db() + resp = self.client.post( + reverse("api-1:start", args=[self.exp.pk]), + HTTP_AUTHORIZATION=self.exp_auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["status"], "running") + + def test_pause_and_resume(self) -> None: + experiment_submit_for_review( + experiment=self.exp, user=self.experimenter + ) + experiment_approve(experiment=self.exp, approver=self.approver) + self.exp.refresh_from_db() + experiment_start(experiment=self.exp, user=self.experimenter) + self.exp.refresh_from_db() + + resp = self.client.post( + reverse("api-1:pause", args=[self.exp.pk]), + data=json.dumps({"comment": "pausing"}), + content_type="application/json", + HTTP_AUTHORIZATION=self.exp_auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["status"], "paused") + + resp = self.client.post( + reverse("api-1:resume", args=[self.exp.pk]), + HTTP_AUTHORIZATION=self.exp_auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["status"], "running") + + def test_complete(self) -> None: + experiment_submit_for_review( + experiment=self.exp, user=self.experimenter + ) + experiment_approve(experiment=self.exp, approver=self.approver) + self.exp.refresh_from_db() + experiment_start(experiment=self.exp, user=self.experimenter) + self.exp.refresh_from_db() + vt = self.exp.variants.filter(is_control=False).first() + + resp = self.client.post( + reverse("api-1:complete", args=[self.exp.pk]), + data=json.dumps( + { + "outcome": "rollout", + "rationale": "Treatment won", + "winning_variant_id": str(vt.pk), + } + ), + content_type="application/json", + HTTP_AUTHORIZATION=self.exp_auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["status"], "completed") + + def test_archive(self) -> None: + experiment_submit_for_review( + experiment=self.exp, user=self.experimenter + ) + experiment_approve(experiment=self.exp, approver=self.approver) + self.exp.refresh_from_db() + experiment_start(experiment=self.exp, user=self.experimenter) + self.exp.refresh_from_db() + + experiment_complete( + experiment=self.exp, + user=self.experimenter, + outcome="rollback", + rationale="done", + ) + self.exp.refresh_from_db() + + resp = self.client.post( + reverse("api-1:archive", args=[self.exp.pk]), + HTTP_AUTHORIZATION=self.exp_auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["status"], "archived") + + def test_get_logs(self) -> None: + experiment_submit_for_review( + experiment=self.exp, user=self.experimenter + ) + resp = self.client.get( + reverse("api-1:get_logs", args=[self.exp.pk]), + HTTP_AUTHORIZATION=self.exp_auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertGreaterEqual(len(resp.json()), 1) + + def test_get_approvals(self) -> None: + experiment_submit_for_review( + experiment=self.exp, user=self.experimenter + ) + experiment_approve( + experiment=self.exp, + approver=self.approver, + ) + resp = self.client.get( + reverse("api-1:get_approvals", args=[self.exp.pk]), + HTTP_AUTHORIZATION=self.exp_auth, + ) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["approver"]["username"], self.approver.username + )