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