From a8d5c35c8d4c7c20896ecfbc77783587492be11f Mon Sep 17 00:00:00 2001 From: phillip stanley Date: Thu, 25 Jun 2026 11:27:28 +0100 Subject: [PATCH 1/3] CDD-3359: Create new APIHeadline model --- ingestion/metrics_interface/interface.py | 8 + metrics/data/managers/api_models/headline.py | 310 ++++++++++++++++++ .../migrations/0043_create_apiheadline.py | 92 ++++++ metrics/data/models/api_models.py | 86 +++++ .../factories/metrics/api_models/headline.py | 87 +++++ tests/fakes/models/metrics/api_headline.py | 16 + .../data/managers/api_models/test_headline.py | 65 ++++ .../metrics/data/models/test_api_models.py | 83 +++++ 8 files changed, 747 insertions(+) create mode 100644 metrics/data/managers/api_models/headline.py create mode 100644 metrics/data/migrations/0043_create_apiheadline.py create mode 100644 tests/factories/metrics/api_models/headline.py create mode 100644 tests/fakes/models/metrics/api_headline.py create mode 100644 tests/unit/metrics/data/managers/api_models/test_headline.py diff --git a/ingestion/metrics_interface/interface.py b/ingestion/metrics_interface/interface.py index a9600eb80b..66e2ef0485 100644 --- a/ingestion/metrics_interface/interface.py +++ b/ingestion/metrics_interface/interface.py @@ -44,6 +44,10 @@ def get_stratum_manager(): def get_core_headline_manager(): return core_models.CoreHeadline.objects + @staticmethod + def get_api_headline_manager(): + return api_models.APIHeadline.objects + @staticmethod def get_core_timeseries_manager(): return core_models.CoreTimeSeries.objects @@ -60,6 +64,10 @@ def get_time_period_enum() -> TimePeriod: def get_core_headline(): return core_models.CoreHeadline + @staticmethod + def get_api_headline(): + return api_models.APIHeadline + @staticmethod def get_core_timeseries(): return core_models.CoreTimeSeries diff --git a/metrics/data/managers/api_models/headline.py b/metrics/data/managers/api_models/headline.py new file mode 100644 index 0000000000..13e78c0738 --- /dev/null +++ b/metrics/data/managers/api_models/headline.py @@ -0,0 +1,310 @@ +""" +This file contains the custom queryset and Manger classes associated with the `APIHeadline` model. + +""" +from typing import Self + +from django.db import models +from django.db.models.functions.window import Rank +from django.utils import timezone + + +class APIHeadlineQuerySet(models.QuerySet): + """Custom queryset which can be used by the `APIHeadlineManger`""" + @staticmethod + def _newest_to_oldest( + *, queryset: models.QuerySet, apply_refresh_date_only: bool + ) -> models.QuerySet: + if apply_refresh_date_only: + return queryset.order_by("-refresh_date") + return queryset.order_by("-period_end", "-refresh_date") + + @staticmethod + def _exclude_data_under_embargo(self, *, queryset: models.QuerySet) -> models.QuerySet: + """Excludes any data which is currently embargoed from the given `queryset`. + + Notes: + if the `embargo` value is None then it will be included + in the returned queryset + + Args: + queryset: The queryset to exclude dates under embargo from + + RETURNS: + The filtered queryset which includes dates under embargo + """ + current_time = timezone.now() + return queryset.filter( + models.Q(embargo__lte=current_time) | models.Q(embargo=None) + ) + + def get_all_headlines_released_from_embargo( + self, + *, + theme: str, + sub_theme: str, + topic: str, + metric: str, + geography: str, + geography_type: str, + geography_code: str = "", + stratum: str, + sex: str, + age: str, + ): + """Filters by the given parameters, includes public and non-public data. + + Args: + theme: The name of the parent theme being queried. + E.g. `infectious_disease` + sub_theme: The name of the child theme being queried. + E.g. `respiratory` + topic: The name of the threat being queried. + E.g. `COVID-19` + metric: The name of the metric being queried. + E.g. `COVID-19_headline_7DayAdmissions` + geography: The name of the geography being queried. + E.g. `England` + geography_type: The name of the geography type being queried. + E.g. `Nation` + geography_code: Code associated with the geography being queried. + E.g. "E45000010" + stratum: The value of the stratum to apply additional filtering to. + E.g. `default`, which would be used to capture all strata. + sex: The gender to apply additional filtering to. + E.g. `F`, would be used to capture Females. + Note that options are `M`, `F`, or `ALL`. + age: The age range to apply additional filtering to. + E.g. `0_4` would be used to capture the age of 0-4 years old + + Returns: + An ordered queryset from oldest -> newest: + """ + queryset = self.filter( + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography=geography, + geography_type=geography_type, + geography_code=geography_code, + stratum=stratum, + sex=sex, + age=age, + ) + queryset = self._exclude_data_under_embargo(self, queryset=queryset) + apply_refresh_date_only: bool = "alert" in topic + return self._newest_to_oldest(queryset=queryset, apply_refresh_date_only=apply_refresh_date_only) + + def get_public_only_headlines_released_from_embargo( + self, + *, + theme: str, + sub_theme: str, + topic: str, + metric: str, + geography: str, + geography_type: str, + geography_code: str = "", + stratum: str, + sex: str, + age: str, + ) -> Self: + queryset = self.get_all_headlines_released_from_embargo( + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography=geography, + geography_type=geography_type, + geography_code=geography_code, + stratum=stratum, + age=age, + sex=sex, + ) + queryset = queryset.filter(is_public=True) + apply_refresh_date_only: bool = "alert" in topic + return self._newest_to_oldest( + queryset=queryset, apply_refresh_date_only=apply_refresh_date_only + ) + + def get_non_public_only_headlines_released_from_embargo( + self, + *, + theme: str, + sub_theme: str, + topic: str, + metric: str, + geography: str, + geography_type: str, + geography_code: str = "", + stratum: str, + sex: str, + age: str, + ) -> Self: + queryset = self.get_all_headlines_released_from_embargo( + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography=geography, + geography_type=geography_type, + geography_code=geography_code, + stratum=stratum, + age=age, + sex=sex, + ) + queryset = queryset.filter(is_public=False) + apply_refresh_date_only: bool = "alert" in topic + return self._newest_to_oldest( + queryset=queryset, apply_refresh_date_only=apply_refresh_date_only + ) + + +class APIHeadlineManager(models.Manager): + """Custom model manager class for the `APIHeadline` model.""" + def get_queryset(self) -> APIHeadlineQuerySet: + return APIHeadlineQuerySet(self.model, using=self._db) + + def query_for_superseded_data( + self, + *, + theme: str, + sub_theme: str, + topic: str, + metric: str, + geography: str, + geography_type: str, + geography_code: str, + stratum: str, + sex: str, + age: str, + is_public: bool = True, + ): + """Grabs all stale records which are not under embargo. + + Args: + theme: The name of the parent theme being queried. + E.g. `infectious_disease` + sub_theme: The name of the child theme being queried. + E.g. `respiratory` + topic: The name of the threat being queried. + E.g. `COVID-19` + metric: The name of the metric being queried. + E.g. `COVID-19_headline_7DayAdmissions` + geography: The name of the geography being queried. + E.g. `England` + geography_type: The name of the geography type being queried. + E.g. `Nation` + geography_code: Code associated with the geography being queried. + E.g. "E45000010" + stratum: The value of the stratum to apply additional filtering to. + E.g. `default`, which would be used to capture all strata. + sex: The gender to apply additional filtering to. + E.g. `F`, would be used to capture Females. + Note that options are `M`, `F`, or `ALL`. + age: The age range to apply additional filtering to. + E.g. `0_4` would be used to capture the age of 0-4 years old + is_public: Boolean to decide whether to query for public data. + If False, then non-public data will be queried for instead. + + Returns: + The stale records in their entirety as a queryset + """ + if is_public: + queryset = self.get_queryset().get_public_only_headlines_released_from_embargo( + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography=geography, + geography_type=geography_type, + geography_code=geography_code, + stratum=stratum, + age=age, + sex=sex, + ) + else: + queryset = self.get_queryset().get_non_public_only_headlines_released_from_embargo( + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography=geography, + geography_type=geography_type, + geography_code=geography_code, + stratum=stratum, + age=age, + sex=sex, + ) + + try: + live_headline_id: int = queryset.first().id + except AttributeError: + # Thrown when the queryset was empty + # And `first()` returned `None` + return queryset + + return queryset.exclude(id=live_headline_id) + + def delete_superseded_data( + self, + *, + theme: str, + sub_theme: str, + topic: str, + metric: str, + geography: str, + geography_type: str, + geography_code: str, + stratum: str, + sex: str, + age: str, + is_public: bool = True, + ) -> None: + """Deletes all stale records which are not under embargo. + + + Args: + theme: The name of the parent theme being queried. + E.g. `infectious_disease` + sub_theme: The name of the child theme being queried. + E.g. `respiratory` + topic: The name of the threat being queried. + E.g. `COVID-19` + metric: The name of the metric being queried. + E.g. `COVID-19_headline_7DayAdmissions` + geography: The name of the geography being queried. + E.g. `England` + geography_type: The name of the geography type being queried. + E.g. `Nation` + geography_code: Code associated with the geography being queried. + E.g. "E45000010" + stratum: The value of the stratum to apply additional filtering to. + E.g. `default`, which would be used to capture all strata. + sex: The gender to apply additional filtering to. + E.g. `F`, would be used to capture Females. + Note that options are `M`, `F`, or `ALL`. + age: The age range to apply additional filtering to. + E.g. `0_4` would be used to capture the age of 0-4 years old + is_public: Boolean to decide whether to query for public data. + If False, then non-public data will be queried for instead. + + Returns: + None + """ + superseded_records = self.query_for_superseded_data( + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography=geography, + geography_type=geography_type, + geography_code=geography_code, + stratum=stratum, + age=age, + sex=sex, + is_public=is_public, + ) + superseded_records.delete() + diff --git a/metrics/data/migrations/0043_create_apiheadline.py b/metrics/data/migrations/0043_create_apiheadline.py new file mode 100644 index 0000000000..0dd9442762 --- /dev/null +++ b/metrics/data/migrations/0043_create_apiheadline.py @@ -0,0 +1,92 @@ +# Generated by Django 5.2.15 on 2026-06-23 23:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("data", "0042_alter_apitimeseries_metric_value"), + ] + + operations = [ + migrations.CreateModel( + name="APIHeadline", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("theme", models.CharField(max_length=50)), + ("sub_theme", models.CharField(max_length=50)), + ("topic", models.CharField(max_length=50)), + ("metric", models.CharField(max_length=100)), + ("metric_group", models.CharField(max_length=50, null=True)), + ("geography", models.CharField(max_length=100)), + ("geography_type", models.CharField(max_length=50)), + ("geography_code", models.CharField(max_length=9, null=True)), + ("stratum", models.CharField(max_length=50)), + ("sex", models.CharField(max_length=3, null=True)), + ("age", models.CharField(max_length=50, null=True)), + ("period_start", models.DateTimeField(null=True)), + ("period_end", models.DateTimeField(null=True)), + ("refresh_date", models.DateTimeField(null=True)), + ("embargo", models.DateTimeField(null=True)), + ("metric_value", models.FloatField()), + ( + "upper_confidence", + models.DecimalField( + blank=True, decimal_places=4, max_digits=11, null=True + ), + ), + ( + "lower_confidence", + models.DecimalField( + blank=True, decimal_places=4, max_digits=11, null=True + ), + ), + ("force_write", models.BooleanField(default=False)), + ("is_public", models.BooleanField(default=True)), + ], + options={ + "indexes": [ + models.Index( + fields=[ + "theme", + "sub_theme", + "topic", + "metric", + "metric_value", + "geography", + "geography_type", + ], + name="data_apihea_theme_d13b3b_idx", + ) + ], + "constraints": [ + models.UniqueConstraint( + condition=models.Q(("force_write", False)), + fields=( + "theme", + "sub_theme", + "topic", + "metric", + "metric_value", + "geography", + "geography_type", + "geography_code", + "stratum", + "sex", + "age", + ), + name="The `APITHeadline` record should be unique if `force_write` is False", + ) + ], + }, + ), + ] diff --git a/metrics/data/models/api_models.py b/metrics/data/models/api_models.py index 092be52122..ad5f288362 100644 --- a/metrics/data/models/api_models.py +++ b/metrics/data/models/api_models.py @@ -2,11 +2,14 @@ from django.db.models import Q from metrics.data.managers.api_models.time_series import APITimeSeriesManager +from metrics.data.managers.api_models.headline import APIHeadlineManager from metrics.data.models.constants import ( CHAR_COLUMN_MAX_CONSTRAINT, GEOGRAPHY_CODE_MAX_CHAR_CONSTRAINT, LARGE_CHAR_COLUMN_MAX_CONSTRAINT, METRIC_FREQUENCY_MAX_CHAR_CONSTRAINT, + METRIC_VALUE_MAX_DIGITS, + METRIC_VALUE_DECIMAL_PLACES, SEX_MAX_CHAR_CONSTRAINT, ) from metrics.data.models.core_models import help_texts @@ -92,3 +95,86 @@ def __str__(self): f"stratum '{self.stratum}', " f"value: {self.metric_value}" ) + + +class APIHeadline(models.Model): + + theme = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT) + sub_theme = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT) + topic = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT) + metric = models.CharField(max_length=LARGE_CHAR_COLUMN_MAX_CONSTRAINT) + metric_group = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT, null=True) + + geography = models.CharField(max_length=LARGE_CHAR_COLUMN_MAX_CONSTRAINT) + geography_type = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT) + geography_code = models.CharField( + max_length=GEOGRAPHY_CODE_MAX_CHAR_CONSTRAINT, null=True + ) + stratum = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT) + sex = models.CharField(max_length=SEX_MAX_CHAR_CONSTRAINT, null=True) + age = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT, null=True) + + period_start = models.DateTimeField(null=True) + period_end = models.DateTimeField(null=True) + refresh_date = models.DateTimeField(null=True) + embargo = models.DateTimeField(null=True) + metric_value = models.FloatField() + upper_confidence = models.DecimalField( + max_digits=METRIC_VALUE_MAX_DIGITS, + decimal_places=METRIC_VALUE_DECIMAL_PLACES, + blank=True, + null=True, + ) + lower_confidence = models.DecimalField( + max_digits=METRIC_VALUE_MAX_DIGITS, + decimal_places=METRIC_VALUE_DECIMAL_PLACES, + null=True, + blank=True, + ) + + force_write = models.BooleanField(default=False) + is_public = models.BooleanField(default=True, null=False) + + objects = APIHeadlineManager() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=( + "theme", + "sub_theme", + "topic", + "metric", + "metric_value", + "geography", + "geography_type", + "geography_code", + "stratum", + "sex", + "age", + ), + name="The `APITHeadline` record should be unique if `force_write` is False", + condition=Q(force_write=False), + ) + ] + indexes = [ + models.Index( + fields=[ + "theme", + "sub_theme", + "topic", + "metric", + "metric_value", + "geography", + "geography_type", + ] + ), + ] + + def __str__(self): + return ( + f"{self.__class__.__name__} for {self.date}, " + f"metric '{self.metric}', " + f"stratum '{self.stratum}', " + f"value: {self.metric_value}" + ) diff --git a/tests/factories/metrics/api_models/headline.py b/tests/factories/metrics/api_models/headline.py new file mode 100644 index 0000000000..eeaa84db54 --- /dev/null +++ b/tests/factories/metrics/api_models/headline.py @@ -0,0 +1,87 @@ +import contextlib +import datetime + +import factory +from django.utils import timezone +from metrics.data.models.api_models import APIHeadline + + +class APIHeadlineFactory(factory.django.DjangoModelFactory): + """ + Factory for creating `APIHeadline` instances for tests + """ + + class Meta: + model = APIHeadline + + @classmethod + def create_record( + cls, + metric_value: float = 123.456, + theme: str = "infectious_disease", + sub_theme: str = "respiratory", + topic: str = "COVID-19", + metric: str = "COVID-19_headline_positivity_latest", + metric_group: str = "headline", + geography: str = "England", + geography_type: str = "Nation", + geography_code: str = "E92000001", + stratum: str = "default", + age: str = "all", + sex: str = "all", + refresh_date: str | datetime.datetime = datetime.datetime(2023, 10, 1), + period_start: str | datetime.date = "2023-01-01", + period_end: str | datetime.date = "2023-01-07", + embargo: str | datetime.datetime = datetime.datetime(2024, 4, 10), + upper_confidence: float | None = None, + lower_confidence: float | None = None, + is_public: bool = True, + **kwargs + ): + refresh_date: datetime.datetime = cls._make_datetime_timezone_aware( + datetime_obj=refresh_date + ) + embargo: datetime.datetime = cls._make_datetime_timezone_aware( + datetime_obj=embargo + ) + + return cls.create( + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric_value=metric_value, + metric=metric, + metric_group=metric_group, + geography=geography, + geography_type=geography_type, + geography_code=geography_code, + stratum=stratum, + age=age, + sex=sex, + period_start=period_start, + period_end=period_end, + embargo=embargo, + upper_confidence=upper_confidence, + lower_confidence=lower_confidence, + is_public=is_public, + refresh_date=refresh_date, + **kwargs + ) + + @classmethod + def _make_datetime_timezone_aware( + cls, + datetime_obj: str | datetime.datetime | None + ) -> datetime.datetime: + + if datetime_obj.tzinfo is None: + return datetime_obj + + with contextlib.suppress(ValueError): + datetime_obj = datetime.datetime.strptime(datetime_obj, "%Y-%m-%d") + + try: + return timezone.make_aware(value=datetime_obj) + except ValueError: + # The object is already timezone aware + return datetime_obj diff --git a/tests/fakes/models/metrics/api_headline.py b/tests/fakes/models/metrics/api_headline.py new file mode 100644 index 0000000000..0227910b80 --- /dev/null +++ b/tests/fakes/models/metrics/api_headline.py @@ -0,0 +1,16 @@ +from metrics.data.models.api_models import APIHeadline +from tests.fakes.models.fake_model_meta import FakeMeta + + +class FakeAPIHeadline(APIHeadline): + """ + A fake version of the Django model `APIHeadline` + which has had its dependencies altered so that it does not interact with the database + """ + Meta = FakeMeta + + def __init__(self, **kwargs): + """ + Constructor takes the same arguments as a normal `APIHeadline` model. + """ + super().__init__(**kwargs) diff --git a/tests/unit/metrics/data/managers/api_models/test_headline.py b/tests/unit/metrics/data/managers/api_models/test_headline.py new file mode 100644 index 0000000000..8a62421d9a --- /dev/null +++ b/tests/unit/metrics/data/managers/api_models/test_headline.py @@ -0,0 +1,65 @@ +from unittest import mock + +from metrics.data.managers.api_models.headline import APIHeadlineManager +from validation import age, is_public + + +class TestAPIHeadlineManager: + @mock.patch.object(APIHeadlineManager, "query_for_superseded_data") + def test_delete_superseded_data( + self, spy_query_for_superseded_data: mock.MagicMock + ): + """ + Given a payload containing the required fields + for a dataset slice + When `delete_superseded_data` is called + from an instance of the `APIHeadlineManager` + Then the records are retrieved via the + call made to the `query_for_superseded_data()` method + And then the retrieved records are deleted + """ + # Given + fake_theme = "infectious_disease" + fake_sub_theme = "respiratory" + fake_topic = "COVID-19" + fake_metric = "COVID-19_deaths_ONSByWeek" + fake_geography = "England" + fake_geography_type = "Nation" + fake_geography_code = "E92000001" + fake_stratum = "default" + fake_sex = "all" + fake_age = "all" + fake_is_public = True + + # When + APIHeadlineManager().delete_superseded_data( + theme=fake_theme, + sub_theme=fake_sub_theme, + topic=fake_topic, + metric=fake_metric, + geography=fake_geography, + geography_type=fake_geography_type, + geography_code=fake_geography_code, + stratum=fake_stratum, + sex=fake_sex, + age=fake_age, + is_public=fake_is_public, + ) + + # Then + spy_query_for_superseded_data.assert_called_once_with( + theme=fake_theme, + sub_theme=fake_sub_theme, + topic=fake_topic, + metric=fake_metric, + geography=fake_geography, + geography_type=fake_geography_type, + geography_code=fake_geography_code, + stratum=fake_stratum, + sex=fake_sex, + age=fake_age, + is_public=fake_is_public, + ) + + returned_records = spy_query_for_superseded_data.return_value + returned_records.delete.assert_called_once() diff --git a/tests/unit/metrics/data/models/test_api_models.py b/tests/unit/metrics/data/models/test_api_models.py index 07fe558493..664f1f9e92 100644 --- a/tests/unit/metrics/data/models/test_api_models.py +++ b/tests/unit/metrics/data/models/test_api_models.py @@ -8,6 +8,7 @@ SEX_MAX_CHAR_CONSTRAINT, ) from tests.fakes.models.metrics.api_time_series import FakeAPITimeSeries +from tests.fakes.models.metrics.api_headline import FakeAPIHeadline class TestAPITimeSeries: @@ -93,3 +94,85 @@ def test_correct_max_length_constraints_returned_from_model( # Then assert field_concreate_field.max_length == max_length_constraint + + +class TestAPIHeadline: + @pytest.mark.parametrize( + "field_name, field_value", + ( + ["age", "all"], + ["refresh_date", "2023-07-11"], + ["metric_group", "headline"], + ["theme", "infectious_disease"], + ["sub_theme", "respiratory"], + ["topic", "Influenza"], + ["geography_type", "Nation"], + ["geography_code", "E92000001"], + ["geography", "England"], + ["metric", "influenza_headline_ICUHDUadmissionRateChange"], + ["stratum", "default"], + ["sex", "all"], + ["metric_value", 0], + ["period_start", "2023-07-11"], + ["period_end", "2023-07-18"], + ["isPublic", True], + ) + ) + def test_correct_fields_can_be_given_to_model( + self, field_name: str, field_value: int | str | bool + ): + """ + Given I have a valid field for the APIHeadline model. + When I initialise a new instance of the api headline model passing a field. + Then the value will be assigned to the model. + """ + # Given + field = field_value + + # When + api_headline_model = FakeAPIHeadline() + setattr(api_headline_model, field_name, field_value) + + # Then + field_value_from_model = getattr(api_headline_model, field_name) + assert field_value_from_model == field + + @pytest.mark.parametrize( + "field_name, field_value, field_max_length", + ( + ["age", "all", CHAR_COLUMN_MAX_CONSTRAINT], + ["metric_group", "deaths", CHAR_COLUMN_MAX_CONSTRAINT], + ["theme", "infectious_disease", CHAR_COLUMN_MAX_CONSTRAINT], + ["sub_theme", "respiratory", CHAR_COLUMN_MAX_CONSTRAINT], + ["topic", "COVID-19", CHAR_COLUMN_MAX_CONSTRAINT], + ["geography_type", "Government Office Region", CHAR_COLUMN_MAX_CONSTRAINT], + ["geography_code", "E45000001", GEOGRAPHY_CODE_MAX_CHAR_CONSTRAINT], + ["geography", "North West", LARGE_CHAR_COLUMN_MAX_CONSTRAINT], + ["metric", "COVID-19_deaths_ONSByDay", LARGE_CHAR_COLUMN_MAX_CONSTRAINT], + ["stratum", "default", CHAR_COLUMN_MAX_CONSTRAINT], + ["sex", "all", SEX_MAX_CHAR_CONSTRAINT], + ), + ) + def test_correct_max_length_constraints_returned_from_model( + self, field_name: str, field_value: int | str, field_max_length: int + ): + """ + Given I have a valid field for the API headline mdoel and a max_length constraint + When I initialise a new instance of the api headline model passing in the field + Then the instance should have meta data of a max_length matching the max_length_constraint + """ + # Given + field = field_value + max_length_constraint = field_max_length + + # When + api_headline_model = FakeAPIHeadline() + setattr(api_headline_model, field_name, field) + field_concreate_field = next( + field + for field in api_headline_model._meta.concrete_fields + if field.attname == field_name + ) + + # Then + assert field_concreate_field.max_length == max_length_constraint From 311fb37826085bfeb85143c1ce25932823a8b760 Mon Sep 17 00:00:00 2001 From: phillip stanley Date: Thu, 25 Jun 2026 11:28:13 +0100 Subject: [PATCH 2/3] CDD-3359: Integrate APIHeadline with ingestion --- ingestion/consumer.py | 95 ++++++++++++++++++- ingestion/data_transfer_models/headline.py | 1 + ingestion/file_ingestion.py | 2 +- ingestion/operations/truncated_dataset.py | 2 + .../consumer/test_clear_stale_models.py | 33 ++++++- .../ingestion/consumer/test_process_models.py | 10 +- tests/unit/ingestion/test_file_ingestion.py | 12 +-- 7 files changed, 135 insertions(+), 20 deletions(-) diff --git a/ingestion/consumer.py b/ingestion/consumer.py index 5d6f6d6ae1..a775744214 100644 --- a/ingestion/consumer.py +++ b/ingestion/consumer.py @@ -27,6 +27,7 @@ API_TIME_SERIES_MODEL = MetricsAPIInterface.get_api_timeseries() CORE_TIME_SERIES_MODEL = MetricsAPIInterface.get_core_timeseries() CORE_HEADLINE_MODEL = MetricsAPIInterface.get_core_headline() +API_HEADLINE_MODEL = MetricsAPIInterface.get_api_headline() class SupportingModelsLookup(NamedTuple): @@ -115,6 +116,7 @@ def __init__( age_manager: Manager = DEFAULT_AGE_MANAGER, stratum_manager: Manager = DEFAULT_STRATUM_MANAGER, core_headline_manager: Manager = CORE_HEADLINE_MODEL.objects, + api_headline_manager: Manager = API_HEADLINE_MODEL.objects, core_timeseries_manager: Manager = CORE_TIME_SERIES_MODEL.objects, api_timeseries_manager: Manager = API_TIME_SERIES_MODEL.objects, ): @@ -133,6 +135,7 @@ def __init__( self.age_manager = age_manager self.stratum_manager = stratum_manager self.core_headline_manager = core_headline_manager + self.api_headline_manager = api_headline_manager self.core_timeseries_manager = core_timeseries_manager self.api_timeseries_manager = api_timeseries_manager @@ -345,7 +348,7 @@ def update_supporting_models(self) -> SupportingModelsLookup: age_id=age.id, ) - def process_core_headlines(self) -> None: + def process_core_and_api_headlines(self) -> None: """Creates `CoreHeadline` database records from the ingested data after stale records are deleted ahead of time. Notes: @@ -361,7 +364,7 @@ def process_core_headlines(self) -> None: """ self.clear_stale_headlines() - self.create_core_headlines() + self.create_core_and_api_headlines() def process_core_and_api_timeseries(self) -> None: """Creates `APITimeSeries` and `CoreTimeSeries` records from the ingested data after stale records are deleted. @@ -440,6 +443,62 @@ def create_core_headlines(self) -> None: model_manager=self.core_headline_manager, model_instances=core_headlines ) + def build_api_headlines(self): + """Builds `APIHeadline` model instances from the ingested data + + Returns: + List of `APIHeadline` model instances + + """ + return [ + API_HEADLINE_MODEL( + theme=self.dto.parent_theme, + sub_theme=self.dto.child_theme, + topic=self.dto.topic, + metric=self.dto.metric, + metric_group=self.dto.metric_group, + metric_value=headline_data.metric_value, + geography=self.dto.geography, + geography_type=self.dto.geography_type, + geography_code=self.dto.geography_code, + stratum=self.dto.stratum, + sex=self.dto.sex, + age=self.dto.age, + period_start=headline_data.period_start, + period_end=headline_data.period_end, + refresh_date=self.dto.refresh_date, + embargo=headline_data.embargo, + upper_confidence=headline_data.upper_confidence, + lower_confidence=headline_data.lower_confidence, + force_write=headline_data.force_write, + is_public=headline_data.is_public, + ) + for headline_data in self.dto.data + ] + + def create_api_headlines(self): + """Builds `APIHeadline` model instances from the ingested data + + Returns: + List of `APIHeadline` model instances + + """ + api_headlines = self.build_api_headlines() + return create_records( + model_manager=self.api_headline_manager, model_instances=api_headlines + ) + + + def create_core_and_api_headlines(self) -> None: + """Creates `APIHeadline` and `CoreHeadline` records from the ingested data after stale records are deleted. + + Returns: + None + """ + self.create_core_headlines() + self.create_api_headlines() + + def build_core_time_series(self) -> list[CORE_TIME_SERIES_MODEL]: """Builds `CoreTimeSeries` model instances from the ingested data @@ -505,11 +564,41 @@ def create_core_time_series(self) -> None: ) def clear_stale_headlines(self): - """Deletes all stale records for the `CoreHeadline` records relevant to the ingested dataset + """Deletes all stale records for both `CoreHeadline` and `APIHeadline` relevant to the ingested dataset + + Returns: + None + + """ + self._clear_stale_api_headlines() + self._clear_stale_core_headlines() + + def _clear_stale_api_headlines(self): + """Deletes all stale records for the `APIHeadline` records relevant to the ingested dataset Returns: None + """ + params = { + "theme": self.dto.parent_theme, + "sub_theme": self.dto.child_theme, + "topic": self.dto.topic, + "metric": self.dto.metric, + "geography": self.dto.geography, + "geography_type": self.dto.geography_type, + "geography_code": self.dto.geography_code, + "stratum": self.dto.stratum, + "sex": self.dto.sex, + "age": self.dto.age, + } + self.api_headline_manager.delete_superseded_data(**params, is_public=True) + self.api_headline_manager.delete_superseded_data(**params, is_public=False) + def _clear_stale_core_headlines(self): + """Deletes all stale records for the `CoreHeadline` records relevant to the ingested dataset + + Returns: + None """ params = { "topic": self.dto.topic, diff --git a/ingestion/data_transfer_models/headline.py b/ingestion/data_transfer_models/headline.py index b8c7d5f1e2..40a8af3c18 100644 --- a/ingestion/data_transfer_models/headline.py +++ b/ingestion/data_transfer_models/headline.py @@ -20,6 +20,7 @@ class InboundHeadlineSpecificFields(BaseModel): embargo: datetime.datetime | None metric_value: float lower_confidence: float | None = None + force_write: bool = False is_public: bool = True @field_validator("embargo") diff --git a/ingestion/file_ingestion.py b/ingestion/file_ingestion.py index 195f8592c8..20bb17adb5 100644 --- a/ingestion/file_ingestion.py +++ b/ingestion/file_ingestion.py @@ -39,7 +39,7 @@ def data_ingester(*, data: INCOMING_DATA_TYPE, filename: str) -> None: consumer = Consumer(source_data=data, filename=filename) if consumer.is_headline_data: - return consumer.process_core_headlines() + return consumer.process_core_and_api_headlines() return consumer.process_core_and_api_timeseries() diff --git a/ingestion/operations/truncated_dataset.py b/ingestion/operations/truncated_dataset.py index 9aec9d8aa3..7ae5f3e87d 100644 --- a/ingestion/operations/truncated_dataset.py +++ b/ingestion/operations/truncated_dataset.py @@ -33,6 +33,7 @@ def collect_all_metric_model_managers() -> tuple[models.Manager, ...]: """Collects all model managers associated with the metrics app""" return ( MetricsAPIInterface.get_core_headline_manager(), + MetricsAPIInterface.get_api_headline_manager(), MetricsAPIInterface.get_core_timeseries_manager(), MetricsAPIInterface.get_api_timeseries_manager(), MetricsAPIInterface.get_metric_manager(), @@ -63,6 +64,7 @@ def clear_metrics_tables() -> None: - Sex - Stratum - CoreHeadline + - APIHeadline - CoreTimeSeries - APITimeSeries diff --git a/tests/unit/ingestion/consumer/test_clear_stale_models.py b/tests/unit/ingestion/consumer/test_clear_stale_models.py index 2be39e0a47..a77a875b7e 100644 --- a/tests/unit/ingestion/consumer/test_clear_stale_models.py +++ b/tests/unit/ingestion/consumer/test_clear_stale_models.py @@ -1,6 +1,7 @@ from unittest import mock from ingestion.consumer import Consumer +from metrics.data.managers.api_models.headline import APIHeadlineManager from metrics.data.managers.api_models.time_series import APITimeSeriesManager from metrics.data.managers.core_models.headline import CoreHeadlineManager from metrics.data.managers.core_models.time_series import CoreTimeSeriesManager @@ -19,17 +20,19 @@ def test_clear_stale_headlines( """ # Given spy_core_headline_manager = mock.Mock(spec_set=CoreHeadlineManager) + spy_api_headline_manager = mock.Mock(spec_set=APIHeadlineManager) consumer = Consumer( source_data=example_headline_data, filename=test_filename, core_headline_manager=spy_core_headline_manager, + api_headline_manager=spy_api_headline_manager, ) # When consumer.clear_stale_headlines() # Then - expected_params = { + expected_core_headline_params = { "topic": example_headline_data["topic"], "metric": example_headline_data["metric"], "geography": example_headline_data["geography"], @@ -39,12 +42,32 @@ def test_clear_stale_headlines( "sex": example_headline_data["sex"], "age": example_headline_data["age"], } - expected_calls = [ - mock.call(**expected_params, is_public=True), - mock.call(**expected_params, is_public=False), + expected_core_headline_calls = [ + mock.call(**expected_core_headline_params, is_public=True), + mock.call(**expected_core_headline_params, is_public=False), ] spy_core_headline_manager.delete_superseded_data.assert_has_calls( - calls=expected_calls + calls=expected_core_headline_calls + ) + + expected_api_headline_params = { + "theme": example_headline_data["parent_theme"], + "sub_theme": example_headline_data["child_theme"], + "topic": example_headline_data["topic"], + "metric": example_headline_data["metric"], + "geography": example_headline_data["geography"], + "geography_type": example_headline_data["geography_type"], + "geography_code": example_headline_data["geography_code"], + "stratum": example_headline_data["stratum"], + "sex": example_headline_data["sex"], + "age": example_headline_data["age"], + } + expected_api_headline_calls = [ + mock.call(**expected_api_headline_params, is_public=True), + mock.call(**expected_api_headline_params, is_public=False), + ] + spy_api_headline_manager.delete_superseded_data.assert_has_calls( + calls=expected_api_headline_calls ) def test_clear_stale_timeseries( diff --git a/tests/unit/ingestion/consumer/test_process_models.py b/tests/unit/ingestion/consumer/test_process_models.py index e8e3b75c37..3ac1eafc0f 100644 --- a/tests/unit/ingestion/consumer/test_process_models.py +++ b/tests/unit/ingestion/consumer/test_process_models.py @@ -4,12 +4,12 @@ class TestConsumerProcessModels: - @mock.patch.object(Consumer, "create_core_headlines") + @mock.patch.object(Consumer, "create_core_and_api_headlines") @mock.patch.object(Consumer, "clear_stale_headlines") def test_process_core_headlines( self, spy_clear_stale_headlines: mock.MagicMock, - spy_create_core_headlines: mock.MagicMock, + spy_create_core_and_api_headlines: mock.MagicMock, example_headline_data, test_filename: str, ): @@ -23,16 +23,16 @@ def test_process_core_headlines( # Given spy_manager = mock.Mock() spy_manager.attach_mock(spy_clear_stale_headlines, "spy_clear_stale_headlines") - spy_manager.attach_mock(spy_create_core_headlines, "spy_create_core_headlines") + spy_manager.attach_mock(spy_create_core_and_api_headlines, "spy_create_core_and_api_headlines") consumer = Consumer(source_data=example_headline_data, filename=test_filename) # When - consumer.process_core_headlines() + consumer.process_core_and_api_headlines() # Then expected_calls = [ mock.call.spy_clear_stale_headlines, - mock.call.spy_create_core_headlines, + mock.call.spy_create_core_and_api_headlines, ] spy_manager.assert_has_calls(calls=expected_calls, any_order=False) diff --git a/tests/unit/ingestion/test_file_ingestion.py b/tests/unit/ingestion/test_file_ingestion.py index 34e1580abb..0251aa99a7 100644 --- a/tests/unit/ingestion/test_file_ingestion.py +++ b/tests/unit/ingestion/test_file_ingestion.py @@ -19,10 +19,10 @@ class TestDataIngester: @mock.patch.object(Consumer, "process_core_and_api_timeseries") - @mock.patch.object(Consumer, "process_core_headlines") + @mock.patch.object(Consumer, "process_core_and_api_headlines") def test_delegates_call_to_create_headlines_for_headline_data( self, - spy_process_core_headlines: mock.MagicMock, + spy_process_core_and_api_headlines: mock.MagicMock, spy_process_core_and_api_timeseries: mock.MagicMock, example_headline_data: type_hints.INCOMING_DATA_TYPE, test_filename: str, @@ -41,7 +41,7 @@ def test_delegates_call_to_create_headlines_for_headline_data( data_ingester(data=fake_data, filename=test_filename) # Then - spy_process_core_headlines.assert_called_once() + spy_process_core_and_api_headlines.assert_called_once() spy_process_core_and_api_timeseries.assert_not_called() @pytest.mark.parametrize( @@ -62,12 +62,12 @@ def test_delegates_call_to_create_headlines_for_headline_data( ), ), ) - @mock.patch.object(Consumer, "process_core_headlines") + @mock.patch.object(Consumer, "process_core_and_api_headlines") @mock.patch.object(Consumer, "process_core_and_api_timeseries") def test_delegates_call_to_create_timeseries_for_timeseries_data( self, spy_process_core_and_api_timeseries: mock.MagicMock, - spy_process_core_headlines: mock.MagicMock, + process_core_and_api_headlines: mock.MagicMock, metric: str, metric_group: str, topic: str, @@ -92,7 +92,7 @@ def test_delegates_call_to_create_timeseries_for_timeseries_data( # Then spy_process_core_and_api_timeseries.assert_called_once() - spy_process_core_headlines.assert_not_called() + process_core_and_api_headlines.assert_not_called() class TestUploadData: From b0670f279bbea8f3b27cfd4e594b90747f49bf3d Mon Sep 17 00:00:00 2001 From: Aidan Skinner Date: Fri, 26 Jun 2026 15:10:20 +0100 Subject: [PATCH 3/3] fixup! CDD-3359: Create new APIHeadline model --- ingestion/consumer.py | 2 - metrics/data/managers/api_models/headline.py | 60 +++++++++++-------- metrics/data/models/api_models.py | 18 +++--- .../factories/metrics/api_models/headline.py | 3 +- tests/fakes/models/metrics/api_headline.py | 1 + .../ingestion/consumer/test_process_models.py | 4 +- .../metrics/data/models/test_api_models.py | 60 +++++++++---------- 7 files changed, 77 insertions(+), 71 deletions(-) diff --git a/ingestion/consumer.py b/ingestion/consumer.py index a775744214..fb6dd40a1b 100644 --- a/ingestion/consumer.py +++ b/ingestion/consumer.py @@ -488,7 +488,6 @@ def create_api_headlines(self): model_manager=self.api_headline_manager, model_instances=api_headlines ) - def create_core_and_api_headlines(self) -> None: """Creates `APIHeadline` and `CoreHeadline` records from the ingested data after stale records are deleted. @@ -498,7 +497,6 @@ def create_core_and_api_headlines(self) -> None: self.create_core_headlines() self.create_api_headlines() - def build_core_time_series(self) -> list[CORE_TIME_SERIES_MODEL]: """Builds `CoreTimeSeries` model instances from the ingested data diff --git a/metrics/data/managers/api_models/headline.py b/metrics/data/managers/api_models/headline.py index 13e78c0738..682194ce11 100644 --- a/metrics/data/managers/api_models/headline.py +++ b/metrics/data/managers/api_models/headline.py @@ -2,6 +2,7 @@ This file contains the custom queryset and Manger classes associated with the `APIHeadline` model. """ + from typing import Self from django.db import models @@ -11,6 +12,7 @@ class APIHeadlineQuerySet(models.QuerySet): """Custom queryset which can be used by the `APIHeadlineManger`""" + @staticmethod def _newest_to_oldest( *, queryset: models.QuerySet, apply_refresh_date_only: bool @@ -20,7 +22,7 @@ def _newest_to_oldest( return queryset.order_by("-period_end", "-refresh_date") @staticmethod - def _exclude_data_under_embargo(self, *, queryset: models.QuerySet) -> models.QuerySet: + def _exclude_data_under_embargo(*, queryset: models.QuerySet) -> models.QuerySet: """Excludes any data which is currently embargoed from the given `queryset`. Notes: @@ -92,9 +94,11 @@ def get_all_headlines_released_from_embargo( sex=sex, age=age, ) - queryset = self._exclude_data_under_embargo(self, queryset=queryset) + queryset = self._exclude_data_under_embargo(queryset=queryset) apply_refresh_date_only: bool = "alert" in topic - return self._newest_to_oldest(queryset=queryset, apply_refresh_date_only=apply_refresh_date_only) + return self._newest_to_oldest( + queryset=queryset, apply_refresh_date_only=apply_refresh_date_only + ) def get_public_only_headlines_released_from_embargo( self, @@ -163,6 +167,7 @@ def get_non_public_only_headlines_released_from_embargo( class APIHeadlineManager(models.Manager): """Custom model manager class for the `APIHeadline` model.""" + def get_queryset(self) -> APIHeadlineQuerySet: return APIHeadlineQuerySet(self.model, using=self._db) @@ -212,30 +217,34 @@ def query_for_superseded_data( The stale records in their entirety as a queryset """ if is_public: - queryset = self.get_queryset().get_public_only_headlines_released_from_embargo( - theme=theme, - sub_theme=sub_theme, - topic=topic, - metric=metric, - geography=geography, - geography_type=geography_type, - geography_code=geography_code, - stratum=stratum, - age=age, - sex=sex, + queryset = ( + self.get_queryset().get_public_only_headlines_released_from_embargo( + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography=geography, + geography_type=geography_type, + geography_code=geography_code, + stratum=stratum, + age=age, + sex=sex, + ) ) else: - queryset = self.get_queryset().get_non_public_only_headlines_released_from_embargo( - theme=theme, - sub_theme=sub_theme, - topic=topic, - metric=metric, - geography=geography, - geography_type=geography_type, - geography_code=geography_code, - stratum=stratum, - age=age, - sex=sex, + queryset = ( + self.get_queryset().get_non_public_only_headlines_released_from_embargo( + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography=geography, + geography_type=geography_type, + geography_code=geography_code, + stratum=stratum, + age=age, + sex=sex, + ) ) try: @@ -307,4 +316,3 @@ def delete_superseded_data( is_public=is_public, ) superseded_records.delete() - diff --git a/metrics/data/models/api_models.py b/metrics/data/models/api_models.py index ad5f288362..823d2a12a1 100644 --- a/metrics/data/models/api_models.py +++ b/metrics/data/models/api_models.py @@ -103,32 +103,30 @@ class APIHeadline(models.Model): sub_theme = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT) topic = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT) metric = models.CharField(max_length=LARGE_CHAR_COLUMN_MAX_CONSTRAINT) - metric_group = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT, null=True) + metric_group = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT, blank=True) geography = models.CharField(max_length=LARGE_CHAR_COLUMN_MAX_CONSTRAINT) geography_type = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT) geography_code = models.CharField( - max_length=GEOGRAPHY_CODE_MAX_CHAR_CONSTRAINT, null=True + max_length=GEOGRAPHY_CODE_MAX_CHAR_CONSTRAINT, blank=True ) stratum = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT) - sex = models.CharField(max_length=SEX_MAX_CHAR_CONSTRAINT, null=True) - age = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT, null=True) + sex = models.CharField(max_length=SEX_MAX_CHAR_CONSTRAINT, blank=True) + age = models.CharField(max_length=CHAR_COLUMN_MAX_CONSTRAINT, blank=True) - period_start = models.DateTimeField(null=True) - period_end = models.DateTimeField(null=True) - refresh_date = models.DateTimeField(null=True) - embargo = models.DateTimeField(null=True) + period_start = models.DateTimeField(blank=True) + period_end = models.DateTimeField(blank=True) + refresh_date = models.DateTimeField(blank=True) + embargo = models.DateTimeField(blank=True) metric_value = models.FloatField() upper_confidence = models.DecimalField( max_digits=METRIC_VALUE_MAX_DIGITS, decimal_places=METRIC_VALUE_DECIMAL_PLACES, blank=True, - null=True, ) lower_confidence = models.DecimalField( max_digits=METRIC_VALUE_MAX_DIGITS, decimal_places=METRIC_VALUE_DECIMAL_PLACES, - null=True, blank=True, ) diff --git a/tests/factories/metrics/api_models/headline.py b/tests/factories/metrics/api_models/headline.py index eeaa84db54..bcef22616a 100644 --- a/tests/factories/metrics/api_models/headline.py +++ b/tests/factories/metrics/api_models/headline.py @@ -70,8 +70,7 @@ def create_record( @classmethod def _make_datetime_timezone_aware( - cls, - datetime_obj: str | datetime.datetime | None + cls, datetime_obj: str | datetime.datetime | None ) -> datetime.datetime: if datetime_obj.tzinfo is None: diff --git a/tests/fakes/models/metrics/api_headline.py b/tests/fakes/models/metrics/api_headline.py index 0227910b80..1b6c9411d3 100644 --- a/tests/fakes/models/metrics/api_headline.py +++ b/tests/fakes/models/metrics/api_headline.py @@ -7,6 +7,7 @@ class FakeAPIHeadline(APIHeadline): A fake version of the Django model `APIHeadline` which has had its dependencies altered so that it does not interact with the database """ + Meta = FakeMeta def __init__(self, **kwargs): diff --git a/tests/unit/ingestion/consumer/test_process_models.py b/tests/unit/ingestion/consumer/test_process_models.py index 3ac1eafc0f..d7a338241c 100644 --- a/tests/unit/ingestion/consumer/test_process_models.py +++ b/tests/unit/ingestion/consumer/test_process_models.py @@ -23,7 +23,9 @@ def test_process_core_headlines( # Given spy_manager = mock.Mock() spy_manager.attach_mock(spy_clear_stale_headlines, "spy_clear_stale_headlines") - spy_manager.attach_mock(spy_create_core_and_api_headlines, "spy_create_core_and_api_headlines") + spy_manager.attach_mock( + spy_create_core_and_api_headlines, "spy_create_core_and_api_headlines" + ) consumer = Consumer(source_data=example_headline_data, filename=test_filename) # When diff --git a/tests/unit/metrics/data/models/test_api_models.py b/tests/unit/metrics/data/models/test_api_models.py index 664f1f9e92..a043e311c7 100644 --- a/tests/unit/metrics/data/models/test_api_models.py +++ b/tests/unit/metrics/data/models/test_api_models.py @@ -100,26 +100,26 @@ class TestAPIHeadline: @pytest.mark.parametrize( "field_name, field_value", ( - ["age", "all"], - ["refresh_date", "2023-07-11"], - ["metric_group", "headline"], - ["theme", "infectious_disease"], - ["sub_theme", "respiratory"], - ["topic", "Influenza"], - ["geography_type", "Nation"], - ["geography_code", "E92000001"], - ["geography", "England"], - ["metric", "influenza_headline_ICUHDUadmissionRateChange"], - ["stratum", "default"], - ["sex", "all"], - ["metric_value", 0], - ["period_start", "2023-07-11"], - ["period_end", "2023-07-18"], - ["isPublic", True], - ) + ["age", "all"], + ["refresh_date", "2023-07-11"], + ["metric_group", "headline"], + ["theme", "infectious_disease"], + ["sub_theme", "respiratory"], + ["topic", "Influenza"], + ["geography_type", "Nation"], + ["geography_code", "E92000001"], + ["geography", "England"], + ["metric", "influenza_headline_ICUHDUadmissionRateChange"], + ["stratum", "default"], + ["sex", "all"], + ["metric_value", 0], + ["period_start", "2023-07-11"], + ["period_end", "2023-07-18"], + ["isPublic", True], + ), ) def test_correct_fields_can_be_given_to_model( - self, field_name: str, field_value: int | str | bool + self, field_name: str, field_value: int | str | bool ): """ Given I have a valid field for the APIHeadline model. @@ -140,21 +140,21 @@ def test_correct_fields_can_be_given_to_model( @pytest.mark.parametrize( "field_name, field_value, field_max_length", ( - ["age", "all", CHAR_COLUMN_MAX_CONSTRAINT], - ["metric_group", "deaths", CHAR_COLUMN_MAX_CONSTRAINT], - ["theme", "infectious_disease", CHAR_COLUMN_MAX_CONSTRAINT], - ["sub_theme", "respiratory", CHAR_COLUMN_MAX_CONSTRAINT], - ["topic", "COVID-19", CHAR_COLUMN_MAX_CONSTRAINT], - ["geography_type", "Government Office Region", CHAR_COLUMN_MAX_CONSTRAINT], - ["geography_code", "E45000001", GEOGRAPHY_CODE_MAX_CHAR_CONSTRAINT], - ["geography", "North West", LARGE_CHAR_COLUMN_MAX_CONSTRAINT], - ["metric", "COVID-19_deaths_ONSByDay", LARGE_CHAR_COLUMN_MAX_CONSTRAINT], - ["stratum", "default", CHAR_COLUMN_MAX_CONSTRAINT], - ["sex", "all", SEX_MAX_CHAR_CONSTRAINT], + ["age", "all", CHAR_COLUMN_MAX_CONSTRAINT], + ["metric_group", "deaths", CHAR_COLUMN_MAX_CONSTRAINT], + ["theme", "infectious_disease", CHAR_COLUMN_MAX_CONSTRAINT], + ["sub_theme", "respiratory", CHAR_COLUMN_MAX_CONSTRAINT], + ["topic", "COVID-19", CHAR_COLUMN_MAX_CONSTRAINT], + ["geography_type", "Government Office Region", CHAR_COLUMN_MAX_CONSTRAINT], + ["geography_code", "E45000001", GEOGRAPHY_CODE_MAX_CHAR_CONSTRAINT], + ["geography", "North West", LARGE_CHAR_COLUMN_MAX_CONSTRAINT], + ["metric", "COVID-19_deaths_ONSByDay", LARGE_CHAR_COLUMN_MAX_CONSTRAINT], + ["stratum", "default", CHAR_COLUMN_MAX_CONSTRAINT], + ["sex", "all", SEX_MAX_CHAR_CONSTRAINT], ), ) def test_correct_max_length_constraints_returned_from_model( - self, field_name: str, field_value: int | str, field_max_length: int + self, field_name: str, field_value: int | str, field_max_length: int ): """ Given I have a valid field for the API headline mdoel and a max_length constraint