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