531 lines
14 KiB
Python
531 lines
14 KiB
Python
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]:
|
|
user = _get_user(request)
|
|
exp = get_object_or_404(
|
|
Experiment.objects.select_related("flag", "owner"),
|
|
pk=experiment_id,
|
|
)
|
|
v = variant_create(
|
|
experiment=exp,
|
|
user=user,
|
|
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]:
|
|
user = _get_user(request)
|
|
v = get_object_or_404(
|
|
Variant.objects.select_related(
|
|
"experiment__flag", "experiment__owner"
|
|
),
|
|
pk=variant_id,
|
|
experiment_id=experiment_id,
|
|
)
|
|
v = variant_update(
|
|
variant=v,
|
|
user=user,
|
|
**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]:
|
|
user = _get_user(request)
|
|
v = get_object_or_404(
|
|
Variant.objects.select_related("experiment__owner"),
|
|
pk=variant_id,
|
|
experiment_id=experiment_id,
|
|
)
|
|
variant_delete(variant=v, user=user)
|
|
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)
|