feat(reports): added reports API
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReportsApiConfig(AppConfig):
|
||||
name = "api.v1.reports"
|
||||
label = "api_v1_reports"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user