From c3ba28e6757d1fe3b2ff36c712277e41c717fbcc Mon Sep 17 00:00:00 2001 From: Taiwo Kareem Date: Wed, 11 Feb 2026 11:39:29 +0000 Subject: [PATCH 1/3] SPIKE: Public API wildcard performance investigation --- public_api/urls.py | 31 ++++++- .../version_02/views/timeseries_viewset.py | 87 +++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/public_api/urls.py b/public_api/urls.py index ca1b3870f..5f73019b5 100644 --- a/public_api/urls.py +++ b/public_api/urls.py @@ -16,7 +16,10 @@ TopicDetailViewV2, TopicListViewV2, ) -from public_api.version_02.views.timeseries_viewset import APITimeSeriesViewSetV2 +from public_api.version_02.views.timeseries_viewset import ( + APITimeSeriesViewSetV2, + APITimeSeriesViewSetV3, +) from public_api.views import ( GeographyDetailView, GeographyListView, @@ -50,6 +53,7 @@ def construct_url_patterns_for_public_api( urls = [] urls.extend(_construct_version_one_urls(prefix=prefix)) urls.extend(_construct_version_two_urls(prefix=prefix)) + urls.extend(_construct_version_three_urls(prefix=prefix)) if MetricsPublicAPIInterface.is_auth_enabled(): urls.append( @@ -222,3 +226,28 @@ def _construct_version_two_urls( name="timeseries-list-v2", ), ] + + +def _construct_version_three_urls( + *, + prefix: str, +) -> list[resolvers.URLResolver]: + """Returns a list of URLResolvers for the public_api version 3 + + Args: + prefix: The prefix to add to the start of the url paths + + Returns: + List of `URLResolver` objects each representing a + set of versioned URLS. + """ + + return [ + path( + f"{prefix}v3/", + APITimeSeriesViewSetV3.as_view( + {"get": "list"}, name=APITimeSeriesViewSetV3.name + ), + name="timeseries-list-v3", + ), + ] diff --git a/public_api/version_02/views/timeseries_viewset.py b/public_api/version_02/views/timeseries_viewset.py index 2579207c9..5b4d88305 100644 --- a/public_api/version_02/views/timeseries_viewset.py +++ b/public_api/version_02/views/timeseries_viewset.py @@ -1,3 +1,4 @@ +import django_filters from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema from rest_framework import pagination, viewsets @@ -101,3 +102,89 @@ def get_queryset(self): metric=self.kwargs["metric"], restrict_to_public=True, ) + + +class APITimeSeriesFilterSetv3(django_filters.FilterSet): + geography = django_filters.CharFilter(field_name="geography", lookup_expr="iexact") + geography_type = django_filters.CharFilter( + field_name="geography_type", lookup_expr="iexact" + ) + + class Meta: + model = MetricsPublicAPIInterface.get_api_timeseries_model() + fields = [ + "theme", + "sub_theme", + "topic", + "geography_type", + "geography", + "metric", + "stratum", + "sex", + "age", + "year", + "epiweek", + "date", + "in_reporting_delay_period", + ] + + +@extend_schema(tags=[PUBLIC_API_TAG]) +class APITimeSeriesViewSetV3(viewsets.ReadOnlyModelViewSet): + """ + This endpoint will provide the full timeseries of a slice of data. + + There are only optional query parameters: + + Note that by default, results are paginated by a page size of 100 + + This page size can be changed using the *page_size* parameter. + The maximum supported page size is **365**. + + --- + + The optional query parameters are as follows in order from first to last: + + - `theme` - The largest topical subgroup e.g. **infectious_disease** + + - `sub_theme` - A topical subgroup e.g. **respiratory** + + - `topic` - The name of the topic e.g. **COVID-19** + + - `geography_type` - The type of the geography type e.g. **Nation** + + - `geography` - The name of the geography associated with metric e.g. **London** + + - `metric` - The name of the metric being queried for e.g. **COVID-19_deaths_ONSByDay** + + - `stratum` - Smallest subgroup a metric can be broken down into e.g. ethnicity, testing pillar + + - `age` - Smallest subgroup a metric can be broken down into e.g. **15_44** for the age group of 15-44 years + + - `sex` - Patient gender e.g. **f** for Female or **all** for all genders + + - `year` - Epi year of the metrics value (important for annual metrics) e.g. **2020** + + - `month` - Epi month of the metric value (important for monthly metrics) e.g. **12** + + - `epiweek` - Epi week of the metric value (important for weekly metrics) e.g. **30** + + - `date` - The date which this metric value was recorded in the format **YYYY-MM-DD** e.g. **2020-07-20** + + - `in_reporting_delay_period` - A boolean indicating whether the data point is considered to be subject + to retrospective updates. + + """ + + permission_classes = [] + name = "API Time Series Version 3" + queryset = ( + MetricsPublicAPIInterface.get_api_timeseries_model() + .objects.all() + .order_by("date") + ) + serializer_class = APITimeSeriesListSerializerv2 + pagination_class = APITimeSeriesPaginationv2 + page_size = 100 + filter_backends = [DjangoFilterBackend] + filterset_class = APITimeSeriesFilterSetv3 From 260b9609d4c9b88c7efea7e888b0c44a714d4c2e Mon Sep 17 00:00:00 2001 From: Taiwo Kareem Date: Tue, 17 Feb 2026 12:40:47 +0000 Subject: [PATCH 2/3] SPIKE: Remove unused page size param --- public_api/version_02/views/timeseries_viewset.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/public_api/version_02/views/timeseries_viewset.py b/public_api/version_02/views/timeseries_viewset.py index 5b4d88305..14958bf59 100644 --- a/public_api/version_02/views/timeseries_viewset.py +++ b/public_api/version_02/views/timeseries_viewset.py @@ -136,7 +136,7 @@ class APITimeSeriesViewSetV3(viewsets.ReadOnlyModelViewSet): There are only optional query parameters: - Note that by default, results are paginated by a page size of 100 + Note that by default, results are paginated by a page size of 5 This page size can be changed using the *page_size* parameter. The maximum supported page size is **365**. @@ -185,6 +185,5 @@ class APITimeSeriesViewSetV3(viewsets.ReadOnlyModelViewSet): ) serializer_class = APITimeSeriesListSerializerv2 pagination_class = APITimeSeriesPaginationv2 - page_size = 100 filter_backends = [DjangoFilterBackend] filterset_class = APITimeSeriesFilterSetv3 From 5c7217014db0e5e7685bde5e1f78f752a5594b24 Mon Sep 17 00:00:00 2001 From: Taiwo Kareem Date: Tue, 17 Feb 2026 14:19:35 +0000 Subject: [PATCH 3/3] SPIKE: Make theme mandatory --- public_api/urls.py | 2 +- .../version_02/views/timeseries_viewset.py | 16 +++++-- .../public_api/test_timeseries_viewset_v3.py | 43 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 tests/unit/public_api/test_timeseries_viewset_v3.py diff --git a/public_api/urls.py b/public_api/urls.py index 5f73019b5..649c3a2e3 100644 --- a/public_api/urls.py +++ b/public_api/urls.py @@ -244,7 +244,7 @@ def _construct_version_three_urls( return [ path( - f"{prefix}v3/", + f"{prefix}v3/themes/", APITimeSeriesViewSetV3.as_view( {"get": "list"}, name=APITimeSeriesViewSetV3.name ), diff --git a/public_api/version_02/views/timeseries_viewset.py b/public_api/version_02/views/timeseries_viewset.py index 14958bf59..c1f3d28a7 100644 --- a/public_api/version_02/views/timeseries_viewset.py +++ b/public_api/version_02/views/timeseries_viewset.py @@ -113,7 +113,6 @@ class APITimeSeriesFilterSetv3(django_filters.FilterSet): class Meta: model = MetricsPublicAPIInterface.get_api_timeseries_model() fields = [ - "theme", "sub_theme", "topic", "geography_type", @@ -134,7 +133,7 @@ class APITimeSeriesViewSetV3(viewsets.ReadOnlyModelViewSet): """ This endpoint will provide the full timeseries of a slice of data. - There are only optional query parameters: + There is one mandatory URL parameter and other optional query parameters: Note that by default, results are paginated by a page size of 5 @@ -143,10 +142,14 @@ class APITimeSeriesViewSetV3(viewsets.ReadOnlyModelViewSet): --- - The optional query parameters are as follows in order from first to last: + The mandatory URL parameter is: - `theme` - The largest topical subgroup e.g. **infectious_disease** + --- + + The optional query parameters are as follows in order from first to last: + - `sub_theme` - A topical subgroup e.g. **respiratory** - `topic` - The name of the topic e.g. **COVID-19** @@ -187,3 +190,10 @@ class APITimeSeriesViewSetV3(viewsets.ReadOnlyModelViewSet): pagination_class = APITimeSeriesPaginationv2 filter_backends = [DjangoFilterBackend] filterset_class = APITimeSeriesFilterSetv3 + + def get_queryset(self): + queryset = super().get_queryset() + + return queryset.filter( + theme=self.kwargs["theme"], + ) diff --git a/tests/unit/public_api/test_timeseries_viewset_v3.py b/tests/unit/public_api/test_timeseries_viewset_v3.py new file mode 100644 index 000000000..9238f7dc8 --- /dev/null +++ b/tests/unit/public_api/test_timeseries_viewset_v3.py @@ -0,0 +1,43 @@ +import pytest +from rest_framework.test import APIRequestFactory + +from public_api.version_02.views.timeseries_viewset import APITimeSeriesViewSetV3 +from tests.factories.metrics.api_models.time_series import APITimeSeriesFactory + + +@pytest.mark.django_db +class TestAPITimeSeriesViewSetV3GetQueryset: + def test_filters_queryset_by_theme_kwarg(self) -> None: + """ + Ensure `get_queryset` applies the `theme` URL kwarg as a filter. + """ + # Given: three records with different themes + infectious_record_1 = APITimeSeriesFactory.create_record( + theme_name="infectious_disease", + date="2023-01-01", + ) + infectious_record_2 = APITimeSeriesFactory.create_record( + theme_name="infectious_disease", + date="2023-01-02", + ) + APITimeSeriesFactory.create_record( + theme_name="extreme_event", + ) + + # And a view instance with the theme URL kwarg set + view = APITimeSeriesViewSetV3() + view.kwargs = {"theme": "infectious_disease"} + + # DRF's GenericAPIView expects a `request` attribute, even though our + # overridden `get_queryset` does not use it directly. + view.request = APIRequestFactory().get("//v3/themes/infectious_disease") + + # When + queryset = view.get_queryset() + + # Then: only records matching the URL theme are returned + themes = {instance.theme for instance in queryset} + assert themes == {"infectious_disease"} + assert queryset.count() == 2 + assert queryset.first() == infectious_record_1 + assert queryset.last() == infectious_record_2