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
+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)