Skip to content

Commit 7ed7e1d

Browse files
committed
CDD-3295: Add in_reporting delay and fix unit tests
1 parent 845f4c1 commit 7ed7e1d

10 files changed

Lines changed: 459 additions & 62 deletions

File tree

metrics/api/serializers/charts/dual_category_charts.py

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
DEFAULT_Y_AXIS,
1313
ChartAxisFields,
1414
ChartTypes,
15+
DataSourceFileType,
1516
DEFAULT_Y_AXIS_MINIMUM_VAlUE,
17+
extract_metric_group_from_metric,
1618
)
1719
from metrics.domain.models.charts.dual_category_charts import (
1820
DualCategoryChartRequestParams,
@@ -67,24 +69,31 @@ def validate(cls, attrs: dict) -> dict:
6769
"""Validate primary_field_values based on the selected x-axis."""
6870
x_axis = attrs.get("x_axis") or DEFAULT_X_AXIS
6971
primary_field_values = attrs.get("primary_field_values") or []
72+
metric = attrs["static_fields"]["metric"]
73+
metric_group = extract_metric_group_from_metric(metric=metric)
74+
is_timeseries_data = DataSourceFileType[metric_group].is_timeseries
7075

71-
# When x-axis is date, primary_field_values should not be provided as the chart will be a timeseries
72-
if x_axis == ChartAxisFields.date.name and primary_field_values:
73-
raise serializers.ValidationError(
74-
{
75-
"primary_field_values": (
76-
"This field should not be provided when x_axis is 'date'."
77-
)
78-
}
79-
)
76+
if is_timeseries_data:
77+
if primary_field_values:
78+
raise serializers.ValidationError(
79+
{
80+
"primary_field_values": (
81+
"This field should not be provided for timeseries data."
82+
)
83+
}
84+
)
85+
if x_axis != ChartAxisFields.date.name:
86+
raise serializers.ValidationError(
87+
{
88+
"x_axis": (
89+
"This field should be set to 'date' for timeseries data."
90+
)
91+
}
92+
)
8093

81-
if x_axis != ChartAxisFields.date.name and not primary_field_values:
94+
elif not is_timeseries_data and not primary_field_values:
8295
raise serializers.ValidationError(
83-
{
84-
"primary_field_values": (
85-
"This field is required when x_axis is not 'date'."
86-
)
87-
}
96+
{"primary_field_values": ("This field is required for headline data.")}
8897
)
8998

9099
return attrs

metrics/api/views/charts/dual_category_charts.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,7 @@
4747
"file_format": "svg",
4848
"chart_height": 200,
4949
"chart_width": 320,
50-
"x_axis": "sex",
51-
"primary_field_values": ["m", "f"],
50+
"x_axis": "date",
5251
"y_axis": "metric",
5352
"x_axis_title": "",
5453
"y_axis_title": "",

metrics/domain/charts/reporting_delay_period.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import contextlib
22
import logging
3-
from datetime import datetime
3+
from datetime import date, datetime
44

55
import plotly
66
from plotly.graph_objs import Scatter
@@ -55,9 +55,13 @@ def _get_last_x_value_at_end_of_reporting_delay_period(
5555

5656
def get_x_value_at_start_of_reporting_delay_period(
5757
chart_plots_data: list[PlotGenerationData],
58-
) -> str:
59-
index: int = chart_plots_data[0].start_of_reporting_delay_period_index
60-
return chart_plots_data[0].x_axis_values[index]
58+
) -> date:
59+
values = [
60+
plot.x_axis_values[plot.start_of_reporting_delay_period_index]
61+
for plot in chart_plots_data
62+
]
63+
64+
return min(values)
6165

6266

6367
def add_reporting_delay_period(

metrics/domain/charts/stacked_bar/generation.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import plotly.graph_objects as go
44

55
from metrics.domain.charts.chart_settings.dual_category import DualCategoryChartSettings
6+
from metrics.domain.charts.reporting_delay_period import add_reporting_delay_period
67
from metrics.domain.models.plots import ChartGenerationPayload
78

89

@@ -20,6 +21,9 @@ def generate_stacked_bar(
2021
The figure for the stacked bar chart.
2122
"""
2223
figure = go.Figure()
24+
settings = DualCategoryChartSettings(
25+
chart_generation_payload=chart_generation_payload,
26+
)
2327

2428
grouped: dict[str, dict] = defaultdict(
2529
lambda: {"x_axis_values": [], "y_axis_values": []}
@@ -41,9 +45,13 @@ def generate_stacked_bar(
4145
)
4246
)
4347

44-
settings = DualCategoryChartSettings(
45-
chart_generation_payload=chart_generation_payload,
46-
)
47-
figure.update_layout(**settings.get_stacked_bar_chart_config())
48+
if settings.is_date_type_x_axis:
49+
add_reporting_delay_period(
50+
chart_plots_data=chart_generation_payload.plots,
51+
figure=figure,
52+
)
53+
54+
layout_args = settings.get_stacked_bar_chart_config()
55+
figure.update_layout(**layout_args)
4856

4957
return figure

tests/integration/metrics/api/views/charts/test_dual_category_charts.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@ def path(self) -> str:
2525
],
2626
)
2727
@pytest.mark.django_db
28-
def test_returns_correct_response_for_age_based_chart(
28+
def test_returns_correct_response_for_timeseries_chart(
2929
self,
3030
query_params: dict[str, bool],
3131
response_header: str,
3232
admin_user: User,
3333
):
3434
"""
35-
Given a valid payload to create a chart
35+
Given a valid payload to create a timeseries dual-category chart
3636
When the `POST /api/charts/dual-category/v1/` endpoint is hit
3737
with the given preview query params
3838
Then an HTTP 200 OK response is returned
@@ -44,18 +44,17 @@ def test_returns_correct_response_for_age_based_chart(
4444
static_fields = valid_payload["static_fields"]
4545

4646
for age in ("00-04", "05-11"):
47-
for sex in ("m", "f"):
48-
CoreTimeSeriesFactory.create_record(
49-
topic_name=static_fields["topic"],
50-
metric_name=static_fields["metric"],
51-
stratum_name=static_fields["stratum"],
52-
age_name=age,
53-
geography_name=static_fields["geography"],
54-
geography_type_name=static_fields["geography_type"],
55-
sex=sex,
56-
date=static_fields["date_from"],
57-
metric_value=100,
58-
)
47+
CoreTimeSeriesFactory.create_record(
48+
topic_name=static_fields["topic"],
49+
metric_name=static_fields["metric"],
50+
stratum_name=static_fields["stratum"],
51+
age_name=age,
52+
geography_name=static_fields["geography"],
53+
geography_type_name=static_fields["geography_type"],
54+
sex=static_fields["sex"],
55+
date=static_fields["date_from"],
56+
metric_value=100,
57+
)
5958

6059
# When
6160
response: Response = client.post(

tests/unit/metrics/api/serializers/charts/test_dual_category_charts.py

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
from metrics.domain.models.charts.dual_category_charts import (
1717
DualCategoryChartRequestParams,
1818
)
19+
from tests.fakes.factories.metrics.metric_factory import FakeMetricFactory
20+
from tests.fakes.managers.metric_manager import FakeMetricManager
21+
from tests.fakes.managers.topic_manager import FakeTopicManager
22+
23+
HEADLINE_METRIC = "COVID-19_headline_vaccines_spring24Uptake"
24+
TIMESERIES_METRIC = "COVID-19_cases_rateRollingMean"
1925

2026

2127
class TestDualCategoryChartSegmentSerializer:
@@ -201,26 +207,26 @@ def test_missing_label_is_still_deemed_valid(self):
201207
class TestDualCategoryChartSerializer:
202208
# Success cases
203209
@pytest.mark.parametrize(
204-
"x_axis,primary_field_values",
210+
"metric,x_axis,primary_field_values",
205211
[
206-
("age", ["m", "f"]),
207-
# Test that primary_field_values can be omitted when x_axis is date
208-
("date", None),
209-
# Test defaulting to date when x_axis is not provided
210-
(None, None),
212+
(HEADLINE_METRIC, "age", ["m", "f"]),
213+
(TIMESERIES_METRIC, "date", None),
214+
(TIMESERIES_METRIC, None, None),
211215
],
212216
)
213-
def test_validation_with_valid_x_axis_and_primary_field_values_combination(
214-
self, x_axis, primary_field_values
217+
def test_validation_with_valid_metric_and_primary_field_values_combination(
218+
self, metric: str, x_axis: str | None, primary_field_values: list[str] | None
215219
):
216220
"""
217-
Given a payload containing varying x_axis and primary_field_values
221+
Given a payload containing a valid metric and primary_field_values combination
218222
passed to a `DualCategoryChartSerializer` object
219223
When `validate()` is called from the serializer
220-
Then no ValidationError is raised and the data is returned as these are valid combination of fields
224+
Then no ValidationError is raised and the data is returned unchanged
221225
"""
222226
# Given
223227
valid_payload = EXAMPLE_DUAL_CATEGORY_CHART_REQUEST_PAYLOAD.copy()
228+
valid_payload["static_fields"] = valid_payload["static_fields"].copy()
229+
valid_payload["static_fields"]["metric"] = metric
224230
valid_payload["x_axis"] = x_axis
225231
valid_payload["primary_field_values"] = primary_field_values
226232

@@ -233,16 +239,15 @@ def test_validation_with_valid_x_axis_and_primary_field_values_combination(
233239
assert is_valid == valid_payload
234240

235241
# Failure cases
236-
def test_validation_with_x_axis_and_primary_field_values_present(self):
242+
def test_validation_with_primary_field_values_for_timeseries_data(self):
237243
"""
238-
Given a payload containing x_axis and primary_field_values
244+
Given a payload containing primary_field_values for a timeseries metric
239245
passed to a `DualCategoryChartSerializer` object
240246
When `validate()` is called from the serializer
241-
Then a `ValidationError` is raised as primary_field_values should not be provided when x_axis value is date
247+
Then a `ValidationError` is raised as primary_field_values should not be provided
242248
"""
243249
# Given
244250
invalid_payload = EXAMPLE_DUAL_CATEGORY_CHART_REQUEST_PAYLOAD.copy()
245-
invalid_payload["x_axis"] = "date"
246251
invalid_payload["primary_field_values"] = ["m", "f"]
247252

248253
serializer = DualCategoryChartSerializer()
@@ -252,22 +257,43 @@ def test_validation_with_x_axis_and_primary_field_values_present(self):
252257
serializer.validate(attrs=invalid_payload)
253258

254259
assert exc_info.value.detail["primary_field_values"] == (
255-
"This field should not be provided when x_axis is 'date'."
260+
"This field should not be provided for timeseries data."
261+
)
262+
263+
def test_validation_with_non_date_x_axis_for_timeseries_data(self):
264+
"""
265+
Given a payload containing a non-date x_axis for a timeseries metric
266+
passed to a `DualCategoryChartSerializer` object
267+
When `validate()` is called from the serializer
268+
Then a `ValidationError` is raised as x_axis must be date for timeseries data
269+
"""
270+
# Given
271+
invalid_payload = EXAMPLE_DUAL_CATEGORY_CHART_REQUEST_PAYLOAD.copy()
272+
invalid_payload["x_axis"] = "age"
273+
274+
serializer = DualCategoryChartSerializer()
275+
276+
# When / Then
277+
with pytest.raises(ValidationError) as exc_info:
278+
serializer.validate(attrs=invalid_payload)
279+
280+
assert exc_info.value.detail["x_axis"] == (
281+
"This field should be set to 'date' for timeseries data."
256282
)
257283

258-
def test_validation_with_primary_field_values_missing_when_x_axis_is_not_date(self):
284+
def test_validation_with_primary_field_values_missing_for_headline_data(self):
259285
"""
260-
Given a payload containing x_axis but no primary_field_values
286+
Given a payload containing a headline metric but no primary_field_values
261287
passed to a `DualCategoryChartSerializer` object
262288
When `validate()` is called from the serializer
263-
Then a `ValidationError` is raised as primary_field_values should be provided when x_axis value is not date
289+
Then a `ValidationError` is raised as primary_field_values are required
264290
"""
265291
# Given
266292
invalid_payload = EXAMPLE_DUAL_CATEGORY_CHART_REQUEST_PAYLOAD.copy()
293+
invalid_payload["static_fields"] = invalid_payload["static_fields"].copy()
294+
invalid_payload["static_fields"]["metric"] = HEADLINE_METRIC
267295
invalid_payload["x_axis"] = "age"
268-
invalid_payload.pop(
269-
"primary_field_values"
270-
) # Remove primary_field_values to simulate it being missing
296+
invalid_payload.pop("primary_field_values", None)
271297

272298
serializer = DualCategoryChartSerializer()
273299

@@ -276,7 +302,7 @@ def test_validation_with_primary_field_values_missing_when_x_axis_is_not_date(se
276302
serializer.validate(attrs=invalid_payload)
277303

278304
assert exc_info.value.detail["primary_field_values"] == (
279-
"This field is required when x_axis is not 'date'."
305+
"This field is required for headline data."
280306
)
281307

282308
def test_to_models_builds_timeseries_plots_when_x_axis_is_date(
@@ -330,3 +356,59 @@ def test_to_models_builds_timeseries_plots_when_x_axis_is_date(
330356
assert plot.metric == static_fields["metric"]
331357
assert plot.date_from == static_fields["date_from"]
332358
assert plot.date_to == static_fields["date_to"]
359+
360+
def test_to_models_builds_headline_plots_with_primary_field_values(self):
361+
"""
362+
Given a valid payload with a headline metric and primary field values
363+
When `to_models()` is called from the serializer
364+
Then one plot per segment is returned for each primary field value
365+
"""
366+
# Given
367+
fake_metric = FakeMetricFactory.build_example_metric(
368+
metric_name=HEADLINE_METRIC,
369+
metric_group_name="headline",
370+
)
371+
fake_topic = fake_metric.metric_group.topic
372+
metric_manager = FakeMetricManager([fake_metric])
373+
topic_manager = FakeTopicManager([fake_topic])
374+
valid_payload = EXAMPLE_DUAL_CATEGORY_CHART_REQUEST_PAYLOAD.copy()
375+
valid_payload["static_fields"] = valid_payload["static_fields"].copy()
376+
valid_payload["static_fields"]["topic"] = fake_topic.name
377+
valid_payload["static_fields"]["metric"] = HEADLINE_METRIC
378+
valid_payload["x_axis"] = ChartAxisFields.sex.name
379+
valid_payload["primary_field_values"] = ["m", "f"]
380+
381+
serializer_context = {
382+
"topic_manager": topic_manager,
383+
"metric_manager": metric_manager,
384+
}
385+
serializer = DualCategoryChartSerializer(
386+
data=valid_payload,
387+
context=serializer_context,
388+
)
389+
serializer.fields["static_fields"] = PlotSerializer(context=serializer_context)
390+
serializer.is_valid(raise_exception=True)
391+
392+
# When
393+
result: DualCategoryChartRequestParams = serializer.to_models(request=None)
394+
395+
# Then
396+
segments = valid_payload["segments"]
397+
secondary_category = valid_payload["secondary_category"]
398+
x_axis = valid_payload["x_axis"]
399+
400+
assert result.primary_field_values == ["m", "f"]
401+
assert len(result.plots) == len(segments) * len(
402+
valid_payload["primary_field_values"]
403+
)
404+
405+
for index, plot in enumerate(result.plots):
406+
segment_index = index % len(segments)
407+
primary_index = index // len(segments)
408+
segment = segments[segment_index]
409+
primary_field_value = valid_payload["primary_field_values"][primary_index]
410+
411+
assert plot.x_axis == x_axis
412+
assert getattr(plot, x_axis) == primary_field_value
413+
assert getattr(plot, secondary_category) == segment["secondary_field_value"]
414+
assert plot.line_colour == segment["colour"]

0 commit comments

Comments
 (0)