feat(experiments): added experiments API

This commit is contained in:
ITQ
2026-02-18 15:46:40 +03:00
parent 8994f8fb96
commit 7119240e38
6 changed files with 1134 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ExperimentsApiConfig(AppConfig):
name = "api.v1.experiments"
label = "api_v1_experiments"
+523
View File
@@ -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)
+239
View File
@@ -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 = ""
@@ -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
)