diff --git a/src/backend/api/v1/reports/__init__.py b/src/backend/api/v1/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/v1/reports/apps.py b/src/backend/api/v1/reports/apps.py new file mode 100644 index 0000000..16c7559 --- /dev/null +++ b/src/backend/api/v1/reports/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ReportsApiConfig(AppConfig): + name = "api.v1.reports" + label = "api_v1_reports" diff --git a/src/backend/api/v1/reports/endpoints.py b/src/backend/api/v1/reports/endpoints.py new file mode 100644 index 0000000..a6a2416 --- /dev/null +++ b/src/backend/api/v1/reports/endpoints.py @@ -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) diff --git a/src/backend/api/v1/reports/schemas.py b/src/backend/api/v1/reports/schemas.py new file mode 100644 index 0000000..bdff541 --- /dev/null +++ b/src/backend/api/v1/reports/schemas.py @@ -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] diff --git a/src/backend/api/v1/reports/tests/__init__.py b/src/backend/api/v1/reports/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/v1/reports/tests/test_reports_api.py b/src/backend/api/v1/reports/tests/test_reports_api.py new file mode 100644 index 0000000..06f5118 --- /dev/null +++ b/src/backend/api/v1/reports/tests/test_reports_api.py @@ -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)