From de06239f4afd900bc25cc65cc7aece46bbf26747 Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Tue, 30 Jun 2026 14:15:02 +0100 Subject: [PATCH 01/20] CDD-3451: add theme and sub_theme DB storage to a CMS page body (by copying the most recent wagtail.fields.StreamField and inserting theme and sub_theme into it) --- .../migrations/0037_alter_landingpage_body.py | 1168 +++++++++++++ .../migrations/0034_alter_topicpage_body.py | 1482 +++++++++++++++++ .../0008_alter_topicslistpage_body.py | 1168 +++++++++++++ 3 files changed, 3818 insertions(+) create mode 100644 cms/home/migrations/0037_alter_landingpage_body.py create mode 100644 cms/topic/migrations/0034_alter_topicpage_body.py create mode 100644 cms/topics_list/migrations/0008_alter_topicslistpage_body.py diff --git a/cms/home/migrations/0037_alter_landingpage_body.py b/cms/home/migrations/0037_alter_landingpage_body.py new file mode 100644 index 0000000000..df9c2a8183 --- /dev/null +++ b/cms/home/migrations/0037_alter_landingpage_body.py @@ -0,0 +1,1168 @@ +# Generated by Django 5.2.14 on 2026-06-29 19:15 + +import cms.dynamic_content.cards +import cms.metrics_interface.field_choices_callables +import validation.url +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("home", "0036_alter_landingpage_make_health_topic_optional"), + ] + + operations = [ + migrations.AlterField( + model_name="landingpage", + name="body", + field=wagtail.fields.StreamField( + [("section", 110)], + block_lookup={ + 0: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe text you add here will be used as the heading for this section. \n", + "required": True, + }, + ), + 1: ( + "cms.dynamic_content.blocks.PageLinkChooserBlock", + (), + { + "help_text": "\nThe related index page you want to link to. Eg: `Respiratory viruses` or `Outbreaks`\n", + "page_type": ["composite.CompositePage"], + "required": False, + }, + ), + 2: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": ["h2", "h3", "h4", "bold", "ul", "link"], + "help_text": "\nThis section of text will comprise this card. \nNote that this card will span the length of the available page width if sufficient text content is provided.\n", + }, + ), + 3: ("wagtail.blocks.StructBlock", [[("body", 2)]], {}), + 4: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe title to display for this component. \nNote that this will be shown in the hex colour #505A5F\n", + "required": True, + }, + ), + 5: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed below the chart title.\n", + "label": "Subtitle", + "required": False, + }, + ), + 6: ( + "wagtail.blocks.TextBlock", + (), + { + "default": "", + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed in the about content of the chart.\n", + "required": False, + }, + ), + 7: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe Text that will be displayed for the URL.\n", + "required": True, + }, + ), + 8: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe URL that the user will be navigated to when clicked.\nAn optional body of text to accompany this block. This text will be displayed below the chart title.\n", + "required": True, + }, + ), + 9: ( + "wagtail.blocks.StructBlock", + [[("link_display_text", 7), ("link", 8)]], + {}, + ), + 10: ( + "wagtail.blocks.StreamBlock", + [[("related_link", 9)]], + { + "help_text": "\nProvide optional URLs that can provide further contextual information for the data displayed in the chart.\n", + "required": False, + }, + ), + 11: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe ID to associate with this component. \nThis allows for tracking of events when users interact with this component.\nNote that changing this multiple times will result in the recording of different groups of events.\n", + "label": "Tag manager event ID", + "required": False, + }, + ), + 12: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nAn optional choice of what to display along the x-axis of the chart.\nIf nothing is provided, `dates` will be used by default.\nDates are used by default\n", + "required": False, + }, + ), + 13: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "", + "help_text": "\nAn optional title to display along the x-axis of the chart.\nIf nothing is provided, then no title will be displayed.\n", + "required": False, + }, + ), + 14: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nAn optional choice of what to display along the y-axis of the chart.\nIf nothing is provided, `metric value` will be used by default.\n", + "required": False, + }, + ), + 15: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "", + "help_text": "\nAn optional title to display along the y-axis of the chart.\nIf nothing is provided, then no title will be displayed.\n", + "required": False, + }, + ), + 16: ( + "wagtail.blocks.DecimalBlock", + (), + { + "default": 0, + "help_text": "\nThis field allows you to set the first value in the chart's y-axis range. Please\nnote that a value provided here, which is higher than the lowest value in the data will\nbe overridden and the value from the dataset will be used.\n", + "required": False, + }, + ), + 17: ( + "wagtail.blocks.DecimalBlock", + (), + { + "help_text": "\nThis field allows you to set the last value in the chart's y-axis range. Please\nnote that a value provided here, which is lower than the highest value in the data will\nbe overridden and the value from the dataset will be used. \n", + "required": False, + }, + ), + 18: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nThis is a switch to show tooltips on hover within the chart.\nDefaults to False.\n", + "required": False, + }, + ), + 19: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "Up to and including", + "help_texts": "\nThis is the accompanying text for chart dates Eg: `Up to and including` 21 Oct 2024\n", + "requried": True, + }, + ), + 20: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nThis option enables timeseries filter for this chart.\nThe timeseries filter allows a user to change the timeseries range for example between\n1m, 3m, 6m, 1y etc.\n", + "required": False, + }, + ), + 21: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_theme_names, + "help_text": "\nThe related theme for the metric. Eg: infectious disease\n", + }, + ), + 22: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_sub_theme_names, + "help_text": "\nThe related sub theme for the metric. Eg: respiratory\n", + }, + ), + 23: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_topic_names, + "help_text": "The name of the topic to pull data e.g. COVID-19.", + }, + ), + 24: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_timeseries_metric_names, + "help_text": '\nThe name of the metric to pull data for e.g. "COVID-19_deaths_ONSByDay".\n', + }, + ), + 25: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_geography_names, + "help_text": "\nThe name of the geography associated with this particular piece of data.\nIf nothing is provided, then no filtering will be applied for this field.\n", + }, + ), + 26: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_geography_type_names, + "help_text": "\nThe type of geographical categorisation to apply any data filtering to.\nIf nothing is provided, then no filtering will be applied for this field.\n", + }, + ), + 27: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_sex_names, + "help_text": "\nThe gender to filter for, if any.\nThe only options available are `M`, `F` and `ALL`.\nBy default, no filtering will be applied to the underlying query if no selection is made.\n", + }, + ), + 28: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_age_names, + "help_text": "\nThe age band to filter for, if any.\nBy default, no filtering will be applied to the underlying query if no selection is made.\n", + }, + ), + 29: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_stratum_names, + "help_text": "\nThe smallest subgroup a piece of data can be broken down into.\nFor example, this could be broken down by ethnicity or testing pillar.\nIf nothing is provided, then no filtering will be applied for this field.\n", + }, + ), + 30: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_chart_types, + "help_text": "\nThe name of the type of chart which you want to create e.g. bar\n", + }, + ), + 31: ( + "wagtail.blocks.DateBlock", + (), + { + "help_text": "\nThe date from which to begin the supporting plot data. \nNote that if nothing is provided, a default of 1 year ago from the current date will be applied.\n", + "required": False, + }, + ), + 32: ( + "wagtail.blocks.DateBlock", + (), + { + "help_text": "\nThe date to which to end the supporting plot data. \nNote that if nothing is provided, a default of the current date will be applied.\n", + "required": False, + }, + ), + 33: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe label to assign on the legend for this individual plot.\nE.g. `15 to 44 years old`\n", + "required": False, + }, + ), + 34: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_colours, + "help_text": '\nThe colour to apply to this individual line plot. The colours conform to the GDS specification.\nCurrently, only the `line_multi_coloured` chart type supports different line colours.\nFor all other chart types, this field will be ignored.\nNote that if nothing is provided, a default of "BLACK" will be applied.\nE.g. `GREEN`\n', + }, + ), + 35: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_chart_line_types, + "help_text": '\nThe line type to apply to this individual line plot.\nCurrently, only the `line_multi_coloured` chart type supports different line types.\nFor all other chart types, this field will be ignored.\nNote that if nothing is provided, a default of "SOLID" will be applied.\nE.g. `DASH`\n', + "required": False, + }, + ), + 36: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nIf set to true, markers are drawn on each individual data point.\nIf set to false, markers are not drawn at all.\nThis is only applicable to line-type charts.\n", + "required": False, + }, + ), + 37: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": True, + "help_text": "\nIf set to true, draws the plot as a spline line, resulting in smooth curves between data points.\nIf set to false, draws the plot as a linear line, \nresulting in linear point-to-point lines being drawn between data points.\nThis is only applicable to line-type charts.\n", + "required": False, + }, + ), + 38: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 24), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("chart_type", 30), + ("date_from", 31), + ("date_to", 32), + ("label", 33), + ("line_colour", 34), + ("line_type", 35), + ("use_markers", 36), + ("use_smooth_lines", 37), + ] + ], + {}, + ), + 39: ( + "wagtail.blocks.StreamBlock", + [[("plot", 38)]], + { + "help_text": "\nAdd the plots required for your chart. \nWithin each plot, you will be required to add a set of fields which will be used to fetch the supporting data \nfor that plot.\n" + }, + ), + 40: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 6), + ("related_links", 10), + ("tag_manager_event_id", 11), + ("x_axis", 12), + ("x_axis_title", 13), + ("y_axis", 14), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("show_tooltips", 18), + ("date_prefix", 19), + ("show_timeseries_filter", 20), + ("chart", 39), + ] + ], + {}, + ), + 41: ( + "cms.dynamic_content.blocks.PageLinkChooserBlock", + (), + { + "help_text": "\nThe related topic page you want to link to. Eg: `COVID-19`\n", + "page_type": ["topic.TopicPage"], + "required": True, + }, + ), + 42: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nRequired description for the chart.\n", + "required": True, + }, + ), + 43: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nDisplay text for the link.\n", + "required": False, + }, + ), + 44: ( + "cms.dynamic_content.blocks.PageLinkChooserBlock", + (), + { + "help_text": "\nFor linking to internal pages. (If chosen, external_url must be blank).\n", + "page_type": ["topic.TopicPage"], + "required": False, + }, + ), + 45: ( + "wagtail.blocks.URLBlock", + (), + { + "help_text": "\nFor linking to external url. (Only one of page or external_url must be filled not both).\n", + "required": False, + "validators": [validation.url.validate_https_scheme], + }, + ), + 46: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 43), + ("page", 44), + ("external_url", 45), + ] + ], + { + "help_text": "\nSource link (internal or external).\n", + "required": False, + }, + ), + 47: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("sub_title", 5), + ("topic_page", 41), + ("description", 42), + ("source", 46), + ("tag_manager_event_id", 11), + ("x_axis", 12), + ("x_axis_title", 13), + ("y_axis", 14), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("show_tooltips", 18), + ("chart", 39), + ] + ], + {}, + ), + 48: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nA required choice of what to display along the x-axis of the chart.\n", + }, + ), + 49: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_headline_metric_names, + "help_text": '\nThe name of the metric to pull data for e.g. "COVID-19_deaths_ONSByDay".\n', + }, + ), + 50: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_headline_chart_types, + "help_text": "\nThe name of the type of chart which you want to create e.g. bar\n", + }, + ), + 51: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 49), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("chart_type", 50), + ("line_colour", 34), + ("label", 33), + ] + ], + {}, + ), + 52: ( + "wagtail.blocks.StreamBlock", + [[("plot", 51)]], + { + "help_texts": "\nAdd the plots required for your chart. \nWithin each plot, you will be required to add a set of fields which will be used to fetch the supporting data \nfor that plot.\n" + }, + ), + 53: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nToggle to enable confidence intervals if they are present in the data set\n", + "required": False, + }, + ), + 54: ( + "wagtail.blocks.TextBlock", + (), + { + "default": "Metric column includes 95% lower and upper confidence intervals, in brackets.", + "help_text": "\nAn optional body of text to accompany this block.\nThis text will be displayed above the metrics table if confidence intervals is enabled.\n", + "required": False, + }, + ), + 55: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_colours, + "help_text": '\nThe colour to display the confidence interval in. The colours conform to the GDS specification.\nNote that if nothing is provided, a default of "BLACK" will be applied.\n', + "required": False, + }, + ), + 56: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 6), + ("related_links", 10), + ("tag_manager_event_id", 11), + ("x_axis", 48), + ("x_axis_title", 13), + ("y_axis", 14), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("show_tooltips", 18), + ("date_prefix", 19), + ("show_timeseries_filter", 20), + ("chart", 52), + ("confidence_intervals", 53), + ("confidence_intervals_description", 54), + ("confidence_colour", 55), + ] + ], + {}, + ), + 57: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed in the about content of the chart.\n", + "required": False, + }, + ), + 58: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "Up to and including", + "help_text": "\nThis is the accompanying text for chart dates Eg: `Up to and including` 21 Oct 2024\n", + "required": True, + }, + ), + 59: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_metric_names, + "help_text": '\nThe name of the metric to pull data for e.g. "COVID-19_deaths_ONSByDay".\n', + }, + ), + 60: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed below the chart title.\n", + "required": False, + }, + ), + 61: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 59), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("body", 60), + ] + ], + { + "help_text": '\nThis component will display a key headline number type metric.\nYou can also optionally add a body of text to accompany that headline number.\nE.g. "Patients admitted"\n' + }, + ), + 62: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_change_type_metric_names, + "help_text": "\nThe name of the trend type metric to pull data e.g. \"COVID-19_headline_ONSdeaths_7daychange\". \nNote that only 'change' type metrics are available for selection for this field type.\n", + }, + ), + 63: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_percent_change_type_names, + "help_text": "\nThe name of the accompanying percentage trend type metric to pull data \ne.g. \"COVID-19_headline_ONSdeaths_7daypercentchange\". \nNote that only 'percent' type metrics are available for selection for this field type.\n", + }, + ), + 64: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 62), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("body", 60), + ("percentage_metric", 63), + ] + ], + { + "help_text": '\nThis component will display a trend number type metric.\nThis will display an arrow pointing in the direction of the metric change \nas well as colouring of the block to indicate the context of the change.\nYou can also optionally add a body of text to accompany that headline number.\nE.g. "Last 7 days"\n' + }, + ), + 65: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 59), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("body", 60), + ] + ], + { + "help_text": '\nThis component will display a percentage number type metric.\nThis will display the value of the metric appended with a % character.\nYou can also optionally add a body of text to accompany this percentage number.\nE.g. "Virus tests positivity".\n' + }, + ), + 66: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("headline_number", 61), + ("trend_number", 64), + ("percentage_number", 65), + ] + ], + { + "help_text": "\nAdd up to 2 headline or trend number column components within this space.\nNote that these figures will be displayed within the card, and above the chart itself.\n", + "max_num": 2, + "min_num": 0, + "required": False, + }, + ), + 67: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 57), + ("related_links", 10), + ("tag_manager_event_id", 11), + ("x_axis", 12), + ("x_axis_title", 13), + ("y_axis", 14), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("show_tooltips", 18), + ("date_prefix", 58), + ("show_timeseries_filter", 20), + ("chart", 39), + ("headline_number_columns", 66), + ] + ], + {}, + ), + 68: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe sub title to display for this component.\n", + "required": False, + }, + ), + 69: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nA required choice of what to display along the x-axis of the chart.\n", + "ready_only": True, + }, + ), + 70: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nA required choice of what to display along the y-axis of the chart.\n", + }, + ), + 71: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_simplified_chart_types + }, + ), + 72: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nIf set to true, draws the plot as a spline line, resulting in smooth curves between data points.\nIf set to false, draws the plot as a linear line, \nresulting in linear point-to-point lines being drawn between data points.\nThis is only applicable to line-type charts.\n", + "required": False, + }, + ), + 73: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 24), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("chart_type", 71), + ("date_from", 31), + ("date_to", 32), + ("use_smooth_lines", 72), + ] + ], + {}, + ), + 74: ( + "wagtail.blocks.StreamBlock", + [[("plot", 73)]], + { + "help_text": "\nAdd the plots required for your chart. \nWithin each plot, you will be required to add a set of fields which will be used to fetch the supporting data \nfor that plot.\n", + "max_num": 1, + "required": True, + }, + ), + 75: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("sub_title", 68), + ("tag_manager_event_id", 11), + ("topic_page", 41), + ("x_axis", 69), + ("x_axis_title", 13), + ("y_axis", 70), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("chart", 74), + ] + ], + {}, + ), + 76: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nAn optional choice of what to display along the x-axis of the chart.\nIf nothing is provided, `dates` will be used by default.\nDates are used by default\n", + }, + ), + 77: ( + "wagtail.blocks.MultipleChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_subcategory_choices, + "help_text": "\nSelect a list of primary field values for the chart, these will be you're x-axis.\nFor example if we're creating a stacked bar chart that has a metric value in y and geographies along\nthe x-axis. The `primary field values` should be the list of geographies to include in the chart.\n", + "required": False, + }, + ), + 78: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nAn optional choice of what to display along the y-axis of the chart.\nIf nothing is provided, `metric value` will be used by default.\n", + }, + ), + 79: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_dual_category_chart_types, + "help_text": "\nThe name of the type of chart which you want to create e.g. bar\n", + "required": False, + }, + ), + 80: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 59), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("date_from", 31), + ("date_to", 32), + ] + ], + {}, + ), + 81: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_dual_chart_secondary_category_choices, + "help_text": "\nThis is for selecting the Second categorical variable type for Dual category charts.\nFor example when building a `Stacked bar chart` where the x-axis may be of type `Sex` and\ndisplay `Male` and `Female` along the x-axis. If our stacked bar chart then breaks each bar up into\nage groups, then our `Secondary Category` type is `Age`.\n", + }, + ), + 82: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_subcategory_choices, + "help_text": "\nSelect the secondary field for a `Segments` this is the second categorical variable used to create segments\nof a `stacked bar` chart. For example if we're creating a stacked bar chart that has a metric in the y-axis\nand geographies along the x-axis. If each bar is broken into segments by `age group` this field\nshould be the age group for this segment.\n", + }, + ), + 83: ( + "wagtail.blocks.StructBlock", + [ + [ + ("secondary_field_value", 82), + ("colour", 34), + ("label", 33), + ] + ], + {}, + ), + 84: ( + "wagtail.blocks.StreamBlock", + [[("segment", 83)]], + {"min_num": 1}, + ), + 85: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 6), + ("related_links", 10), + ("tag_manager_event_id", 11), + ("x_axis", 76), + ("x_axis_title", 13), + ("primary_field_values", 77), + ("y_axis", 78), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("chart_type", 79), + ("static_fields", 80), + ("second_category", 81), + ("segments", 84), + ] + ], + {}, + ), + 86: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("chart_card", 40), + ("chart_with_description_card", 47), + ("headline_chart_card", 56), + ("chart_with_headline_and_trend_card", 67), + ("simplified_chart_with_link", 75), + ("dual_category_chart_card", 85), + ] + ], + { + "help_text": "\nHere you can add chart cards to a section and the layout will change based on the number of cards added.\nA single card will expand to take up half the row. When 2 or 3 cards are added they will share the width\nof a row equally, creating either a 2 or 3 column layout.\n", + "min_num": 1, + }, + ), + 87: ("wagtail.blocks.StructBlock", [[("cards", 86)]], {}), + 88: ( + "wagtail.blocks.TextBlock", + (), + { + "default": "Up to", + "help_text": "\nThis is the accompanying text for headline column dates Eg: `Up to` 27 Oct 2024 \n", + "required": True, + }, + ), + 89: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("headline_number", 61), + ("trend_number", 64), + ("percentage_number", 65), + ] + ], + { + "help_text": "\nHere you can add up to 2 rows within this column component.\nEach row can be used to add a number block. \nThis can be a headline number, a trend number or a percentage number.\nIf you only add 1 row, then that block will be rendered on the upper half of the column.\nAnd the bottom row of the column will remain empty.\n", + "max_num": 2, + "min_num": 1, + "required": True, + }, + ), + 90: ( + "wagtail.blocks.StructBlock", + [[("title", 4), ("date_prefix", 88), ("rows", 89)]], + {}, + ), + 91: ( + "wagtail.blocks.StreamBlock", + [[("column", 90)]], + { + "help_text": "\nAdd up to 5 number column components within this row. \nThe columns are ordered from left to right, top to bottom respectively. \nSo by moving 1 column component above the other, that component will be rendered in the column left of the other. \n", + "max_num": 5, + "min_num": 1, + }, + ), + 92: ("wagtail.blocks.StructBlock", [[("columns", 91)]], {}), + 93: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe sub title to display for this component.\n", + "required": True, + }, + ), + 94: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nOptional description for the weather health alerts card.\n", + "required": False, + }, + ), + 95: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.dynamic_content.cards.WHAlerts.get_alerts, + "help_text": "\nThis is used to select the current weather health alert type Eg: Heat or Cold alert season.\n", + }, + ), + 96: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 43), + ("page", 44), + ("external_url", 45), + ] + ], + {"help_text": "\nOptional source link.\n", "required": False}, + ), + 97: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("sub_title", 93), + ("description", 94), + ("alert_type", 95), + ("source", 96), + ] + ], + {}, + ), + 98: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("weather_health_alert_card", 97), + ("chart_card_with_description", 47), + ] + ], + { + "help_text": "\nThis will be used to display a full height card on the left column.\nChoose either a weather health alerts card or a chart card with description.\n", + "max_num": 1, + "min_num": 1, + }, + ), + 99: ( + "wagtail.blocks.StreamBlock", + [[("chart_card", 75)]], + { + "help_text": "\nThis will be used to display a chart card in the top row of the second (right) column.\n", + "max_num": 1, + "min_num": 1, + }, + ), + 100: ( + "wagtail.blocks.StreamBlock", + [[("headline_number", 61), ("trend_number", 64)]], + { + "help_text": "\nThis block only allows 2 headline number blocks to be added.\nIt can be used to add headline number and trend number.\n", + "max_num": 2, + "min_num": 2, + "required": True, + }, + ), + 101: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("date_prefix", 88), + ("topic_page", 41), + ("headline_metrics", 100), + ] + ], + { + "help_text": "\nEach card will be displayed from left to right and will share and occupy half \nof the bottom row right column of the popular topics component.\n", + "max_num": 2, + "min_num": 2, + "required": True, + }, + ), + 102: ( + "wagtail.blocks.StreamBlock", + [[("headline_metric_card", 101)]], + { + "help_text": "\nThis will require 2 headline metrics cards which will be displayed from left to right \nwith each card occupying and sharing half of the bottom row right column of the \npopular topics component.\n", + "max_num": 2, + "min_num": 2, + }, + ), + 103: ( + "wagtail.blocks.StructBlock", + [ + [ + ("left_column", 98), + ("right_column_top_row", 99), + ("right_column_bottom_row", 102), + ] + ], + {}, + ), + 104: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("text_card", 3), + ("chart_card_section", 87), + ("headline_numbers_row_card", 92), + ("weather_health_alert_card", 97), + ("popular_topics_card", 103), + ] + ], + { + "help_text": "\nHere you can add any number of content row cards for this section.\nNote that these cards will be displayed across the available width.\n" + }, + ), + 105: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": '\nThis is the label used for the a section footer link badge"\n', + "required": True, + }, + ), + 106: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThis is the text to displayed along side a link in a the sections footer link.\n", + "required": True, + }, + ), + 107: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 43), + ("page", 44), + ("external_url", 45), + ] + ], + { + "help_text": "\nThis is a link component that allows the user to setup an internal or external link along with a short description of the link's content.\n", + "required": True, + }, + ), + 108: ( + "wagtail.blocks.StructBlock", + [[("badge_label", 105), ("text", 106), ("link", 107)]], + {"max_num": 1}, + ), + 109: ( + "wagtail.blocks.StreamBlock", + [[("section_link", 108)]], + { + "help_text": "\nThis is an optional footer for a section to provide a link to further information.\n", + "required": False, + }, + ), + 110: ( + "wagtail.blocks.StructBlock", + [ + [ + ("heading", 0), + ("page_link", 1), + ("content", 104), + ("footer", 109), + ] + ], + {}, + ), + }, + ), + ), + ] diff --git a/cms/topic/migrations/0034_alter_topicpage_body.py b/cms/topic/migrations/0034_alter_topicpage_body.py new file mode 100644 index 0000000000..37b267f661 --- /dev/null +++ b/cms/topic/migrations/0034_alter_topicpage_body.py @@ -0,0 +1,1482 @@ +# Generated by Django 5.2.14 on 2026-06-29 19:15 + +import cms.metrics_interface.field_choices_callables +import validation.url +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("topic", "0033_alter_topicpage_sub_theme_alter_topicpage_theme_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="topicpage", + name="body", + field=wagtail.fields.StreamField( + [("section", 154)], + block_lookup={ + 0: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe text you add here will be used as the heading for this section. \n", + "required": True, + }, + ), + 1: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": ["h2", "h3", "h4", "bold", "ul", "link"], + "help_text": "\nThis section of text will comprise this card. \nNote that this card will span the length of the available page width if sufficient text content is provided.\n", + }, + ), + 2: ("wagtail.blocks.StructBlock", [[("body", 1)]], {}), + 3: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe title to display for this component. \nNote that this will be shown in the hex colour #505A5F\n", + "required": True, + }, + ), + 4: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed below the chart title.\n", + "label": "Subtitle", + "required": False, + }, + ), + 5: ( + "wagtail.blocks.TextBlock", + (), + { + "default": "", + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed in the about content of the chart.\n", + "required": False, + }, + ), + 6: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe Text that will be displayed for the URL.\n", + "required": True, + }, + ), + 7: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe URL that the user will be navigated to when clicked.\nAn optional body of text to accompany this block. This text will be displayed below the chart title.\n", + "required": True, + }, + ), + 8: ( + "wagtail.blocks.StructBlock", + [[("link_display_text", 6), ("link", 7)]], + {}, + ), + 9: ( + "wagtail.blocks.StreamBlock", + [[("related_link", 8)]], + { + "help_text": "\nProvide optional URLs that can provide further contextual information for the data displayed in the chart.\n", + "required": False, + }, + ), + 10: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe ID to associate with this component. \nThis allows for tracking of events when users interact with this component.\nNote that changing this multiple times will result in the recording of different groups of events.\n", + "label": "Tag manager event ID", + "required": False, + }, + ), + 11: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nAn optional choice of what to display along the x-axis of the chart.\nIf nothing is provided, `dates` will be used by default.\nDates are used by default\n", + "required": False, + }, + ), + 12: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "", + "help_text": "\nAn optional title to display along the x-axis of the chart.\nIf nothing is provided, then no title will be displayed.\n", + "required": False, + }, + ), + 13: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nAn optional choice of what to display along the y-axis of the chart.\nIf nothing is provided, `metric value` will be used by default.\n", + "required": False, + }, + ), + 14: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "", + "help_text": "\nAn optional title to display along the y-axis of the chart.\nIf nothing is provided, then no title will be displayed.\n", + "required": False, + }, + ), + 15: ( + "wagtail.blocks.DecimalBlock", + (), + { + "default": 0, + "help_text": "\nThis field allows you to set the first value in the chart's y-axis range. Please\nnote that a value provided here, which is higher than the lowest value in the data will\nbe overridden and the value from the dataset will be used.\n", + "required": False, + }, + ), + 16: ( + "wagtail.blocks.DecimalBlock", + (), + { + "help_text": "\nThis field allows you to set the last value in the chart's y-axis range. Please\nnote that a value provided here, which is lower than the highest value in the data will\nbe overridden and the value from the dataset will be used. \n", + "required": False, + }, + ), + 17: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nThis is a switch to show tooltips on hover within the chart.\nDefaults to False.\n", + "required": False, + }, + ), + 18: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "Up to and including", + "help_texts": "\nThis is the accompanying text for chart dates Eg: `Up to and including` 21 Oct 2024\n", + "requried": True, + }, + ), + 19: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nThis option enables timeseries filter for this chart.\nThe timeseries filter allows a user to change the timeseries range for example between\n1m, 3m, 6m, 1y etc.\n", + "required": False, + }, + ), + 20: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_theme_names, + "help_text": "\nThe related theme for the metric. Eg: infectious disease\n", + }, + ), + 21: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_sub_theme_names, + "help_text": "\nThe related sub theme for the metric. Eg: respiratory\n", + }, + ), + 22: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_topic_names, + "help_text": "The name of the topic to pull data e.g. COVID-19.", + }, + ), + 23: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_timeseries_metric_names, + "help_text": '\nThe name of the metric to pull data for e.g. "COVID-19_deaths_ONSByDay".\n', + }, + ), + 24: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_geography_names, + "help_text": "\nThe name of the geography associated with this particular piece of data.\nIf nothing is provided, then no filtering will be applied for this field.\n", + }, + ), + 25: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_geography_type_names, + "help_text": "\nThe type of geographical categorisation to apply any data filtering to.\nIf nothing is provided, then no filtering will be applied for this field.\n", + }, + ), + 26: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_sex_names, + "help_text": "\nThe gender to filter for, if any.\nThe only options available are `M`, `F` and `ALL`.\nBy default, no filtering will be applied to the underlying query if no selection is made.\n", + }, + ), + 27: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_age_names, + "help_text": "\nThe age band to filter for, if any.\nBy default, no filtering will be applied to the underlying query if no selection is made.\n", + }, + ), + 28: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_stratum_names, + "help_text": "\nThe smallest subgroup a piece of data can be broken down into.\nFor example, this could be broken down by ethnicity or testing pillar.\nIf nothing is provided, then no filtering will be applied for this field.\n", + }, + ), + 29: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_chart_types, + "help_text": "\nThe name of the type of chart which you want to create e.g. bar\n", + }, + ), + 30: ( + "wagtail.blocks.DateBlock", + (), + { + "help_text": "\nThe date from which to begin the supporting plot data. \nNote that if nothing is provided, a default of 1 year ago from the current date will be applied.\n", + "required": False, + }, + ), + 31: ( + "wagtail.blocks.DateBlock", + (), + { + "help_text": "\nThe date to which to end the supporting plot data. \nNote that if nothing is provided, a default of the current date will be applied.\n", + "required": False, + }, + ), + 32: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe label to assign on the legend for this individual plot.\nE.g. `15 to 44 years old`\n", + "required": False, + }, + ), + 33: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_colours, + "help_text": '\nThe colour to apply to this individual line plot. The colours conform to the GDS specification.\nCurrently, only the `line_multi_coloured` chart type supports different line colours.\nFor all other chart types, this field will be ignored.\nNote that if nothing is provided, a default of "BLACK" will be applied.\nE.g. `GREEN`\n', + }, + ), + 34: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_chart_line_types, + "help_text": '\nThe line type to apply to this individual line plot.\nCurrently, only the `line_multi_coloured` chart type supports different line types.\nFor all other chart types, this field will be ignored.\nNote that if nothing is provided, a default of "SOLID" will be applied.\nE.g. `DASH`\n', + "required": False, + }, + ), + 35: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nIf set to true, markers are drawn on each individual data point.\nIf set to false, markers are not drawn at all.\nThis is only applicable to line-type charts.\n", + "required": False, + }, + ), + 36: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": True, + "help_text": "\nIf set to true, draws the plot as a spline line, resulting in smooth curves between data points.\nIf set to false, draws the plot as a linear line, \nresulting in linear point-to-point lines being drawn between data points.\nThis is only applicable to line-type charts.\n", + "required": False, + }, + ), + 37: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 20), + ("sub_theme", 21), + ("topic", 22), + ("metric", 23), + ("geography", 24), + ("geography_type", 25), + ("sex", 26), + ("age", 27), + ("stratum", 28), + ("chart_type", 29), + ("date_from", 30), + ("date_to", 31), + ("label", 32), + ("line_colour", 33), + ("line_type", 34), + ("use_markers", 35), + ("use_smooth_lines", 36), + ] + ], + {}, + ), + 38: ( + "wagtail.blocks.StreamBlock", + [[("plot", 37)]], + { + "help_text": "\nAdd the plots required for your chart. \nWithin each plot, you will be required to add a set of fields which will be used to fetch the supporting data \nfor that plot.\n" + }, + ), + 39: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 3), + ("body", 4), + ("about", 5), + ("related_links", 9), + ("tag_manager_event_id", 10), + ("x_axis", 11), + ("x_axis_title", 12), + ("y_axis", 13), + ("y_axis_title", 14), + ("y_axis_minimum_value", 15), + ("y_axis_maximum_value", 16), + ("show_tooltips", 17), + ("date_prefix", 18), + ("show_timeseries_filter", 19), + ("chart", 38), + ] + ], + {}, + ), + 40: ( + "cms.dynamic_content.blocks.PageLinkChooserBlock", + (), + { + "help_text": "\nThe related topic page you want to link to. Eg: `COVID-19`\n", + "page_type": ["topic.TopicPage"], + "required": True, + }, + ), + 41: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nRequired description for the chart.\n", + "required": True, + }, + ), + 42: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nDisplay text for the link.\n", + "required": False, + }, + ), + 43: ( + "cms.dynamic_content.blocks.PageLinkChooserBlock", + (), + { + "help_text": "\nFor linking to internal pages. (If chosen, external_url must be blank).\n", + "page_type": ["topic.TopicPage"], + "required": False, + }, + ), + 44: ( + "wagtail.blocks.URLBlock", + (), + { + "help_text": "\nFor linking to external url. (Only one of page or external_url must be filled not both).\n", + "required": False, + "validators": [validation.url.validate_https_scheme], + }, + ), + 45: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 42), + ("page", 43), + ("external_url", 44), + ] + ], + { + "help_text": "\nSource link (internal or external).\n", + "required": False, + }, + ), + 46: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 3), + ("sub_title", 4), + ("topic_page", 40), + ("description", 41), + ("source", 45), + ("tag_manager_event_id", 10), + ("x_axis", 11), + ("x_axis_title", 12), + ("y_axis", 13), + ("y_axis_title", 14), + ("y_axis_minimum_value", 15), + ("y_axis_maximum_value", 16), + ("show_tooltips", 17), + ("chart", 38), + ] + ], + {}, + ), + 47: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nA required choice of what to display along the x-axis of the chart.\n", + }, + ), + 48: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_headline_metric_names, + "help_text": '\nThe name of the metric to pull data for e.g. "COVID-19_deaths_ONSByDay".\n', + }, + ), + 49: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_headline_chart_types, + "help_text": "\nThe name of the type of chart which you want to create e.g. bar\n", + }, + ), + 50: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 20), + ("sub_theme", 21), + ("topic", 22), + ("metric", 48), + ("geography", 24), + ("geography_type", 25), + ("sex", 26), + ("age", 27), + ("stratum", 28), + ("chart_type", 49), + ("line_colour", 33), + ("label", 32), + ] + ], + {}, + ), + 51: ( + "wagtail.blocks.StreamBlock", + [[("plot", 50)]], + { + "help_texts": "\nAdd the plots required for your chart. \nWithin each plot, you will be required to add a set of fields which will be used to fetch the supporting data \nfor that plot.\n" + }, + ), + 52: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nToggle to enable confidence intervals if they are present in the data set\n", + "required": False, + }, + ), + 53: ( + "wagtail.blocks.TextBlock", + (), + { + "default": "Metric column includes 95% lower and upper confidence intervals, in brackets.", + "help_text": "\nAn optional body of text to accompany this block.\nThis text will be displayed above the metrics table if confidence intervals is enabled.\n", + "required": False, + }, + ), + 54: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_colours, + "help_text": '\nThe colour to display the confidence interval in. The colours conform to the GDS specification.\nNote that if nothing is provided, a default of "BLACK" will be applied.\n', + "required": False, + }, + ), + 55: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 3), + ("body", 4), + ("about", 5), + ("related_links", 9), + ("tag_manager_event_id", 10), + ("x_axis", 47), + ("x_axis_title", 12), + ("y_axis", 13), + ("y_axis_title", 14), + ("y_axis_minimum_value", 15), + ("y_axis_maximum_value", 16), + ("show_tooltips", 17), + ("date_prefix", 18), + ("show_timeseries_filter", 19), + ("chart", 51), + ("confidence_intervals", 52), + ("confidence_intervals_description", 53), + ("confidence_colour", 54), + ] + ], + {}, + ), + 56: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed in the about content of the chart.\n", + "required": False, + }, + ), + 57: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "Up to and including", + "help_text": "\nThis is the accompanying text for chart dates Eg: `Up to and including` 21 Oct 2024\n", + "required": True, + }, + ), + 58: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_metric_names, + "help_text": '\nThe name of the metric to pull data for e.g. "COVID-19_deaths_ONSByDay".\n', + }, + ), + 59: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed below the chart title.\n", + "required": False, + }, + ), + 60: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 20), + ("sub_theme", 21), + ("topic", 22), + ("metric", 58), + ("geography", 24), + ("geography_type", 25), + ("sex", 26), + ("age", 27), + ("stratum", 28), + ("body", 59), + ] + ], + { + "help_text": '\nThis component will display a key headline number type metric.\nYou can also optionally add a body of text to accompany that headline number.\nE.g. "Patients admitted"\n' + }, + ), + 61: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_change_type_metric_names, + "help_text": "\nThe name of the trend type metric to pull data e.g. \"COVID-19_headline_ONSdeaths_7daychange\". \nNote that only 'change' type metrics are available for selection for this field type.\n", + }, + ), + 62: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_percent_change_type_names, + "help_text": "\nThe name of the accompanying percentage trend type metric to pull data \ne.g. \"COVID-19_headline_ONSdeaths_7daypercentchange\". \nNote that only 'percent' type metrics are available for selection for this field type.\n", + }, + ), + 63: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 20), + ("sub_theme", 21), + ("topic", 22), + ("metric", 61), + ("geography", 24), + ("geography_type", 25), + ("sex", 26), + ("age", 27), + ("stratum", 28), + ("body", 59), + ("percentage_metric", 62), + ] + ], + { + "help_text": '\nThis component will display a trend number type metric.\nThis will display an arrow pointing in the direction of the metric change \nas well as colouring of the block to indicate the context of the change.\nYou can also optionally add a body of text to accompany that headline number.\nE.g. "Last 7 days"\n' + }, + ), + 64: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 20), + ("sub_theme", 21), + ("topic", 22), + ("metric", 58), + ("geography", 24), + ("geography_type", 25), + ("sex", 26), + ("age", 27), + ("stratum", 28), + ("body", 59), + ] + ], + { + "help_text": '\nThis component will display a percentage number type metric.\nThis will display the value of the metric appended with a % character.\nYou can also optionally add a body of text to accompany this percentage number.\nE.g. "Virus tests positivity".\n' + }, + ), + 65: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("headline_number", 60), + ("trend_number", 63), + ("percentage_number", 64), + ] + ], + { + "help_text": "\nAdd up to 2 headline or trend number column components within this space.\nNote that these figures will be displayed within the card, and above the chart itself.\n", + "max_num": 2, + "min_num": 0, + "required": False, + }, + ), + 66: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 3), + ("body", 4), + ("about", 56), + ("related_links", 9), + ("tag_manager_event_id", 10), + ("x_axis", 11), + ("x_axis_title", 12), + ("y_axis", 13), + ("y_axis_title", 14), + ("y_axis_minimum_value", 15), + ("y_axis_maximum_value", 16), + ("show_tooltips", 17), + ("date_prefix", 57), + ("show_timeseries_filter", 19), + ("chart", 38), + ("headline_number_columns", 65), + ] + ], + {}, + ), + 67: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe sub title to display for this component.\n", + "required": False, + }, + ), + 68: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nA required choice of what to display along the x-axis of the chart.\n", + "ready_only": True, + }, + ), + 69: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nA required choice of what to display along the y-axis of the chart.\n", + }, + ), + 70: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_simplified_chart_types + }, + ), + 71: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nIf set to true, draws the plot as a spline line, resulting in smooth curves between data points.\nIf set to false, draws the plot as a linear line, \nresulting in linear point-to-point lines being drawn between data points.\nThis is only applicable to line-type charts.\n", + "required": False, + }, + ), + 72: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 20), + ("sub_theme", 21), + ("topic", 22), + ("metric", 23), + ("geography", 24), + ("geography_type", 25), + ("sex", 26), + ("age", 27), + ("stratum", 28), + ("chart_type", 70), + ("date_from", 30), + ("date_to", 31), + ("use_smooth_lines", 71), + ] + ], + {}, + ), + 73: ( + "wagtail.blocks.StreamBlock", + [[("plot", 72)]], + { + "help_text": "\nAdd the plots required for your chart. \nWithin each plot, you will be required to add a set of fields which will be used to fetch the supporting data \nfor that plot.\n", + "max_num": 1, + "required": True, + }, + ), + 74: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 3), + ("sub_title", 67), + ("tag_manager_event_id", 10), + ("topic_page", 40), + ("x_axis", 68), + ("x_axis_title", 12), + ("y_axis", 69), + ("y_axis_title", 14), + ("y_axis_minimum_value", 15), + ("y_axis_maximum_value", 16), + ("chart", 73), + ] + ], + {}, + ), + 75: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nAn optional choice of what to display along the x-axis of the chart.\nIf nothing is provided, `dates` will be used by default.\nDates are used by default\n", + }, + ), + 76: ( + "wagtail.blocks.MultipleChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_subcategory_choices, + "help_text": "\nSelect a list of primary field values for the chart, these will be you're x-axis.\nFor example if we're creating a stacked bar chart that has a metric value in y and geographies along\nthe x-axis. The `primary field values` should be the list of geographies to include in the chart.\n", + "required": False, + }, + ), + 77: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nAn optional choice of what to display along the y-axis of the chart.\nIf nothing is provided, `metric value` will be used by default.\n", + }, + ), + 78: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_dual_category_chart_types, + "help_text": "\nThe name of the type of chart which you want to create e.g. bar\n", + "required": False, + }, + ), + 79: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 20), + ("sub_theme", 21), + ("topic", 22), + ("metric", 58), + ("geography", 24), + ("geography_type", 25), + ("sex", 26), + ("age", 27), + ("stratum", 28), + ("date_from", 30), + ("date_to", 31), + ] + ], + {}, + ), + 80: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_dual_chart_secondary_category_choices, + "help_text": "\nThis is for selecting the Second categorical variable type for Dual category charts.\nFor example when building a `Stacked bar chart` where the x-axis may be of type `Sex` and\ndisplay `Male` and `Female` along the x-axis. If our stacked bar chart then breaks each bar up into\nage groups, then our `Secondary Category` type is `Age`.\n", + }, + ), + 81: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_subcategory_choices, + "help_text": "\nSelect the secondary field for a `Segments` this is the second categorical variable used to create segments\nof a `stacked bar` chart. For example if we're creating a stacked bar chart that has a metric in the y-axis\nand geographies along the x-axis. If each bar is broken into segments by `age group` this field\nshould be the age group for this segment.\n", + }, + ), + 82: ( + "wagtail.blocks.StructBlock", + [ + [ + ("secondary_field_value", 81), + ("colour", 33), + ("label", 32), + ] + ], + {}, + ), + 83: ( + "wagtail.blocks.StreamBlock", + [[("segment", 82)]], + {"min_num": 1}, + ), + 84: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 3), + ("body", 4), + ("about", 5), + ("related_links", 9), + ("tag_manager_event_id", 10), + ("x_axis", 75), + ("x_axis_title", 12), + ("primary_field_values", 76), + ("y_axis", 77), + ("y_axis_title", 14), + ("y_axis_minimum_value", 15), + ("y_axis_maximum_value", 16), + ("chart_type", 78), + ("static_fields", 79), + ("second_category", 80), + ("segments", 83), + ] + ], + {}, + ), + 85: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("chart_card", 39), + ("chart_with_description_card", 46), + ("headline_chart_card", 55), + ("chart_with_headline_and_trend_card", 66), + ("simplified_chart_with_link", 74), + ("dual_category_chart_card", 84), + ] + ], + { + "help_text": "\nHere you can add 1 or 2 columns to contain a particular chart card.\nIf you add the 1 column, then the chart card will spread across the available width.\nIf you add 2 columns, then the cards will be split across 2 columns within the available width.\n", + "max_num": 2, + "min_num": 1, + }, + ), + 86: ("wagtail.blocks.StructBlock", [[("columns", 85)]], {}), + 87: ( + "wagtail.blocks.TextBlock", + (), + { + "default": "Up to", + "help_text": "\nThis is the accompanying text for headline column dates Eg: `Up to` 27 Oct 2024 \n", + "required": True, + }, + ), + 88: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("headline_number", 60), + ("trend_number", 63), + ("percentage_number", 64), + ] + ], + { + "help_text": "\nHere you can add up to 2 rows within this column component.\nEach row can be used to add a number block. \nThis can be a headline number, a trend number or a percentage number.\nIf you only add 1 row, then that block will be rendered on the upper half of the column.\nAnd the bottom row of the column will remain empty.\n", + "max_num": 2, + "min_num": 1, + "required": True, + }, + ), + 89: ( + "wagtail.blocks.StructBlock", + [[("title", 3), ("date_prefix", 87), ("rows", 88)]], + {}, + ), + 90: ( + "wagtail.blocks.StreamBlock", + [[("column", 89)]], + { + "help_text": "\nAdd up to 5 number column components within this row. \nThe columns are ordered from left to right, top to bottom respectively. \nSo by moving 1 column component above the other, that component will be rendered in the column left of the other. \n", + "max_num": 5, + "min_num": 1, + }, + ), + 91: ("wagtail.blocks.StructBlock", [[("columns", 90)]], {}), + 92: ("wagtail.blocks.CharBlock", (), {"required": True}), + 93: ( + "wagtail.blocks.DateBlock", + (), + { + "help_text": "\nThis field is used to provide a start date for the current time range option. Its date\nmust be earlier than the date provided for the corresponding `date_to`field. \n", + "required": True, + }, + ), + 94: ( + "wagtail.blocks.DateBlock", + (), + { + "help_text": "\nThis field is used to provide an end date for the current time range option. Its date\nmust be later than the date provided in the corresponding `date_from` field.\n", + "required": True, + }, + ), + 95: ( + "wagtail.blocks.StructBlock", + [[("label", 92), ("date_from", 93), ("date_to", 94)]], + {}, + ), + 96: ( + "wagtail.blocks.StreamBlock", + [[("time_period", 95)]], + { + "help_text": "\nThis mandatory time range is used to set the time range that a user can navigate through when\ninteracting with a filter linked chart or map card. Eg. Year selection can include a range of\nyears as dates that can be displayed and navigated between.\nWhen entering multiple periods in the time filter each proceeding `date_from` must be 1 day after the `date_to` \nthat came before it to maintain sequence order.\n", + "min_num": 1, + }, + ), + 97: ( + "wagtail.blocks.StructBlock", + [[("title", 92), ("time_periods", 96)]], + { + "help_text": "\nThis mandatory time range is used to set the time range that a user can navigate through when\ninteracting with a filter linked chart or map card. Eg. Year selection can include a range of\nyears as dates that can be displayed and navigated between.\nWhen entering multiple periods in the time filter each proceeding `date_from` must be 1 day after the `date_to` \nthat came before it to maintain sequence order.\n", + "required": True, + }, + ), + 98: ("wagtail.blocks.CharBlock", (), {"required": False}), + 99: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_colours, + "help_text": "\nFilter colour selections are used to represent the colours of a filter across filter linked\ncomponents Eg. a colour chosen for a given geography type such as `UKHSA Region` will be used\nto highlight that region on filter linked charts\n", + }, + ), + 100: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_geography_type_names, + "help_text": "\nThis generates a list of selectable geographies on the dashboard based on the geography type selected\nhere. Eg. A selection of `Lower Tier Local Authority` here will result in all LTLA's being displayed\nin the filter drop down on the dashboard for a user to select from.\n", + }, + ), + 101: ( + "wagtail.blocks.StructBlock", + [[("label", 98), ("colour", 99), ("geography_type", 100)]], + {}, + ), + 102: ( + "wagtail.blocks.StreamBlock", + [[("geography_filter", 101)]], + { + "help_text": "\nThis geography filter can be used to setup selectable geography lists based on geography types\nsuch as `UKHSA Region` or `Lower Tier Local Authority`.\n", + "min_num": 1, + }, + ), + 103: ( + "wagtail.blocks.StructBlock", + [[("geography_types", 102)]], + {}, + ), + 104: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_colours, + "help_text": "\nThis colour option is used to represent the threshold across other filter linked components.\nEg: The colour used for the legend on filter linked map component.\n", + }, + ), + 105: ( + "wagtail.blocks.DecimalBlock", + (), + { + "help_text": "\nThe minimum and maximum boundary values are used to set the range for the threshold as a float between\n0.00 and 100 Eg. a threshold of over 95% will have a minimum value of 95 and a maximum value of 100\n", + "required": True, + }, + ), + 106: ( + "wagtail.blocks.StructBlock", + [ + [ + ("label", 92), + ("colour", 104), + ("boundary_minimum_value", 105), + ("boundary_maximum_value", 105), + ] + ], + {}, + ), + 107: ( + "wagtail.blocks.StreamBlock", + [[("threshold", 106)]], + { + "help_text": "\nThe threshold filter is used to configure threshold boundaries that can be used to filter data on the dashboard.\nEg. over 95% boundary 95 -> 100\n", + "min_num": 1, + }, + ), + 108: ( + "wagtail.blocks.StructBlock", + [[("label", 92), ("thresholds", 107)]], + {}, + ), + 109: ( + "wagtail.blocks.CharBlock", + (), + {"help_text": "", "required": True}, + ), + 110: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_colours, + "help_text": "", + }, + ), + 111: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_theme_names + }, + ), + 112: ( + "wagtail.blocks.StructBlock", + [[("label", 98), ("value", 111)]], + { + "help_text": "\nThe related theme for the metric. Eg: infectious disease\n" + }, + ), + 113: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_sub_theme_names + }, + ), + 114: ( + "wagtail.blocks.StructBlock", + [[("label", 98), ("value", 113)]], + { + "help_text": "\nThe related sub theme for the metric. Eg: respiratory\n" + }, + ), + 115: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_topic_names + }, + ), + 116: ( + "wagtail.blocks.StructBlock", + [[("label", 98), ("value", 115)]], + { + "help_text": "The name of the topic to pull data e.g. COVID-19." + }, + ), + 117: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_stratum_names + }, + ), + 118: ( + "wagtail.blocks.StructBlock", + [[("label", 98), ("value", 117)]], + { + "help_text": "\nThe smallest subgroup a piece of data can be broken down into.\nFor example, this could be broken down by ethnicity or testing pillar.\n" + }, + ), + 119: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_metric_names + }, + ), + 120: ( + "wagtail.blocks.StructBlock", + [[("label", 98), ("value", 119)]], + { + "help_text": '\nThe name of the metric to pull data for e.g. "COVID-19_deaths_ONSByDay".\n' + }, + ), + 121: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_age_names + }, + ), + 122: ( + "wagtail.blocks.StructBlock", + [[("label", 98), ("value", 121)]], + { + "help_text": "\nThe age band to filter for, if any.\nBy default, no filtering will be applied to the underlying query if no selection is made.\n" + }, + ), + 123: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_sex_names + }, + ), + 124: ( + "wagtail.blocks.StructBlock", + [[("label", 98), ("value", 123)]], + { + "help_text": "\nThe gender to filter for, if any.\nThe only options available are `M`, `F` and `ALL`.\nBy default, no filtering will be applied to the underlying query if no selection is made.\n" + }, + ), + 125: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 112), + ("sub_theme", 114), + ("topic", 116), + ("stratum", 118), + ("metric", 120), + ("age", 122), + ("sex", 124), + ] + ], + {}, + ), + 126: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_geography_names + }, + ), + 127: ( + "wagtail.blocks.StructBlock", + [[("label", 98), ("value", 126)]], + {"help_text": ""}, + ), + 128: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_geography_type_names + }, + ), + 129: ( + "wagtail.blocks.StructBlock", + [[("label", 98), ("value", 128)]], + {"help_text": ""}, + ), + 130: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("theme", 112), + ("sub_theme", 114), + ("topic", 116), + ("stratum", 118), + ("metric", 120), + ("age", 122), + ("sex", 124), + ("geography", 127), + ("geography_type", 129), + ] + ], + {}, + ), + 131: ( + "wagtail.blocks.StructBlock", + [ + [ + ("label_prefix", 92), + ("label_suffix", 98), + ("parameters", 130), + ] + ], + { + "help_text": '\nThese additional points configure alternative requests to compare against or supplement the current data filter. For example,\nselecting a `metric` field here could provide a percentage change metric that complements the main data filter selection.\nThe label prefix appears before the metric value in map tooltips, while the suffix appears after.\nFor a percentage change metric, using prefix `change` and suffix `%` would display as "change %" in tooltips.\n', + "required": False, + }, + ), + 132: ( + "wagtail.blocks.StreamBlock", + [[("accompanying_point", 131)]], + { + "help_text": '\nThese additional points configure alternative requests to compare against or supplement the current data filter. For example,\nselecting a `metric` field here could provide a percentage change metric that complements the main data filter selection.\nThe label prefix appears before the metric value in map tooltips, while the suffix appears after.\nFor a percentage change metric, using prefix `change` and suffix `%` would display as "change %" in tooltips.\n', + "required": False, + }, + ), + 133: ( + "wagtail.blocks.StructBlock", + [ + [ + ("label", 109), + ("colour", 110), + ("parameters", 125), + ("accompanying_points", 132), + ] + ], + {}, + ), + 134: ( + "wagtail.blocks.StreamBlock", + [[("data_filter", 133)]], + { + "help_text": "\nThe data filter is used to create filter options based on metrics and combined attributes. Eg:\nmetric and stratum.\n", + "min_num": 1, + }, + ), + 135: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": [ + ("theme", "theme"), + ("sub_theme", "sub_theme"), + ("topic", "topic"), + ("metric", "metric"), + ("sex", "sex"), + ("age", "age"), + ("stratum", "stratum"), + ] + }, + ), + 136: ("wagtail.blocks.StructBlock", [[("data_category", 135)]], {}), + 137: ( + "wagtail.blocks.StreamBlock", + [[("category", 136)]], + { + "help_text": "\nSelect the categories for the dropdown options to be grouped by.\nE.g. To build the following:\n
\n|- stratum one\n    |- topic one\n    |- topic two\n| stratum two\n    |- topic one\n    |- topic two\n
\nThe selections for this should be `stratum` and then `topic`.\n", + "max_num": 2, + "min_num": 1, + }, + ), + 138: ( + "wagtail.blocks.StructBlock", + [ + [ + ("label", 92), + ("data_filters", 134), + ("categories_to_group_by", 137), + ] + ], + {}, + ), + 139: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("geography_filters", 103), + ("threshold_filters", 108), + ("data_filters", 138), + ] + ], + {}, + ), + 140: ( + "wagtail.blocks.StructBlock", + [[("title", 92), ("filters", 139)]], + {}, + ), + 141: ( + "wagtail.blocks.StreamBlock", + [[("row", 140)]], + { + "help_text": "\nA filter row can contain one or more filters for the global filter component.\nThey can be used to group filters around a context Eg: data filter with threshold filter.\n", + "min_num": 1, + "required": True, + }, + ), + 142: ( + "wagtail.blocks.StructBlock", + [[("time_range", 97), ("rows", 141)]], + { + "help_text": "\nThe global filter component enables a user of the dashboard to create a variable number of filters\nwhich can be applied across multi components on a dashboard page. This CMS component can be used to configure \nthose options.\n" + }, + ), + 143: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThis will form the prefix of the title which will be associated with the filter linked component.\nThe full title will be structured as follows:\n\n
\n[this prefix] between [the selected time period label] ([the selected region], [the selected lower geography type value]).\n
\n", + "required": True, + }, + ), + 144: ( + "wagtail.blocks.RichTextBlock", + (), + { + "default": "", + "features": ["h2", "h3", "h4", "bold", "ul", "link"], + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed in the about content for this map.\n", + "required": False, + }, + ), + 145: ( + "wagtail.blocks.StructBlock", + [[("title_prefix", 143), ("about", 144)]], + { + "help_text": "\nThis filter linked map can be used to display metric data configured through a data filter\nin the global filter component to represent this data geographically.\n", + "required": False, + }, + ), + 146: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThis legend title will be used for the title of the sub plot chart legends. \nEg. the colours associated with the geographies from global filter.\n", + "required": True, + }, + ), + 147: ( + "wagtail.blocks.FloatBlock", + (), + { + "help_text": "\nAn optional value to draw a target threshold for.\n", + "required": False, + }, + ), + 148: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nAn optional label to add as for the target threshold indicator on the charts.\n", + "required": False, + }, + ), + 149: ( + "wagtail.blocks.RichTextBlock", + (), + { + "default": "", + "features": ["h2", "h3", "h4", "bold", "ul", "link"], + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed in the about content of the chart.\n", + "required": False, + }, + ), + 150: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title_prefix", 143), + ("legend_title", 146), + ("target_threshold", 147), + ("target_threshold_label", 148), + ("about", 149), + ] + ], + { + "help_text": "\nThis filter linked sub plot chart can be used to display metric data configured through a data filter\nin the global filter component. \nThis template will render a new chart for each geography selected by the user \nfor the selected entities according to the data filter.\n", + "required": False, + }, + ), + 151: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThis legend title will be used for the title of the time series chart legends. \nEg. the colours associated with the selected data entities from global filter.\n", + "required": True, + }, + ), + 152: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title_prefix", 143), + ("legend_title", 151), + ("about", 149), + ] + ], + { + "help_text": "\nThis filter linked time series chart can be used to display metric data configured through a data filter\nin the global filter component. \nThis template will render a new chart for each geography selected by the user \nfor the selected entities according to the data filter.\n", + "required": False, + }, + ), + 153: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("text_card", 2), + ("chart_row_card", 86), + ("headline_numbers_row_card", 91), + ("global_filter_card", 142), + ("filter_linked_map", 145), + ("filter_linked_sub_plot_chart_template", 150), + ("filter_linked_time_series_chart_template", 152), + ] + ], + { + "help_text": "\nHere you can add any number of content row cards for this section.\nNote that these cards will be displayed across the available width.\n" + }, + ), + 154: ( + "wagtail.blocks.StructBlock", + [[("heading", 0), ("content", 153)]], + {}, + ), + }, + ), + ), + ] diff --git a/cms/topics_list/migrations/0008_alter_topicslistpage_body.py b/cms/topics_list/migrations/0008_alter_topicslistpage_body.py new file mode 100644 index 0000000000..9c9ecef1f8 --- /dev/null +++ b/cms/topics_list/migrations/0008_alter_topicslistpage_body.py @@ -0,0 +1,1168 @@ +# Generated by Django 5.2.14 on 2026-06-29 19:15 + +import cms.dynamic_content.cards +import cms.metrics_interface.field_choices_callables +import validation.url +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("topics_list", "0007_alter_topicslistpage_body_headline_metric_card"), + ] + + operations = [ + migrations.AlterField( + model_name="topicslistpage", + name="body", + field=wagtail.fields.StreamField( + [("section", 110)], + block_lookup={ + 0: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe text you add here will be used as the heading for this section. \n", + "required": True, + }, + ), + 1: ( + "cms.dynamic_content.blocks.PageLinkChooserBlock", + (), + { + "help_text": "\nThe related index page you want to link to. Eg: `Respiratory viruses` or `Outbreaks`\n", + "page_type": ["composite.CompositePage"], + "required": False, + }, + ), + 2: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": ["h2", "h3", "h4", "bold", "ul", "link"], + "help_text": "\nThis section of text will comprise this card. \nNote that this card will span the length of the available page width if sufficient text content is provided.\n", + }, + ), + 3: ("wagtail.blocks.StructBlock", [[("body", 2)]], {}), + 4: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe title to display for this component. \nNote that this will be shown in the hex colour #505A5F\n", + "required": True, + }, + ), + 5: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed below the chart title.\n", + "label": "Subtitle", + "required": False, + }, + ), + 6: ( + "wagtail.blocks.TextBlock", + (), + { + "default": "", + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed in the about content of the chart.\n", + "required": False, + }, + ), + 7: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe Text that will be displayed for the URL.\n", + "required": True, + }, + ), + 8: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe URL that the user will be navigated to when clicked.\nAn optional body of text to accompany this block. This text will be displayed below the chart title.\n", + "required": True, + }, + ), + 9: ( + "wagtail.blocks.StructBlock", + [[("link_display_text", 7), ("link", 8)]], + {}, + ), + 10: ( + "wagtail.blocks.StreamBlock", + [[("related_link", 9)]], + { + "help_text": "\nProvide optional URLs that can provide further contextual information for the data displayed in the chart.\n", + "required": False, + }, + ), + 11: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe ID to associate with this component. \nThis allows for tracking of events when users interact with this component.\nNote that changing this multiple times will result in the recording of different groups of events.\n", + "label": "Tag manager event ID", + "required": False, + }, + ), + 12: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nAn optional choice of what to display along the x-axis of the chart.\nIf nothing is provided, `dates` will be used by default.\nDates are used by default\n", + "required": False, + }, + ), + 13: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "", + "help_text": "\nAn optional title to display along the x-axis of the chart.\nIf nothing is provided, then no title will be displayed.\n", + "required": False, + }, + ), + 14: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nAn optional choice of what to display along the y-axis of the chart.\nIf nothing is provided, `metric value` will be used by default.\n", + "required": False, + }, + ), + 15: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "", + "help_text": "\nAn optional title to display along the y-axis of the chart.\nIf nothing is provided, then no title will be displayed.\n", + "required": False, + }, + ), + 16: ( + "wagtail.blocks.DecimalBlock", + (), + { + "default": 0, + "help_text": "\nThis field allows you to set the first value in the chart's y-axis range. Please\nnote that a value provided here, which is higher than the lowest value in the data will\nbe overridden and the value from the dataset will be used.\n", + "required": False, + }, + ), + 17: ( + "wagtail.blocks.DecimalBlock", + (), + { + "help_text": "\nThis field allows you to set the last value in the chart's y-axis range. Please\nnote that a value provided here, which is lower than the highest value in the data will\nbe overridden and the value from the dataset will be used. \n", + "required": False, + }, + ), + 18: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nThis is a switch to show tooltips on hover within the chart.\nDefaults to False.\n", + "required": False, + }, + ), + 19: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "Up to and including", + "help_texts": "\nThis is the accompanying text for chart dates Eg: `Up to and including` 21 Oct 2024\n", + "requried": True, + }, + ), + 20: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nThis option enables timeseries filter for this chart.\nThe timeseries filter allows a user to change the timeseries range for example between\n1m, 3m, 6m, 1y etc.\n", + "required": False, + }, + ), + 21: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_theme_names, + "help_text": "\nThe related theme for the metric. Eg: infectious disease\n", + }, + ), + 22: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_sub_theme_names, + "help_text": "\nThe related sub theme for the metric. Eg: respiratory\n", + }, + ), + 23: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_topic_names, + "help_text": "The name of the topic to pull data e.g. COVID-19.", + }, + ), + 24: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_timeseries_metric_names, + "help_text": '\nThe name of the metric to pull data for e.g. "COVID-19_deaths_ONSByDay".\n', + }, + ), + 25: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_geography_names, + "help_text": "\nThe name of the geography associated with this particular piece of data.\nIf nothing is provided, then no filtering will be applied for this field.\n", + }, + ), + 26: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_geography_type_names, + "help_text": "\nThe type of geographical categorisation to apply any data filtering to.\nIf nothing is provided, then no filtering will be applied for this field.\n", + }, + ), + 27: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_sex_names, + "help_text": "\nThe gender to filter for, if any.\nThe only options available are `M`, `F` and `ALL`.\nBy default, no filtering will be applied to the underlying query if no selection is made.\n", + }, + ), + 28: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_age_names, + "help_text": "\nThe age band to filter for, if any.\nBy default, no filtering will be applied to the underlying query if no selection is made.\n", + }, + ), + 29: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_stratum_names, + "help_text": "\nThe smallest subgroup a piece of data can be broken down into.\nFor example, this could be broken down by ethnicity or testing pillar.\nIf nothing is provided, then no filtering will be applied for this field.\n", + }, + ), + 30: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_chart_types, + "help_text": "\nThe name of the type of chart which you want to create e.g. bar\n", + }, + ), + 31: ( + "wagtail.blocks.DateBlock", + (), + { + "help_text": "\nThe date from which to begin the supporting plot data. \nNote that if nothing is provided, a default of 1 year ago from the current date will be applied.\n", + "required": False, + }, + ), + 32: ( + "wagtail.blocks.DateBlock", + (), + { + "help_text": "\nThe date to which to end the supporting plot data. \nNote that if nothing is provided, a default of the current date will be applied.\n", + "required": False, + }, + ), + 33: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe label to assign on the legend for this individual plot.\nE.g. `15 to 44 years old`\n", + "required": False, + }, + ), + 34: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_colours, + "help_text": '\nThe colour to apply to this individual line plot. The colours conform to the GDS specification.\nCurrently, only the `line_multi_coloured` chart type supports different line colours.\nFor all other chart types, this field will be ignored.\nNote that if nothing is provided, a default of "BLACK" will be applied.\nE.g. `GREEN`\n', + }, + ), + 35: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_chart_line_types, + "help_text": '\nThe line type to apply to this individual line plot.\nCurrently, only the `line_multi_coloured` chart type supports different line types.\nFor all other chart types, this field will be ignored.\nNote that if nothing is provided, a default of "SOLID" will be applied.\nE.g. `DASH`\n', + "required": False, + }, + ), + 36: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nIf set to true, markers are drawn on each individual data point.\nIf set to false, markers are not drawn at all.\nThis is only applicable to line-type charts.\n", + "required": False, + }, + ), + 37: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": True, + "help_text": "\nIf set to true, draws the plot as a spline line, resulting in smooth curves between data points.\nIf set to false, draws the plot as a linear line, \nresulting in linear point-to-point lines being drawn between data points.\nThis is only applicable to line-type charts.\n", + "required": False, + }, + ), + 38: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 24), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("chart_type", 30), + ("date_from", 31), + ("date_to", 32), + ("label", 33), + ("line_colour", 34), + ("line_type", 35), + ("use_markers", 36), + ("use_smooth_lines", 37), + ] + ], + {}, + ), + 39: ( + "wagtail.blocks.StreamBlock", + [[("plot", 38)]], + { + "help_text": "\nAdd the plots required for your chart. \nWithin each plot, you will be required to add a set of fields which will be used to fetch the supporting data \nfor that plot.\n" + }, + ), + 40: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 6), + ("related_links", 10), + ("tag_manager_event_id", 11), + ("x_axis", 12), + ("x_axis_title", 13), + ("y_axis", 14), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("show_tooltips", 18), + ("date_prefix", 19), + ("show_timeseries_filter", 20), + ("chart", 39), + ] + ], + {}, + ), + 41: ( + "cms.dynamic_content.blocks.PageLinkChooserBlock", + (), + { + "help_text": "\nThe related topic page you want to link to. Eg: `COVID-19`\n", + "page_type": ["topic.TopicPage"], + "required": True, + }, + ), + 42: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nRequired description for the chart.\n", + "required": True, + }, + ), + 43: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nDisplay text for the link.\n", + "required": False, + }, + ), + 44: ( + "cms.dynamic_content.blocks.PageLinkChooserBlock", + (), + { + "help_text": "\nFor linking to internal pages. (If chosen, external_url must be blank).\n", + "page_type": ["topic.TopicPage"], + "required": False, + }, + ), + 45: ( + "wagtail.blocks.URLBlock", + (), + { + "help_text": "\nFor linking to external url. (Only one of page or external_url must be filled not both).\n", + "required": False, + "validators": [validation.url.validate_https_scheme], + }, + ), + 46: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 43), + ("page", 44), + ("external_url", 45), + ] + ], + { + "help_text": "\nSource link (internal or external).\n", + "required": False, + }, + ), + 47: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("sub_title", 5), + ("topic_page", 41), + ("description", 42), + ("source", 46), + ("tag_manager_event_id", 11), + ("x_axis", 12), + ("x_axis_title", 13), + ("y_axis", 14), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("show_tooltips", 18), + ("chart", 39), + ] + ], + {}, + ), + 48: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nA required choice of what to display along the x-axis of the chart.\n", + }, + ), + 49: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_headline_metric_names, + "help_text": '\nThe name of the metric to pull data for e.g. "COVID-19_deaths_ONSByDay".\n', + }, + ), + 50: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_headline_chart_types, + "help_text": "\nThe name of the type of chart which you want to create e.g. bar\n", + }, + ), + 51: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 49), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("chart_type", 50), + ("line_colour", 34), + ("label", 33), + ] + ], + {}, + ), + 52: ( + "wagtail.blocks.StreamBlock", + [[("plot", 51)]], + { + "help_texts": "\nAdd the plots required for your chart. \nWithin each plot, you will be required to add a set of fields which will be used to fetch the supporting data \nfor that plot.\n" + }, + ), + 53: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nToggle to enable confidence intervals if they are present in the data set\n", + "required": False, + }, + ), + 54: ( + "wagtail.blocks.TextBlock", + (), + { + "default": "Metric column includes 95% lower and upper confidence intervals, in brackets.", + "help_text": "\nAn optional body of text to accompany this block.\nThis text will be displayed above the metrics table if confidence intervals is enabled.\n", + "required": False, + }, + ), + 55: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_colours, + "help_text": '\nThe colour to display the confidence interval in. The colours conform to the GDS specification.\nNote that if nothing is provided, a default of "BLACK" will be applied.\n', + "required": False, + }, + ), + 56: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 6), + ("related_links", 10), + ("tag_manager_event_id", 11), + ("x_axis", 48), + ("x_axis_title", 13), + ("y_axis", 14), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("show_tooltips", 18), + ("date_prefix", 19), + ("show_timeseries_filter", 20), + ("chart", 52), + ("confidence_intervals", 53), + ("confidence_intervals_description", 54), + ("confidence_colour", 55), + ] + ], + {}, + ), + 57: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed in the about content of the chart.\n", + "required": False, + }, + ), + 58: ( + "wagtail.blocks.CharBlock", + (), + { + "default": "Up to and including", + "help_text": "\nThis is the accompanying text for chart dates Eg: `Up to and including` 21 Oct 2024\n", + "required": True, + }, + ), + 59: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_metric_names, + "help_text": '\nThe name of the metric to pull data for e.g. "COVID-19_deaths_ONSByDay".\n', + }, + ), + 60: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nAn optional body of text to accompany this block. This text will be displayed below the chart title.\n", + "required": False, + }, + ), + 61: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 59), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("body", 60), + ] + ], + { + "help_text": '\nThis component will display a key headline number type metric.\nYou can also optionally add a body of text to accompany that headline number.\nE.g. "Patients admitted"\n' + }, + ), + 62: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_change_type_metric_names, + "help_text": "\nThe name of the trend type metric to pull data e.g. \"COVID-19_headline_ONSdeaths_7daychange\". \nNote that only 'change' type metrics are available for selection for this field type.\n", + }, + ), + 63: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_unique_percent_change_type_names, + "help_text": "\nThe name of the accompanying percentage trend type metric to pull data \ne.g. \"COVID-19_headline_ONSdeaths_7daypercentchange\". \nNote that only 'percent' type metrics are available for selection for this field type.\n", + }, + ), + 64: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 62), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("body", 60), + ("percentage_metric", 63), + ] + ], + { + "help_text": '\nThis component will display a trend number type metric.\nThis will display an arrow pointing in the direction of the metric change \nas well as colouring of the block to indicate the context of the change.\nYou can also optionally add a body of text to accompany that headline number.\nE.g. "Last 7 days"\n' + }, + ), + 65: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 59), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("body", 60), + ] + ], + { + "help_text": '\nThis component will display a percentage number type metric.\nThis will display the value of the metric appended with a % character.\nYou can also optionally add a body of text to accompany this percentage number.\nE.g. "Virus tests positivity".\n' + }, + ), + 66: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("headline_number", 61), + ("trend_number", 64), + ("percentage_number", 65), + ] + ], + { + "help_text": "\nAdd up to 2 headline or trend number column components within this space.\nNote that these figures will be displayed within the card, and above the chart itself.\n", + "max_num": 2, + "min_num": 0, + "required": False, + }, + ), + 67: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 57), + ("related_links", 10), + ("tag_manager_event_id", 11), + ("x_axis", 12), + ("x_axis_title", 13), + ("y_axis", 14), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("show_tooltips", 18), + ("date_prefix", 58), + ("show_timeseries_filter", 20), + ("chart", 39), + ("headline_number_columns", 66), + ] + ], + {}, + ), + 68: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe sub title to display for this component.\n", + "required": False, + }, + ), + 69: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nA required choice of what to display along the x-axis of the chart.\n", + "ready_only": True, + }, + ), + 70: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nA required choice of what to display along the y-axis of the chart.\n", + }, + ), + 71: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_simplified_chart_types + }, + ), + 72: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nIf set to true, draws the plot as a spline line, resulting in smooth curves between data points.\nIf set to false, draws the plot as a linear line, \nresulting in linear point-to-point lines being drawn between data points.\nThis is only applicable to line-type charts.\n", + "required": False, + }, + ), + 73: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 24), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("chart_type", 71), + ("date_from", 31), + ("date_to", 32), + ("use_smooth_lines", 72), + ] + ], + {}, + ), + 74: ( + "wagtail.blocks.StreamBlock", + [[("plot", 73)]], + { + "help_text": "\nAdd the plots required for your chart. \nWithin each plot, you will be required to add a set of fields which will be used to fetch the supporting data \nfor that plot.\n", + "max_num": 1, + "required": True, + }, + ), + 75: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("sub_title", 68), + ("tag_manager_event_id", 11), + ("topic_page", 41), + ("x_axis", 69), + ("x_axis_title", 13), + ("y_axis", 70), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("chart", 74), + ] + ], + {}, + ), + 76: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nAn optional choice of what to display along the x-axis of the chart.\nIf nothing is provided, `dates` will be used by default.\nDates are used by default\n", + }, + ), + 77: ( + "wagtail.blocks.MultipleChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_subcategory_choices, + "help_text": "\nSelect a list of primary field values for the chart, these will be you're x-axis.\nFor example if we're creating a stacked bar chart that has a metric value in y and geographies along\nthe x-axis. The `primary field values` should be the list of geographies to include in the chart.\n", + "required": False, + }, + ), + 78: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_possible_axis_choices, + "help_text": "\nAn optional choice of what to display along the y-axis of the chart.\nIf nothing is provided, `metric value` will be used by default.\n", + }, + ), + 79: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_dual_category_chart_types, + "help_text": "\nThe name of the type of chart which you want to create e.g. bar\n", + "required": False, + }, + ), + 80: ( + "wagtail.blocks.StructBlock", + [ + [ + ("theme", 21), + ("sub_theme", 22), + ("topic", 23), + ("metric", 59), + ("geography", 25), + ("geography_type", 26), + ("sex", 27), + ("age", 28), + ("stratum", 29), + ("date_from", 31), + ("date_to", 32), + ] + ], + {}, + ), + 81: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_dual_chart_secondary_category_choices, + "help_text": "\nThis is for selecting the Second categorical variable type for Dual category charts.\nFor example when building a `Stacked bar chart` where the x-axis may be of type `Sex` and\ndisplay `Male` and `Female` along the x-axis. If our stacked bar chart then breaks each bar up into\nage groups, then our `Secondary Category` type is `Age`.\n", + }, + ), + 82: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_all_subcategory_choices, + "help_text": "\nSelect the secondary field for a `Segments` this is the second categorical variable used to create segments\nof a `stacked bar` chart. For example if we're creating a stacked bar chart that has a metric in the y-axis\nand geographies along the x-axis. If each bar is broken into segments by `age group` this field\nshould be the age group for this segment.\n", + }, + ), + 83: ( + "wagtail.blocks.StructBlock", + [ + [ + ("secondary_field_value", 82), + ("colour", 34), + ("label", 33), + ] + ], + {}, + ), + 84: ( + "wagtail.blocks.StreamBlock", + [[("segment", 83)]], + {"min_num": 1}, + ), + 85: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 6), + ("related_links", 10), + ("tag_manager_event_id", 11), + ("x_axis", 76), + ("x_axis_title", 13), + ("primary_field_values", 77), + ("y_axis", 78), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("chart_type", 79), + ("static_fields", 80), + ("second_category", 81), + ("segments", 84), + ] + ], + {}, + ), + 86: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("chart_card", 40), + ("chart_with_description_card", 47), + ("headline_chart_card", 56), + ("chart_with_headline_and_trend_card", 67), + ("simplified_chart_with_link", 75), + ("dual_category_chart_card", 85), + ] + ], + { + "help_text": "\nHere you can add chart cards to a section and the layout will change based on the number of cards added.\nA single card will expand to take up half the row. When 2 or 3 cards are added they will share the width\nof a row equally, creating either a 2 or 3 column layout.\n", + "min_num": 1, + }, + ), + 87: ("wagtail.blocks.StructBlock", [[("cards", 86)]], {}), + 88: ( + "wagtail.blocks.TextBlock", + (), + { + "default": "Up to", + "help_text": "\nThis is the accompanying text for headline column dates Eg: `Up to` 27 Oct 2024 \n", + "required": True, + }, + ), + 89: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("headline_number", 61), + ("trend_number", 64), + ("percentage_number", 65), + ] + ], + { + "help_text": "\nHere you can add up to 2 rows within this column component.\nEach row can be used to add a number block. \nThis can be a headline number, a trend number or a percentage number.\nIf you only add 1 row, then that block will be rendered on the upper half of the column.\nAnd the bottom row of the column will remain empty.\n", + "max_num": 2, + "min_num": 1, + "required": True, + }, + ), + 90: ( + "wagtail.blocks.StructBlock", + [[("title", 4), ("date_prefix", 88), ("rows", 89)]], + {}, + ), + 91: ( + "wagtail.blocks.StreamBlock", + [[("column", 90)]], + { + "help_text": "\nAdd up to 5 number column components within this row. \nThe columns are ordered from left to right, top to bottom respectively. \nSo by moving 1 column component above the other, that component will be rendered in the column left of the other. \n", + "max_num": 5, + "min_num": 1, + }, + ), + 92: ("wagtail.blocks.StructBlock", [[("columns", 91)]], {}), + 93: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe sub title to display for this component.\n", + "required": True, + }, + ), + 94: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nOptional description for the weather health alerts card.\n", + "required": False, + }, + ), + 95: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.dynamic_content.cards.WHAlerts.get_alerts, + "help_text": "\nThis is used to select the current weather health alert type Eg: Heat or Cold alert season.\n", + }, + ), + 96: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 43), + ("page", 44), + ("external_url", 45), + ] + ], + {"help_text": "\nOptional source link.\n", "required": False}, + ), + 97: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("sub_title", 93), + ("description", 94), + ("alert_type", 95), + ("source", 96), + ] + ], + {}, + ), + 98: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("weather_health_alert_card", 97), + ("chart_card_with_description", 47), + ] + ], + { + "help_text": "\nThis will be used to display a full height card on the left column.\nChoose either a weather health alerts card or a chart card with description.\n", + "max_num": 1, + "min_num": 1, + }, + ), + 99: ( + "wagtail.blocks.StreamBlock", + [[("chart_card", 75)]], + { + "help_text": "\nThis will be used to display a chart card in the top row of the second (right) column.\n", + "max_num": 1, + "min_num": 1, + }, + ), + 100: ( + "wagtail.blocks.StreamBlock", + [[("headline_number", 61), ("trend_number", 64)]], + { + "help_text": "\nThis block only allows 2 headline number blocks to be added.\nIt can be used to add headline number and trend number.\n", + "max_num": 2, + "min_num": 2, + "required": True, + }, + ), + 101: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("date_prefix", 88), + ("topic_page", 41), + ("headline_metrics", 100), + ] + ], + { + "help_text": "\nEach card will be displayed from left to right and will share and occupy half \nof the bottom row right column of the popular topics component.\n", + "max_num": 2, + "min_num": 2, + "required": True, + }, + ), + 102: ( + "wagtail.blocks.StreamBlock", + [[("headline_metric_card", 101)]], + { + "help_text": "\nThis will require 2 headline metrics cards which will be displayed from left to right \nwith each card occupying and sharing half of the bottom row right column of the \npopular topics component.\n", + "max_num": 2, + "min_num": 2, + }, + ), + 103: ( + "wagtail.blocks.StructBlock", + [ + [ + ("left_column", 98), + ("right_column_top_row", 99), + ("right_column_bottom_row", 102), + ] + ], + {}, + ), + 104: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("text_card", 3), + ("chart_card_section", 87), + ("headline_numbers_row_card", 92), + ("weather_health_alert_card", 97), + ("popular_topics_card", 103), + ] + ], + { + "help_text": "\nHere you can add any number of content row cards for this section.\nNote that these cards will be displayed across the available width.\n" + }, + ), + 105: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": '\nThis is the label used for the a section footer link badge"\n', + "required": True, + }, + ), + 106: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThis is the text to displayed along side a link in a the sections footer link.\n", + "required": True, + }, + ), + 107: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 43), + ("page", 44), + ("external_url", 45), + ] + ], + { + "help_text": "\nThis is a link component that allows the user to setup an internal or external link along with a short description of the link's content.\n", + "required": True, + }, + ), + 108: ( + "wagtail.blocks.StructBlock", + [[("badge_label", 105), ("text", 106), ("link", 107)]], + {"max_num": 1}, + ), + 109: ( + "wagtail.blocks.StreamBlock", + [[("section_link", 108)]], + { + "help_text": "\nThis is an optional footer for a section to provide a link to further information.\n", + "required": False, + }, + ), + 110: ( + "wagtail.blocks.StructBlock", + [ + [ + ("heading", 0), + ("page_link", 1), + ("content", 104), + ("footer", 109), + ] + ], + {}, + ), + }, + ), + ), + ] From 47a739f3479fb23d294a109dfb40e21b992e90fc Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Tue, 30 Jun 2026 14:16:29 +0100 Subject: [PATCH 02/20] CDD-3451: add theme and sub_theme drop-downs to the CMS page body --- cms/dynamic_content/elements.py | 20 ++++++++++++++++++ metrics/api/serializers/help_texts.py | 2 ++ metrics/api/serializers/plots.py | 8 +++++++ metrics/domain/models/plots.py | 2 ++ metrics/interfaces/plots/access.py | 5 ----- tests/conftest.py | 2 ++ .../charts/test_single_category_charts.py | 6 ++++++ .../test_single_category_downloads.py | 4 ++++ .../tables/test_single_category_tables.py | 14 +++++++++++++ .../unit/cms/dynamic_content/test_elements.py | 2 ++ .../metrics/api/serializers/test_downloads.py | 2 ++ .../unit/metrics/domain/models/test_plots.py | 4 ++++ .../metrics/interfaces/plots/test_access.py | 21 ++++++++++--------- 13 files changed, 77 insertions(+), 15 deletions(-) diff --git a/cms/dynamic_content/elements.py b/cms/dynamic_content/elements.py index 3675d32c1f..2245274bb2 100644 --- a/cms/dynamic_content/elements.py +++ b/cms/dynamic_content/elements.py @@ -11,9 +11,11 @@ get_all_headline_metric_names, get_all_sex_names, get_all_stratum_names, + get_all_theme_names, get_all_timeseries_metric_names, get_all_topic_names, get_all_unique_metric_names, + get_all_unique_sub_theme_names, get_chart_line_types, get_chart_types, get_colours, @@ -32,6 +34,24 @@ class BaseMetricsElement(blocks.StructBlock): + """ + Base block for the chart plots that are embedded in each CMS page body. + + Each field below is a dropdown with options that are supplied by its + "choices=get_all_XXX_names", which queries valid values. The values a CMS + editor selects are stored on the page's body and serialized by the pages API. + """ + + theme = blocks.ChoiceBlock( + required=True, + choices=get_all_theme_names, + help_text=help_texts.THEME_FIELD, + ) + sub_theme = blocks.ChoiceBlock( + required=True, + choices=get_all_unique_sub_theme_names, + help_text=help_texts.SUB_THEME_FIELD, + ) topic = blocks.ChoiceBlock( required=True, choices=get_all_topic_names, diff --git a/metrics/api/serializers/help_texts.py b/metrics/api/serializers/help_texts.py index dc0366489a..847484e526 100644 --- a/metrics/api/serializers/help_texts.py +++ b/metrics/api/serializers/help_texts.py @@ -1,3 +1,5 @@ +THEME_FIELD: str = "The name of the theme being queried for. E.g. `infectious_disease`" +SUB_THEME_FIELD: str = "The name of the sub theme being queried for. E.g. `respiratory`" TOPIC_FIELD: str = "The name of the topic being queried for. E.g. `COVID-19`" METRIC_FIELD: str = "The name of the metric being queried for. E.g. `{}`" STRATUM_FIELD: str = """ diff --git a/metrics/api/serializers/plots.py b/metrics/api/serializers/plots.py index 795c114777..934e605bea 100644 --- a/metrics/api/serializers/plots.py +++ b/metrics/api/serializers/plots.py @@ -10,6 +10,14 @@ class PlotSerializer(serializers.Serializer): # Required fields + theme = serializers.CharField( + help_text=help_texts.THEME_FIELD, + required=True, + ) + sub_theme = serializers.CharField( + help_text=help_texts.SUB_THEME_FIELD, + required=True, + ) topic = serializers.ChoiceField( help_text=help_texts.TOPIC_FIELD, choices=[], diff --git a/metrics/domain/models/plots.py b/metrics/domain/models/plots.py index 5093c99915..66df01ad78 100644 --- a/metrics/domain/models/plots.py +++ b/metrics/domain/models/plots.py @@ -103,6 +103,8 @@ def to_dict_for_query(self) -> dict[str, str]: ], "metric": self.metric or "", "topic": self.topic or "", + "theme": self.theme or "", + "sub_theme": self.sub_theme or "", "stratum": self.stratum or "", "geography": self.geography or "", "geography_type": self.geography_type or "", diff --git a/metrics/interfaces/plots/access.py b/metrics/interfaces/plots/access.py index ed1832e91d..298b1c7ad0 100644 --- a/metrics/interfaces/plots/access.py +++ b/metrics/interfaces/plots/access.py @@ -147,11 +147,6 @@ def get_queryset_from_core_model_manager( ('05-10', Decimal('9.0')) ]>` """ - if auth.AUTH_ENABLED: - # Needed for the downstream permissions check - topic = self.topic_model_manager.get_by_name(name=plot_params["topic"]) - plot_params["theme"] = topic.sub_theme.theme.name - plot_params["sub_theme"] = topic.sub_theme.name # Sometimes this is a Subplots request which doesn't support confidence intervals confidence_intervals = getattr( diff --git a/tests/conftest.py b/tests/conftest.py index fec71efb9f..d376095103 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -303,6 +303,8 @@ def plot_serializer_payload_and_model_managers() -> ( fake_topic = fake_metric.metric_group.topic data: DATA_PAYLOAD_HINT = { + "theme": fake_topic.sub_theme.theme.name, + "sub_theme": fake_topic.sub_theme.name, "topic": fake_topic.name, "metric": fake_metric.name, "chart_type": ChartTypes.bar.value, diff --git a/tests/integration/metrics/api/views/charts/test_single_category_charts.py b/tests/integration/metrics/api/views/charts/test_single_category_charts.py index 000a9df052..68ba961466 100644 --- a/tests/integration/metrics/api/views/charts/test_single_category_charts.py +++ b/tests/integration/metrics/api/views/charts/test_single_category_charts.py @@ -17,6 +17,8 @@ def _build_valid_payload_for_existing_timeseries(core_timeseries: CoreTimeSeries "file_format": "png", "plots": [ { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": core_timeseries.metric.metric_group.topic.name, "metric": core_timeseries.metric.name, "chart_type": "bar", @@ -152,6 +154,8 @@ def test_returns_correct_response_for_v3_age_based_chart(self): "x_axis": "age", "plots": [ { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_deaths_ONSByDay", "chart_type": "bar", @@ -203,6 +207,8 @@ def test_returns_bad_request_response_when_queried_data_does_not_exist( "file_format": "svg", "plots": [ { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": core_timeseries_example[0].metric.topic.name, "metric": core_timeseries_example[0].metric.name, "chart_type": "bar", diff --git a/tests/integration/metrics/api/views/downloads/test_single_category_downloads.py b/tests/integration/metrics/api/views/downloads/test_single_category_downloads.py index e477e955e0..3860a84b37 100644 --- a/tests/integration/metrics/api/views/downloads/test_single_category_downloads.py +++ b/tests/integration/metrics/api/views/downloads/test_single_category_downloads.py @@ -52,6 +52,8 @@ def _build_valid_payload(self) -> dict[str, str | list[dict[str, str]]]: "file_format": "csv", "plots": [ { + "theme": self.core_timeseries_data["theme"], + "sub_theme": self.core_timeseries_data["sub_theme"], "metric": self.core_timeseries_data["metric"], "topic": self.core_timeseries_data["topic"], "stratum": self.core_timeseries_data["stratum"], @@ -72,6 +74,8 @@ def _build_valid_headline_payload(self) -> dict[str, str | list[dict[str, str]]] "y_axis": "metric", "plots": [ { + "theme": self.core_headline_data["theme"], + "sub_theme": self.core_headline_data["sub_theme"], "metric": self.core_headline_data["metric"], "topic": self.core_headline_data["topic"], "stratum": self.core_headline_data["stratum"], diff --git a/tests/integration/metrics/api/views/tables/test_single_category_tables.py b/tests/integration/metrics/api/views/tables/test_single_category_tables.py index b138870335..3bda02501a 100644 --- a/tests/integration/metrics/api/views/tables/test_single_category_tables.py +++ b/tests/integration/metrics/api/views/tables/test_single_category_tables.py @@ -33,6 +33,8 @@ def test_hitting_endpoint_without_appended_forward_slash_redirects_correctly( "file_format": "svg", "plots": [ { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, "chart_type": "bar", @@ -73,6 +75,8 @@ def test_returns_correct_response_type( "file_format": "svg", "plots": [ { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, "chart_type": "bar", @@ -115,6 +119,8 @@ def test_single_plot_output_is_as_expected( "file_format": "svg", "plots": [ { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, "chart_type": "bar", @@ -178,6 +184,8 @@ def test_multiple_plot_output_is_as_expected( "file_format": "svg", "plots": [ { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, "chart_type": "bar", @@ -185,6 +193,8 @@ def test_multiple_plot_output_is_as_expected( "date_to": datetime.date.today(), }, { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, "chart_type": "bar", @@ -259,6 +269,8 @@ def test_headline_charts_with_plot_label_uses_plot_label_for_reference( "y_axis": "metric", "plots": [ { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, "chart_type": "bar", @@ -303,6 +315,8 @@ def test_returns_bad_request_response_when_queried_data_does_not_exist( valid_payload = { "plots": [ { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": core_timeseries_example[0].metric.topic.name, "metric": core_timeseries_example[0].metric.name, "chart_type": "bar", diff --git a/tests/unit/cms/dynamic_content/test_elements.py b/tests/unit/cms/dynamic_content/test_elements.py index 3c40a1cfd0..2f7be507da 100644 --- a/tests/unit/cms/dynamic_content/test_elements.py +++ b/tests/unit/cms/dynamic_content/test_elements.py @@ -114,6 +114,8 @@ def valid_payload(self) -> dict[str, str]: A valid basic block payload for all tests to use. """ return { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Adenovirus", "metric": "adenovirus_testing_positivityByWeek", "geography": "England", diff --git a/tests/unit/metrics/api/serializers/test_downloads.py b/tests/unit/metrics/api/serializers/test_downloads.py index 38b1a875b1..a176fd8d28 100644 --- a/tests/unit/metrics/api/serializers/test_downloads.py +++ b/tests/unit/metrics/api/serializers/test_downloads.py @@ -9,6 +9,8 @@ class TestDownloadsSerializer: def _build_valid_payload() -> dict[str, str | list[dict[str, str]]]: requested_file_format = "csv" requested_plot = { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_cases_rateRollingMean", "age": "all", diff --git a/tests/unit/metrics/domain/models/test_plots.py b/tests/unit/metrics/domain/models/test_plots.py index fb2073db46..0bb911d205 100644 --- a/tests/unit/metrics/domain/models/test_plots.py +++ b/tests/unit/metrics/domain/models/test_plots.py @@ -125,6 +125,8 @@ def test_to_dict_for_query_for_timeseries_data( ].value, "topic": fake_chart_plot_parameters.topic, "metric": fake_chart_plot_parameters.metric, + "theme": fake_chart_plot_parameters.theme, + "sub_theme": fake_chart_plot_parameters.sub_theme, "stratum": fake_chart_plot_parameters.stratum, "geography": fake_chart_plot_parameters.geography, "geography_type": fake_chart_plot_parameters.geography_type, @@ -180,6 +182,8 @@ def test_to_dict_for_query_for_headline_data( ], "topic": fake_chart_plot_parameters.topic, "metric": fake_chart_plot_parameters.metric, + "theme": fake_chart_plot_parameters.theme, + "sub_theme": fake_chart_plot_parameters.sub_theme, "stratum": fake_chart_plot_parameters.stratum, "geography": fake_chart_plot_parameters.geography, "geography_type": fake_chart_plot_parameters.geography_type, diff --git a/tests/unit/metrics/interfaces/plots/test_access.py b/tests/unit/metrics/interfaces/plots/test_access.py index b794cb3810..98ee42b41f 100644 --- a/tests/unit/metrics/interfaces/plots/test_access.py +++ b/tests/unit/metrics/interfaces/plots/test_access.py @@ -27,7 +27,6 @@ ) from tests.fakes.factories.metrics.metric_factory import FakeMetricFactory from tests.fakes.managers.time_series_manager import FakeCoreTimeSeriesManager -from tests.fakes.managers.topic_manager import FakeTopicManager from tests.fakes.models.metrics.core_time_series import FakeCoreTimeSeries from tests.fakes.models.queryset import FakeQuerySet @@ -617,19 +616,20 @@ def test_get_headline_data_calls_core_headline_manager_with_confidence_intervals permission_sets=mocked_chart_request_params.permission_sets, ) - @mock.patch(f"{MODULE_PATH}.auth.AUTH_ENABLED", True) - def test_get_queryset_from_core_model_manager_passes_theme_and_topic_into_query_when_auth_enabled( + def test_get_queryset_from_core_model_manager_passes_theme_and_sub_theme_through_from_plot_params( self, ): """ - Given a `CoreHeadlineManager` - When `get_headline_data` is called from an instance of `PlotsInterface` - Then the correct method is called from `CoreHeadlineManager` to retrieve headline data. + Given `plot_params` which already contain `theme` and `sub_theme` + (posted by the front-end) + When `get_queryset_from_core_model_manager` is called + from an instance of `PlotsInterface` + Then those `theme` and `sub_theme` values are passed straight through to + the core model manager (i.e. they are no longer derived from the topic) """ # Given spy_core_headline_manager = mock.Mock() fake_metric = FakeMetricFactory.build_example_metric() - fake_topic_manager = FakeTopicManager(topics=[fake_metric.topic]) mocked_x_axis = mock.Mock() mocked_y_axis = mock.Mock() @@ -644,7 +644,6 @@ def test_get_queryset_from_core_model_manager_passes_theme_and_topic_into_query_ plots_interface = PlotsInterface( chart_request_params=mocked_chart_request_params, core_model_manager=spy_core_headline_manager, - topic_model_manager=fake_topic_manager, ) # When @@ -652,6 +651,8 @@ def test_get_queryset_from_core_model_manager_passes_theme_and_topic_into_query_ "fields_to_export": [mocked_x_axis, mocked_y_axis], "topic": fake_metric.topic.name, "metric": fake_metric.name, + "theme": "infectious_disease", + "sub_theme": "respiratory", "geography": mocked_geography, "geography_type": mocked_geography_type, "geography_code": "", @@ -669,6 +670,8 @@ def test_get_queryset_from_core_model_manager_passes_theme_and_topic_into_query_ fields_to_export=[mocked_x_axis, mocked_y_axis], topic=fake_metric.topic.name, metric=fake_metric.name, + theme="infectious_disease", + sub_theme="respiratory", geography=mocked_geography, geography_type=mocked_geography_type, geography_code="", @@ -676,8 +679,6 @@ def test_get_queryset_from_core_model_manager_passes_theme_and_topic_into_query_ sex=mocked_sex, age=mocked_age, rbac_permissions=mocked_chart_request_params.rbac_permissions, - theme=fake_metric.topic.sub_theme.theme.name, - sub_theme=fake_metric.topic.sub_theme.name, permission_sets=mocked_chart_request_params.permission_sets, ) From 8f9564ee7cd703ba10e5bc020ec95a1e8d976652 Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Tue, 30 Jun 2026 16:53:21 +0100 Subject: [PATCH 03/20] CDD-3451: refactor get_id_by_name() functions into get_theme_sub_theme_topic_and_metric_id_by_name() and get_geography_type_id_and_code_by_name() so that any inconsistent combination is denied access --- common/auth/permissions.py | 15 +-- .../data/managers/core_models/geography.py | 40 +++++++ metrics/data/managers/core_models/topic.py | 61 +++++++--- .../metrics/api/views/test_permission_sets.py | 6 +- .../managers/core_models/test_geography.py | 60 +++++++++- .../core_models/test_geography_types.py | 4 +- .../data/managers/core_models/test_metric.py | 4 +- .../data/managers/core_models/test_topic.py | 50 +++++--- tests/unit/common/auth/test_permissions.py | 111 +++--------------- 9 files changed, 208 insertions(+), 143 deletions(-) diff --git a/common/auth/permissions.py b/common/auth/permissions.py index 55d63599f6..b0fbe7dc8c 100644 --- a/common/auth/permissions.py +++ b/common/auth/permissions.py @@ -54,16 +54,17 @@ def check_chart_permissions_by_name( return True topic_manager = MetricsAPIInterface.get_topic_manager() - metric_manager = MetricsAPIInterface.get_metric_manager() - geography_type_manager = MetricsAPIInterface.get_geography_type_manager() geography_manager = MetricsAPIInterface.get_geography_manager() - theme_id, sub_theme_id, topic_id = topic_manager.get_id_by_name( - theme_name, sub_theme_name, topic_name + # Any inconsistent combination below resolves to None ids and is denied + theme_id, sub_theme_id, topic_id, metric_id = topic_manager.get_theme_sub_theme_topic_and_metric_id_by_name( + theme_name, sub_theme_name, topic_name, metric_name + ) + geography_type_id, geography_id = ( + geography_manager.get_geography_type_id_and_code_by_name( + geography_name, geography_type + ) ) - metric_id = metric_manager.get_id_by_name(metric_name) - geography_type_id = geography_type_manager.get_id_by_name(geography_type) - geography_id = geography_manager.get_code_by_name(geography_name, geography_type) # Sanity check, because front-end must always # send content for any of these 6 requests diff --git a/metrics/data/managers/core_models/geography.py b/metrics/data/managers/core_models/geography.py index f280df4cbd..7d8c3c1d58 100644 --- a/metrics/data/managers/core_models/geography.py +++ b/metrics/data/managers/core_models/geography.py @@ -83,6 +83,31 @@ def get_code_by_name( .first() ) + def get_geography_type_id_and_code_by_name( + self, geography_name: str, geography_type_name: str + ) -> tuple[int | None, str | None]: + """ + Gets the geography_type id and geography code for the given names. + + Both are resolved in a single query, which also enforces that the geography + actually belongs to the given geography_type. An inconsistent pair matches + no row, so this returns (None, None) and access is denied. + + Returns: + A tuple of (geography_type_id, geography_code) if the pair exists, + or (None, None) if it does not. + """ + record = ( + self.select_related("geography_type") + .filter(name=geography_name, geography_type__name=geography_type_name) + .first() + ) + + if record: + return int(record.geography_type_id), record.geography_code + + return None, None + def get_all_geography_codes_by_geography_type( self, geography_type_name: str ) -> Self: @@ -233,6 +258,21 @@ def get_code_by_name( geography_name, geography_type_name=geography_type_name ) + def get_geography_type_id_and_code_by_name( + self, geography_name: str, geography_type_name: str + ) -> tuple[int | None, str | None]: + """ + Gets the geography_type id and geography code for the given names in a + single query that also validates the pair is consistent. + + Returns: + A tuple of (geography_type_id, geography_code) if the pair exists, + or (None, None) if it does not. + """ + return self.get_queryset().get_geography_type_id_and_code_by_name( + geography_name, geography_type_name=geography_type_name + ) + def get_all_names(self) -> GeographyQuerySet: """Gets all available deduplicated geography names as a flat list queryset. diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index 7a1342efa4..209df543e1 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -41,32 +41,50 @@ def get_name_by_id(self, topic_id: int) -> str | None: return self.filter(id=topic_id).values_list("name", flat=True).first() def get_id_by_name( - self, theme_name: str, sub_theme_name: str, topic_name: str - ) -> tuple[int | None, int | None, int | None]: + self, + theme_name: str, + sub_theme_name: str, + topic_name: str, + metric_name: str, + ) -> tuple[int | None, int | None, int | None, int | None]: """ - Gets the theme, sub-theme and topic IDs matching the given names. + Gets the theme, sub-theme, topic and metric IDs matching the given names. + + This resolves all 4 ids in a single query which also enforces that the + combination is internally consistent. The metric must belong to the topic, + which must belong to the given sub-theme and theme. An invalid combination + (e.g. "respiratory" paired with "MMR1") matches no row, so this returns + (None, None, None, None) and access is denied. Returns: - A tuple of (theme_id, sub_theme_id, topic_id) if found, - or (None, None, None) if not found. - """ - record = self.filter( - sub_theme__theme__name=theme_name, - sub_theme__name=sub_theme_name, - name=topic_name, - ).first() + A tuple of (theme_id, sub_theme_id, topic_id, metric_id) if the whole + combination exists, or (None, None, None, None) if it does not. + """ + record = ( + self.filter( + sub_theme__theme__name=theme_name, + sub_theme__name=sub_theme_name, + name=topic_name, + metric__name=metric_name, + ) + .select_related("sub_theme__theme") + .annotate(matched_metric_id=models.F("metric__id")) + .first() + ) if record: return ( int(record.sub_theme.theme_id), int(record.sub_theme_id), int(record.id), + int(record.matched_metric_id), ) return ( None, None, None, + None, ) def get_all_unique_names(self) -> models.QuerySet: @@ -142,18 +160,25 @@ def get_name_by_id(self, topic_id: int) -> str | None: """ return self.get_queryset().get_name_by_id(topic_id) - def get_id_by_name( - self, theme_name: str, sub_theme_name: str, topic_name: str - ) -> tuple[int | None, int | None, int | None]: + def get_theme_sub_theme_topic_and_metric_id_by_name( + self, + theme_name: str, + sub_theme_name: str, + topic_name: str, + metric_name: str, + ) -> tuple[int | None, int | None, int | None, int | None]: """ - Gets the theme, sub-theme and topic IDs matching the given names. + Gets the theme, sub-theme, topic and metric IDs matching the given names. + + Resolves all 4 ids in a single query that also validates the + combination is consistent. Returns: - A tuple of (theme_id, sub_theme_id, topic_id) if found, - or (None, None, None) if not found. + A tuple of (theme_id, sub_theme_id, topic_id, metric_id) if the whole + combination exists, or (None, None, None, None) if it does not. """ return self.get_queryset().get_id_by_name( - theme_name, sub_theme_name, topic_name + theme_name, sub_theme_name, topic_name, metric_name ) def get_all_names(self) -> TopicQuerySet: diff --git a/tests/integration/metrics/api/views/test_permission_sets.py b/tests/integration/metrics/api/views/test_permission_sets.py index 2c9b22f64d..b3a0bb478f 100644 --- a/tests/integration/metrics/api/views/test_permission_sets.py +++ b/tests/integration/metrics/api/views/test_permission_sets.py @@ -25,7 +25,7 @@ def test_get_sub_themes_by_theme_id_should_return_tuple_of_id_and_name(self): name="respiratory", theme="infectious_disease" ) childhoodVaccinesSubtheme = SubThemeFactory.create_with_theme( - name="immunisation", theme="childhood_vaccines" + name="immunisation", theme="childhood-vaccines" ) # Retrieve the subthemes @@ -51,7 +51,7 @@ def test_get_sub_themes_by_theme_id_should_return_wildcard(self): name="respiratory", theme="infectious_disease" ) childhoodVaccinesSubtheme = SubThemeFactory.create_with_theme( - name="immunisation", theme="childhood_vaccines" + name="immunisation", theme="childhood-vaccines" ) # Retrieve the subthemes @@ -77,7 +77,7 @@ def test_get_sub_themes_by_theme_id_should_return_an_error(self): name="respiratory", theme="infectious_disease" ) childhoodVaccinesSubtheme = SubThemeFactory.create_with_theme( - name="immunisation", theme="childhood_vaccines" + name="immunisation", theme="childhood-vaccines" ) # Retrieve the subthemes diff --git a/tests/integration/metrics/data/managers/core_models/test_geography.py b/tests/integration/metrics/data/managers/core_models/test_geography.py index 77fa001e30..d337d66f45 100644 --- a/tests/integration/metrics/data/managers/core_models/test_geography.py +++ b/tests/integration/metrics/data/managers/core_models/test_geography.py @@ -132,7 +132,7 @@ def test_get_name_by_code(self): def test_get_id_by_name(self, lookup_name: str, expected_index: int | None): """ Given some Geography records - When get_id_by_name() is called + When get_theme_sub_theme_topic_and_metric_id_by_name() is called Then the matching geography_id is returned, or None if no match """ @@ -151,7 +151,7 @@ def test_get_id_by_name(self, lookup_name: str, expected_index: int | None): ] # When - geography_id = Geography.objects.get_id_by_name(lookup_name, "DUMMY") + geography_id = Geography.objects.get_theme_sub_theme_topic_and_metric_id_by_name(lookup_name, "DUMMY") # Then expected_id = ( @@ -186,3 +186,59 @@ def test_get_code_by_name(self): # Then assert geography_code == liverpool_combined_authority.geography_code + + @pytest.mark.django_db + def test_get_geography_type_id_and_code_by_name(self): + """ + Given some Geography records that share a name across geography types + When get_geography_type_id_and_code_by_name() is called with a specific geography_type + Then the matching (geography_type_id, geography_code) pair is returned + """ + + # Given + GeographyFactory.create_with_geography_type( + name="Liverpool", + geography_code="E08000012", + geography_type="Lower Tier Local Authority", + ) + liverpool_combined_authority = GeographyFactory.create_with_geography_type( + name="Liverpool", + geography_code="E47000004", + geography_type="Combined Authority", + ) + + # When + result = Geography.objects.get_geography_type_id_and_code_by_name( + "Liverpool", "Combined Authority" + ) + + # Then + assert result == ( + liverpool_combined_authority.geography_type_id, + liverpool_combined_authority.geography_code, + ) + + @pytest.mark.django_db + def test_get_geography_type_id_and_code_by_name_returns_none_for_inconsistent_pair( + self, + ): + """ + Given a Geography that belongs to one geography_type + When get_geography_type_id_and_code_by_name() is called with a different geography_type + Then (None, None) is returned + """ + + # Given + GeographyFactory.create_with_geography_type( + name="Liverpool", + geography_code="E47000004", + geography_type="Combined Authority", + ) + + # When + result = Geography.objects.get_geography_type_id_and_code_by_name( + "Liverpool", "Nation" + ) + + # Then + assert result == (None, None) diff --git a/tests/integration/metrics/data/managers/core_models/test_geography_types.py b/tests/integration/metrics/data/managers/core_models/test_geography_types.py index a28d66b97a..c21c1699cd 100644 --- a/tests/integration/metrics/data/managers/core_models/test_geography_types.py +++ b/tests/integration/metrics/data/managers/core_models/test_geography_types.py @@ -74,7 +74,7 @@ def test_get_name_by_id(self): def test_get_id_by_name(self, lookup_name: str, expected_index: int | None): """ Given some GeographyType records - When get_id_by_name() is called + When get_theme_sub_theme_topic_and_metric_id_by_name() is called Then the matching geography_type_id is returned, or None if no match """ @@ -85,7 +85,7 @@ def test_get_id_by_name(self, lookup_name: str, expected_index: int | None): ] # When - geography_type_id = GeographyType.objects.get_id_by_name(lookup_name) + geography_type_id = GeographyType.objects.get_theme_sub_theme_topic_and_metric_id_by_name(lookup_name) # Then expected_id = ( diff --git a/tests/integration/metrics/data/managers/core_models/test_metric.py b/tests/integration/metrics/data/managers/core_models/test_metric.py index f5dea413d2..a337c45e76 100644 --- a/tests/integration/metrics/data/managers/core_models/test_metric.py +++ b/tests/integration/metrics/data/managers/core_models/test_metric.py @@ -129,7 +129,7 @@ def test_get_name_by_id(self): def test_get_id_by_name(self, lookup_name: str, expected_index: int | None): """ Given some Metric records - When get_id_by_name() is called + When get_theme_sub_theme_topic_and_metric_id_by_name() is called Then the matching metric id is returned, or None if no match """ @@ -146,7 +146,7 @@ def test_get_id_by_name(self, lookup_name: str, expected_index: int | None): ] # When - metric_id = Metric.objects.get_id_by_name(lookup_name) + metric_id = Metric.objects.get_theme_sub_theme_topic_and_metric_id_by_name(lookup_name) # Then expected_id = ( diff --git a/tests/integration/metrics/data/managers/core_models/test_topic.py b/tests/integration/metrics/data/managers/core_models/test_topic.py index da01286b5a..b99165c1d6 100644 --- a/tests/integration/metrics/data/managers/core_models/test_topic.py +++ b/tests/integration/metrics/data/managers/core_models/test_topic.py @@ -1,6 +1,6 @@ import pytest -from metrics.data.models.core_models.supporting import SubTheme, Theme, Topic +from metrics.data.models.core_models.supporting import Metric, SubTheme, Theme, Topic from tests.factories.metrics.topic import TopicFactory @@ -75,12 +75,25 @@ def test_query_get_name_by_id(self): @pytest.mark.django_db @pytest.mark.parametrize( - "theme_name, sub_theme_name, topic_name, expected_index", + "theme_name, sub_theme_name, topic_name, metric_name, is_match", [ - ("Infectious Diseases", "Respiratory", "COVID-19", 0), - ("NON-EXISTENT", "Respiratory", "COVID-19", None), - ("Infectious Diseases", "NON-EXISTENT", "COVID-19", None), - ("Infectious Diseases", "Respiratory", "NON-EXISTENT", None), + ("Infectious Diseases", "Respiratory", "COVID-19", "COVID-19_metric", True), + ("NON-EXISTENT", "Respiratory", "COVID-19", "COVID-19_metric", False), + ( + "Infectious Diseases", + "NON-EXISTENT", + "COVID-19", + "COVID-19_metric", + False, + ), + ( + "Infectious Diseases", + "Respiratory", + "NON-EXISTENT", + "COVID-19_metric", + False, + ), + ("Infectious Diseases", "Respiratory", "COVID-19", "NON-EXISTENT", False), ], ) def test_get_id_by_name( @@ -88,32 +101,35 @@ def test_get_id_by_name( theme_name: str, sub_theme_name: str, topic_name: str, - expected_index: int | None, + metric_name: str, + is_match: bool, ): """ - Given some theme, sub-theme and topic records - When get_id_by_name() is called - Then the matching 3 ids are returned, or 3 None values if no match + Given some theme, sub-theme, topic and metric records + When get_theme_sub_theme_topic_and_metric_id_by_name() is called + Then the matching 4 ids are returned, or 4 None values if the combination is not consistent """ # Given given_theme = Theme.objects.create(name="Infectious Diseases") given_sub_theme = SubTheme.objects.create(name="Respiratory", theme=given_theme) - given_topics = [ - Topic.objects.create(name="COVID-19", sub_theme=given_sub_theme) - ] + given_topic = Topic.objects.create(name="COVID-19", sub_theme=given_sub_theme) + given_metric = Metric.objects.create(name="COVID-19_metric", topic=given_topic) # When - ids = Topic.objects.get_id_by_name(theme_name, sub_theme_name, topic_name) + ids = Topic.objects.get_theme_sub_theme_topic_and_metric_id_by_name( + theme_name, sub_theme_name, topic_name, metric_name + ) # Then expected_ids = ( ( given_theme.id, given_sub_theme.id, - given_topics[expected_index].id, + given_topic.id, + given_metric.id, ) - if expected_index is not None - else (None, None, None) + if is_match + else (None, None, None, None) ) assert ids == expected_ids diff --git a/tests/unit/common/auth/test_permissions.py b/tests/unit/common/auth/test_permissions.py index cf077e2b07..f83385717c 100644 --- a/tests/unit/common/auth/test_permissions.py +++ b/tests/unit/common/auth/test_permissions.py @@ -59,39 +59,32 @@ def _check_permissions_by_name(self, permission_sets: PermissionSetsType) -> boo def _patch_lookups( self, topic_result=None, - metric_result=None, - geography_type_result=None, geography_result=None, ): - """Return patches for all four DB manager methods.""" + """Return patches for the two combined DB manager lookups.""" - topic_result = topic_result or (self.THEME_ID, self.SUB_THEME_ID, self.TOPIC_ID) - metric_result = metric_result or self.METRIC_ID - geography_type_result = geography_type_result or self.GEOGRAPHY_TYPE_ID - geography_result = geography_result or self.GEOGRAPHY_ID + topic_result = topic_result or ( + self.THEME_ID, + self.SUB_THEME_ID, + self.TOPIC_ID, + self.METRIC_ID, + ) + geography_result = geography_result or ( + self.GEOGRAPHY_TYPE_ID, + self.GEOGRAPHY_ID, + ) stack = ExitStack() stack.enter_context( patch( - "metrics.data.managers.core_models.topic.TopicQuerySet.get_id_by_name", + "metrics.data.managers.core_models.topic.TopicQuerySet.get_theme_sub_theme_topic_and_metric_id_by_name", return_value=topic_result, ) ) stack.enter_context( patch( - "metrics.data.managers.core_models.metric.MetricQuerySet.get_id_by_name", - return_value=metric_result, - ) - ) - stack.enter_context( - patch( - "metrics.data.managers.core_models.geography_type.GeographyTypeQuerySet.get_id_by_name", - return_value=geography_type_result, - ) - ) - stack.enter_context( - patch( - "metrics.data.managers.core_models.geography.GeographyQuerySet.get_code_by_name", + "metrics.data.managers.core_models.geography.GeographyQuerySet" + ".get_geography_type_id_and_code_by_name", return_value=geography_result, ) ) @@ -99,93 +92,27 @@ def _patch_lookups( return stack def test_returns_false_when_topic_lookup_fails(self): - with ( - patch( - "metrics.data.managers.core_models.topic.TopicQuerySet.get_id_by_name", - return_value=(None, None, None), - ), - patch( - "metrics.data.managers.core_models.metric.MetricQuerySet.get_id_by_name", - return_value=self.METRIC_ID, - ), - patch( - "metrics.data.managers.core_models.geography_type.GeographyTypeQuerySet.get_id_by_name", - return_value=self.GEOGRAPHY_TYPE_ID, - ), - patch( - "metrics.data.managers.core_models.geography.GeographyQuerySet.get_code_by_name", - return_value=self.GEOGRAPHY_ID, - ), - ): + with self._patch_lookups(topic_result=(None, None, None, None)): assert not self._check_permissions_by_name( self._build_permission_sets([self._permissions_by_id()]) ) def test_returns_false_when_metric_lookup_fails(self): - with ( - patch( - "metrics.data.managers.core_models.topic.TopicQuerySet.get_id_by_name", - return_value=(self.THEME_ID, self.SUB_THEME_ID, self.TOPIC_ID), - ), - patch( - "metrics.data.managers.core_models.metric.MetricQuerySet.get_id_by_name", - return_value=None, - ), - patch( - "metrics.data.managers.core_models.geography_type.GeographyTypeQuerySet.get_id_by_name", - return_value=self.GEOGRAPHY_TYPE_ID, - ), - patch( - "metrics.data.managers.core_models.geography.GeographyQuerySet.get_code_by_name", - return_value=self.GEOGRAPHY_ID, - ), + with self._patch_lookups( + topic_result=(self.THEME_ID, self.SUB_THEME_ID, self.TOPIC_ID, None) ): assert not self._check_permissions_by_name( self._build_permission_sets([self._permissions_by_id()]) ) def test_returns_false_when_geography_type_lookup_fails(self): - with ( - patch( - "metrics.data.managers.core_models.topic.TopicQuerySet.get_id_by_name", - return_value=(self.THEME_ID, self.SUB_THEME_ID, self.TOPIC_ID), - ), - patch( - "metrics.data.managers.core_models.metric.MetricQuerySet.get_id_by_name", - return_value=self.METRIC_ID, - ), - patch( - "metrics.data.managers.core_models.geography_type.GeographyTypeQuerySet.get_id_by_name", - return_value=None, - ), - patch( - "metrics.data.managers.core_models.geography.GeographyQuerySet.get_code_by_name", - return_value=self.GEOGRAPHY_ID, - ), - ): + with self._patch_lookups(geography_result=(None, self.GEOGRAPHY_ID)): assert not self._check_permissions_by_name( self._build_permission_sets([self._permissions_by_id()]) ) def test_returns_false_when_geography_lookup_fails(self): - with ( - patch( - "metrics.data.managers.core_models.topic.TopicQuerySet.get_id_by_name", - return_value=(self.THEME_ID, self.SUB_THEME_ID, self.TOPIC_ID), - ), - patch( - "metrics.data.managers.core_models.metric.MetricQuerySet.get_id_by_name", - return_value=self.METRIC_ID, - ), - patch( - "metrics.data.managers.core_models.geography_type.GeographyTypeQuerySet.get_id_by_name", - return_value=self.GEOGRAPHY_TYPE_ID, - ), - patch( - "metrics.data.managers.core_models.geography.GeographyQuerySet.get_code_by_name", - return_value=None, - ), - ): + with self._patch_lookups(geography_result=(self.GEOGRAPHY_TYPE_ID, None)): assert not self._check_permissions_by_name( self._build_permission_sets([self._permissions_by_id()]) ) From e5c7df666e300868f0bf4d5a0ef5cfdfd39f91b8 Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Fri, 26 Jun 2026 13:16:49 +0100 Subject: [PATCH 04/20] CDD 3174 - Downloads v2 endpoint (#3221) * Update theme functionality to pull available themes from the db via the metrics interface. * CDD-3175: added endpoints for retrieving subtheme/topics/metrics and wired up javascript to call endpoints. * Update the model and the permission_set javascript when handling wildcard selection * Update to add serializer to handle request and response for subthemes and update to subthemes to handle querying db * CDD-3175: updated the JS to add wildcard and empty object options * CDD-3175: Updated the topics and metrics endpoints to retrieve data from the DB * CDD-3175: wired up the logic for selecting geography types * CDD-3175: update permission set for geographies * CDD-3175: updates for limiting the creation of duplicate permission sets * CDD-3175: updates for handling the naming of permission sets * CDD-3085: updated validations and wildcard functionality * CDD-3175: update migrations and add tidy up javascript and validation * CDD-3175: remove console logs from javascript * CDD-3175: Update PermissionSet model * CDD-3175: Update wagtail hooks * CDD-3175: remove print statements and tidy up field_choice_callables * CDD-3175: Update method descriptions * CDD-3175: tidied up the geography serializer * CDD-3175: formatting * Update documentation * CDD-3175: linting * CDD-3175: tests * CDD-3176: Add initial model * CDD-3175: Initial Commit * Create initial permission set * Add conditional sub_theme dropdown * Update migration file and tidy up child_theme.js * pip: (deps): bump python-dotenv from 1.2.1 to 1.2.2 Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 1.2.1 to 1.2.2. - [Release notes](https://github.com/theskumar/python-dotenv/releases) - [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md) - [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2) --- updated-dependencies: - dependency-name: python-dotenv dependency-version: 1.2.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Testing dummy secret with gitleaks * Testing dummy secret with gitleaks * Added secret scan to the existing action.yaml * Reverted to script installation of gitleaks * Changed job name * Changed ubuntu version * Using official gitleaks action * Updated ubuntu version * Gitleaks arg removed * CDD-3175: populate the Topic dropdown * Update theme functionality to pull available themes from the db via the metrics interface. * Update to add serializer to handle request and response for subthemes and update to subthemes to handle querying db * CDD-3175: updated the JS to add wildcard and empty object options * CDD-3175: Updated the topics and metrics endpoints to retrieve data from the DB * CDD-3085: updated validations and wildcard functionality * WIP: Separate model files and create permission set block * add name back in * working draft * Split models into two files * CDD-3175: removed duplicate functionality * CDD-3175: refactored naming of endpoints * Add unit testing * CDD-3175: update to fix wildcard selection * Simplified version * remove old code * Remove old code * CDD-3175: update for PR comments * CDD-3175: update for PR comments * remove old file * CDD-3175: Update method annotation * CDD-3175: Update method annotation * Update checkboxes * Linting fixes * remove merge issue * linting things * CDD-3175: Update urls for permission set endpoints * CDD-3176: remove duplicated tests * CDD-3172: Update to add the functionality for retrieving user permission sets * CDD-3172: linting * CDD-3175: Update to add ability to get by id and to create initial permission set hierarchy * CDD-3175: add group by functionality * CDD-3172: small refactor of permission_hierarchy and users and topics * Remove testing changes to truncated_dataset * Remove group by geography * refactor permission grouping to group by id rather than name * CDD-3172: linting * CDD-3172: tests * CDD-3172: tests * CDD-3172: tests and refactoring * CDD-3172: Update response format * sonar feedback: update based on sonarqube output * sonar feedback: update based on sonarqube output * Linting * CDD-3171: Update permission set form now it's a page not a snippet * Update topic page to include theme/subtheme/topic fields * WIP: filter getPages based on is_public field * Move auth content underneath CMS * Expose themes/subthemes/topics on topic and metric doc child pages * CDD-3172: move class for blocks * WIP: Add theme/subtheme/topic to pages * linting and permission set url changes * CDD-3172: refactored naming of geography method and updated the tests based on feedback. * CDD-3172: updated test to better name test and updated permission hierarchy error * CDD-2172: Add examples for each of the potential responses for get permissions sets hierarchy requests * CDD-2172: linting * CDD-3147: Update Cognito User for permission sets Add permission sets to request.user object Make cognito user ephemeral for speed - DB access is not needed Move auth header name to settings for flexibility * CDD-3147: Improve logging of JWT * CDD-3147: Update readme for using JWT locally * CDD-3147: Update readme for using JWT locally * CDD-3119 Add a new SimpleMenu model. This is a simplified version of the current Menu model where menus are now just simple links with a title. * CDD-3119 Add panels attribute to SimpleMenu model. * CDD-3119 Beef up the SimpleMenu serializer tests. * CDD-3232 Update chart response styles. Bar and line charts now have borders and grid lines on both axis. * pip dev: (deps-dev): bump pre-commit from 4.5.1 to 4.6.0 Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 4.5.1 to 4.6.0. - [Release notes](https://github.com/pre-commit/pre-commit/releases) - [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md) - [Commits](https://github.com/pre-commit/pre-commit/compare/v4.5.1...v4.6.0) --- updated-dependencies: - dependency-name: pre-commit dependency-version: 4.6.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * pip: (deps): bump idna from 3.11 to 3.12 Bumps [idna](https://github.com/kjd/idna) from 3.11 to 3.12. - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst) - [Commits](https://github.com/kjd/idna/compare/v3.11...v3.12) --- updated-dependencies: - dependency-name: idna dependency-version: '3.12' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * pip dev: (deps-dev): bump gitpython from 3.1.46 to 3.1.47 Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.46 to 3.1.47. - [Release notes](https://github.com/gitpython-developers/GitPython/releases) - [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES) - [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.46...3.1.47) --- updated-dependencies: - dependency-name: gitpython dependency-version: 3.1.47 dependency-type: direct:development ... Signed-off-by: dependabot[bot] * pip: (deps): bump click from 8.3.2 to 8.3.3 Bumps [click](https://github.com/pallets/click) from 8.3.2 to 8.3.3. - [Release notes](https://github.com/pallets/click/releases) - [Changelog](https://github.com/pallets/click/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/click/compare/8.3.2...8.3.3) --- updated-dependencies: - dependency-name: click dependency-version: 8.3.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * pip: (deps): bump psycopg2-binary from 2.9.10 to 2.9.12 Bumps [psycopg2-binary](https://github.com/psycopg/psycopg2) from 2.9.10 to 2.9.12. - [Changelog](https://github.com/psycopg/psycopg2/blob/master/NEWS) - [Commits](https://github.com/psycopg/psycopg2/compare/2.9.10...2.9.12) --- updated-dependencies: - dependency-name: psycopg2-binary dependency-version: 2.9.12 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * pip: (deps): bump pydantic from 2.13.2 to 2.13.3 Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.13.2 to 2.13.3. - [Release notes](https://github.com/pydantic/pydantic/releases) - [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md) - [Commits](https://github.com/pydantic/pydantic/compare/v2.13.2...v2.13.3) --- updated-dependencies: - dependency-name: pydantic dependency-version: 2.13.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * build: remove simplejson dependency Saw a dependabot update for this package and in investigating if it was safe to update realised we're not actually using it... * CDD-3313: Add topic page link to headline metrics card (#3151) * pip: (deps): bump idna from 3.12 to 3.13 Bumps [idna](https://github.com/kjd/idna) from 3.12 to 3.13. - [Release notes](https://github.com/kjd/idna/releases) - [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst) - [Commits](https://github.com/kjd/idna/compare/v3.12...v3.13) --- updated-dependencies: - dependency-name: idna dependency-version: '3.13' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * topics: add HIV topic * CDD-3087: new CMS page for logged-out functionality (#3163) * pip: (deps): bump filelock from 3.28.0 to 3.29.0 Bumps [filelock](https://github.com/tox-dev/py-filelock) from 3.28.0 to 3.29.0. - [Release notes](https://github.com/tox-dev/py-filelock/releases) - [Changelog](https://github.com/tox-dev/filelock/blob/main/docs/changelog.rst) - [Commits](https://github.com/tox-dev/py-filelock/compare/3.28.0...3.29.0) --- updated-dependencies: - dependency-name: filelock dependency-version: 3.29.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * WIP: pseudo code / note form of solution * WIP: filter pages on permission sets * Permission check updates and form handling * WIP: Fix comparison function * Finish getPages endpoint * CDD-3172: Remove permission_sets from CMS API * CDD-3172: Update docstring * remove redundant code * fixes for existing unit tests * Update imports * add endpoint back in for testing * fix import * New tests, and fixes and updates to existing tests * Naming fixes * test coverage * CDD-3171: Tweaks * CDD-3171: Add display name to permission sets * remove log file * Fix js file * Linting * Update migration * refactor for sonarqube checks * Fix constraints on permission sets * update unit tests * fix allowed_pages overwrite * Fix unit test * Fix test * linting * CDD-3171: Move permission_set.js insert to Media class * CDD-3171: Add ignores for importlint This could do with some more time spent on it to simplify the imports being ignored * Update architectural constraints * linting * Update wildcard value in viewsets * Update test * remove comment * Update import * fix test name * linting * combine imports * refactor for simplicity * linting * CDD-3173: prototype authorization curl call on /api/downloads/v2 * CDD-3173: get rid of check_permissions_by_name() and make /api/downloads/v2 use check_permissions() instead * CDD-3173: let cms/dashboard/viewsets.py from CDD-3171 use my fully equivalent check_permissions() function instead * CDD-3173: add debugging code to user_manager.py to be able to test this JIRA ticket in isolation * CDD-3173: evaluate metric- and geography-related permissions separately * CDD-3173: lint * CDD-3173: lint * CDD-3173: convert permission function arguments into named arguments (safer) * CDD-3173: move things around for architectural constraints * CDD-3173: reduce noisy comments * CDD-3173: remove rbac_permissions parameter (easy to do, cos doesn't cause other failures) * Pass permission set array to check permissions * CDD-3174: re-added noisy comment * CDD-3174: formalize comment * CDD-3174: revert rbac_permissions warning fix * CDD-3174: revert every logger.info("Entered function ...) * Add logs for permission sets * CDD-3174: revert SQL printing * CDD-3174: revert permission set debugging * CDD-3174: add type hints to permission functions & vars * CDD-3174: make permissions disallow empty "" requests * Update test mock and linting * CDD-3174: centralize WILDCARD_ID_VALUE * CDD-3174: use MetricsAPIInterface to access data mappers from common folder * CDD-3174: remove duplicate check_metric_related_permissions() call * CDD-3174: add BaseRequestParams() class that all other request classes inherit from * Update log * Update log line * CDD-3174: add named arguments to check_permissions() * CDD-3174: fix viewsets.py error since merge * CDD-3174: simplify convoluted WHERE clauses in SQL * CDD-3174: add _get_id_string_or_none() to normalize strings to prevent unintentional "None" == "None" string comparisons * CDD-3174: separate check_page_permissions() and check_chart_permissions() which allows all arguments to be mandatory (makes behaviour more predictable) * CDD-3174: add tests for permission filtering functionality * CDD-3174: move TestCheckPagePermissions() from test_viewsets.py to test_permissions.py * CDD-3174: log user permissions for every API call that comes with a JWT * CDD-3174: make comment more prominent (so it can't be missed) * CDD-3174: simplify permission logging * CDD-3174: avoid illogical ChartRequestParams(ChartRequestParams) class name * CDD-3174: simplify to "if permission_sets and check_chart_permissions_by_name()" * CDD-3174: remove 2 redundant check_chart_permissions() tests * CDD-3174: add docstrings to functions * CDD-3174: remove duplicate sanity check * CDD-3174: build permission set from the non-public instead of the public recordset * CDD-3174: fix type hints * CDD-3174: call log_user_permission_summary() not on every API call anymore * CDD-3174: fix lint * CDD-3174: protect against geographies with the same name across geography types * CDD-3174: fix geography code bug * CDD-3174: remove old RBAC @require_authorisation decorator from DownloadsView.post --------- Signed-off-by: dependabot[bot] Co-authored-by: Luke Towell Co-authored-by: Luke Towell Co-authored-by: Kathryn Dale Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: abdihakim92x1 Co-authored-by: Matt Reynolds <18287679+mattjreynolds@users.noreply.github.com> Co-authored-by: David Logie Co-authored-by: Josh Humphries Co-authored-by: Taiwo Kareem <13158672+tushortz@users.noreply.github.com> Co-authored-by: Aidan Skinner Co-authored-by: itsthatianguy Co-authored-by: kathryn-dale <161315272+kathryn-dale@users.noreply.github.com> Co-authored-by: sahmed06 <58435820+sahmed06@users.noreply.github.com> --- metrics/data/managers/core_models/topic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index 209df543e1..f68136f493 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -40,7 +40,7 @@ def get_name_by_id(self, topic_id: int) -> str | None: """ return self.filter(id=topic_id).values_list("name", flat=True).first() - def get_id_by_name( + def get_theme_sub_theme_topic_and_metric_id_by_name( self, theme_name: str, sub_theme_name: str, From e8e696059ddaf76ce56f53e9b5d5a45596f2c6fc Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Tue, 30 Jun 2026 22:52:33 +0100 Subject: [PATCH 05/20] CDD-3451: amend tests to match the new mandatory theme and sub_theme fields & the new strict permission name-to-id requirements --- common/auth/permissions.py | 6 +++-- metrics/api/serializers/headlines.py | 8 ++++++ metrics/data/managers/core_models/topic.py | 2 +- metrics/domain/models/headline.py | 12 +++++++++ metrics/interfaces/headlines/access.py | 10 +------- metrics/interfaces/trends/access.py | 11 +------- tests/fakes/managers/headline_manager.py | 2 ++ .../metrics/api/views/test_headlines.py | 2 ++ .../metrics/api/views/test_trends.py | 4 ++- .../managers/core_models/test_geography.py | 4 +-- .../core_models/test_geography_types.py | 4 +-- .../data/managers/core_models/test_metric.py | 4 +-- .../interfaces/headlines/test_access.py | 16 +++++------- tests/system/non_public_data/test_charts.py | 8 +++--- .../system/non_public_data/test_downloads.py | 6 +++-- .../system/non_public_data/test_headlines.py | 2 ++ tests/system/non_public_data/test_tables.py | 2 ++ tests/system/non_public_data/test_trends.py | 4 ++- tests/system/test_ingestion.py | 25 ++++++++++++++++--- .../metrics/api/serializers/test_headlines.py | 2 ++ .../metrics/api/serializers/test_trends.py | 6 +++-- .../unit/metrics/domain/models/test_common.py | 2 ++ .../metrics/domain/models/test_headline.py | 12 ++++++--- .../unit/metrics/domain/models/test_trends.py | 16 +++++++++--- .../interfaces/headlines/test_access.py | 4 +++ .../metrics/interfaces/plots/test_access.py | 8 +++--- .../metrics/interfaces/trends/test_access.py | 2 ++ 27 files changed, 122 insertions(+), 62 deletions(-) diff --git a/common/auth/permissions.py b/common/auth/permissions.py index b0fbe7dc8c..90b045d8ae 100644 --- a/common/auth/permissions.py +++ b/common/auth/permissions.py @@ -57,8 +57,10 @@ def check_chart_permissions_by_name( geography_manager = MetricsAPIInterface.get_geography_manager() # Any inconsistent combination below resolves to None ids and is denied - theme_id, sub_theme_id, topic_id, metric_id = topic_manager.get_theme_sub_theme_topic_and_metric_id_by_name( - theme_name, sub_theme_name, topic_name, metric_name + theme_id, sub_theme_id, topic_id, metric_id = ( + topic_manager.get_theme_sub_theme_topic_and_metric_id_by_name( + theme_name, sub_theme_name, topic_name, metric_name + ) ) geography_type_id, geography_id = ( geography_manager.get_geography_type_id_and_code_by_name( diff --git a/metrics/api/serializers/headlines.py b/metrics/api/serializers/headlines.py index 0ccfcea1a7..94e0d6965b 100644 --- a/metrics/api/serializers/headlines.py +++ b/metrics/api/serializers/headlines.py @@ -20,6 +20,14 @@ class HeadlinesQuerySerializer(serializers.Serializer): + theme = serializers.CharField( + required=True, + help_text=help_texts.THEME_FIELD, + ) + sub_theme = serializers.CharField( + required=True, + help_text=help_texts.SUB_THEME_FIELD, + ) topic = serializers.ChoiceField( choices=[], required=True, diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index f68136f493..66982bd80a 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -177,7 +177,7 @@ def get_theme_sub_theme_topic_and_metric_id_by_name( A tuple of (theme_id, sub_theme_id, topic_id, metric_id) if the whole combination exists, or (None, None, None, None) if it does not. """ - return self.get_queryset().get_id_by_name( + return self.get_queryset().get_theme_sub_theme_topic_and_metric_id_by_name( theme_name, sub_theme_name, topic_name, metric_name ) diff --git a/metrics/domain/models/headline.py b/metrics/domain/models/headline.py index dce65414c5..4cf91620c3 100644 --- a/metrics/domain/models/headline.py +++ b/metrics/domain/models/headline.py @@ -2,6 +2,8 @@ class HeadlineParameters(BaseRequestParams): + theme: str + sub_theme: str topic: str metric: str stratum: str @@ -12,6 +14,14 @@ class HeadlineParameters(BaseRequestParams): is_public: bool | None = True data_classification: str | None = None + @property + def theme_name(self) -> str: + return self.theme + + @property + def sub_theme_name(self) -> str: + return self.sub_theme + @property def topic_name(self) -> str: return self.topic @@ -52,6 +62,8 @@ def to_dict_for_query(self) -> dict[str, str]: """ return { + "theme": self.theme_name, + "sub_theme": self.sub_theme_name, "topic": self.topic_name, "metric": self.metric_name, "geography": self.geography_name, diff --git a/metrics/interfaces/headlines/access.py b/metrics/interfaces/headlines/access.py index 74ec223364..64fe58c384 100644 --- a/metrics/interfaces/headlines/access.py +++ b/metrics/interfaces/headlines/access.py @@ -1,6 +1,5 @@ from django.db.models import Manager -from metrics.api.settings import auth from metrics.data.models.core_models import CoreHeadline, Topic from metrics.domain.headlines.state import Headline from metrics.domain.models.headline import HeadlineParameters @@ -35,15 +34,8 @@ def get_latest_metric_value(self) -> Headline: `HeadlineNumberDataNotFoundError`: If the query returned no records. """ - params = self.headline_parameters.to_dict_for_query() - if auth.AUTH_ENABLED: - # Needed for the downstream permissions check - topic = self.topic_manager.get_by_name( - name=self.headline_parameters.topic_name - ) - params["theme"] = topic.sub_theme.theme.name - params["sub_theme"] = topic.sub_theme.name + params = self.headline_parameters.to_dict_for_query() core_headline: CoreHeadline | None = ( self.core_headline_manager.get_latest_headline(**params) diff --git a/metrics/interfaces/trends/access.py b/metrics/interfaces/trends/access.py index e0ee399b5f..107237ea27 100644 --- a/metrics/interfaces/trends/access.py +++ b/metrics/interfaces/trends/access.py @@ -1,6 +1,5 @@ from django.db.models import Manager -from metrics.api.settings import auth from metrics.data.models.core_models import CoreHeadline, Topic from metrics.domain.models.trends import TrendsParameters from metrics.domain.trends.state import TREND_AS_DICT, Trend @@ -66,15 +65,12 @@ def get_trend(self) -> Trend: `topic` / `metric` / `percentage_metric`. """ + main_metric_params = self.trend_parameters.to_dict_for_main_metric_query() percentage_metric_params = ( self.trend_parameters.to_dict_for_percentage_metric_query() ) - if auth.AUTH_ENABLED: - self._add_theme_info_to_params(params=main_metric_params) - self._add_theme_info_to_params(params=percentage_metric_params) - core_headline_percentage_metric: CoreHeadline = self.get_latest_metric_value( params=percentage_metric_params ) @@ -91,11 +87,6 @@ def get_trend(self) -> Trend: percentage_metric_period_end=core_headline_percentage_metric.period_end, ) - def _add_theme_info_to_params(self, *, params: dict) -> None: - topic = self.topic_manager.get_by_name(name=self.trend_parameters.topic_name) - params["theme"] = topic.sub_theme.theme.name - params["sub_theme"] = topic.sub_theme.name - def generate_trend_numbers( *, diff --git a/tests/fakes/managers/headline_manager.py b/tests/fakes/managers/headline_manager.py index 5c917c7563..6b55dad41b 100644 --- a/tests/fakes/managers/headline_manager.py +++ b/tests/fakes/managers/headline_manager.py @@ -19,6 +19,8 @@ def get_latest_headline( *, topic: str, metric: str, + theme: str = "", + sub_theme: str = "", geography: str | None = None, geography_type: str | None = None, geography_code: str | None = None, diff --git a/tests/integration/metrics/api/views/test_headlines.py b/tests/integration/metrics/api/views/test_headlines.py index 6cef0e2fcb..2c3d867d8e 100644 --- a/tests/integration/metrics/api/views/test_headlines.py +++ b/tests/integration/metrics/api/views/test_headlines.py @@ -28,6 +28,8 @@ def test_get_returns_correct_response( # Given client = APIClient() payload = { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": core_headline_example.metric.topic.name, "metric": core_headline_example.metric.name, "geography": core_headline_example.geography.name, diff --git a/tests/integration/metrics/api/views/test_trends.py b/tests/integration/metrics/api/views/test_trends.py index aa25f95917..07ec3d270b 100644 --- a/tests/integration/metrics/api/views/test_trends.py +++ b/tests/integration/metrics/api/views/test_trends.py @@ -34,11 +34,13 @@ def test_get_returns_correct_response( response: Response = client.get( path=self.path, data={ + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, - "percentage_metric": percentage_metric_name, "geography": main_record.geography.name, "geography_type": main_record.geography.geography_type.name, + "percentage_metric": percentage_metric_name, "sex": main_record.sex, "age": main_record.age, "stratum": main_record.stratum.name, diff --git a/tests/integration/metrics/data/managers/core_models/test_geography.py b/tests/integration/metrics/data/managers/core_models/test_geography.py index d337d66f45..d4c47dab22 100644 --- a/tests/integration/metrics/data/managers/core_models/test_geography.py +++ b/tests/integration/metrics/data/managers/core_models/test_geography.py @@ -132,7 +132,7 @@ def test_get_name_by_code(self): def test_get_id_by_name(self, lookup_name: str, expected_index: int | None): """ Given some Geography records - When get_theme_sub_theme_topic_and_metric_id_by_name() is called + When get_id_by_name() is called Then the matching geography_id is returned, or None if no match """ @@ -151,7 +151,7 @@ def test_get_id_by_name(self, lookup_name: str, expected_index: int | None): ] # When - geography_id = Geography.objects.get_theme_sub_theme_topic_and_metric_id_by_name(lookup_name, "DUMMY") + geography_id = Geography.objects.get_id_by_name(lookup_name, "DUMMY") # Then expected_id = ( diff --git a/tests/integration/metrics/data/managers/core_models/test_geography_types.py b/tests/integration/metrics/data/managers/core_models/test_geography_types.py index c21c1699cd..a28d66b97a 100644 --- a/tests/integration/metrics/data/managers/core_models/test_geography_types.py +++ b/tests/integration/metrics/data/managers/core_models/test_geography_types.py @@ -74,7 +74,7 @@ def test_get_name_by_id(self): def test_get_id_by_name(self, lookup_name: str, expected_index: int | None): """ Given some GeographyType records - When get_theme_sub_theme_topic_and_metric_id_by_name() is called + When get_id_by_name() is called Then the matching geography_type_id is returned, or None if no match """ @@ -85,7 +85,7 @@ def test_get_id_by_name(self, lookup_name: str, expected_index: int | None): ] # When - geography_type_id = GeographyType.objects.get_theme_sub_theme_topic_and_metric_id_by_name(lookup_name) + geography_type_id = GeographyType.objects.get_id_by_name(lookup_name) # Then expected_id = ( diff --git a/tests/integration/metrics/data/managers/core_models/test_metric.py b/tests/integration/metrics/data/managers/core_models/test_metric.py index a337c45e76..f5dea413d2 100644 --- a/tests/integration/metrics/data/managers/core_models/test_metric.py +++ b/tests/integration/metrics/data/managers/core_models/test_metric.py @@ -129,7 +129,7 @@ def test_get_name_by_id(self): def test_get_id_by_name(self, lookup_name: str, expected_index: int | None): """ Given some Metric records - When get_theme_sub_theme_topic_and_metric_id_by_name() is called + When get_id_by_name() is called Then the matching metric id is returned, or None if no match """ @@ -146,7 +146,7 @@ def test_get_id_by_name(self, lookup_name: str, expected_index: int | None): ] # When - metric_id = Metric.objects.get_theme_sub_theme_topic_and_metric_id_by_name(lookup_name) + metric_id = Metric.objects.get_id_by_name(lookup_name) # Then expected_id = ( diff --git a/tests/integration/metrics/interfaces/headlines/test_access.py b/tests/integration/metrics/interfaces/headlines/test_access.py index 2e5134e706..12aad97120 100644 --- a/tests/integration/metrics/interfaces/headlines/test_access.py +++ b/tests/integration/metrics/interfaces/headlines/test_access.py @@ -10,29 +10,24 @@ from tests.factories.metrics.headline import CoreHeadlineFactory from tests.factories.metrics.rbac_models.rbac_permission import RBACPermissionFactory -MODULE_PATH = "metrics.interfaces.headlines.access" - class TestHeadlinesInterface: @pytest.mark.django_db - @mock.patch(f"{MODULE_PATH}.auth.AUTH_ENABLED") @mock.patch( "metrics.api.permissions.fluent_permissions.auth.ENFORCE_PUBLIC_DATA_ONLY", False, ) def test_get_latest_metric_value_returns_non_public_record_for_matching_permission( - self, mocked_auth_enabled: mock.MagicMock + self, ): """ Given public and non-public `CoreHeadline` records And an `RBACPermission` which gives access to the non-public portion of the data - And `AUTH_ENABLED` is set to True And `ENFORCE_PUBLIC_DATA_ONLY` is disabled When `get_latest_metric_value()` is called from the `HeadlinesInterface` Then the non-public record is returned """ # Given - mocked_auth_enabled.return_value = True public_record = CoreHeadlineFactory.create_record( period_end="2025-01-01", metric_value=1, is_public=True ) @@ -53,6 +48,8 @@ def test_get_latest_metric_value_returns_non_public_record_for_matching_permissi fake_request.rbac_permissions = [rbac_permission] headline_parameters = HeadlineParameters( + theme=public_record.metric.topic.sub_theme.theme.name, + sub_theme=public_record.metric.topic.sub_theme.name, topic=public_record.metric.topic.name, metric=public_record.metric.name, stratum=public_record.stratum.name, @@ -77,19 +74,16 @@ def test_get_latest_metric_value_returns_non_public_record_for_matching_permissi ) @pytest.mark.django_db - @mock.patch(f"{MODULE_PATH}.auth.AUTH_ENABLED") def test_get_latest_metric_value_excludes_non_public_record_for_no_matching_permission( - self, mocked_auth_enabled: mock.MagicMock + self, ): """ Given public and non-public `CoreHeadline` records And no `RBACPermission` which allows access to the non-public portion of this dataset - And `AUTH_ENABLED` is set to True When `get_latest_metric_value()` is called from the `HeadlinesInterface` Then the non-public record is excluded """ # Given - mocked_auth_enabled.return_value = True public_record = CoreHeadlineFactory.create_record( period_end="2025-01-01", metric_value=1, is_public=True ) @@ -102,6 +96,8 @@ def test_get_latest_metric_value_excludes_non_public_record_for_no_matching_perm fake_request.rbac_permissions = [] headline_parameters = HeadlineParameters( + theme=public_record.metric.topic.sub_theme.theme.name, + sub_theme=public_record.metric.topic.sub_theme.name, topic=public_record.metric.topic.name, metric=public_record.metric.name, stratum=public_record.stratum.name, diff --git a/tests/system/non_public_data/test_charts.py b/tests/system/non_public_data/test_charts.py index 6e720a6711..8d53a8ca0c 100644 --- a/tests/system/non_public_data/test_charts.py +++ b/tests/system/non_public_data/test_charts.py @@ -25,13 +25,15 @@ def get_valid_payload(cls, core_time_series) -> dict: "file_format": "svg", "plots": [ { - "metric": core_time_series.metric.name, + "theme": core_time_series.metric.topic.sub_theme.theme.name, + "sub_theme": core_time_series.metric.topic.sub_theme.name, "topic": core_time_series.metric.topic.name, + "metric": core_time_series.metric.name, + "geography": core_time_series.geography.name, + "geography_type": core_time_series.geography.geography_type.name, "stratum": core_time_series.stratum.name, "age": core_time_series.age.name, "sex": core_time_series.sex, - "geography": core_time_series.geography.name, - "geography_type": core_time_series.geography.geography_type.name, "date_from": "2000-01-01", "date_to": "2025-12-31", "chart_type": "bar", diff --git a/tests/system/non_public_data/test_downloads.py b/tests/system/non_public_data/test_downloads.py index b5d9986acd..57ed68bdef 100644 --- a/tests/system/non_public_data/test_downloads.py +++ b/tests/system/non_public_data/test_downloads.py @@ -25,13 +25,15 @@ def get_valid_payload(cls, core_time_series) -> dict: "file_format": "json", "plots": [ { + "theme": core_time_series.metric.topic.sub_theme.theme.name, + "sub_theme": core_time_series.metric.topic.sub_theme.name, "metric": core_time_series.metric.name, "topic": core_time_series.metric.topic.name, + "geography": core_time_series.geography.name, + "geography_type": core_time_series.geography.geography_type.name, "stratum": core_time_series.stratum.name, "age": core_time_series.age.name, "sex": core_time_series.sex, - "geography": core_time_series.geography.name, - "geography_type": core_time_series.geography.geography_type.name, "date_from": "2000-01-01", "date_to": "2025-12-31", } diff --git a/tests/system/non_public_data/test_headlines.py b/tests/system/non_public_data/test_headlines.py index be6d19f6e5..d836df8fdf 100644 --- a/tests/system/non_public_data/test_headlines.py +++ b/tests/system/non_public_data/test_headlines.py @@ -24,6 +24,8 @@ def path(self) -> str: @classmethod def get_valid_payload(cls, core_headline) -> dict: return { + "theme": core_headline.metric.topic.sub_theme.theme.name, + "sub_theme": core_headline.metric.topic.sub_theme.name, "topic": core_headline.metric.topic.name, "metric": core_headline.metric.name, "geography": core_headline.geography.name, diff --git a/tests/system/non_public_data/test_tables.py b/tests/system/non_public_data/test_tables.py index f225cda902..0e028f6143 100644 --- a/tests/system/non_public_data/test_tables.py +++ b/tests/system/non_public_data/test_tables.py @@ -25,6 +25,8 @@ def get_valid_payload(cls, core_time_series) -> dict: "file_format": "svg", "plots": [ { + "theme": core_time_series.metric.topic.sub_theme.theme.name, + "sub_theme": core_time_series.metric.topic.sub_theme.name, "topic": core_time_series.metric.topic.name, "metric": core_time_series.metric.name, "chart_type": "bar", diff --git a/tests/system/non_public_data/test_trends.py b/tests/system/non_public_data/test_trends.py index 6bab58aaab..2a46c36378 100644 --- a/tests/system/non_public_data/test_trends.py +++ b/tests/system/non_public_data/test_trends.py @@ -24,11 +24,13 @@ def path(self) -> str: @classmethod def get_valid_payload(cls, core_main_headline, core_percentage_headline) -> dict: return { + "theme": core_main_headline.metric.topic.sub_theme.theme.name, + "sub_theme": core_main_headline.metric.topic.sub_theme.name, "topic": core_main_headline.metric.topic.name, "metric": core_main_headline.metric.name, - "percentage_metric": core_percentage_headline.metric.name, "geography": core_main_headline.geography.name, "geography_type": core_main_headline.geography.geography_type.name, + "percentage_metric": core_percentage_headline.metric.name, "age": core_main_headline.age.name, "sex": core_main_headline.sex, "stratum": core_main_headline.stratum.name, diff --git a/tests/system/test_ingestion.py b/tests/system/test_ingestion.py index 46bb15bf16..a165fefb86 100644 --- a/tests/system/test_ingestion.py +++ b/tests/system/test_ingestion.py @@ -80,6 +80,8 @@ def test_data_can_be_ingested_and_queried_from_tables_endpoint( "file_format": "svg", "plots": [ { + "theme": example_time_series_data["parent_theme"], + "sub_theme": example_time_series_data["child_theme"], "topic": example_time_series_data["topic"], "metric": example_time_series_data["metric"], "date_from": "2020-01-01", @@ -255,9 +257,14 @@ def test_data_is_deduplicated_on_write_to_db_and_return_latest_data_from_apis( # Check that the `tables/` endpoint returns the correct data # which matches the first ingested file + theme = first_sample_data["parent_theme"] + sub_theme = first_sample_data["child_theme"] topic = first_sample_data["topic"] metric = first_sample_data["metric"] - tables_response = self._hit_tables_endpoint(topic=topic, metric=metric) + + tables_response = self._hit_tables_endpoint( + theme=theme, sub_theme=sub_theme, topic=topic, metric=metric + ) expected_first_metric_value = ( f"{first_sample_data['time_series'][1]['metric_value']:.4f}" ) @@ -302,7 +309,9 @@ def test_data_is_deduplicated_on_write_to_db_and_return_latest_data_from_apis( ) # After the 2nd file was ingested and subsequently de-duplicated # the `tables/` endpoint should still return the same values as before - tables_response = self._hit_tables_endpoint(topic=topic, metric=metric) + tables_response = self._hit_tables_endpoint( + theme=theme, sub_theme=sub_theme, topic=topic, metric=metric + ) returned_metric_values = [row["values"][0]["value"] for row in tables_response] assert returned_metric_values[0] == expected_first_metric_value assert returned_metric_values[1] == expected_second_metric_value @@ -329,7 +338,9 @@ def test_data_is_deduplicated_on_write_to_db_and_return_latest_data_from_apis( assert final_refresh_date in CoreTimeSeries.objects.all().values_list( "refresh_date", flat=True ) - tables_response = self._hit_tables_endpoint(topic=topic, metric=metric) + tables_response = self._hit_tables_endpoint( + theme=theme, sub_theme=sub_theme, topic=topic, metric=metric + ) returned_metric_values = [row["values"][0]["value"] for row in tables_response] assert returned_metric_values[0] == expected_first_metric_value # We expect the 1st row to be as per the original data @@ -366,6 +377,8 @@ def test_multiple_data_points_with_sequential_refresh_dates_can_be_ingested( example_headline_data["data"].pop(1) headlines_endpoint_payload = { + "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"], @@ -556,7 +569,9 @@ def _rebuild_data_with_single_retrospective_update( return sample_data_with_one_retrospective_update @staticmethod - def _hit_tables_endpoint(topic: str, metric: str) -> list[dict]: + def _hit_tables_endpoint( + theme: str, sub_theme: str, topic: str, metric: str + ) -> list[dict]: client = APIClient() path = "/api/tables/v4/" @@ -564,6 +579,8 @@ def _hit_tables_endpoint(topic: str, metric: str) -> list[dict]: "file_format": "svg", "plots": [ { + "theme": theme, + "sub_theme": sub_theme, "topic": topic, "metric": metric, "date_from": "2020-01-01", diff --git a/tests/unit/metrics/api/serializers/test_headlines.py b/tests/unit/metrics/api/serializers/test_headlines.py index a358dfb6d4..13fcab02b4 100644 --- a/tests/unit/metrics/api/serializers/test_headlines.py +++ b/tests/unit/metrics/api/serializers/test_headlines.py @@ -52,6 +52,8 @@ def _setup_valid_data_payload_and_model_managers( fake_topic = fake_metric.metric_group.topic data: cls.DATA_PAYLOAD_HINT = { + "theme": fake_topic.sub_theme.theme.name, + "sub_theme": fake_topic.sub_theme.name, "topic": fake_topic.name, "metric": fake_metric.name, } diff --git a/tests/unit/metrics/api/serializers/test_trends.py b/tests/unit/metrics/api/serializers/test_trends.py index 0ce17d37f3..efbb06b000 100644 --- a/tests/unit/metrics/api/serializers/test_trends.py +++ b/tests/unit/metrics/api/serializers/test_trends.py @@ -54,14 +54,16 @@ def _setup_valid_data_payload_and_model_managers( fake_stratum = FakeStratumFactory.build_example(stratum_name="default") data: cls.DATA_PAYLOAD_HINT = { + "theme": fake_topic.sub_theme.theme.name, + "sub_theme": fake_topic.sub_theme.name, "topic": fake_topic.name, "metric": fake_metric.name, + "geography": fake_geography.name, + "geography_type": fake_geography.geography_type.name, "percentage_metric": fake_percentage_metric.name, "stratum": fake_stratum.name, "age": fake_age.name, "sex": "all", - "geography": fake_geography.name, - "geography_type": fake_geography.geography_type.name, } return ( diff --git a/tests/unit/metrics/domain/models/test_common.py b/tests/unit/metrics/domain/models/test_common.py index 8a7f810e3c..78dd4a1044 100644 --- a/tests/unit/metrics/domain/models/test_common.py +++ b/tests/unit/metrics/domain/models/test_common.py @@ -33,6 +33,8 @@ def _build_request(*, permission_sets=None) -> Request: def _build_models(request: Request) -> tuple: return ( HeadlineParameters( + theme="infectious_disease", + sub_theme="respiratory", topic="COVID-19", metric="COVID-19_metric", geography="England", diff --git a/tests/unit/metrics/domain/models/test_headline.py b/tests/unit/metrics/domain/models/test_headline.py index cd39bcb182..78a5078762 100644 --- a/tests/unit/metrics/domain/models/test_headline.py +++ b/tests/unit/metrics/domain/models/test_headline.py @@ -6,6 +6,8 @@ class TestHeadlineParameters: valid_payload = { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_headline_ONSdeaths_7DayTotals", "geography": "England", @@ -19,13 +21,15 @@ class TestHeadlineParameters: @pytest.mark.parametrize( "field", [ - "stratum", + "theme", + "sub_theme", + "topic", + "metric", "geography", "geography_type", + "stratum", "sex", "age", - "topic", - "metric", ], ) def test_mandatory_fields_are_enforced(self, field: str): @@ -58,6 +62,8 @@ def test_to_dict_for_query(self): # Then expected_params = { + "theme": self.valid_payload["theme"], + "sub_theme": self.valid_payload["sub_theme"], "topic": self.valid_payload["topic"], "metric": self.valid_payload["metric"], "geography": self.valid_payload["geography"], diff --git a/tests/unit/metrics/domain/models/test_trends.py b/tests/unit/metrics/domain/models/test_trends.py index 0fcc7b2b46..5b139e4a5d 100644 --- a/tests/unit/metrics/domain/models/test_trends.py +++ b/tests/unit/metrics/domain/models/test_trends.py @@ -6,11 +6,13 @@ class TestTrendParameters: valid_payload = { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_headline_ONSdeaths_7DayChange", - "percentage_metric": "COVID-19_headline_ONSdeaths_7DayPercentChange", "geography": "England", "geography_type": "Nation", + "percentage_metric": "COVID-19_headline_ONSdeaths_7DayPercentChange", "stratum": "default", "sex": "all", "age": "all", @@ -19,13 +21,15 @@ class TestTrendParameters: @pytest.mark.parametrize( "field", [ - "stratum", + "theme", + "sub_theme", + "topic", + "metric", "geography", "geography_type", + "stratum", "sex", "age", - "topic", - "metric", "percentage_metric", ], ) @@ -59,6 +63,8 @@ def test_to_dict_for_main_metric_query(self): # Then expected = { + "theme": input_data["theme"], + "sub_theme": input_data["sub_theme"], "topic": input_data["topic"], "metric": input_data["metric"], "geography": input_data["geography"], @@ -85,6 +91,8 @@ def test_to_dict_for_percentage_metric_query(self): # Then expected = { + "theme": input_data["theme"], + "sub_theme": input_data["sub_theme"], "topic": input_data["topic"], "metric": input_data["percentage_metric"], "geography": input_data["geography"], diff --git a/tests/unit/metrics/interfaces/headlines/test_access.py b/tests/unit/metrics/interfaces/headlines/test_access.py index 5e7b1e3c41..42b199b4b3 100644 --- a/tests/unit/metrics/interfaces/headlines/test_access.py +++ b/tests/unit/metrics/interfaces/headlines/test_access.py @@ -15,6 +15,8 @@ @pytest.fixture def example_headline_args() -> dict[str, str]: return { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_headline_ONSdeaths_7DayChange", "geography": "England", @@ -82,6 +84,8 @@ def test_get_metric_value_calls_core_time_series_manager_with_correct_args( ) spy_core_headline_manager.get_latest_headline.assert_called_once_with( + theme=headline_parameters.theme_name, + sub_theme=headline_parameters.sub_theme_name, topic=headline_parameters.topic, metric=headline_parameters.metric, geography=headline_parameters.geography, diff --git a/tests/unit/metrics/interfaces/plots/test_access.py b/tests/unit/metrics/interfaces/plots/test_access.py index 98ee42b41f..a59b646647 100644 --- a/tests/unit/metrics/interfaces/plots/test_access.py +++ b/tests/unit/metrics/interfaces/plots/test_access.py @@ -649,10 +649,10 @@ def test_get_queryset_from_core_model_manager_passes_theme_and_sub_theme_through # When plots_params = { "fields_to_export": [mocked_x_axis, mocked_y_axis], + "theme": fake_metric.topic.sub_theme.theme.name, + "sub_theme": fake_metric.topic.sub_theme.name, "topic": fake_metric.topic.name, "metric": fake_metric.name, - "theme": "infectious_disease", - "sub_theme": "respiratory", "geography": mocked_geography, "geography_type": mocked_geography_type, "geography_code": "", @@ -668,10 +668,10 @@ def test_get_queryset_from_core_model_manager_passes_theme_and_sub_theme_through assert headline_data == spy_core_headline_manager.query_for_data.return_value spy_core_headline_manager.query_for_data.assert_called_once_with( fields_to_export=[mocked_x_axis, mocked_y_axis], + theme=fake_metric.topic.sub_theme.theme.name, + sub_theme=fake_metric.topic.sub_theme.name, topic=fake_metric.topic.name, metric=fake_metric.name, - theme="infectious_disease", - sub_theme="respiratory", geography=mocked_geography, geography_type=mocked_geography_type, geography_code="", diff --git a/tests/unit/metrics/interfaces/trends/test_access.py b/tests/unit/metrics/interfaces/trends/test_access.py index 805113e956..4ae4d61d8b 100644 --- a/tests/unit/metrics/interfaces/trends/test_access.py +++ b/tests/unit/metrics/interfaces/trends/test_access.py @@ -15,6 +15,8 @@ class TestTrendsInterface: @property def example_trend_parameters(self) -> TrendsParameters: return TrendsParameters( + theme="infectious_disease", + sub_theme="respiratory", topic="COVID-19", metric="COVID-19_headline_ONSdeaths_7DayChange", percentage_metric="COVID-19_headline_ONSdeaths_7DayPercentChange", From 6998429361892e4c0ac7aa4f73a894328f6621b1 Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Tue, 30 Jun 2026 23:13:35 +0100 Subject: [PATCH 06/20] CDD-3451: get test coverage up --- .../managers/core_models/test_geography.py | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/tests/unit/metrics/data/managers/core_models/test_geography.py b/tests/unit/metrics/data/managers/core_models/test_geography.py index a5eb953838..baa7bd5b82 100644 --- a/tests/unit/metrics/data/managers/core_models/test_geography.py +++ b/tests/unit/metrics/data/managers/core_models/test_geography.py @@ -1,9 +1,13 @@ from unittest import mock +import pytest + from metrics.data.managers.core_models.geography import ( GeographyManager, GeographyQuerySet, ) +from metrics.data.models.core_models import Geography +from tests.factories.metrics.geography import GeographyFactory class TestGeographyManager: @@ -76,3 +80,185 @@ def test_get_name_by_code(self, spy_get_name_by_code: mock.MagicMock): # Then spy_get_name_by_code.assert_called_with(fake_geography_code) + + +@pytest.mark.django_db +class TestGeographyManagerDatabaseQueries: + @pytest.fixture + def geography_records(self): + nation = GeographyFactory.create_with_geography_type( + name="England", + geography_code="E92000001", + geography_type="Nation", + ) + region = GeographyFactory.create_with_geography_type( + name="London", + geography_code="E12000007", + geography_type="Region", + ) + utla = GeographyFactory.create_with_geography_type( + name="London", + geography_code="E09000001", + geography_type="Upper tier local authority", + ) + + return { + "nation": nation, + "region": region, + "utla": utla, + } + + def test_get_all_names_returns_distinct_ordered_names(self, geography_records): + assert list(Geography.objects.get_all_names()) == ["England", "London"] + + def test_get_name_by_code_returns_matching_name(self, geography_records): + assert Geography.objects.get_name_by_code("E92000001") == "England" + + def test_get_name_by_code_returns_none_when_missing(self, geography_records): + assert Geography.objects.get_name_by_code("missing") is None + + def test_get_id_by_name_uses_geography_type_to_disambiguate_shared_names( + self, geography_records + ): + assert ( + Geography.objects.get_id_by_name( + geography_name="London", + geography_type_name="Region", + ) + == geography_records["region"].id + ) + + def test_get_id_by_name_returns_none_when_pair_does_not_exist( + self, geography_records + ): + assert ( + Geography.objects.get_id_by_name( + geography_name="England", + geography_type_name="Region", + ) + is None + ) + + def test_get_code_by_name_uses_geography_type_to_disambiguate_shared_names( + self, geography_records + ): + assert ( + Geography.objects.get_code_by_name( + geography_name="London", + geography_type_name="Upper tier local authority", + ) + == "E09000001" + ) + + def test_get_code_by_name_returns_none_when_pair_does_not_exist( + self, geography_records + ): + assert ( + Geography.objects.get_code_by_name( + geography_name="England", + geography_type_name="Region", + ) + is None + ) + + def test_get_geography_type_id_and_code_by_name_returns_matching_pair( + self, geography_records + ): + geography_type_id, geography_code = ( + Geography.objects.get_geography_type_id_and_code_by_name( + geography_name="London", + geography_type_name="Region", + ) + ) + + assert geography_type_id == geography_records["region"].geography_type_id + assert geography_code == "E12000007" + + def test_get_geography_type_id_and_code_by_name_returns_none_pair_when_missing( + self, geography_records + ): + assert Geography.objects.get_geography_type_id_and_code_by_name( + geography_name="England", + geography_type_name="Region", + ) == (None, None) + + def test_get_all_geography_codes_by_geography_type_returns_ordered_codes( + self, geography_records + ): + assert list( + Geography.objects.get_all_geography_codes_by_geography_type( + geography_type_name="Region", + ) + ) == ["E12000007"] + + def test_get_all_geography_names_by_geography_type_returns_ordered_names( + self, geography_records + ): + assert list( + Geography.objects.get_all_geography_names_by_geography_type( + geography_type_name="Region", + ) + ) == ["London"] + + def test_get_geography_codes_and_names_by_geography_type_returns_ordered_tuples( + self, geography_records + ): + assert list( + Geography.objects.get_geography_codes_and_names_by_geography_type( + geography_type_name="Region", + ) + ) == [("E12000007", "London")] + + def test_get_geography_codes_and_names_by_geography_type_id_returns_ordered_dicts( + self, geography_records + ): + assert list( + Geography.objects.get_geography_codes_and_names_by_geography_type_id( + geography_type_id=geography_records["region"].geography_type_id, + ) + ) == [{"geography_code": "E12000007", "name": "London"}] + + def test_get_geographies_by_geography_type_returns_ordered_dicts( + self, geography_records + ): + assert list( + Geography.objects.get_geographies_by_geography_type( + geography_type_name="Nation", + ) + ) == [{"name": "England", "geography_code": "E92000001"}] + + def test_does_geography_code_exist_returns_true_for_matching_type( + self, geography_records + ): + assert Geography.objects.does_geography_code_exist( + geography_code="E12000007", + geography_type_name="Region", + ) + + def test_does_geography_code_exist_returns_false_for_different_type( + self, geography_records + ): + assert not Geography.objects.does_geography_code_exist( + geography_code="E12000007", + geography_type_name="Nation", + ) + + def test_get_geography_code_for_geography_returns_matching_code( + self, geography_records + ): + assert ( + Geography.objects.get_geography_code_for_geography( + geography="England", + geography_type="Nation", + ) + == "E92000001" + ) + + def test_get_all_names_and_codes_returns_records_ordered_by_code( + self, geography_records + ): + assert list(Geography.objects.get_all_names_and_codes()) == [ + {"name": "London", "geography_code": "E09000001"}, + {"name": "London", "geography_code": "E12000007"}, + {"name": "England", "geography_code": "E92000001"}, + ] From bc024faf58482963b1e1de5c7dcc850e7587c5be Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Tue, 30 Jun 2026 23:21:11 +0100 Subject: [PATCH 07/20] CDD-3451: remove unused import --- metrics/interfaces/plots/access.py | 1 - 1 file changed, 1 deletion(-) diff --git a/metrics/interfaces/plots/access.py b/metrics/interfaces/plots/access.py index 298b1c7ad0..4bbe8349b8 100644 --- a/metrics/interfaces/plots/access.py +++ b/metrics/interfaces/plots/access.py @@ -7,7 +7,6 @@ from django.db.models import Manager, QuerySet from pydantic import BaseModel -from metrics.api.settings import auth from metrics.data.models.core_models import CoreTimeSeries, Topic from metrics.domain.common.utils import ChartAxisFields from metrics.domain.models import ( From dc561f42c844beb0bb8880a5e9f792d7c3019f97 Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Wed, 1 Jul 2026 12:23:32 +0100 Subject: [PATCH 08/20] CDD-3451: get test coverage up to 100% --- .../metrics/api/views/test_headlines.py | 2 ++ .../metrics/api/views/test_trends.py | 2 ++ .../data/managers/core_models/test_topic.py | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/tests/integration/metrics/api/views/test_headlines.py b/tests/integration/metrics/api/views/test_headlines.py index 2c3d867d8e..a1ec405342 100644 --- a/tests/integration/metrics/api/views/test_headlines.py +++ b/tests/integration/metrics/api/views/test_headlines.py @@ -74,6 +74,8 @@ def test_get_returns_error_for_invalid_request( response: Response = client.get( path=self.path, data={ + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": incorrect_topic_name, "metric": core_headline_example.metric.name, "geography": core_headline_example.geography.name, diff --git a/tests/integration/metrics/api/views/test_trends.py b/tests/integration/metrics/api/views/test_trends.py index 07ec3d270b..1aba44c6cb 100644 --- a/tests/integration/metrics/api/views/test_trends.py +++ b/tests/integration/metrics/api/views/test_trends.py @@ -86,6 +86,8 @@ def test_get_returns_error_message_for_timeseries_type_metric( response: Response = client.get( path=self.path, data={ + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": incorrect_topic_name, "metric": metric_name, "percentage_metric": percentage_metric_name, diff --git a/tests/integration/metrics/data/managers/core_models/test_topic.py b/tests/integration/metrics/data/managers/core_models/test_topic.py index b99165c1d6..205779a857 100644 --- a/tests/integration/metrics/data/managers/core_models/test_topic.py +++ b/tests/integration/metrics/data/managers/core_models/test_topic.py @@ -73,6 +73,24 @@ def test_query_get_name_by_id(self): # Then assert get_name_by_id == fake_topic_name_three + @pytest.mark.django_db + def test_get_by_name(self): + """ + Given a number of existing Topic records + When get_by_name is called with a matching name + Then the corresponding Topic record is returned + """ + + # Given + TopicFactory(name="Cold-alert") + expected_topic = TopicFactory(name="COVID-19") + + # When + retrieved_topic = Topic.objects.get_by_name(name="COVID-19") + + # Then + assert retrieved_topic == expected_topic + @pytest.mark.django_db @pytest.mark.parametrize( "theme_name, sub_theme_name, topic_name, metric_name, is_match", From 6b7023c81693deed3ecc4bd928182f85153823f9 Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Wed, 1 Jul 2026 14:46:18 +0100 Subject: [PATCH 09/20] CDD-3451: make geography and geography_type mandatory across those 8 API endpoints --- metrics/api/serializers/headlines.py | 4 +-- metrics/api/serializers/plots.py | 22 +++++------- metrics/domain/models/plots.py | 8 ++--- tests/conftest.py | 35 +++++++++++++++---- tests/fakes/managers/time_series_manager.py | 2 +- tests/integration/conftest.py | 7 ++++ .../charts/test_single_category_charts.py | 6 ++++ .../test_single_category_downloads.py | 4 +-- .../tables/test_single_category_tables.py | 14 ++++++++ .../line_multi_coloured/test_generation.py | 2 ++ .../single_category_charts/test_access.py | 2 ++ tests/system/non_public_data/test_tables.py | 2 ++ tests/system/test_ingestion.py | 34 +++++++++++++++--- .../charts/test_single_category_charts.py | 2 -- .../metrics/api/serializers/test_headlines.py | 11 ++++++ .../metrics/api/serializers/test_plots.py | 2 -- .../metrics/api/serializers/test_tables.py | 2 -- .../unit/metrics/domain/models/test_common.py | 2 ++ .../unit/metrics/domain/models/test_plots.py | 8 +++-- .../domain/tables/test_tables_generation.py | 2 ++ .../metrics/interfaces/plots/test_access.py | 12 ++++--- .../interfaces/plots/test_validation.py | 6 ++-- 22 files changed, 141 insertions(+), 48 deletions(-) diff --git a/metrics/api/serializers/headlines.py b/metrics/api/serializers/headlines.py index 94e0d6965b..8b366e79ba 100644 --- a/metrics/api/serializers/headlines.py +++ b/metrics/api/serializers/headlines.py @@ -42,12 +42,12 @@ class HeadlinesQuerySerializer(serializers.Serializer): ) geography = serializers.ChoiceField( choices=[], - required=False, + required=True, help_text=help_texts.GEOGRAPHY_FIELD, ) geography_type = serializers.ChoiceField( choices=[], - required=False, + required=True, help_text=help_texts.GEOGRAPHY_TYPE_FIELD, ) stratum = serializers.ChoiceField( diff --git a/metrics/api/serializers/plots.py b/metrics/api/serializers/plots.py index 934e605bea..7244aad9c2 100644 --- a/metrics/api/serializers/plots.py +++ b/metrics/api/serializers/plots.py @@ -28,6 +28,14 @@ class PlotSerializer(serializers.Serializer): choices=[], required=True, ) + geography = serializers.CharField( + help_text=help_texts.GEOGRAPHY_FIELD, + required=True, + ) + geography_type = serializers.CharField( + help_text=help_texts.GEOGRAPHY_TYPE_FIELD, + required=True, + ) # Optional fields stratum = serializers.CharField( help_text=help_texts.STRATUM_FIELD, @@ -43,20 +51,6 @@ class PlotSerializer(serializers.Serializer): allow_null=True, default="", ) - geography = serializers.CharField( - help_text=help_texts.GEOGRAPHY_FIELD, - required=False, - allow_blank=True, - allow_null=True, - default="", - ) - geography_type = serializers.CharField( - help_text=help_texts.GEOGRAPHY_TYPE_FIELD, - required=False, - allow_blank=True, - allow_null=True, - default="", - ) sex = serializers.CharField( help_text=help_texts.SEX_FIELD, required=False, diff --git a/metrics/domain/models/plots.py b/metrics/domain/models/plots.py index 66df01ad78..42f8f4b3cc 100644 --- a/metrics/domain/models/plots.py +++ b/metrics/domain/models/plots.py @@ -20,11 +20,13 @@ class PlotParameters(BaseModel): """Holds all the request information / parameters for an individual plot on a chart.""" chart_type: str = "" + theme: str = "" + sub_theme: str = "" topic: str metric: str + geography: str + geography_type: str stratum: str | None = "" - geography: str | None = "" - geography_type: str | None = "" sex: str | None = "" age: str | None = "" date_from: str | None = "" @@ -40,8 +42,6 @@ class PlotParameters(BaseModel): use_smooth_lines: bool = True use_markers: bool = False metric_value_ranges: Iterable[tuple[Decimal, Decimal]] | None = None - theme: str = "" - sub_theme: str = "" @property def metric_group(self) -> str: diff --git a/tests/conftest.py b/tests/conftest.py index d376095103..c2acc81416 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,6 +52,7 @@ def example_subplot_chart_generation_payload() -> list[dict[str, str | Decimal]] "metric": "fake-metric", "topic": "fake-topic", "geography": "Darlington", + "geography_type": "Upper Tier Local Authority", }, "x_axis_values": ["Darlington"], "y_axis_values": [Decimal("95.4")], @@ -61,6 +62,7 @@ def example_subplot_chart_generation_payload() -> list[dict[str, str | Decimal]] "metric": "fake-metric", "topic": "fake-topic", "geography": "Hartlepool", + "geography_type": "Upper Tier Local Authority", }, "x_axis_values": ["Hartlepool"], "y_axis_values": [Decimal("93.4")], @@ -70,6 +72,7 @@ def example_subplot_chart_generation_payload() -> list[dict[str, str | Decimal]] "metric": "fake-metric", "topic": "fake-topic", "geography": "Stockton-on-Tees", + "geography_type": "Upper Tier Local Authority", }, "x_axis_values": ["Stockton-on-Tees"], "y_axis_values": [Decimal("95.4")], @@ -84,6 +87,7 @@ def example_subplot_chart_generation_payload() -> list[dict[str, str | Decimal]] "metric": "fake-metric-two", "topic": "fake-topic-two", "geography": "Darlington", + "geography_type": "Upper Tier Local Authority", }, "x_axis_values": ["Darlington"], "y_axis_values": [Decimal("92.4")], @@ -93,6 +97,7 @@ def example_subplot_chart_generation_payload() -> list[dict[str, str | Decimal]] "metric": "fake-metric-two", "topic": "fake-topic-two", "geography": "Hartlepool", + "geography_type": "Upper Tier Local Authority", }, "x_axis_values": ["Hartlepool"], "y_axis_values": [Decimal("91.4")], @@ -102,6 +107,7 @@ def example_subplot_chart_generation_payload() -> list[dict[str, str | Decimal]] "metric": "fake-metric", "topic": "fake-topic", "geography": "Stockton-on-Tees", + "geography_type": "Upper Tier Local Authority", }, "x_axis_values": ["Stockton-on-Tees"], "y_axis_values": [Decimal("95.4")], @@ -121,6 +127,8 @@ def create_subplots_data() -> list[Subplots]: chart_type="bar", topic="COVID-19", metric="COVID-19_testing_positivity7DayRolling", + geography="England", + geography_type="Nation", stratum="default", date_from="2023-01-01", date_to="2023-12-31", @@ -188,6 +196,8 @@ def fake_chart_plot_parameters() -> PlotParameters: chart_type="line_multi_coloured", topic="COVID-19", metric="COVID-19_testing_positivity7DayRolling", + geography="England", + geography_type="Nation", stratum="default", date_from="2023-01-01", date_to="2023-12-31", @@ -213,9 +223,11 @@ def fake_chart_request_params( @pytest.fixture def fake_chart_plot_parameters_headline_data() -> PlotParameters: return PlotParameters( - metric="COVID-19_headline_vaccines_spring24Uptake", - topic="COVID-19", chart_type=ChartTypes.bar, + topic="COVID-19", + metric="COVID-19_headline_vaccines_spring24Uptake", + geography="England", + geography_type="Nation", x_axis="age", y_axis="metric", date_from=None, @@ -229,6 +241,8 @@ def fake_chart_plot_parameters_covid_cases() -> PlotParameters: chart_type="line_multi_coloured", topic="COVID-19", metric="COVID-19_deaths_ONSByDay", + geography="England", + geography_type="Nation", date_from="2023-01-01", date_to="2023-12-31", ) @@ -237,9 +251,11 @@ def fake_chart_plot_parameters_covid_cases() -> PlotParameters: @pytest.fixture def valid_plot_parameters() -> PlotParameters: return PlotParameters( - metric="COVID-19_deaths_ONSByDay", - topic="COVID-19", chart_type=ChartTypes.line_multi_coloured.value, + topic="COVID-19", + metric="COVID-19_deaths_ONSByDay", + geography="England", + geography_type="Nation", date_from="2023-01-01", date_to="2023-12-31", x_axis="date", @@ -250,9 +266,11 @@ def valid_plot_parameters() -> PlotParameters: @pytest.fixture def valid_plot_parameters_for_headline_data() -> PlotParameters: return PlotParameters( - metric="COVID-19_headline_vaccines_spring24Uptake", - topic="COVID-19", chart_type=ChartTypes.bar.value, + topic="COVID-19", + metric="COVID-19_headline_vaccines_spring24Uptake", + geography="England", + geography_type="Nation", date_from="", date_to="", x_axis="age", @@ -266,9 +284,10 @@ def fake_plot_data() -> PlotGenerationData: chart_type="line_multi_coloured", topic="COVID-19", metric="COVID-19_deaths_ONSByDay", + geography="London", + geography_type="Nation", x_axis="metric", y_axis="date", - geography="London", age="all", sex="all", line_type="SOLID", @@ -307,6 +326,8 @@ def plot_serializer_payload_and_model_managers() -> ( "sub_theme": fake_topic.sub_theme.name, "topic": fake_topic.name, "metric": fake_metric.name, + "geography": "England", + "geography_type": "Nation", "chart_type": ChartTypes.bar.value, } diff --git a/tests/fakes/managers/time_series_manager.py b/tests/fakes/managers/time_series_manager.py index 5fb591571c..f0479b4b81 100644 --- a/tests/fakes/managers/time_series_manager.py +++ b/tests/fakes/managers/time_series_manager.py @@ -78,7 +78,7 @@ def query_for_data( filtered_time_series = [ x for x in filtered_time_series - if x.geography.geography_type.name == geography + if x.geography.geography_type.name == geography_type ] if stratum: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 86c26cc68c..57d98ef7ef 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -9,6 +9,8 @@ Metric, MetricGroup, Topic, + Geography, + GeographyType, ) from tests.factories.metrics.headline import CoreHeadlineFactory @@ -86,6 +88,10 @@ def core_timeseries_example() -> list[CoreTimeSeries]: metric_group=metric_group, topic=topic, ) + geography_type = GeographyType.objects.create(name="Nation") + geography = Geography.objects.create( + name="England", geography_type=geography_type, geography_code="E92000001" + ) age = Age.objects.create(name="all") year = 2023 month = 1 @@ -93,6 +99,7 @@ def core_timeseries_example() -> list[CoreTimeSeries]: CoreTimeSeries.objects.create( metric_value=123, metric=metric, + geography=geography, age=age, year=year, epiweek=1, diff --git a/tests/integration/metrics/api/views/charts/test_single_category_charts.py b/tests/integration/metrics/api/views/charts/test_single_category_charts.py index 68ba961466..c8081d6a20 100644 --- a/tests/integration/metrics/api/views/charts/test_single_category_charts.py +++ b/tests/integration/metrics/api/views/charts/test_single_category_charts.py @@ -21,6 +21,8 @@ def _build_valid_payload_for_existing_timeseries(core_timeseries: CoreTimeSeries "sub_theme": "respiratory", "topic": core_timeseries.metric.metric_group.topic.name, "metric": core_timeseries.metric.name, + "geography": core_timeseries.geography.name, + "geography_type": core_timeseries.geography.geography_type.name, "chart_type": "bar", "date_from": "2000-01-01", "date_to": datetime.date.today(), @@ -158,6 +160,8 @@ def test_returns_correct_response_for_v3_age_based_chart(self): "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_deaths_ONSByDay", + "geography": "England", + "geography_type": "Nation", "chart_type": "bar", "date_from": "2000-01-01", "date_to": datetime.date.today(), @@ -211,6 +215,8 @@ def test_returns_bad_request_response_when_queried_data_does_not_exist( "sub_theme": "respiratory", "topic": core_timeseries_example[0].metric.topic.name, "metric": core_timeseries_example[0].metric.name, + "geography": "England", + "geography_type": "Nation", "chart_type": "bar", "age": "non-existent-age", } diff --git a/tests/integration/metrics/api/views/downloads/test_single_category_downloads.py b/tests/integration/metrics/api/views/downloads/test_single_category_downloads.py index 3860a84b37..b57d8ec2a7 100644 --- a/tests/integration/metrics/api/views/downloads/test_single_category_downloads.py +++ b/tests/integration/metrics/api/views/downloads/test_single_category_downloads.py @@ -36,9 +36,9 @@ class TestDownloadsView: "theme": "infectious_disease", "sub_theme": "respiratory", "topic": "COVID-19", - "geography_type": "", - "geography": "", "metric": "COVID-19_headline_vaccines_spring24Uptake", + "geography_type": "Nation", + "geography": "England", "stratum": "default", "sex": "", "age": "01-04", diff --git a/tests/integration/metrics/api/views/tables/test_single_category_tables.py b/tests/integration/metrics/api/views/tables/test_single_category_tables.py index 3bda02501a..bdd1e2d671 100644 --- a/tests/integration/metrics/api/views/tables/test_single_category_tables.py +++ b/tests/integration/metrics/api/views/tables/test_single_category_tables.py @@ -37,6 +37,8 @@ def test_hitting_endpoint_without_appended_forward_slash_redirects_correctly( "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, + "geography": "England", + "geography_type": "Nation", "chart_type": "bar", "chart_height": 220, "chart_width": 435, @@ -79,6 +81,8 @@ def test_returns_correct_response_type( "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, + "geography": "England", + "geography_type": "Nation", "chart_type": "bar", "chart_height": 220, "chart_width": 435, @@ -123,6 +127,8 @@ def test_single_plot_output_is_as_expected( "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, + "geography": "England", + "geography_type": "Nation", "chart_type": "bar", "chart_height": 220, "chart_width": 435, @@ -188,6 +194,8 @@ def test_multiple_plot_output_is_as_expected( "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, + "geography": "England", + "geography_type": "Nation", "chart_type": "bar", "date_from": "2000-01-01", "date_to": datetime.date.today(), @@ -197,6 +205,8 @@ def test_multiple_plot_output_is_as_expected( "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, + "geography": "England", + "geography_type": "Nation", "chart_type": "bar", "label": "plot_label", "date_from": "2000-01-01", @@ -273,6 +283,8 @@ def test_headline_charts_with_plot_label_uses_plot_label_for_reference( "sub_theme": "respiratory", "topic": topic_name, "metric": metric_name, + "geography": "England", + "geography_type": "Nation", "chart_type": "bar", "sex": "f", "label": "female", @@ -319,6 +331,8 @@ def test_returns_bad_request_response_when_queried_data_does_not_exist( "sub_theme": "respiratory", "topic": core_timeseries_example[0].metric.topic.name, "metric": core_timeseries_example[0].metric.name, + "geography": "England", + "geography_type": "Nation", "chart_type": "bar", "age": "non-existent-age", } diff --git a/tests/integration/metrics/domain/charts/line_multi_coloured/test_generation.py b/tests/integration/metrics/domain/charts/line_multi_coloured/test_generation.py index 534ac466ac..81d733c936 100644 --- a/tests/integration/metrics/domain/charts/line_multi_coloured/test_generation.py +++ b/tests/integration/metrics/domain/charts/line_multi_coloured/test_generation.py @@ -45,6 +45,8 @@ def _setup_chart_plot_data( chart_type="line_multi_coloured", topic="COVID-19", metric="COVID-19_deaths_ONSByDay", + geography="England", + geography_type="Nation", stratum="default", label=label, line_type=line_type, diff --git a/tests/integration/metrics/interfaces/charts/single_category_charts/test_access.py b/tests/integration/metrics/interfaces/charts/single_category_charts/test_access.py index 2e913215e2..b3d87248eb 100644 --- a/tests/integration/metrics/interfaces/charts/single_category_charts/test_access.py +++ b/tests/integration/metrics/interfaces/charts/single_category_charts/test_access.py @@ -91,6 +91,8 @@ def _setup_chart_plot_data( chart_type="line_multi_coloured", topic="COVID-19", metric="COVID-19_cases_casesByDay", + geography="England", + geography_type="Nation", stratum="default", label=label, line_type=line_type, diff --git a/tests/system/non_public_data/test_tables.py b/tests/system/non_public_data/test_tables.py index 0e028f6143..55fde97e71 100644 --- a/tests/system/non_public_data/test_tables.py +++ b/tests/system/non_public_data/test_tables.py @@ -29,6 +29,8 @@ def get_valid_payload(cls, core_time_series) -> dict: "sub_theme": core_time_series.metric.topic.sub_theme.name, "topic": core_time_series.metric.topic.name, "metric": core_time_series.metric.name, + "geography": core_time_series.geography.name, + "geography_type": core_time_series.geography.geography_type.name, "chart_type": "bar", "chart_height": 220, "chart_width": 435, diff --git a/tests/system/test_ingestion.py b/tests/system/test_ingestion.py index a165fefb86..daf11fc437 100644 --- a/tests/system/test_ingestion.py +++ b/tests/system/test_ingestion.py @@ -84,6 +84,8 @@ def test_data_can_be_ingested_and_queried_from_tables_endpoint( "sub_theme": example_time_series_data["child_theme"], "topic": example_time_series_data["topic"], "metric": example_time_series_data["metric"], + "geography": example_time_series_data["geography"], + "geography_type": example_time_series_data["geography_type"], "date_from": "2020-01-01", "chart_type": "bar", } @@ -261,9 +263,16 @@ def test_data_is_deduplicated_on_write_to_db_and_return_latest_data_from_apis( sub_theme = first_sample_data["child_theme"] topic = first_sample_data["topic"] metric = first_sample_data["metric"] + geography = first_sample_data["geography"] + geography_type = first_sample_data["geography_type"] tables_response = self._hit_tables_endpoint( - theme=theme, sub_theme=sub_theme, topic=topic, metric=metric + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography=geography, + geography_type=geography_type, ) expected_first_metric_value = ( f"{first_sample_data['time_series'][1]['metric_value']:.4f}" @@ -310,7 +319,12 @@ def test_data_is_deduplicated_on_write_to_db_and_return_latest_data_from_apis( # After the 2nd file was ingested and subsequently de-duplicated # the `tables/` endpoint should still return the same values as before tables_response = self._hit_tables_endpoint( - theme=theme, sub_theme=sub_theme, topic=topic, metric=metric + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography=geography, + geography_type=geography_type, ) returned_metric_values = [row["values"][0]["value"] for row in tables_response] assert returned_metric_values[0] == expected_first_metric_value @@ -339,7 +353,12 @@ def test_data_is_deduplicated_on_write_to_db_and_return_latest_data_from_apis( "refresh_date", flat=True ) tables_response = self._hit_tables_endpoint( - theme=theme, sub_theme=sub_theme, topic=topic, metric=metric + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography=geography, + geography_type=geography_type, ) returned_metric_values = [row["values"][0]["value"] for row in tables_response] assert returned_metric_values[0] == expected_first_metric_value @@ -570,7 +589,12 @@ def _rebuild_data_with_single_retrospective_update( @staticmethod def _hit_tables_endpoint( - theme: str, sub_theme: str, topic: str, metric: str + theme: str, + sub_theme: str, + topic: str, + metric: str, + geography: str, + geography_type: str, ) -> list[dict]: client = APIClient() @@ -583,6 +607,8 @@ def _hit_tables_endpoint( "sub_theme": sub_theme, "topic": topic, "metric": metric, + "geography": geography, + "geography_type": geography_type, "date_from": "2020-01-01", "chart_type": "bar", } diff --git a/tests/unit/metrics/api/serializers/charts/test_single_category_charts.py b/tests/unit/metrics/api/serializers/charts/test_single_category_charts.py index 71385de0da..a2c8c49f1e 100644 --- a/tests/unit/metrics/api/serializers/charts/test_single_category_charts.py +++ b/tests/unit/metrics/api/serializers/charts/test_single_category_charts.py @@ -23,8 +23,6 @@ class TestChartPlotSerializer: optional_field_names = [ "stratum", - "geography", - "geography_type", "sex", "label", "line_colour", diff --git a/tests/unit/metrics/api/serializers/test_headlines.py b/tests/unit/metrics/api/serializers/test_headlines.py index 13fcab02b4..26de9e07d2 100644 --- a/tests/unit/metrics/api/serializers/test_headlines.py +++ b/tests/unit/metrics/api/serializers/test_headlines.py @@ -56,6 +56,8 @@ def _setup_valid_data_payload_and_model_managers( "sub_theme": fake_topic.sub_theme.name, "topic": fake_topic.name, "metric": fake_metric.name, + "geography": "England", + "geography_type": "Nation", } return data, FakeMetricManager([fake_metric]), FakeTopicManager([fake_topic]) @@ -73,11 +75,20 @@ def test_can_validate_successfully(self): topic_manager, ) = self._setup_valid_data_payload_and_model_managers() + fake_geography = mock.Mock() + fake_geography.name = "England" + fake_geography_type = mock.Mock() + fake_geography_type.name = "Nation" + serializer = HeadlinesQuerySerializer( data=data_payload, context={ "topic_manager": topic_manager, "metric_manager": metric_manager, + "geography_manager": FakeGeographyManager([fake_geography]), + "geography_type_manager": FakeGeographyTypeManager( + [fake_geography_type] + ), }, ) diff --git a/tests/unit/metrics/api/serializers/test_plots.py b/tests/unit/metrics/api/serializers/test_plots.py index 7616a6b6e5..c1332fda2c 100644 --- a/tests/unit/metrics/api/serializers/test_plots.py +++ b/tests/unit/metrics/api/serializers/test_plots.py @@ -7,8 +7,6 @@ class TestPlotSerializer: optional_field_names = [ "stratum", - "geography", - "geography_type", "sex", "age", ] diff --git a/tests/unit/metrics/api/serializers/test_tables.py b/tests/unit/metrics/api/serializers/test_tables.py index e411dec02f..43fd32ef60 100644 --- a/tests/unit/metrics/api/serializers/test_tables.py +++ b/tests/unit/metrics/api/serializers/test_tables.py @@ -15,8 +15,6 @@ class TestTablePlotSerializer: optional_field_names = [ "stratum", - "geography", - "geography_type", "sex", "label", "x_axis", diff --git a/tests/unit/metrics/domain/models/test_common.py b/tests/unit/metrics/domain/models/test_common.py index 78dd4a1044..93f5eb939c 100644 --- a/tests/unit/metrics/domain/models/test_common.py +++ b/tests/unit/metrics/domain/models/test_common.py @@ -79,6 +79,8 @@ def _build_models(request: Request) -> tuple: chart_type="bar", topic="COVID-19", metric="COVID-19_metric", + geography="England", + geography_type="Nation", ) ], request=request, diff --git a/tests/unit/metrics/domain/models/test_plots.py b/tests/unit/metrics/domain/models/test_plots.py index 0bb911d205..ccfb9b4b65 100644 --- a/tests/unit/metrics/domain/models/test_plots.py +++ b/tests/unit/metrics/domain/models/test_plots.py @@ -21,11 +21,11 @@ class TestPlotParameters: "chart_type": "bar", "topic": "COVID-19", "metric": "COVID-19_deaths_ONSByDay", + "geography": "England", + "geography_type": "Nation", } optional_field_names = [ "stratum", - "geography", - "geography_type", "sex", "age", "date_from", @@ -262,6 +262,8 @@ def test_override_y_axis_choice_to_none_defaults_as_false(self): topic=topic_name, metric=metric_name, chart_type="bar", + geography="England", + geography_type="Nation", ) # Then @@ -283,6 +285,8 @@ def test_y_axis_returns_none_when_override_y_axis_choice_to_none_is_true(self): topic=topic_name, metric=metric_name, chart_type="bar", + geography="England", + geography_type="Nation", override_y_axis_choice_to_none=True, ) diff --git a/tests/unit/metrics/domain/tables/test_tables_generation.py b/tests/unit/metrics/domain/tables/test_tables_generation.py index c79e7701d8..5bf8218a9e 100644 --- a/tests/unit/metrics/domain/tables/test_tables_generation.py +++ b/tests/unit/metrics/domain/tables/test_tables_generation.py @@ -35,6 +35,8 @@ def _create_plot_data( chart_type="", topic="COVID-19", metric="COVID-19_deaths_ONSByDay", + geography="England", + geography_type="Nation", stratum="default", label=label, x_axis=x_axis, diff --git a/tests/unit/metrics/interfaces/plots/test_access.py b/tests/unit/metrics/interfaces/plots/test_access.py index a59b646647..1fcb1586e3 100644 --- a/tests/unit/metrics/interfaces/plots/test_access.py +++ b/tests/unit/metrics/interfaces/plots/test_access.py @@ -105,9 +105,11 @@ def test_build_plots_data_passes_for_plot_parameters_with_no_supporting_data( """ # Given plot_parameters_with_no_supporting_data = PlotParameters( - metric="non_cases_topic_cases_abc", - topic="non_cases_topic", chart_type="line", + topic="non_cases_topic", + metric="non_cases_topic_cases_abc", + geography="England", + geography_type="Nation", date_from="2023-01-01", date_to="2023-12-31", ) @@ -240,9 +242,11 @@ def test_build_plots_data_for_full_queryset_passes_for_plot_parameters_with_no_s """ # Given plot_parameters_with_no_supporting_data = PlotParameters( - metric="non_cases_topic_cases_abc", - topic="non_cases_topic", chart_type="line", + topic="non_cases_topic", + metric="non_cases_topic_cases_abc", + geography="England", + geography_type="Nation", date_from="2023-01-01", date_to="2023-12-31", ) diff --git a/tests/unit/metrics/interfaces/plots/test_validation.py b/tests/unit/metrics/interfaces/plots/test_validation.py index 7f04ce48a9..0ee933ce79 100644 --- a/tests/unit/metrics/interfaces/plots/test_validation.py +++ b/tests/unit/metrics/interfaces/plots/test_validation.py @@ -157,9 +157,11 @@ def test_raises_error_if_not_in_chronological_order(self): date_from = datetime.datetime(year=2022, month=10, day=2) date_to = datetime.datetime(year=2021, month=7, day=1) plot_parameters = PlotParameters( - metric="COVID-19_deaths_ONSByDay", - topic="COVID-19", chart_type=ChartTypes.line_multi_coloured.value, + topic="COVID-19", + metric="COVID-19_deaths_ONSByDay", + geography="England", + geography_type="Nation", date_from=date_from.strftime(EXPECTED_DATE_FORMAT), date_to=date_to.strftime(EXPECTED_DATE_FORMAT), ) From 2ec24038d148ef4db36d081d427b3dee0a2a3b2e Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Fri, 10 Jul 2026 09:31:10 +0100 Subject: [PATCH 10/20] PR comments --- metrics/data/managers/core_models/geography.py | 2 +- metrics/data/managers/core_models/topic.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/metrics/data/managers/core_models/geography.py b/metrics/data/managers/core_models/geography.py index 7d8c3c1d58..15941d6dc6 100644 --- a/metrics/data/managers/core_models/geography.py +++ b/metrics/data/managers/core_models/geography.py @@ -270,7 +270,7 @@ def get_geography_type_id_and_code_by_name( or (None, None) if it does not. """ return self.get_queryset().get_geography_type_id_and_code_by_name( - geography_name, geography_type_name=geography_type_name + geography_name, geography_type_name ) def get_all_names(self) -> GeographyQuerySet: diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index 66982bd80a..e1f63c5acc 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -138,7 +138,7 @@ def get_all_names_and_ids(self) -> models.QuerySet: class TopicManager(models.Manager): - """Custom model manager class for the `Metric` model.""" + """Custom model manager class for the `Topic` model.""" def get_queryset(self) -> TopicQuerySet: return TopicQuerySet(model=self.model, using=self.db) From 64690d95ce946a988c4083b153610cd4b43dc481 Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Tue, 4 Aug 2026 15:47:47 +0100 Subject: [PATCH 11/20] Updating migration order following merge with main --- ...alter_landingpage_body.py => 0039_alter_landingpage_body.py} | 2 +- ...034_alter_topicpage_body.py => 0036_alter_topicpage_body.py} | 2 +- ...topicslistpage_body.py => 0010_alter_topicslistpage_body.py} | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename cms/home/migrations/{0037_alter_landingpage_body.py => 0039_alter_landingpage_body.py} (99%) rename cms/topic/migrations/{0034_alter_topicpage_body.py => 0036_alter_topicpage_body.py} (99%) rename cms/topics_list/migrations/{0008_alter_topicslistpage_body.py => 0010_alter_topicslistpage_body.py} (99%) diff --git a/cms/home/migrations/0037_alter_landingpage_body.py b/cms/home/migrations/0039_alter_landingpage_body.py similarity index 99% rename from cms/home/migrations/0037_alter_landingpage_body.py rename to cms/home/migrations/0039_alter_landingpage_body.py index df9c2a8183..51c684164b 100644 --- a/cms/home/migrations/0037_alter_landingpage_body.py +++ b/cms/home/migrations/0039_alter_landingpage_body.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ - ("home", "0036_alter_landingpage_make_health_topic_optional"), + ("home", "0038_alter_landingpage_body_dual_category_update"), ] operations = [ diff --git a/cms/topic/migrations/0034_alter_topicpage_body.py b/cms/topic/migrations/0036_alter_topicpage_body.py similarity index 99% rename from cms/topic/migrations/0034_alter_topicpage_body.py rename to cms/topic/migrations/0036_alter_topicpage_body.py index 37b267f661..34f01b4102 100644 --- a/cms/topic/migrations/0034_alter_topicpage_body.py +++ b/cms/topic/migrations/0036_alter_topicpage_body.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ("topic", "0033_alter_topicpage_sub_theme_alter_topicpage_theme_and_more"), + ("topic", "0035_alter_topicpage_body_dual_category_update"), ] operations = [ diff --git a/cms/topics_list/migrations/0008_alter_topicslistpage_body.py b/cms/topics_list/migrations/0010_alter_topicslistpage_body.py similarity index 99% rename from cms/topics_list/migrations/0008_alter_topicslistpage_body.py rename to cms/topics_list/migrations/0010_alter_topicslistpage_body.py index 9c9ecef1f8..2fba6cdee2 100644 --- a/cms/topics_list/migrations/0008_alter_topicslistpage_body.py +++ b/cms/topics_list/migrations/0010_alter_topicslistpage_body.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ - ("topics_list", "0007_alter_topicslistpage_body_headline_metric_card"), + ("topics_list", "0009_alter_topicslistpage_body_dual_category_update"), ] operations = [ From 60112db8b07db1686cb6ac8dae0a61334cc398d2 Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Wed, 5 Aug 2026 09:04:06 +0100 Subject: [PATCH 12/20] Fixing tests --- .../api/views/charts/dual_category_charts.py | 2 ++ tests/integration/conftest.py | 6 +++++- .../api/views/tables/test_dual_category.py | 20 +++++++++++++++++++ .../downloads/test_dual_category.py | 4 ++++ .../domain/tables/test_dual_category.py | 2 ++ 5 files changed, 33 insertions(+), 1 deletion(-) diff --git a/metrics/api/views/charts/dual_category_charts.py b/metrics/api/views/charts/dual_category_charts.py index cd7eba2230..4cb4d6f507 100644 --- a/metrics/api/views/charts/dual_category_charts.py +++ b/metrics/api/views/charts/dual_category_charts.py @@ -56,6 +56,8 @@ "chart_type": "stacked_bar", "secondary_category": "age", "static_fields": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_cases_rateRollingMean", "stratum": "default", diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 57d98ef7ef..d059a8836b 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -11,6 +11,8 @@ Topic, Geography, GeographyType, + Theme, + SubTheme, ) from tests.factories.metrics.headline import CoreHeadlineFactory @@ -81,7 +83,9 @@ def core_trend_example() -> tuple[CoreHeadline, CoreHeadline]: @pytest.fixture def core_timeseries_example() -> list[CoreTimeSeries]: - topic = Topic.objects.create(name="COVID-19") + theme = Theme.objects.create(name="infectious_disease") + sub_theme = SubTheme.objects.create(name="respiratory", theme=theme) + topic = Topic.objects.create(name="COVID-19", sub_theme=sub_theme) metric_group = MetricGroup.objects.create(name="deaths", topic=topic) metric = Metric.objects.create( name="COVID-19_deaths_ONSByDay", diff --git a/tests/integration/metrics/api/views/tables/test_dual_category.py b/tests/integration/metrics/api/views/tables/test_dual_category.py index f1277dfc6a..5708714c06 100644 --- a/tests/integration/metrics/api/views/tables/test_dual_category.py +++ b/tests/integration/metrics/api/views/tables/test_dual_category.py @@ -29,8 +29,12 @@ def test_timeseries_plot( client = APIClient() core_timeseries: CoreTimeSeries = core_timeseries_example[0] latest_timeseries: CoreTimeSeries = core_timeseries_example[-1] + theme: str = core_timeseries.metric.metric_group.topic.sub_theme.theme.name + sub_theme: str = core_timeseries.metric.metric_group.topic.sub_theme.name topic: str = core_timeseries.metric.metric_group.topic.name metric: str = core_timeseries.metric.name + geography: str = core_timeseries.geography.name + geography_type: str = core_timeseries.geography.geography_type.name valid_payload = { "x_axis": "date", @@ -40,6 +44,10 @@ def test_timeseries_plot( "date_to": datetime.date.today(), "metric": metric, "topic": topic, + "sub_theme": sub_theme, + "theme": theme, + "geography": geography, + "geography_type": geography_type, }, "secondary_category": "age", "segments": [ @@ -77,12 +85,20 @@ def test_headline_plot( """ # Given client = APIClient() + theme: str = ( + core_headline_example.metric.metric_group.topic.sub_theme.theme.name + ) + sub_theme: str = core_headline_example.metric.metric_group.topic.sub_theme.name topic: str = core_headline_example.metric.metric_group.topic.name metric: str = core_headline_example.metric.name + geography: str = core_headline_example.geography.name + geography_type: str = core_headline_example.geography.geography_type.name CoreHeadlineFactory.create_record( metric_value=123.0000, metric=metric, + theme=theme, + sub_theme=sub_theme, topic=topic, geography="England", geography_type="Nation", @@ -106,6 +122,10 @@ def test_headline_plot( "date_to": datetime.date.today(), "metric": metric, "topic": topic, + "sub_theme": sub_theme, + "theme": theme, + "geography": geography, + "geography_type": geography_type, }, "secondary_category": "sex", "segments": [ diff --git a/tests/unit/metrics/api/serializers/downloads/test_dual_category.py b/tests/unit/metrics/api/serializers/downloads/test_dual_category.py index 37299449f1..08c87fe890 100644 --- a/tests/unit/metrics/api/serializers/downloads/test_dual_category.py +++ b/tests/unit/metrics/api/serializers/downloads/test_dual_category.py @@ -37,6 +37,8 @@ def test_to_models_expands_headline_plots(self): payload = EXAMPLE_DUAL_CATEGORY_DOWNLOAD_REQUEST_PAYLOAD.copy() payload["static_fields"] = payload["static_fields"].copy() + payload["static_fields"]["theme"] = fake_topic.sub_theme.theme.name + payload["static_fields"]["sub_theme"] = fake_topic.sub_theme.name payload["static_fields"]["topic"] = fake_topic.name payload["static_fields"]["metric"] = HEADLINE_METRIC @@ -80,6 +82,8 @@ def test_to_models_expands_timeseries_plots( "y_axis": "metric", "chart_type": "stacked_bar", "static_fields": { + "theme": plot_payload["theme"], + "sub_theme": plot_payload["sub_theme"], "topic": plot_payload["topic"], "metric": plot_payload["metric"], "stratum": plot_payload.get("stratum", "default"), diff --git a/tests/unit/metrics/domain/tables/test_dual_category.py b/tests/unit/metrics/domain/tables/test_dual_category.py index db9b19378e..5837957723 100644 --- a/tests/unit/metrics/domain/tables/test_dual_category.py +++ b/tests/unit/metrics/domain/tables/test_dual_category.py @@ -31,6 +31,8 @@ def _plot( x_axis=x_axis, y_axis=ChartAxisFields.metric.name, sex="f" if label == "Females" else "m", + geography="England", + geography_type="Nation", ), x_axis_values=x_axis_values, y_axis_values=y_axis_values, From e613f0ddb83a8582df64eb62192a238d09b56e5d Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Wed, 5 Aug 2026 15:28:02 +0100 Subject: [PATCH 13/20] Migrations to backfill theme and sub_theme, updates to bootstrap data --- .../build_cms_site_helpers/landing_page.py | 6 ++ .../cms_starting_pages/covid_19.json | 62 +++++++++++++++++++ .../cms_starting_pages/influenza.json | 36 +++++++++++ .../other_respiratory_viruses.json | 34 ++++++++++ .../0040_backfill_theme_subtheme.py | 20 ++++++ .../0037_backfill_theme_subtheme.py | 20 ++++++ .../0011_backfill_theme_subtheme.py | 20 ++++++ cms/utils/migrations/backfill_helpers.py | 57 +++++++++++++++++ 8 files changed, 255 insertions(+) create mode 100644 cms/home/migrations/0040_backfill_theme_subtheme.py create mode 100644 cms/topic/migrations/0037_backfill_theme_subtheme.py create mode 100644 cms/topics_list/migrations/0011_backfill_theme_subtheme.py create mode 100644 cms/utils/migrations/backfill_helpers.py diff --git a/cms/dashboard/management/commands/build_cms_site_helpers/landing_page.py b/cms/dashboard/management/commands/build_cms_site_helpers/landing_page.py index ca310a7066..4f3d29099d 100644 --- a/cms/dashboard/management/commands/build_cms_site_helpers/landing_page.py +++ b/cms/dashboard/management/commands/build_cms_site_helpers/landing_page.py @@ -35,6 +35,8 @@ def create_landing_page_body_wih_page_links() -> list[dict]: { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_cases_countRollingMean", "geography": "England", @@ -65,6 +67,8 @@ def create_landing_page_body_wih_page_links() -> list[dict]: { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_healthcare_ICUHDUadmissionRateByWeek", "geography": "England", @@ -95,6 +99,8 @@ def create_landing_page_body_wih_page_links() -> list[dict]: { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "RSV", "metric": "RSV_testing_positivityByWeek", "geography": "England", diff --git a/cms/dashboard/templates/cms_starting_pages/covid_19.json b/cms/dashboard/templates/cms_starting_pages/covid_19.json index 74c465df6c..8c5d035ed8 100644 --- a/cms/dashboard/templates/cms_starting_pages/covid_19.json +++ b/cms/dashboard/templates/cms_starting_pages/covid_19.json @@ -43,6 +43,8 @@ { "type": "headline_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_headline_cases_7DayTotals", "geography": "England", @@ -57,6 +59,8 @@ { "type": "trend_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_headline_cases_7DayChange", "geography": "England", @@ -81,6 +85,8 @@ { "type": "headline_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_headline_ONSdeaths_7DayTotals", "geography": "England", @@ -95,6 +101,8 @@ { "type": "trend_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_headline_ONSdeaths_7DayChange", "geography": "England", @@ -119,6 +127,8 @@ { "type": "headline_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_headline_7DayAdmissions", "geography": "England", @@ -133,6 +143,8 @@ { "type": "trend_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_headline_7DayAdmissionsChange", "geography": "England", @@ -157,6 +169,8 @@ { "type": "percentage_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_headline_vaccines_autumn23Uptake", "geography": "England", @@ -180,6 +194,8 @@ { "type": "percentage_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_headline_positivity_latest", "geography": "England", @@ -225,6 +241,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_cases_casesByDay", "geography": "England", @@ -268,6 +286,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_cases_countRollingMean", "geography": "England", @@ -303,6 +323,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_cases_rateRollingMean", "geography": "England", @@ -324,6 +346,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_cases_rateRollingMean", "geography": "England", @@ -376,6 +400,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_deaths_ONSByDay", "geography": "England", @@ -411,6 +437,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_deaths_ONSRollingMean", "geography": "England", @@ -463,6 +491,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_healthcare_admissionByDay", "geography": "England", @@ -498,6 +528,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_healthcare_occupiedBedsByDay", "geography": "England", @@ -550,6 +582,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_testing_PCRcountByDay", "geography": "England", @@ -585,6 +619,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_testing_positivity7DayRolling", "geography": "England", @@ -637,6 +673,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_spring23_dosesByDay", "geography": "England", @@ -672,6 +710,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_spring23_uptakeByDay", "geography": "England", @@ -693,6 +733,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_spring23_uptakeByDay", "geography": "England", @@ -736,6 +778,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_autumn22_dosesByDay", "geography": "England", @@ -771,6 +815,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_autumn22_uptakeByDay", "geography": "England", @@ -792,6 +838,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_autumn22_uptakeByDay", "geography": "England", @@ -813,6 +861,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_autumn22_uptakeByDay", "geography": "England", @@ -834,6 +884,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_autumn22_uptakeByDay", "geography": "England", @@ -855,6 +907,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_autumn22_uptakeByDay", "geography": "England", @@ -876,6 +930,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_autumn22_uptakeByDay", "geography": "England", @@ -897,6 +953,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_autumn22_uptakeByDay", "geography": "England", @@ -918,6 +976,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_autumn22_uptakeByDay", "geography": "England", @@ -939,6 +999,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "COVID-19", "metric": "COVID-19_vaccinations_autumn22_uptakeByDay", "geography": "England", diff --git a/cms/dashboard/templates/cms_starting_pages/influenza.json b/cms/dashboard/templates/cms_starting_pages/influenza.json index efb9174601..8848371525 100644 --- a/cms/dashboard/templates/cms_starting_pages/influenza.json +++ b/cms/dashboard/templates/cms_starting_pages/influenza.json @@ -43,6 +43,8 @@ { "type": "percentage_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_headline_positivityLatest", "geography": "England", @@ -66,6 +68,8 @@ { "type": "headline_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_headline_ICUHDUadmissionRateLatest", "geography": "England", @@ -80,6 +84,8 @@ { "type": "trend_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_headline_ICUHDUadmissionRateChange", "geography": "England", @@ -126,6 +132,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_healthcare_ICUHDUadmissionRateByWeek", "geography": "England", @@ -161,6 +169,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_healthcare_ICUHDUadmissionRateByWeek", "geography": "England", @@ -182,6 +192,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_healthcare_ICUHDUadmissionRateByWeek", "geography": "England", @@ -203,6 +215,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_healthcare_ICUHDUadmissionRateByWeek", "geography": "England", @@ -224,6 +238,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_healthcare_ICUHDUadmissionRateByWeek", "geography": "England", @@ -245,6 +261,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_healthcare_ICUHDUadmissionRateByWeek", "geography": "England", @@ -266,6 +284,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_healthcare_ICUHDUadmissionRateByWeek", "geography": "England", @@ -287,6 +307,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_healthcare_ICUHDUadmissionRateByWeek", "geography": "England", @@ -308,6 +330,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_healthcare_ICUHDUadmissionRateByWeek", "geography": "England", @@ -360,6 +384,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_testing_positivityByWeek", "geography": "England", @@ -395,6 +421,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_testing_positivityByWeek", "geography": "England", @@ -416,6 +444,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_testing_positivityByWeek", "geography": "England", @@ -437,6 +467,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_testing_positivityByWeek", "geography": "England", @@ -458,6 +490,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_testing_positivityByWeek", "geography": "England", @@ -479,6 +513,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Influenza", "metric": "influenza_testing_positivityByWeek", "geography": "England", diff --git a/cms/dashboard/templates/cms_starting_pages/other_respiratory_viruses.json b/cms/dashboard/templates/cms_starting_pages/other_respiratory_viruses.json index eb558b2b2f..b91ee52d90 100644 --- a/cms/dashboard/templates/cms_starting_pages/other_respiratory_viruses.json +++ b/cms/dashboard/templates/cms_starting_pages/other_respiratory_viruses.json @@ -43,6 +43,8 @@ { "type": "percentage_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Adenovirus", "metric": "adenovirus_headline_positivityLatest", "geography": "England", @@ -66,6 +68,8 @@ { "type": "percentage_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "hMPV", "metric": "hMPV_headline_positivityLatest", "geography": "England", @@ -89,6 +93,8 @@ { "type": "percentage_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Parainfluenza", "metric": "parainfluenza_headline_positivityLatest", "geography": "England", @@ -112,6 +118,8 @@ { "type": "percentage_number", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Rhinovirus", "metric": "rhinovirus_headline_positivityLatest", "geography": "England", @@ -157,6 +165,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "RSV", "metric": "RSV_healthcare_admissionRateByWeek", "geography": "England", @@ -178,6 +188,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "RSV", "metric": "RSV_healthcare_admissionRateByWeek", "geography": "England", @@ -199,6 +211,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "RSV", "metric": "RSV_healthcare_admissionRateByWeek", "geography": "England", @@ -220,6 +234,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "RSV", "metric": "RSV_healthcare_admissionRateByWeek", "geography": "England", @@ -241,6 +257,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "RSV", "metric": "RSV_healthcare_admissionRateByWeek", "geography": "England", @@ -262,6 +280,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "RSV", "metric": "RSV_healthcare_admissionRateByWeek", "geography": "England", @@ -283,6 +303,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "RSV", "metric": "RSV_healthcare_admissionRateByWeek", "geography": "England", @@ -304,6 +326,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "RSV", "metric": "RSV_healthcare_admissionRateByWeek", "geography": "England", @@ -356,6 +380,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Adenovirus", "metric": "adenovirus_testing_positivityByWeek", "geography": "England", @@ -399,6 +425,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "hMPV", "metric": "hMPV_testing_positivityByWeek", "geography": "England", @@ -442,6 +470,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Parainfluenza", "metric": "parainfluenza_testing_positivityByWeek", "geography": "England", @@ -485,6 +515,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "Rhinovirus", "metric": "rhinovirus_testing_positivityByWeek", "geography": "England", @@ -528,6 +560,8 @@ { "type": "plot", "value": { + "theme": "infectious_disease", + "sub_theme": "respiratory", "topic": "RSV", "metric": "RSV_testing_positivityByWeek", "geography": "England", diff --git a/cms/home/migrations/0040_backfill_theme_subtheme.py b/cms/home/migrations/0040_backfill_theme_subtheme.py new file mode 100644 index 0000000000..1b4a232144 --- /dev/null +++ b/cms/home/migrations/0040_backfill_theme_subtheme.py @@ -0,0 +1,20 @@ +from django.db import migrations +from cms.utils.migrations.backfill_helpers import backfill_pages_with_theme_and_subtheme + + +def backfill_landing_pages(apps, schema_editor): + backfill_pages_with_theme_and_subtheme(apps, "home", "LandingPage") + + +def noop(apps, schema_editor): + """Backfill is one-directional; nothing sensible to reverse.""" + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("home", "0039_alter_landingpage_body"), + ] + operations = [ + migrations.RunPython(backfill_landing_pages, noop), + ] diff --git a/cms/topic/migrations/0037_backfill_theme_subtheme.py b/cms/topic/migrations/0037_backfill_theme_subtheme.py new file mode 100644 index 0000000000..39e8055574 --- /dev/null +++ b/cms/topic/migrations/0037_backfill_theme_subtheme.py @@ -0,0 +1,20 @@ +from django.db import migrations +from cms.utils.migrations.backfill_helpers import backfill_pages_with_theme_and_subtheme + + +def backfill_topic_pages(apps, schema_editor): + backfill_pages_with_theme_and_subtheme(apps, "topic", "TopicPage") + + +def noop(apps, schema_editor): + """Backfill is one-directional; nothing sensible to reverse.""" + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("topic", "0036_alter_topicpage_body"), + ] + operations = [ + migrations.RunPython(backfill_topic_pages, noop), + ] \ No newline at end of file diff --git a/cms/topics_list/migrations/0011_backfill_theme_subtheme.py b/cms/topics_list/migrations/0011_backfill_theme_subtheme.py new file mode 100644 index 0000000000..16ad5acf60 --- /dev/null +++ b/cms/topics_list/migrations/0011_backfill_theme_subtheme.py @@ -0,0 +1,20 @@ +from django.db import migrations +from cms.utils.migrations.backfill_helpers import backfill_pages_with_theme_and_subtheme + + +def backfill_topics_list_pages(apps, schema_editor): + backfill_pages_with_theme_and_subtheme(apps, "topics_list", "TopicsListPage") + + +def noop(apps, schema_editor): + """Backfill is one-directional; nothing sensible to reverse.""" + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("topics_list", "0010_alter_topicslistpage_body"), + ] + operations = [ + migrations.RunPython(backfill_topics_list_pages, noop), + ] diff --git a/cms/utils/migrations/backfill_helpers.py b/cms/utils/migrations/backfill_helpers.py new file mode 100644 index 0000000000..0ce04f0dc4 --- /dev/null +++ b/cms/utils/migrations/backfill_helpers.py @@ -0,0 +1,57 @@ +from wagtail.fields import StreamValue + + +def _build_topic_map(apps): + Topic = apps.get_model("data", "Topic") + + topic_map = {} + for topic in Topic.objects.select_related("sub_theme__theme"): + sub_theme = topic.sub_theme + if sub_theme and sub_theme.theme: + topic_map[topic.name] = (sub_theme.theme.name, sub_theme.name) + return topic_map + + +def _fill_node(node, topic_map) -> bool: + """Recursively fill theme/sub_theme on any plot, operating on raw dicts/lists.""" + changed = False + + if isinstance(node, dict): + topic = node.get("topic") + + if isinstance(topic, str) and topic in topic_map: + theme_name, sub_theme_name = topic_map[topic] + + if not node.get("theme"): + node["theme"] = theme_name + changed = True + + if not node.get("sub_theme"): + node["sub_theme"] = sub_theme_name + changed = True + + for value in node.values(): + changed |= _fill_node(value, topic_map) + elif isinstance(node, list): + for item in node: + changed |= _fill_node(item, topic_map) + + return changed + + +def backfill_pages_with_theme_and_subtheme(apps, model_section, model_name): + topic_map = _build_topic_map(apps) + + LandingPage = apps.get_model(model_section, model_name) + + for page in LandingPage.objects.all().iterator(): + if not page.body: + continue + + raw_data = page.body.get_prep_value() + + if not _fill_node(raw_data, topic_map): + continue + + page.body = StreamValue(page.body.stream_block, raw_data, is_lazy=True) + page.save(update_fields=["body"]) From c81abd53a9fe72a599ac41e0cdc7dce6696ee678 Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Wed, 5 Aug 2026 15:38:09 +0100 Subject: [PATCH 14/20] Lint fix --- cms/topic/migrations/0037_backfill_theme_subtheme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/topic/migrations/0037_backfill_theme_subtheme.py b/cms/topic/migrations/0037_backfill_theme_subtheme.py index 39e8055574..bf35f73426 100644 --- a/cms/topic/migrations/0037_backfill_theme_subtheme.py +++ b/cms/topic/migrations/0037_backfill_theme_subtheme.py @@ -17,4 +17,4 @@ class Migration(migrations.Migration): ] operations = [ migrations.RunPython(backfill_topic_pages, noop), - ] \ No newline at end of file + ] From 21c075332262197797fcb91ff14c04d5bd4a1661 Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Mon, 17 Aug 2026 09:22:45 +0100 Subject: [PATCH 15/20] Fixing up migrations --- ...alter_landingpage_body.py => 0040_alter_landingpage_body.py} | 2 +- ...ckfill_theme_subtheme.py => 0041_backfill_theme_subtheme.py} | 2 +- ...036_alter_topicpage_body.py => 0037_alter_topicpage_body.py} | 2 +- ...ckfill_theme_subtheme.py => 0038_backfill_theme_subtheme.py} | 2 +- ...topicslistpage_body.py => 0011_alter_topicslistpage_body.py} | 2 +- ...ckfill_theme_subtheme.py => 0012_backfill_theme_subtheme.py} | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename cms/home/migrations/{0039_alter_landingpage_body.py => 0040_alter_landingpage_body.py} (99%) rename cms/home/migrations/{0040_backfill_theme_subtheme.py => 0041_backfill_theme_subtheme.py} (91%) rename cms/topic/migrations/{0036_alter_topicpage_body.py => 0037_alter_topicpage_body.py} (99%) rename cms/topic/migrations/{0037_backfill_theme_subtheme.py => 0038_backfill_theme_subtheme.py} (91%) rename cms/topics_list/migrations/{0010_alter_topicslistpage_body.py => 0011_alter_topicslistpage_body.py} (99%) rename cms/topics_list/migrations/{0011_backfill_theme_subtheme.py => 0012_backfill_theme_subtheme.py} (89%) diff --git a/cms/home/migrations/0039_alter_landingpage_body.py b/cms/home/migrations/0040_alter_landingpage_body.py similarity index 99% rename from cms/home/migrations/0039_alter_landingpage_body.py rename to cms/home/migrations/0040_alter_landingpage_body.py index 51c684164b..834ceadff9 100644 --- a/cms/home/migrations/0039_alter_landingpage_body.py +++ b/cms/home/migrations/0040_alter_landingpage_body.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ - ("home", "0038_alter_landingpage_body_dual_category_update"), + ("home", "0039_alter_landingpage_add_HeadlineChartWithDescriptionCard"), ] operations = [ diff --git a/cms/home/migrations/0040_backfill_theme_subtheme.py b/cms/home/migrations/0041_backfill_theme_subtheme.py similarity index 91% rename from cms/home/migrations/0040_backfill_theme_subtheme.py rename to cms/home/migrations/0041_backfill_theme_subtheme.py index 1b4a232144..ab4dc44109 100644 --- a/cms/home/migrations/0040_backfill_theme_subtheme.py +++ b/cms/home/migrations/0041_backfill_theme_subtheme.py @@ -13,7 +13,7 @@ def noop(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ("home", "0039_alter_landingpage_body"), + ("home", "0040_alter_landingpage_body"), ] operations = [ migrations.RunPython(backfill_landing_pages, noop), diff --git a/cms/topic/migrations/0036_alter_topicpage_body.py b/cms/topic/migrations/0037_alter_topicpage_body.py similarity index 99% rename from cms/topic/migrations/0036_alter_topicpage_body.py rename to cms/topic/migrations/0037_alter_topicpage_body.py index 34f01b4102..3a30258944 100644 --- a/cms/topic/migrations/0036_alter_topicpage_body.py +++ b/cms/topic/migrations/0037_alter_topicpage_body.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ("topic", "0035_alter_topicpage_body_dual_category_update"), + ("topic", "0036_alter_topicpage_add_HeadlineChartWithDescriptionCard"), ] operations = [ diff --git a/cms/topic/migrations/0037_backfill_theme_subtheme.py b/cms/topic/migrations/0038_backfill_theme_subtheme.py similarity index 91% rename from cms/topic/migrations/0037_backfill_theme_subtheme.py rename to cms/topic/migrations/0038_backfill_theme_subtheme.py index bf35f73426..a0ebed65e3 100644 --- a/cms/topic/migrations/0037_backfill_theme_subtheme.py +++ b/cms/topic/migrations/0038_backfill_theme_subtheme.py @@ -13,7 +13,7 @@ def noop(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ("topic", "0036_alter_topicpage_body"), + ("topic", "0037_alter_topicpage_body"), ] operations = [ migrations.RunPython(backfill_topic_pages, noop), diff --git a/cms/topics_list/migrations/0010_alter_topicslistpage_body.py b/cms/topics_list/migrations/0011_alter_topicslistpage_body.py similarity index 99% rename from cms/topics_list/migrations/0010_alter_topicslistpage_body.py rename to cms/topics_list/migrations/0011_alter_topicslistpage_body.py index 2fba6cdee2..e1dcd78625 100644 --- a/cms/topics_list/migrations/0010_alter_topicslistpage_body.py +++ b/cms/topics_list/migrations/0011_alter_topicslistpage_body.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ - ("topics_list", "0009_alter_topicslistpage_body_dual_category_update"), + ("topics_list", "0010_alter_topicslistpage_add_HeadlineChartWithDescriptionCard"), ] operations = [ diff --git a/cms/topics_list/migrations/0011_backfill_theme_subtheme.py b/cms/topics_list/migrations/0012_backfill_theme_subtheme.py similarity index 89% rename from cms/topics_list/migrations/0011_backfill_theme_subtheme.py rename to cms/topics_list/migrations/0012_backfill_theme_subtheme.py index 16ad5acf60..15ff87bb6c 100644 --- a/cms/topics_list/migrations/0011_backfill_theme_subtheme.py +++ b/cms/topics_list/migrations/0012_backfill_theme_subtheme.py @@ -13,7 +13,7 @@ def noop(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ("topics_list", "0010_alter_topicslistpage_body"), + ("topics_list", "0011_alter_topicslistpage_body"), ] operations = [ migrations.RunPython(backfill_topics_list_pages, noop), From c409c014046ea52913e3d42e6bea3fd2f1f351aa Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Mon, 17 Aug 2026 09:32:34 +0100 Subject: [PATCH 16/20] Addressing PR comments --- cms/utils/migrations/backfill_helpers.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/cms/utils/migrations/backfill_helpers.py b/cms/utils/migrations/backfill_helpers.py index 0ce04f0dc4..24e65f511e 100644 --- a/cms/utils/migrations/backfill_helpers.py +++ b/cms/utils/migrations/backfill_helpers.py @@ -1,3 +1,12 @@ +""" +This file contains a set of utility functions for handling the backfill of data via Django migrations. + +backfill_pages_with_theme_and_subtheme was introduced when changes were added to the codebase +that made theme and subtheme mandatory across all pages. Introducing this would be a breaking +change that would require extensive changes throughout the CMS following deployment if not +automated. These functions apply the required changes throughout the database to prevent +any potential downtime, or error pages encountered by users due to a schema mismatch. +""" from wagtail.fields import StreamValue @@ -42,9 +51,9 @@ def _fill_node(node, topic_map) -> bool: def backfill_pages_with_theme_and_subtheme(apps, model_section, model_name): topic_map = _build_topic_map(apps) - LandingPage = apps.get_model(model_section, model_name) + app_model = apps.get_model(model_section, model_name) - for page in LandingPage.objects.all().iterator(): + for page in app_model.objects.all().iterator(): if not page.body: continue From 3826fdc668e0403867a4fc625aacb65ed7dbe5a5 Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Mon, 17 Aug 2026 09:42:34 +0100 Subject: [PATCH 17/20] Linting --- cms/topics_list/migrations/0011_alter_topicslistpage_body.py | 5 ++++- cms/utils/migrations/backfill_helpers.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cms/topics_list/migrations/0011_alter_topicslistpage_body.py b/cms/topics_list/migrations/0011_alter_topicslistpage_body.py index e1dcd78625..fb29e3d787 100644 --- a/cms/topics_list/migrations/0011_alter_topicslistpage_body.py +++ b/cms/topics_list/migrations/0011_alter_topicslistpage_body.py @@ -10,7 +10,10 @@ class Migration(migrations.Migration): dependencies = [ - ("topics_list", "0010_alter_topicslistpage_add_HeadlineChartWithDescriptionCard"), + ( + "topics_list", + "0010_alter_topicslistpage_add_HeadlineChartWithDescriptionCard", + ), ] operations = [ diff --git a/cms/utils/migrations/backfill_helpers.py b/cms/utils/migrations/backfill_helpers.py index 24e65f511e..f13ee1bc88 100644 --- a/cms/utils/migrations/backfill_helpers.py +++ b/cms/utils/migrations/backfill_helpers.py @@ -7,6 +7,7 @@ automated. These functions apply the required changes throughout the database to prevent any potential downtime, or error pages encountered by users due to a schema mismatch. """ + from wagtail.fields import StreamValue From e9f5b32447c80588862b0dd342deb22ce804b4b1 Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Fri, 4 Sep 2026 15:09:29 +0100 Subject: [PATCH 18/20] Updating backfill script to handle revisions --- cms/utils/migrations/backfill_helpers.py | 53 ++++++++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/cms/utils/migrations/backfill_helpers.py b/cms/utils/migrations/backfill_helpers.py index f13ee1bc88..f5fafbc105 100644 --- a/cms/utils/migrations/backfill_helpers.py +++ b/cms/utils/migrations/backfill_helpers.py @@ -8,9 +8,30 @@ any potential downtime, or error pages encountered by users due to a schema mismatch. """ +import json from wagtail.fields import StreamValue +def backfill_pages_with_theme_and_subtheme(apps, model_section, model_name): + topic_map = _build_topic_map(apps) + + app_model = apps.get_model(model_section, model_name) + + for page in app_model.objects.all().iterator(): + if not page.body: + continue + + raw_data = page.body.get_prep_value() + + if not _fill_node(raw_data, topic_map): + continue + + page.body = StreamValue(page.body.stream_block, raw_data, is_lazy=True) + page.save(update_fields=["body"]) + + _backfill_revisions_for_content_type(apps, model_section, model_name, topic_map) + + def _build_topic_map(apps): Topic = apps.get_model("data", "Topic") @@ -49,19 +70,33 @@ def _fill_node(node, topic_map) -> bool: return changed -def backfill_pages_with_theme_and_subtheme(apps, model_section, model_name): - topic_map = _build_topic_map(apps) +def _backfill_revisions_for_content_type(apps, model_section, model_name, topic_map): + revision_model = apps.get_model("wagtailcore", "Revision") + content_type_model = apps.get_model("contenttypes", "ContentType") - app_model = apps.get_model(model_section, model_name) + try: + content_type = content_type_model.objects.get(app_label=model_section, model=model_name.lower()) + except content_type_model.DoesNotExist: + return - for page in app_model.objects.all().iterator(): - if not page.body: + revisions = revision_model.objects.filter(content_type=content_type).iterator() + + updated = [] + for revision in revisions: + content = revision.content + raw_body = content.get("body") + + if not raw_body: continue - raw_data = page.body.get_prep_value() + body_data = json.loads(raw_body) if isinstance(raw_body, str) else raw_body - if not _fill_node(raw_data, topic_map): + if not _fill_node(body_data, topic_map): continue - page.body = StreamValue(page.body.stream_block, raw_data, is_lazy=True) - page.save(update_fields=["body"]) + content["body"] = json.dumps(body_data) if isinstance(raw_body, str) else body_data + revision.content = content + updated.append(revision) + + if updated: + revision_model.objects.bulk_update(updated, ["content"], batch_size=500) From cee3f7dff125aed29e87301fb3853a845fc9cb15 Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Fri, 4 Sep 2026 15:12:32 +0100 Subject: [PATCH 19/20] Formatting --- cms/utils/migrations/backfill_helpers.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cms/utils/migrations/backfill_helpers.py b/cms/utils/migrations/backfill_helpers.py index f5fafbc105..b043a9ac09 100644 --- a/cms/utils/migrations/backfill_helpers.py +++ b/cms/utils/migrations/backfill_helpers.py @@ -75,7 +75,9 @@ def _backfill_revisions_for_content_type(apps, model_section, model_name, topic_ content_type_model = apps.get_model("contenttypes", "ContentType") try: - content_type = content_type_model.objects.get(app_label=model_section, model=model_name.lower()) + content_type = content_type_model.objects.get( + app_label=model_section, model=model_name.lower() + ) except content_type_model.DoesNotExist: return @@ -94,7 +96,9 @@ def _backfill_revisions_for_content_type(apps, model_section, model_name, topic_ if not _fill_node(body_data, topic_map): continue - content["body"] = json.dumps(body_data) if isinstance(raw_body, str) else body_data + content["body"] = ( + json.dumps(body_data) if isinstance(raw_body, str) else body_data + ) revision.content = content updated.append(revision) From 9650f0af0de9e8d510cc5924102ffeebae9ce583 Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Fri, 18 Sep 2026 09:13:41 +0100 Subject: [PATCH 20/20] Removed duplicate value --- tests/conftest.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 4e5f243434..2b9f4f49a6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -132,8 +132,6 @@ def create_subplots_data() -> list[Subplots]: stratum="default", date_from="2023-01-01", date_to="2023-12-31", - geography="England", - geography_type="Nation", x_axis="date", y_axis="metric", )