feat(experiments): added experiments API
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ExperimentsApiConfig(AppConfig):
|
||||
name = "api.v1.experiments"
|
||||
label = "api_v1_experiments"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user