From 093dfa1263b06558ab53393fc05363dd60ed2941 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 18 Feb 2026 16:22:33 +0300 Subject: [PATCH] feat(metrics): added metrics API --- src/backend/api/v1/metrics/__init__.py | 0 src/backend/api/v1/metrics/apps.py | 6 + src/backend/api/v1/metrics/endpoints.py | 162 ++++++++++++++++ src/backend/api/v1/metrics/schemas.py | 69 +++++++ src/backend/api/v1/metrics/tests/__init__.py | 0 .../api/v1/metrics/tests/test_metrics_api.py | 177 ++++++++++++++++++ 6 files changed, 414 insertions(+) create mode 100644 src/backend/api/v1/metrics/__init__.py create mode 100644 src/backend/api/v1/metrics/apps.py create mode 100644 src/backend/api/v1/metrics/endpoints.py create mode 100644 src/backend/api/v1/metrics/schemas.py create mode 100644 src/backend/api/v1/metrics/tests/__init__.py create mode 100644 src/backend/api/v1/metrics/tests/test_metrics_api.py diff --git a/src/backend/api/v1/metrics/__init__.py b/src/backend/api/v1/metrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/v1/metrics/apps.py b/src/backend/api/v1/metrics/apps.py new file mode 100644 index 0000000..461e2fa --- /dev/null +++ b/src/backend/api/v1/metrics/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MetricsApiConfig(AppConfig): + name = "api.v1.metrics" + label = "api_v1_metrics" diff --git a/src/backend/api/v1/metrics/endpoints.py b/src/backend/api/v1/metrics/endpoints.py new file mode 100644 index 0000000..6d4808d --- /dev/null +++ b/src/backend/api/v1/metrics/endpoints.py @@ -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 diff --git a/src/backend/api/v1/metrics/schemas.py b/src/backend/api/v1/metrics/schemas.py new file mode 100644 index 0000000..d4690b8 --- /dev/null +++ b/src/backend/api/v1/metrics/schemas.py @@ -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, + ) diff --git a/src/backend/api/v1/metrics/tests/__init__.py b/src/backend/api/v1/metrics/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/v1/metrics/tests/test_metrics_api.py b/src/backend/api/v1/metrics/tests/test_metrics_api.py new file mode 100644 index 0000000..0da532d --- /dev/null +++ b/src/backend/api/v1/metrics/tests/test_metrics_api.py @@ -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)