feat(reports): added reports API

This commit is contained in:
ITQ
2026-02-18 16:25:02 +03:00
parent 093dfa1263
commit 5e146c2dfe
6 changed files with 235 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ReportsApiConfig(AppConfig):
name = "api.v1.reports"
label = "api_v1_reports"
+39
View File
@@ -0,0 +1,39 @@
from http import HTTPStatus
from uuid import UUID
from django.http import Http404, HttpRequest
from django.utils.dateparse import parse_datetime
from ninja import Router
from api.v1.reports.schemas import ExperimentReportOut
from apps.experiments.models import Experiment
from apps.reports.services import build_experiment_report
router = Router(tags=["reports"])
@router.get(
"/reports/{experiment_id}",
response={HTTPStatus.OK: ExperimentReportOut},
summary="Get experiment report",
)
def get_experiment_report(
request: HttpRequest,
experiment_id: UUID,
start_date: str | None = None,
end_date: str | None = None,
) -> tuple[int, ExperimentReportOut]:
try:
experiment = Experiment.objects.get(pk=experiment_id)
except Experiment.DoesNotExist:
raise Http404 from Experiment.DoesNotExist
parsed_start = parse_datetime(start_date) if start_date else None
parsed_end = parse_datetime(end_date) if end_date else None
report_data = build_experiment_report(
experiment=experiment,
start_date=parsed_start,
end_date=parsed_end,
)
return HTTPStatus.OK, ExperimentReportOut(**report_data)
+36
View File
@@ -0,0 +1,36 @@
from decimal import Decimal
from uuid import UUID
from ninja import Schema
class ReportMetricOut(Schema):
metric_key: str
metric_name: str
metric_type: str
direction: str
is_primary: bool
value: Decimal | None
class ReportVariantOut(Schema):
variant_id: UUID
variant_name: str
is_control: bool
weight: Decimal
exposures: int
unique_subjects: int
metrics: list[ReportMetricOut]
class ReportPeriodOut(Schema):
start: str | None
end: str | None
class ExperimentReportOut(Schema):
experiment_id: UUID
experiment_name: str
status: str
period: ReportPeriodOut
variants: list[ReportVariantOut]
@@ -0,0 +1,154 @@
import uuid
from typing import override
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from apps.events.services import decision_create, process_events_batch
from apps.events.tests.helpers import make_event_type, make_exposure_type
from apps.experiments.tests.helpers import add_two_variants, make_experiment
from apps.metrics.models import MetricType
from apps.metrics.services import (
experiment_metric_add,
metric_definition_create,
)
class ExperimentReportAPITest(TestCase):
@override
def setUp(self) -> None:
self.client = Client()
self.exposure_type = make_exposure_type()
self.click_type = make_event_type(
name="api_clicked",
display_name="API Clicked",
requires_exposure=True,
)
self.experiment = make_experiment(suffix="_rapi")
self.v_control, self.v_treatment = add_two_variants(self.experiment)
self.metric = metric_definition_create(
key="rapi_clicks",
name="Clicks",
metric_type=MetricType.COUNT,
calculation_rule={"type": "count", "event": "api_clicked"},
)
experiment_metric_add(
experiment=self.experiment,
metric=self.metric,
is_primary=True,
)
self.now = timezone.now()
def _create_data(self) -> None:
decision_create(
decision_id="dec_api1",
flag_key=self.experiment.flag.key,
subject_id="u1",
experiment_id=str(self.experiment.pk),
variant_id=str(self.v_treatment.pk),
value=self.v_treatment.value,
reason="experiment",
)
process_events_batch(
[
{
"event_id": "exp_api1",
"event_type": "exposure",
"decision_id": "dec_api1",
"subject_id": "u1",
"timestamp": self.now.isoformat(),
"properties": {},
}
]
)
process_events_batch(
[
{
"event_id": "evt_api1",
"event_type": "api_clicked",
"decision_id": "dec_api1",
"subject_id": "u1",
"timestamp": self.now.isoformat(),
"properties": {},
}
]
)
def test_get_report_no_auth_required(self) -> None:
resp = self.client.get(
reverse(
"api-1:get_experiment_report",
args=[self.experiment.pk],
),
)
self.assertEqual(resp.status_code, 200)
def test_report_structure(self) -> None:
self._create_data()
resp = self.client.get(
reverse(
"api-1:get_experiment_report",
args=[self.experiment.pk],
),
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["experiment_id"], str(self.experiment.pk))
self.assertEqual(len(data["variants"]), 2)
treatment = next(
v
for v in data["variants"]
if v["variant_id"] == str(self.v_treatment.pk)
)
self.assertEqual(treatment["exposures"], 1)
self.assertEqual(len(treatment["metrics"]), 1)
self.assertEqual(treatment["metrics"][0]["metric_key"], "rapi_clicks")
def test_report_per_variant_metrics(self) -> None:
self._create_data()
resp = self.client.get(
reverse(
"api-1:get_experiment_report",
args=[self.experiment.pk],
),
)
data = resp.json()
treatment = next(
v
for v in data["variants"]
if v["variant_id"] == str(self.v_treatment.pk)
)
control = next(
v
for v in data["variants"]
if v["variant_id"] == str(self.v_control.pk)
)
self.assertEqual(float(treatment["metrics"][0]["value"]), 1.0)
self.assertIsNone(control["metrics"][0]["value"])
def test_report_with_period_filter(self) -> None:
self._create_data()
start = (self.now - timezone.timedelta(hours=1)).isoformat()
end = (self.now + timezone.timedelta(hours=1)).isoformat()
resp = self.client.get(
reverse(
"api-1:get_experiment_report",
args=[self.experiment.pk],
),
{"start_date": start, "end_date": end},
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertIsNotNone(data["period"]["start"])
self.assertIsNotNone(data["period"]["end"])
def test_report_404_for_nonexistent(self) -> None:
resp = self.client.get(
reverse(
"api-1:get_experiment_report",
args=[uuid.uuid4()],
),
)
self.assertEqual(resp.status_code, 404)