feat(metrics): added metrics API
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsApiConfig(AppConfig):
|
||||||
|
name = "api.v1.metrics"
|
||||||
|
label = "api_v1_metrics"
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from http import HTTPStatus
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from django.http import Http404, HttpRequest
|
||||||
|
from ninja import Router
|
||||||
|
|
||||||
|
from api.v1.metrics.schemas import (
|
||||||
|
ExperimentMetricAddIn,
|
||||||
|
ExperimentMetricOut,
|
||||||
|
MetricDefinitionCreateIn,
|
||||||
|
MetricDefinitionOut,
|
||||||
|
MetricDefinitionUpdateIn,
|
||||||
|
)
|
||||||
|
from apps.experiments.models import Experiment
|
||||||
|
from apps.metrics.services import (
|
||||||
|
experiment_metric_add,
|
||||||
|
experiment_metric_list,
|
||||||
|
experiment_metric_remove,
|
||||||
|
metric_definition_create,
|
||||||
|
metric_definition_get,
|
||||||
|
metric_definition_list,
|
||||||
|
metric_definition_update,
|
||||||
|
)
|
||||||
|
from apps.users.auth.bearer import jwt_bearer
|
||||||
|
|
||||||
|
router = Router(tags=["metrics"], auth=jwt_bearer)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/metrics",
|
||||||
|
response={HTTPStatus.CREATED: MetricDefinitionOut},
|
||||||
|
summary="Create a metric definition",
|
||||||
|
)
|
||||||
|
def create_metric(
|
||||||
|
request: HttpRequest,
|
||||||
|
payload: MetricDefinitionCreateIn,
|
||||||
|
) -> tuple[int, MetricDefinitionOut]:
|
||||||
|
metric = metric_definition_create(
|
||||||
|
key=payload.key,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
metric_type=payload.metric_type,
|
||||||
|
direction=payload.direction,
|
||||||
|
calculation_rule=payload.calculation_rule,
|
||||||
|
)
|
||||||
|
return HTTPStatus.CREATED, MetricDefinitionOut.model_validate(metric)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/metrics",
|
||||||
|
response={HTTPStatus.OK: list[MetricDefinitionOut]},
|
||||||
|
summary="List metric definitions",
|
||||||
|
)
|
||||||
|
def list_metrics(
|
||||||
|
request: HttpRequest,
|
||||||
|
is_active: bool | None = None, # noqa: FBT001
|
||||||
|
) -> tuple[int, list[MetricDefinitionOut]]:
|
||||||
|
qs = metric_definition_list(is_active=is_active)
|
||||||
|
return HTTPStatus.OK, [
|
||||||
|
MetricDefinitionOut.model_validate(metric) for metric in qs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/metrics/{metric_id}",
|
||||||
|
response={HTTPStatus.OK: MetricDefinitionOut},
|
||||||
|
summary="Get metric definition",
|
||||||
|
)
|
||||||
|
def get_metric(
|
||||||
|
request: HttpRequest,
|
||||||
|
metric_id: UUID,
|
||||||
|
) -> tuple[int, MetricDefinitionOut]:
|
||||||
|
metric = metric_definition_get(metric_id)
|
||||||
|
if not metric:
|
||||||
|
raise Http404
|
||||||
|
return HTTPStatus.OK, MetricDefinitionOut.model_validate(metric)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/metrics/{metric_id}",
|
||||||
|
response={HTTPStatus.OK: MetricDefinitionOut},
|
||||||
|
summary="Update a metric definition",
|
||||||
|
)
|
||||||
|
def update_metric(
|
||||||
|
request: HttpRequest,
|
||||||
|
metric_id: UUID,
|
||||||
|
payload: MetricDefinitionUpdateIn,
|
||||||
|
) -> tuple[int, MetricDefinitionOut]:
|
||||||
|
metric = metric_definition_get(metric_id)
|
||||||
|
if not metric:
|
||||||
|
raise Http404
|
||||||
|
metric = metric_definition_update(
|
||||||
|
metric=metric,
|
||||||
|
**payload.dict(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, MetricDefinitionOut.model_validate(metric)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/experiments/{experiment_id}/metrics",
|
||||||
|
response={HTTPStatus.OK: list[ExperimentMetricOut]},
|
||||||
|
summary="List experiment metrics",
|
||||||
|
)
|
||||||
|
def list_experiment_metrics(
|
||||||
|
request: HttpRequest,
|
||||||
|
experiment_id: UUID,
|
||||||
|
) -> tuple[int, list[ExperimentMetricOut]]:
|
||||||
|
try:
|
||||||
|
experiment = Experiment.objects.get(pk=experiment_id)
|
||||||
|
except Experiment.DoesNotExist:
|
||||||
|
raise Http404 from Experiment.DoesNotExist
|
||||||
|
ems = experiment_metric_list(experiment)
|
||||||
|
return HTTPStatus.OK, [
|
||||||
|
ExperimentMetricOut.from_experiment_metric(em) for em in ems
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/experiments/{experiment_id}/metrics",
|
||||||
|
response={HTTPStatus.CREATED: ExperimentMetricOut},
|
||||||
|
summary="Add metric to experiment",
|
||||||
|
)
|
||||||
|
def add_experiment_metric(
|
||||||
|
request: HttpRequest,
|
||||||
|
experiment_id: UUID,
|
||||||
|
payload: ExperimentMetricAddIn,
|
||||||
|
) -> tuple[int, ExperimentMetricOut]:
|
||||||
|
try:
|
||||||
|
experiment = Experiment.objects.get(pk=experiment_id)
|
||||||
|
except Experiment.DoesNotExist:
|
||||||
|
raise Http404 from Experiment.DoesNotExist
|
||||||
|
metric = metric_definition_get(payload.metric_id)
|
||||||
|
if not metric:
|
||||||
|
raise Http404
|
||||||
|
em = experiment_metric_add(
|
||||||
|
experiment=experiment,
|
||||||
|
metric=metric,
|
||||||
|
is_primary=payload.is_primary,
|
||||||
|
)
|
||||||
|
em = experiment.experiment_metrics.select_related("metric").get(pk=em.pk)
|
||||||
|
return HTTPStatus.CREATED, ExperimentMetricOut.from_experiment_metric(em)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/experiments/{experiment_id}/metrics/{metric_id}",
|
||||||
|
response={HTTPStatus.NO_CONTENT: None},
|
||||||
|
summary="Remove metric from experiment",
|
||||||
|
)
|
||||||
|
def remove_experiment_metric(
|
||||||
|
request: HttpRequest,
|
||||||
|
experiment_id: UUID,
|
||||||
|
metric_id: UUID,
|
||||||
|
) -> tuple[int, None]:
|
||||||
|
try:
|
||||||
|
experiment = Experiment.objects.get(pk=experiment_id)
|
||||||
|
except Experiment.DoesNotExist:
|
||||||
|
raise Http404 from Experiment.DoesNotExist
|
||||||
|
metric = metric_definition_get(metric_id)
|
||||||
|
if not metric:
|
||||||
|
raise Http404
|
||||||
|
experiment_metric_remove(experiment=experiment, metric=metric)
|
||||||
|
return HTTPStatus.NO_CONTENT, None
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from ninja import Field, ModelSchema, Schema
|
||||||
|
|
||||||
|
from apps.metrics.models import (
|
||||||
|
ExperimentMetric,
|
||||||
|
MetricDefinition,
|
||||||
|
MetricDirection,
|
||||||
|
MetricType,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MetricDefinitionCreateIn(Schema):
|
||||||
|
key: str = Field(min_length=1, max_length=100)
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
description: str = ""
|
||||||
|
metric_type: MetricType
|
||||||
|
direction: MetricDirection = MetricDirection.NEUTRAL
|
||||||
|
calculation_rule: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class MetricDefinitionUpdateIn(Schema):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
direction: MetricDirection | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MetricDefinitionOut(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = MetricDefinition
|
||||||
|
fields: ClassVar[tuple[str, ...]] = (
|
||||||
|
MetricDefinition.id.field.name,
|
||||||
|
MetricDefinition.key.field.name,
|
||||||
|
MetricDefinition.name.field.name,
|
||||||
|
MetricDefinition.description.field.name,
|
||||||
|
MetricDefinition.metric_type.field.name,
|
||||||
|
MetricDefinition.direction.field.name,
|
||||||
|
MetricDefinition.calculation_rule.field.name,
|
||||||
|
MetricDefinition.is_active.field.name,
|
||||||
|
MetricDefinition.created_at.field.name,
|
||||||
|
MetricDefinition.updated_at.field.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentMetricAddIn(Schema):
|
||||||
|
metric_id: UUID
|
||||||
|
is_primary: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentMetricOut(Schema):
|
||||||
|
id: UUID
|
||||||
|
metric: MetricDefinitionOut
|
||||||
|
is_primary: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_experiment_metric(
|
||||||
|
cls,
|
||||||
|
em: ExperimentMetric,
|
||||||
|
) -> "ExperimentMetricOut":
|
||||||
|
return cls(
|
||||||
|
id=em.pk,
|
||||||
|
metric=MetricDefinitionOut.model_validate(em.metric),
|
||||||
|
is_primary=em.is_primary,
|
||||||
|
created_at=em.created_at,
|
||||||
|
)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import json
|
||||||
|
from typing import override
|
||||||
|
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.experiments.tests.helpers import make_experiment
|
||||||
|
from apps.metrics.models import MetricType
|
||||||
|
from apps.metrics.services import experiment_metric_add, metric_definition_create
|
||||||
|
from apps.reviews.tests.helpers import make_admin
|
||||||
|
from apps.users.tests.helpers import auth_header
|
||||||
|
|
||||||
|
|
||||||
|
class MetricDefinitionAPITest(TestCase):
|
||||||
|
@override
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.client = Client()
|
||||||
|
self.admin = make_admin("_md")
|
||||||
|
self.auth = auth_header(self.admin)
|
||||||
|
|
||||||
|
def _create_metric(self, data):
|
||||||
|
return self.client.post(
|
||||||
|
reverse("api-1:create_metric"),
|
||||||
|
data=json.dumps(data),
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_AUTHORIZATION=self.auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_metric(self) -> None:
|
||||||
|
resp = self._create_metric(
|
||||||
|
{
|
||||||
|
"key": "api_click_rate",
|
||||||
|
"name": "Click Rate",
|
||||||
|
"metric_type": "ratio",
|
||||||
|
"direction": "higher_is_better",
|
||||||
|
"calculation_rule": {
|
||||||
|
"type": "ratio",
|
||||||
|
"numerator_event": "click",
|
||||||
|
"denominator_event": "exposure",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 201)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertEqual(data["key"], "api_click_rate")
|
||||||
|
self.assertEqual(data["metric_type"], "ratio")
|
||||||
|
|
||||||
|
def test_list_metrics(self) -> None:
|
||||||
|
self._create_metric(
|
||||||
|
{
|
||||||
|
"key": "api_list_m",
|
||||||
|
"name": "List Metric",
|
||||||
|
"metric_type": "count",
|
||||||
|
"calculation_rule": {"type": "count", "event": "click"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resp = self.client.get(
|
||||||
|
reverse("api-1:list_metrics"),
|
||||||
|
HTTP_AUTHORIZATION=self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertGreaterEqual(len(resp.json()), 1)
|
||||||
|
|
||||||
|
def test_get_metric(self) -> None:
|
||||||
|
create_resp = self._create_metric(
|
||||||
|
{
|
||||||
|
"key": "api_get_m",
|
||||||
|
"name": "Get Metric",
|
||||||
|
"metric_type": "count",
|
||||||
|
"calculation_rule": {"type": "count", "event": "click"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metric_id = create_resp.json()["id"]
|
||||||
|
resp = self.client.get(
|
||||||
|
reverse("api-1:get_metric", args=[metric_id]),
|
||||||
|
HTTP_AUTHORIZATION=self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp.json()["key"], "api_get_m")
|
||||||
|
|
||||||
|
def test_update_metric(self) -> None:
|
||||||
|
create_resp = self._create_metric(
|
||||||
|
{
|
||||||
|
"key": "api_upd_m",
|
||||||
|
"name": "Old Name",
|
||||||
|
"metric_type": "count",
|
||||||
|
"calculation_rule": {"type": "count", "event": "click"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metric_id = create_resp.json()["id"]
|
||||||
|
resp = self.client.patch(
|
||||||
|
reverse("api-1:update_metric", args=[metric_id]),
|
||||||
|
data=json.dumps({"name": "New Name"}),
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_AUTHORIZATION=self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp.json()["name"], "New Name")
|
||||||
|
|
||||||
|
def test_create_requires_auth(self) -> None:
|
||||||
|
resp = self.client.post(
|
||||||
|
reverse("api-1:create_metric"),
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"key": "no_auth",
|
||||||
|
"name": "No Auth",
|
||||||
|
"metric_type": "count",
|
||||||
|
"calculation_rule": {"type": "count", "event": "x"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentMetricAPITest(TestCase):
|
||||||
|
@override
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.client = Client()
|
||||||
|
self.admin = make_admin("_ema")
|
||||||
|
self.auth = auth_header(self.admin)
|
||||||
|
self.experiment = make_experiment(suffix="_ema")
|
||||||
|
self.metric = metric_definition_create(
|
||||||
|
key="ema_metric",
|
||||||
|
name="API Metric",
|
||||||
|
metric_type=MetricType.COUNT,
|
||||||
|
calculation_rule={"type": "count", "event": "click"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_add_metric_to_experiment(self) -> None:
|
||||||
|
resp = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"api-1:add_experiment_metric",
|
||||||
|
args=[self.experiment.pk],
|
||||||
|
),
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"metric_id": str(self.metric.pk),
|
||||||
|
"is_primary": True,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_AUTHORIZATION=self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 201)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertTrue(data["is_primary"])
|
||||||
|
self.assertEqual(data["metric"]["key"], "ema_metric")
|
||||||
|
|
||||||
|
def test_list_experiment_metrics(self) -> None:
|
||||||
|
experiment_metric_add(
|
||||||
|
experiment=self.experiment,
|
||||||
|
metric=self.metric,
|
||||||
|
)
|
||||||
|
resp = self.client.get(
|
||||||
|
reverse(
|
||||||
|
"api-1:list_experiment_metrics",
|
||||||
|
args=[self.experiment.pk],
|
||||||
|
),
|
||||||
|
HTTP_AUTHORIZATION=self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(len(resp.json()), 1)
|
||||||
|
|
||||||
|
def test_remove_metric_from_experiment(self) -> None:
|
||||||
|
experiment_metric_add(
|
||||||
|
experiment=self.experiment,
|
||||||
|
metric=self.metric,
|
||||||
|
)
|
||||||
|
resp = self.client.delete(
|
||||||
|
reverse(
|
||||||
|
"api-1:remove_experiment_metric",
|
||||||
|
args=[self.experiment.pk, self.metric.pk],
|
||||||
|
),
|
||||||
|
HTTP_AUTHORIZATION=self.auth,
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 204)
|
||||||
Reference in New Issue
Block a user