From e2832dd97487f1e232507c83819e4ff79a70f7f5 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 12 Mar 2026 11:22:37 +0000 Subject: [PATCH 001/186] CDD-3175: Initial Commit --- auth_content/__init__.py | 0 auth_content/admin.py | 3 +++ auth_content/apps.py | 6 +++++ auth_content/migrations/0001_initial.py | 29 +++++++++++++++++++++++++ auth_content/migrations/__init__.py | 0 auth_content/models.py | 19 ++++++++++++++++ auth_content/tests.py | 3 +++ auth_content/views.py | 3 +++ cms/dashboard/wagtail_hooks.py | 23 ++++++++++++++++++++ metrics/api/settings/default.py | 1 + 10 files changed, 87 insertions(+) create mode 100644 auth_content/__init__.py create mode 100644 auth_content/admin.py create mode 100644 auth_content/apps.py create mode 100644 auth_content/migrations/0001_initial.py create mode 100644 auth_content/migrations/__init__.py create mode 100644 auth_content/models.py create mode 100644 auth_content/tests.py create mode 100644 auth_content/views.py diff --git a/auth_content/__init__.py b/auth_content/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/auth_content/admin.py b/auth_content/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/auth_content/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/auth_content/apps.py b/auth_content/apps.py new file mode 100644 index 000000000..c7f2ba7ca --- /dev/null +++ b/auth_content/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthContentConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "auth_content" diff --git a/auth_content/migrations/0001_initial.py b/auth_content/migrations/0001_initial.py new file mode 100644 index 000000000..7559ef2c4 --- /dev/null +++ b/auth_content/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.12 on 2026-03-12 11:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="AuthFeature", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("description", models.TextField()), + ], + ), + ] diff --git a/auth_content/migrations/__init__.py b/auth_content/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/auth_content/models.py b/auth_content/models.py new file mode 100644 index 000000000..1744cb5d4 --- /dev/null +++ b/auth_content/models.py @@ -0,0 +1,19 @@ +from django.db import models + +# Create your models here. +from django.db import models +from wagtail.admin.panels import FieldPanel +from wagtail.snippets.models import register_snippet + + +class AuthFeature(models.Model): + title = models.CharField(max_length=255) + description = models.TextField() + + panels = [ + FieldPanel('title'), + FieldPanel('description'), + ] + + def __str__(self): + return self.title \ No newline at end of file diff --git a/auth_content/tests.py b/auth_content/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/auth_content/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/auth_content/views.py b/auth_content/views.py new file mode 100644 index 000000000..91ea44a21 --- /dev/null +++ b/auth_content/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/cms/dashboard/wagtail_hooks.py b/cms/dashboard/wagtail_hooks.py index e20b3d7fc..0d631ca2e 100644 --- a/cms/dashboard/wagtail_hooks.py +++ b/cms/dashboard/wagtail_hooks.py @@ -8,6 +8,11 @@ from wagtail.admin.site_summary import PagesSummaryItem, SummaryItem from wagtail.models import Page from wagtail.whitelist import check_url +from wagtail import hooks +from wagtail.snippets.views.snippets import SnippetViewSet +from wagtail.admin.viewsets.model import ModelViewSetGroup + +from auth_content.models import AuthFeature @hooks.register("insert_global_admin_css") @@ -124,3 +129,21 @@ def register_link_props(features): rule = features.converter_rules_by_converter["contentstate"]["link"] rule["to_database_format"]["entity_decorators"]["LINK"] = link_entity_with_href features.register_converter_rule("contentstate", "link", rule) + +# Initial feature to test out new menu section +class AuthFeatureViewSet(SnippetViewSet): + model = AuthFeature + menu_label = "Features" + icon = "key" + + +class AuthGroup(ModelViewSetGroup): + items = (AuthFeatureViewSet,) + menu_label = "Auth" + menu_icon = "lock" + menu_order = 300 + + +@hooks.register("register_admin_viewset") +def register_auth_viewset(): + return AuthGroup() diff --git a/metrics/api/settings/default.py b/metrics/api/settings/default.py index 87fcfe7bb..ef27e1242 100644 --- a/metrics/api/settings/default.py +++ b/metrics/api/settings/default.py @@ -76,6 +76,7 @@ "wagtail_trash", "modelcluster", "taggit", + "auth_content", ] MIDDLEWARE = [ From fa872b3edefec4042c6286fd89e67a5c2546f10c Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 12 Mar 2026 14:19:12 +0000 Subject: [PATCH 002/186] Create initial permission set --- .../0002_permissionsets_delete_authfeature.py | 36 +++++++++++++++++++ ...003_rename_permissionsets_permissionset.py | 17 +++++++++ auth_content/models.py | 20 +++++++---- auth_content/tests.py | 3 -- cms/dashboard/wagtail_hooks.py | 10 +++--- 5 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 auth_content/migrations/0002_permissionsets_delete_authfeature.py create mode 100644 auth_content/migrations/0003_rename_permissionsets_permissionset.py delete mode 100644 auth_content/tests.py diff --git a/auth_content/migrations/0002_permissionsets_delete_authfeature.py b/auth_content/migrations/0002_permissionsets_delete_authfeature.py new file mode 100644 index 000000000..e33434c88 --- /dev/null +++ b/auth_content/migrations/0002_permissionsets_delete_authfeature.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.12 on 2026-03-12 13:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="PermissionSets", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("theme", models.CharField(max_length=255)), + ("sub_theme", models.CharField(max_length=255)), + ("topic", models.CharField(max_length=255)), + ("metric", models.CharField(max_length=255)), + ("geography_type", models.CharField(max_length=255)), + ("geography", models.CharField(max_length=255)), + ], + ), + migrations.DeleteModel( + name="AuthFeature", + ), + ] diff --git a/auth_content/migrations/0003_rename_permissionsets_permissionset.py b/auth_content/migrations/0003_rename_permissionsets_permissionset.py new file mode 100644 index 000000000..9820c1572 --- /dev/null +++ b/auth_content/migrations/0003_rename_permissionsets_permissionset.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.12 on 2026-03-12 14:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0002_permissionsets_delete_authfeature"), + ] + + operations = [ + migrations.RenameModel( + old_name="PermissionSets", + new_name="PermissionSet", + ), + ] diff --git a/auth_content/models.py b/auth_content/models.py index 1744cb5d4..f20d975a1 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -3,16 +3,24 @@ # Create your models here. from django.db import models from wagtail.admin.panels import FieldPanel -from wagtail.snippets.models import register_snippet -class AuthFeature(models.Model): - title = models.CharField(max_length=255) - description = models.TextField() +class PermissionSet(models.Model): + theme = models.CharField(max_length=255) + sub_theme = models.CharField(max_length=255) + topic = models.CharField(max_length=255) + metric = models.CharField(max_length=255) + geography_type = models.CharField(max_length=255) + geography = models.CharField(max_length=255) + panels = [ - FieldPanel('title'), - FieldPanel('description'), + FieldPanel('theme'), + FieldPanel('sub_theme'), + FieldPanel('topic'), + FieldPanel('metric'), + FieldPanel('geography_type'), + FieldPanel('geography'), ] def __str__(self): diff --git a/auth_content/tests.py b/auth_content/tests.py deleted file mode 100644 index 7ce503c2d..000000000 --- a/auth_content/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/cms/dashboard/wagtail_hooks.py b/cms/dashboard/wagtail_hooks.py index 0d631ca2e..028b3bfb5 100644 --- a/cms/dashboard/wagtail_hooks.py +++ b/cms/dashboard/wagtail_hooks.py @@ -12,7 +12,7 @@ from wagtail.snippets.views.snippets import SnippetViewSet from wagtail.admin.viewsets.model import ModelViewSetGroup -from auth_content.models import AuthFeature +from auth_content.models import PermissionSet @hooks.register("insert_global_admin_css") @@ -131,14 +131,14 @@ def register_link_props(features): features.register_converter_rule("contentstate", "link", rule) # Initial feature to test out new menu section -class AuthFeatureViewSet(SnippetViewSet): - model = AuthFeature - menu_label = "Features" +class PermissionSetViewSet(SnippetViewSet): + model = PermissionSet + menu_label = "Permission Sets" icon = "key" class AuthGroup(ModelViewSetGroup): - items = (AuthFeatureViewSet,) + items = (PermissionSetViewSet,) menu_label = "Auth" menu_icon = "lock" menu_order = 300 From f2876dcbc364710d2bb0f57d9146a1239f93125d Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 12 Mar 2026 14:49:01 +0000 Subject: [PATCH 003/186] create dropdown --- .../0004_alter_permissionset_theme.py | 26 +++++++++++++++++++ .../0005_alter_permissionset_theme.py | 26 +++++++++++++++++++ auth_content/models.py | 6 ++++- 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 auth_content/migrations/0004_alter_permissionset_theme.py create mode 100644 auth_content/migrations/0005_alter_permissionset_theme.py diff --git a/auth_content/migrations/0004_alter_permissionset_theme.py b/auth_content/migrations/0004_alter_permissionset_theme.py new file mode 100644 index 000000000..b71aecec3 --- /dev/null +++ b/auth_content/migrations/0004_alter_permissionset_theme.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.12 on 2026-03-12 14:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0003_rename_permissionsets_permissionset"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.TextField( + choices=[ + ("infectious_disease", "Infectious Disease"), + ("extreme_event", "Extreme Event"), + ("non-communicable", "Non Communicable"), + ("climate_and_environment", "Climate And Environment"), + ("immunisation", "Immunisation"), + ] + ), + ), + ] diff --git a/auth_content/migrations/0005_alter_permissionset_theme.py b/auth_content/migrations/0005_alter_permissionset_theme.py new file mode 100644 index 000000000..7ca82df6d --- /dev/null +++ b/auth_content/migrations/0005_alter_permissionset_theme.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.12 on 2026-03-12 14:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0004_alter_permissionset_theme"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + choices=[ + ("infectious_disease", "Infectious Disease"), + ("extreme_event", "Extreme Event"), + ("non-communicable", "Non Communicable"), + ("climate_and_environment", "Climate And Environment"), + ("immunisation", "Immunisation"), + ] + ), + ), + ] diff --git a/auth_content/models.py b/auth_content/models.py index f20d975a1..b7b2bfbaf 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -4,9 +4,13 @@ from django.db import models from wagtail.admin.panels import FieldPanel +from validation.enums.theme_and_topic_enums import ParentTheme + class PermissionSet(models.Model): - theme = models.CharField(max_length=255) + theme = models.CharField( + choices=[(e.value, e.name.replace("_", " ").title()) for e in ParentTheme] + ) sub_theme = models.CharField(max_length=255) topic = models.CharField(max_length=255) metric = models.CharField(max_length=255) From 263420bc1534f33826898d75503358a4b215d16a Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 12 Mar 2026 16:13:48 +0000 Subject: [PATCH 004/186] Update auth_content migration --- .../0002_permissionset_delete_authfeature.py | 66 +++++++++++++++++++ .../0002_permissionsets_delete_authfeature.py | 36 ---------- ...003_rename_permissionsets_permissionset.py | 17 ----- .../0004_alter_permissionset_theme.py | 26 -------- .../0005_alter_permissionset_theme.py | 26 -------- auth_content/models.py | 6 +- validation/enums/helper_enum.py | 3 + 7 files changed, 73 insertions(+), 107 deletions(-) create mode 100644 auth_content/migrations/0002_permissionset_delete_authfeature.py delete mode 100644 auth_content/migrations/0002_permissionsets_delete_authfeature.py delete mode 100644 auth_content/migrations/0003_rename_permissionsets_permissionset.py delete mode 100644 auth_content/migrations/0004_alter_permissionset_theme.py delete mode 100644 auth_content/migrations/0005_alter_permissionset_theme.py diff --git a/auth_content/migrations/0002_permissionset_delete_authfeature.py b/auth_content/migrations/0002_permissionset_delete_authfeature.py new file mode 100644 index 000000000..1c1376a5d --- /dev/null +++ b/auth_content/migrations/0002_permissionset_delete_authfeature.py @@ -0,0 +1,66 @@ +# Generated by Django 5.2.12 on 2026-03-12 16:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="PermissionSet", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "theme", + models.CharField( + choices=[ + ("infectious_disease", "Infectious Disease"), + ("extreme_event", "Extreme Event"), + ("non-communicable", "Non Communicable"), + ("climate_and_environment", "Climate And Environment"), + ("immunisation", "Immunisation"), + ] + ), + ), + ( + "sub_theme", + models.CharField( + choices=[ + ("vaccine_preventable", "Vaccine Preventable"), + ("respiratory", "Respiratory"), + ("bloodstream_infection", "Bloodstream Infection"), + ("bloodborne", "Bloodborne"), + ("gastrointestinal", "Gastrointestinal"), + ("antimicrobial_resistance", "Antimicrobial Resistance"), + ("contact", "Contact"), + ("childhood_illness", "Childhood Illness"), + ( + "invasive_bacterial_infections", + "Invasive Bacterial Infections", + ), + ("vector_borne", "Vector Borne"), + ] + ), + ), + ("topic", models.CharField(max_length=255)), + ("metric", models.CharField(max_length=255)), + ("geography_type", models.CharField(max_length=255)), + ("geography", models.CharField(max_length=255)), + ], + ), + migrations.DeleteModel( + name="AuthFeature", + ), + ] diff --git a/auth_content/migrations/0002_permissionsets_delete_authfeature.py b/auth_content/migrations/0002_permissionsets_delete_authfeature.py deleted file mode 100644 index e33434c88..000000000 --- a/auth_content/migrations/0002_permissionsets_delete_authfeature.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-12 13:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="PermissionSets", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("theme", models.CharField(max_length=255)), - ("sub_theme", models.CharField(max_length=255)), - ("topic", models.CharField(max_length=255)), - ("metric", models.CharField(max_length=255)), - ("geography_type", models.CharField(max_length=255)), - ("geography", models.CharField(max_length=255)), - ], - ), - migrations.DeleteModel( - name="AuthFeature", - ), - ] diff --git a/auth_content/migrations/0003_rename_permissionsets_permissionset.py b/auth_content/migrations/0003_rename_permissionsets_permissionset.py deleted file mode 100644 index 9820c1572..000000000 --- a/auth_content/migrations/0003_rename_permissionsets_permissionset.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-12 14:05 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0002_permissionsets_delete_authfeature"), - ] - - operations = [ - migrations.RenameModel( - old_name="PermissionSets", - new_name="PermissionSet", - ), - ] diff --git a/auth_content/migrations/0004_alter_permissionset_theme.py b/auth_content/migrations/0004_alter_permissionset_theme.py deleted file mode 100644 index b71aecec3..000000000 --- a/auth_content/migrations/0004_alter_permissionset_theme.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-12 14:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0003_rename_permissionsets_permissionset"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.TextField( - choices=[ - ("infectious_disease", "Infectious Disease"), - ("extreme_event", "Extreme Event"), - ("non-communicable", "Non Communicable"), - ("climate_and_environment", "Climate And Environment"), - ("immunisation", "Immunisation"), - ] - ), - ), - ] diff --git a/auth_content/migrations/0005_alter_permissionset_theme.py b/auth_content/migrations/0005_alter_permissionset_theme.py deleted file mode 100644 index 7ca82df6d..000000000 --- a/auth_content/migrations/0005_alter_permissionset_theme.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-12 14:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0004_alter_permissionset_theme"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - choices=[ - ("infectious_disease", "Infectious Disease"), - ("extreme_event", "Extreme Event"), - ("non-communicable", "Non Communicable"), - ("climate_and_environment", "Climate And Environment"), - ("immunisation", "Immunisation"), - ] - ), - ), - ] diff --git a/auth_content/models.py b/auth_content/models.py index b7b2bfbaf..f96ea431a 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -4,14 +4,16 @@ from django.db import models from wagtail.admin.panels import FieldPanel -from validation.enums.theme_and_topic_enums import ParentTheme +from validation.enums.theme_and_topic_enums import ChildTheme, ParentTheme class PermissionSet(models.Model): theme = models.CharField( choices=[(e.value, e.name.replace("_", " ").title()) for e in ParentTheme] ) - sub_theme = models.CharField(max_length=255) + sub_theme = models.CharField( + choices=ChildTheme["INFECTIOUS_DISEASE"].return_tuple_list() + ) topic = models.CharField(max_length=255) metric = models.CharField(max_length=255) geography_type = models.CharField(max_length=255) diff --git a/validation/enums/helper_enum.py b/validation/enums/helper_enum.py index 6cc7d4095..d46ddc25d 100644 --- a/validation/enums/helper_enum.py +++ b/validation/enums/helper_enum.py @@ -7,3 +7,6 @@ def return_list(self): def return_name_list(self): return [e.name for e in self.value] + + def return_tuple_list(self): + return [(e.value, e.name.replace("_", " ").title()) for e in self.value] From 635302906eaff1f458a5224148054fc0c5b54673 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Fri, 13 Mar 2026 14:29:03 +0000 Subject: [PATCH 005/186] Add conditional sub_theme dropdown --- auth_content/admin.py | 3 -- auth_content/apps.py | 6 --- .../0002_permissionset_delete_authfeature.py | 23 +-------- auth_content/models.py | 40 +++++++++++----- auth_content/static/js/child_theme.js | 47 +++++++++++++++++++ auth_content/views.py | 3 -- auth_content/wagtail_hooks.py | 41 ++++++++++++++++ cms/dashboard/wagtail_hooks.py | 21 --------- 8 files changed, 117 insertions(+), 67 deletions(-) delete mode 100644 auth_content/admin.py delete mode 100644 auth_content/apps.py create mode 100644 auth_content/static/js/child_theme.js delete mode 100644 auth_content/views.py create mode 100644 auth_content/wagtail_hooks.py diff --git a/auth_content/admin.py b/auth_content/admin.py deleted file mode 100644 index 8c38f3f3d..000000000 --- a/auth_content/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/auth_content/apps.py b/auth_content/apps.py deleted file mode 100644 index c7f2ba7ca..000000000 --- a/auth_content/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class AuthContentConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "auth_content" diff --git a/auth_content/migrations/0002_permissionset_delete_authfeature.py b/auth_content/migrations/0002_permissionset_delete_authfeature.py index 1c1376a5d..6900e77a6 100644 --- a/auth_content/migrations/0002_permissionset_delete_authfeature.py +++ b/auth_content/migrations/0002_permissionset_delete_authfeature.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.12 on 2026-03-12 16:13 +# Generated by Django 5.2.12 on 2026-03-13 14:23 from django.db import migrations, models @@ -34,26 +34,7 @@ class Migration(migrations.Migration): ] ), ), - ( - "sub_theme", - models.CharField( - choices=[ - ("vaccine_preventable", "Vaccine Preventable"), - ("respiratory", "Respiratory"), - ("bloodstream_infection", "Bloodstream Infection"), - ("bloodborne", "Bloodborne"), - ("gastrointestinal", "Gastrointestinal"), - ("antimicrobial_resistance", "Antimicrobial Resistance"), - ("contact", "Contact"), - ("childhood_illness", "Childhood Illness"), - ( - "invasive_bacterial_infections", - "Invasive Bacterial Infections", - ), - ("vector_borne", "Vector Borne"), - ] - ), - ), + ("sub_theme", models.CharField(choices=[], max_length=255)), ("topic", models.CharField(max_length=255)), ("metric", models.CharField(max_length=255)), ("geography_type", models.CharField(max_length=255)), diff --git a/auth_content/models.py b/auth_content/models.py index f96ea431a..b06818266 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -1,33 +1,47 @@ from django.db import models -# Create your models here. from django.db import models from wagtail.admin.panels import FieldPanel from validation.enums.theme_and_topic_enums import ChildTheme, ParentTheme +def get_theme_child_map(): + """Returns an object of all parent to child mappings + e.g. + { + infectious_disease: [vaccine_preventable, respiratory ....], + extreme_event: [weather_alert, mortality_report...] + ... + } + + """ + + theme_mapping = {} + for parent in ParentTheme: + # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme + theme_mapping[parent.value] = ChildTheme[parent.name].return_tuple_list() + + return theme_mapping class PermissionSet(models.Model): theme = models.CharField( choices=[(e.value, e.name.replace("_", " ").title()) for e in ParentTheme] ) - sub_theme = models.CharField( - choices=ChildTheme["INFECTIOUS_DISEASE"].return_tuple_list() - ) + sub_theme = models.CharField(max_length=255, choices=[]) topic = models.CharField(max_length=255) metric = models.CharField(max_length=255) geography_type = models.CharField(max_length=255) geography = models.CharField(max_length=255) - + panels = [ - FieldPanel('theme'), - FieldPanel('sub_theme'), - FieldPanel('topic'), - FieldPanel('metric'), - FieldPanel('geography_type'), - FieldPanel('geography'), + FieldPanel("theme"), + FieldPanel("sub_theme"), + FieldPanel("topic"), + FieldPanel("metric"), + FieldPanel("geography_type"), + FieldPanel("geography"), ] - + def __str__(self): - return self.title \ No newline at end of file + return self.theme diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js new file mode 100644 index 000000000..3060e7979 --- /dev/null +++ b/auth_content/static/js/child_theme.js @@ -0,0 +1,47 @@ +;(function () { + let theme, + sub_theme, + themeMapping = {} + + function setSubTheme() { + theme = document.querySelector('select[name="theme"]') + sub_theme = document.querySelector('select[name="sub_theme"]') + + if (!theme || !sub_theme) return + + try { + themeMapping = window.PERMISSIONSET_THEME_MAP + } catch (e) { + console.error("Invalid theme map") + } + + if (!theme.value) { + clearSubTheme() + } else { + populateSubThemeDropDown() + } + } + function populateSubThemeDropDown() { + sub_theme.disabled = false + sub_theme.innerHTML = "" + sub_theme.add(new Option("---------", "")) + + const options = themeMapping[theme.value] || [] + + options.forEach(([value, label]) => sub_theme.add(new Option(label, value))) + } + + function clearSubTheme() { + sub_theme.innerHTML = "" + sub_theme.add(new Option("---------", "")) + sub_theme.value = "" + sub_theme.disabled = true + } + + document.addEventListener("DOMContentLoaded", setSubTheme) + document.addEventListener("change", function (e) { + if (e.target.name === "theme") { + setSubTheme() + } + }) +})() diff --git a/auth_content/views.py b/auth_content/views.py deleted file mode 100644 index 91ea44a21..000000000 --- a/auth_content/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py new file mode 100644 index 000000000..88fc433d6 --- /dev/null +++ b/auth_content/wagtail_hooks.py @@ -0,0 +1,41 @@ +import json + +from wagtail import hooks +from wagtail.snippets.views.snippets import SnippetViewSet +from wagtail.admin.viewsets.model import ModelViewSetGroup +from django.templatetags.static import static + +from auth_content.models import PermissionSet, get_theme_child_map +from django.utils.html import format_html +from django.utils.safestring import mark_safe + + +class PermissionSetViewSet(SnippetViewSet): + model = PermissionSet + menu_label = "Permission Sets" + icon = "key" + + +class AuthGroup(ModelViewSetGroup): + items = (PermissionSetViewSet,) + menu_label = "Auth" + menu_icon = "lock" + menu_order = 300 + + +@hooks.register("register_admin_viewset") +def register_auth_viewset(): + return AuthGroup() + +# exposes the mapping of parent to child themes +@hooks.register("insert_editor_js") +def permission_set_theme_mapping(): + mapping = json.dumps(get_theme_child_map()) + return format_html( + "", mark_safe(mapping) + ) + + +@hooks.register("insert_editor_js") +def permission_set_js(): + return format_html('', static("js/child_theme.js")) diff --git a/cms/dashboard/wagtail_hooks.py b/cms/dashboard/wagtail_hooks.py index 028b3bfb5..2507444bd 100644 --- a/cms/dashboard/wagtail_hooks.py +++ b/cms/dashboard/wagtail_hooks.py @@ -9,10 +9,7 @@ from wagtail.models import Page from wagtail.whitelist import check_url from wagtail import hooks -from wagtail.snippets.views.snippets import SnippetViewSet -from wagtail.admin.viewsets.model import ModelViewSetGroup -from auth_content.models import PermissionSet @hooks.register("insert_global_admin_css") @@ -129,21 +126,3 @@ def register_link_props(features): rule = features.converter_rules_by_converter["contentstate"]["link"] rule["to_database_format"]["entity_decorators"]["LINK"] = link_entity_with_href features.register_converter_rule("contentstate", "link", rule) - -# Initial feature to test out new menu section -class PermissionSetViewSet(SnippetViewSet): - model = PermissionSet - menu_label = "Permission Sets" - icon = "key" - - -class AuthGroup(ModelViewSetGroup): - items = (PermissionSetViewSet,) - menu_label = "Auth" - menu_icon = "lock" - menu_order = 300 - - -@hooks.register("register_admin_viewset") -def register_auth_viewset(): - return AuthGroup() From 46a97488490c075ec813c7a3fe37e44f2e37bf3c Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Fri, 13 Mar 2026 14:42:07 +0000 Subject: [PATCH 006/186] Update migration file and tidy up child_theme.js --- .../migrations/0002_permissionset_delete_authfeature.py | 2 +- auth_content/static/js/child_theme.js | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/auth_content/migrations/0002_permissionset_delete_authfeature.py b/auth_content/migrations/0002_permissionset_delete_authfeature.py index 6900e77a6..3128e3c31 100644 --- a/auth_content/migrations/0002_permissionset_delete_authfeature.py +++ b/auth_content/migrations/0002_permissionset_delete_authfeature.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.12 on 2026-03-13 14:23 +# Generated by Django 5.2.12 on 2026-03-13 14:40 from django.db import migrations, models diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 3060e7979..3b958d499 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -9,11 +9,7 @@ if (!theme || !sub_theme) return - try { - themeMapping = window.PERMISSIONSET_THEME_MAP - } catch (e) { - console.error("Invalid theme map") - } + themeMapping = window.PERMISSIONSET_THEME_MAP if (!theme.value) { clearSubTheme() @@ -21,6 +17,7 @@ populateSubThemeDropDown() } } + function populateSubThemeDropDown() { sub_theme.disabled = false sub_theme.innerHTML = "" From d353ec45d9ee2815ee34eca7ed6e5742408ad5a6 Mon Sep 17 00:00:00 2001 From: David Logie Date: Tue, 17 Feb 2026 13:44:08 +0000 Subject: [PATCH 007/186] CDD-3116 Add more search_fields to common/composite/dashboard/topic models. De-duplicate the search_fields, putting them in the UKHSAPage class. --- cms/common/models.py | 5 ----- cms/composite/models.py | 2 +- cms/dashboard/models.py | 7 +++++++ cms/topic/models.py | 4 +--- tests/unit/cms/composite/test_models.py | 26 +++++++++++++++++++++++ tests/unit/cms/topic/test_models.py | 28 +++++++++++++++++++++++++ 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/cms/common/models.py b/cms/common/models.py index 73f385add..2e5c62429 100644 --- a/cms/common/models.py +++ b/cms/common/models.py @@ -2,7 +2,6 @@ from modelcluster.fields import ParentalKey from wagtail.admin.panels import FieldPanel, InlinePanel, ObjectList, TabbedInterface from wagtail.api import APIField -from wagtail.search import index from cms.common.managers import CommonPageManager from cms.dashboard.enums import ( @@ -26,10 +25,6 @@ class CommonPage(UKHSAPage): choices=RelatedLinksLayoutEnum.choices(), ) - search_fields = UKHSAPage.search_fields + [ - index.SearchField("body"), - ] - content_panels = UKHSAPage.content_panels + [ FieldPanel("body"), ] diff --git a/cms/composite/models.py b/cms/composite/models.py index 8ce8c1eb2..eb823996d 100644 --- a/cms/composite/models.py +++ b/cms/composite/models.py @@ -47,7 +47,7 @@ class CompositePage(UKHSAPage): ) search_fields = UKHSAPage.search_fields + [ - index.SearchField("body"), + index.SearchField("page_description"), ] content_panels = UKHSAPage.content_panels + [ diff --git a/cms/dashboard/models.py b/cms/dashboard/models.py index 16e4441a3..3a080485b 100644 --- a/cms/dashboard/models.py +++ b/cms/dashboard/models.py @@ -9,6 +9,7 @@ from wagtail.api import APIField from wagtail.fields import RichTextField from wagtail.models import Orderable, Page, SiteRootPath +from wagtail.search import index from cms import seo @@ -88,6 +89,12 @@ class UKHSAPage(Page): InlinePanel("announcements", heading="Announcements", label="Announcement"), ] + search_fields = Page.search_fields + [ + index.SearchField("body"), + index.SearchField("title"), + index.SearchField("search_description"), + ] + class Meta: abstract = True diff --git a/cms/topic/models.py b/cms/topic/models.py index 766c3e7a9..4a77610f5 100644 --- a/cms/topic/models.py +++ b/cms/topic/models.py @@ -55,9 +55,7 @@ class TopicPage(UKHSAPage): ] # Search index configuration - search_fields = UKHSAPage.search_fields + [ - index.SearchField("title"), - ] + search_fields = UKHSAPage.search_fields + [index.SearchField("page_description")] # Editor panels configuration content_panels = UKHSAPage.content_panels + [ diff --git a/tests/unit/cms/composite/test_models.py b/tests/unit/cms/composite/test_models.py index 1f6c1978c..f4733a59e 100644 --- a/tests/unit/cms/composite/test_models.py +++ b/tests/unit/cms/composite/test_models.py @@ -2,6 +2,7 @@ from wagtail.admin.panels.field_panel import FieldPanel from wagtail.admin.panels.inline_panel import InlinePanel from wagtail.api.conf import APIField +from wagtail.search.index import SearchField from cms.dashboard.management.commands.build_cms_site_helpers.pages import ( open_example_page_response, @@ -152,3 +153,28 @@ def test_code_block_returns_correct_content(self): response["code_snippet"]["language"] == template["code_snippet"]["language"] ) assert response["code_snippet"]["code"] == template["code_snippet"]["code"] + + @pytest.mark.parametrize( + "expected_search_field", + [ + "page_description", + ], + ) + def test_has_correct_search_fields( + self, + expected_search_field: str, + ): + """ + Given a blank `CompositePage` model. + When `search_field` is called. + Then the expected names are on the returned `APIField` objects. + """ + # Given + blank_page = FakeCompositePageFactory.build_blank_page() + + # When + search_fields: list[SearchField] = blank_page.search_fields + + # Then + search_fields: set[str] = {api_field.field_name for api_field in search_fields} + assert expected_search_field in search_fields diff --git a/tests/unit/cms/topic/test_models.py b/tests/unit/cms/topic/test_models.py index 6f16d9711..df4dcd9a4 100644 --- a/tests/unit/cms/topic/test_models.py +++ b/tests/unit/cms/topic/test_models.py @@ -11,6 +11,34 @@ ) from metrics.domain.common.utils import ChartTypes from tests.fakes.factories.cms.topic_page_factory import FakeTopicPageFactory +from wagtail.search.index import SearchField + + +class TestTopicPage: + @pytest.mark.parametrize( + "expected_search_field", + [ + "page_description", + ], + ) + def test_has_correct_search_fields( + self, + expected_search_field: str, + ): + """ + Given a blank `TopicPage` model. + When `search_field` is called. + Then the expected names are on the returned `APIField` objects. + """ + # Given + blank_page = FakeTopicPageFactory.build_covid_19_page_from_template() + + # When + search_fields: list[SearchField] = blank_page.search_fields + + # Then + search_fields: set[str] = {api_field.field_name for api_field in search_fields} + assert expected_search_field in search_fields class TestTemplateCOVID19Page: From 94d8eed5cf1eca4a2585ee22695c18ef75e6244a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 06:13:41 +0000 Subject: [PATCH 008/186] 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] --- requirements-prod-ingestion.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-prod-ingestion.txt b/requirements-prod-ingestion.txt index 181e657e7..f3a209792 100644 --- a/requirements-prod-ingestion.txt +++ b/requirements-prod-ingestion.txt @@ -4,5 +4,5 @@ botocore==1.34.109 Django==5.2.12 psycopg2-binary==2.9.10 pydantic==2.12.5 -python-dotenv==1.2.1 +python-dotenv==1.2.2 sqlparse==0.5.5 From 52fa1a80c0fe1e1baf5e957d1259ee5d931d0f90 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Wed, 25 Feb 2026 12:48:21 +0000 Subject: [PATCH 009/186] Testing dummy secret with gitleaks --- .github/workflows/secret-scan.yaml | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/secret-scan.yaml diff --git a/.github/workflows/secret-scan.yaml b/.github/workflows/secret-scan.yaml new file mode 100644 index 000000000..02e37af92 --- /dev/null +++ b/.github/workflows/secret-scan.yaml @@ -0,0 +1,41 @@ +name: Secret Scan + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + gitleaks: + name: Gitleaks Secret Scan + runs-on: ubuntu-latest + + steps: + # Checkout PR HEAD commit (not the merge commit) + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + # Install latest Gitleaks + - name: Install Gitleaks + run: | + VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') + curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz -o gitleaks.tar.gz + tar -xzf gitleaks.tar.gz + sudo mv gitleaks /usr/local/bin/ + gitleaks version + + # Fetch base branch commit + - name: Fetch base branch + run: | + git fetch origin ${{ github.event.pull_request.base.sha }} + + # Scan ONLY commits introduced in this PR + - name: Run Gitleaks (PR commits only) + run: | + echo "Scanning commits from base -> PR head" + gitleaks detect \ + --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" \ + --exit-code 1 \ + --verbose \ No newline at end of file From cc9481d06ca494e35acfa12d828dddb45f6a378d Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Wed, 25 Feb 2026 12:51:41 +0000 Subject: [PATCH 010/186] Testing dummy secret with gitleaks --- .github/workflows/secret-scan.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/secret-scan.yaml b/.github/workflows/secret-scan.yaml index 02e37af92..240b69d25 100644 --- a/.github/workflows/secret-scan.yaml +++ b/.github/workflows/secret-scan.yaml @@ -10,7 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - # Checkout PR HEAD commit (not the merge commit) - name: Checkout PR head uses: actions/checkout@v4 with: From 2cba56579b53666d3d4d0b5e0514b2434513093c Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Wed, 25 Feb 2026 13:32:11 +0000 Subject: [PATCH 011/186] Added secret scan to the existing action.yaml --- .github/workflows/actions.yml | 45 +++++++++++++++++++++++++++--- .github/workflows/secret-scan.yaml | 40 -------------------------- 2 files changed, 41 insertions(+), 44 deletions(-) delete mode 100644 .github/workflows/secret-scan.yaml diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 0fa7d3961..2581e14a1 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -1,12 +1,11 @@ name: Pipeline -on: +on: + pull_request: + types: [opened, synchronize, reopened] push: branches: - main - pull_request: - branches: - - '*' env: APIENV: "LOCAL" @@ -18,12 +17,50 @@ permissions: contents: read # This is required for actions/checkout jobs: + + ############################################################################### + # Secret Scan + ############################################################################### + + secret-scan: + name: Gitleaks Secret Scan + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Install Gitleaks + run: | + VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') + curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz -o gitleaks.tar.gz + tar -xzf gitleaks.tar.gz + sudo mv gitleaks /usr/local/bin/ + gitleaks version + + - name: Fetch base branch + run: | + git fetch origin ${{ github.event.pull_request.base.sha }} + + - name: Run Gitleaks (PR commits only) + run: | + echo "Scanning commits from base -> PR head" + gitleaks detect \ + --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" \ + --exit-code 1 \ + --verbose + ############################################################################### # Install dependencies & build packages ############################################################################### build: name: Build + needs: [secret-scan] runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/secret-scan.yaml b/.github/workflows/secret-scan.yaml deleted file mode 100644 index 240b69d25..000000000 --- a/.github/workflows/secret-scan.yaml +++ /dev/null @@ -1,40 +0,0 @@ -name: Secret Scan - -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - gitleaks: - name: Gitleaks Secret Scan - runs-on: ubuntu-latest - - steps: - - name: Checkout PR head - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - # Install latest Gitleaks - - name: Install Gitleaks - run: | - VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') - curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz -o gitleaks.tar.gz - tar -xzf gitleaks.tar.gz - sudo mv gitleaks /usr/local/bin/ - gitleaks version - - # Fetch base branch commit - - name: Fetch base branch - run: | - git fetch origin ${{ github.event.pull_request.base.sha }} - - # Scan ONLY commits introduced in this PR - - name: Run Gitleaks (PR commits only) - run: | - echo "Scanning commits from base -> PR head" - gitleaks detect \ - --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" \ - --exit-code 1 \ - --verbose \ No newline at end of file From d6eef0ea37553894b110fd049f7bb40a6c660299 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Wed, 25 Feb 2026 14:01:53 +0000 Subject: [PATCH 012/186] Changed run to pull request as well --- .github/workflows/actions.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 2581e14a1..2e35782de 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -25,7 +25,6 @@ jobs: secret-scan: name: Gitleaks Secret Scan runs-on: ubuntu-latest - if: github.event_name == 'pull_request' steps: - name: Checkout PR head @@ -46,7 +45,7 @@ jobs: run: | git fetch origin ${{ github.event.pull_request.base.sha }} - - name: Run Gitleaks (PR commits only) + - name: Run Gitleaks run: | echo "Scanning commits from base -> PR head" gitleaks detect \ From e9114c88f31286077da7f3ac089c0e2d9071b3d8 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Fri, 27 Feb 2026 13:40:46 +0000 Subject: [PATCH 013/186] Replaced gitleaks with official marketplace version --- .github/workflows/actions.yml | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 2e35782de..b3fd8c699 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -27,31 +27,17 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout PR head - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - - name: Install Gitleaks - run: | - VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') - curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz -o gitleaks.tar.gz - tar -xzf gitleaks.tar.gz - sudo mv gitleaks /usr/local/bin/ - gitleaks version - - - name: Fetch base branch - run: | - git fetch origin ${{ github.event.pull_request.base.sha }} - - name: Run Gitleaks - run: | - echo "Scanning commits from base -> PR head" - gitleaks detect \ - --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" \ - --exit-code 1 \ - --verbose + uses: gitleaks/gitleaks-action@v2 + with: + args: > + detect + --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + --exit-code 1 ############################################################################### # Install dependencies & build packages From c097e6336ed80dad75eb97e8acccdec102e91803 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Wed, 4 Mar 2026 13:33:13 +0000 Subject: [PATCH 014/186] Reverted to script installation of gitleaks --- .github/workflows/actions.yml | 36 ++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index b3fd8c699..2c2d582d6 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -22,22 +22,40 @@ jobs: # Secret Scan ############################################################################### - secret-scan: + gitleaks: name: Gitleaks Secret Scan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + # Checkout PR HEAD commit (not the merge commit) + - name: Checkout PR head + uses: actions/checkout@v4 with: + ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - - name: Run Gitleaks - uses: gitleaks/gitleaks-action@v2 - with: - args: > - detect - --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" - --exit-code 1 + # Install latest Gitleaks + - name: Install Gitleaks + run: | + VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') + curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz -o gitleaks.tar.gz + tar -xzf gitleaks.tar.gz + sudo mv gitleaks /usr/local/bin/ + gitleaks version + + # Fetch base branch commit + - name: Fetch base branch + run: | + git fetch origin ${{ github.event.pull_request.base.sha }} + + # Scan ONLY commits introduced in this PR + - name: Run Gitleaks (PR commits only) + run: | + echo "Scanning commits from base -> PR head" + gitleaks detect \ + --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" \ + --exit-code 1 \ + --verbose ############################################################################### # Install dependencies & build packages From 54c90018d145b2949cfa77f978a9156e18e063a9 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Wed, 4 Mar 2026 14:13:27 +0000 Subject: [PATCH 015/186] Changed job name --- .github/workflows/actions.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 2c2d582d6..2e35782de 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -22,19 +22,17 @@ jobs: # Secret Scan ############################################################################### - gitleaks: + secret-scan: name: Gitleaks Secret Scan runs-on: ubuntu-latest steps: - # Checkout PR HEAD commit (not the merge commit) - name: Checkout PR head uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - # Install latest Gitleaks - name: Install Gitleaks run: | VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') @@ -43,13 +41,11 @@ jobs: sudo mv gitleaks /usr/local/bin/ gitleaks version - # Fetch base branch commit - name: Fetch base branch run: | git fetch origin ${{ github.event.pull_request.base.sha }} - # Scan ONLY commits introduced in this PR - - name: Run Gitleaks (PR commits only) + - name: Run Gitleaks run: | echo "Scanning commits from base -> PR head" gitleaks detect \ From b2d74c98cc07c89b066b9079809a31256d223029 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Thu, 5 Mar 2026 12:57:26 +0000 Subject: [PATCH 016/186] Changed ubuntu version --- .github/workflows/actions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 2e35782de..139b6cd2c 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -24,7 +24,7 @@ jobs: secret-scan: name: Gitleaks Secret Scan - runs-on: ubuntu-latest + runs-on: ubuntu-22.04-arm steps: - name: Checkout PR head From d295227f7e163603e2fce981b2c543fd0fc66982 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Thu, 5 Mar 2026 13:09:21 +0000 Subject: [PATCH 017/186] Changed x64 to arm64 --- .github/workflows/actions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 139b6cd2c..eb85e5713 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -36,7 +36,7 @@ jobs: - name: Install Gitleaks run: | VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') - curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz -o gitleaks.tar.gz + curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_arm64.tar.gz -o gitleaks.tar.gz tar -xzf gitleaks.tar.gz sudo mv gitleaks /usr/local/bin/ gitleaks version From eb7d6ea35cc2bfe9a14ec1dc6c54309c0a7e2d21 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Fri, 6 Mar 2026 11:43:50 +0000 Subject: [PATCH 018/186] Using official gitleaks action --- .github/workflows/actions.yml | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index eb85e5713..b3fd8c699 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -24,34 +24,20 @@ jobs: secret-scan: name: Gitleaks Secret Scan - runs-on: ubuntu-22.04-arm + runs-on: ubuntu-latest steps: - - name: Checkout PR head - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - - name: Install Gitleaks - run: | - VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') - curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_arm64.tar.gz -o gitleaks.tar.gz - tar -xzf gitleaks.tar.gz - sudo mv gitleaks /usr/local/bin/ - gitleaks version - - - name: Fetch base branch - run: | - git fetch origin ${{ github.event.pull_request.base.sha }} - - name: Run Gitleaks - run: | - echo "Scanning commits from base -> PR head" - gitleaks detect \ - --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" \ - --exit-code 1 \ - --verbose + uses: gitleaks/gitleaks-action@v2 + with: + args: > + detect + --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + --exit-code 1 ############################################################################### # Install dependencies & build packages From d6187e691b1abcaccd149a667bf0f314248372a6 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Fri, 6 Mar 2026 13:51:13 +0000 Subject: [PATCH 019/186] Updated ubuntu version --- .github/workflows/actions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index b3fd8c699..1022655da 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -24,7 +24,7 @@ jobs: secret-scan: name: Gitleaks Secret Scan - runs-on: ubuntu-latest + runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v4 From e600e170682ce1c8fd375dd844427c7e22dc624d Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Tue, 10 Mar 2026 12:30:26 +0000 Subject: [PATCH 020/186] Git License added --- .github/workflows/actions.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 1022655da..c89b88700 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -33,6 +33,8 @@ jobs: - name: Run Gitleaks uses: gitleaks/gitleaks-action@v2 + env: + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} with: args: > detect From e4c7cb21406132778fe5c00bce62440037f59baa Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Tue, 10 Mar 2026 12:34:28 +0000 Subject: [PATCH 021/186] Gitleaks arg removed --- .github/workflows/actions.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index c89b88700..bd79e7076 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -34,13 +34,13 @@ jobs: - name: Run Gitleaks uses: gitleaks/gitleaks-action@v2 env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} - with: - args: > + GITLEAKS_ARGS: > detect --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" - --exit-code 1 - + --exit-code=1 + ############################################################################### # Install dependencies & build packages ############################################################################### From 16ccfa1c6a7a0904212b71a3c5bb21d4c40a6cd1 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Wed, 11 Mar 2026 12:35:54 +0000 Subject: [PATCH 022/186] Aligned with documentation --- .github/workflows/actions.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index bd79e7076..eefa303c9 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -36,10 +36,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} - GITLEAKS_ARGS: > - detect - --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" - --exit-code=1 ############################################################################### # Install dependencies & build packages From a67a30054e7a689c2a5181e06c572df685412340 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Fri, 13 Mar 2026 10:35:54 +0000 Subject: [PATCH 023/186] Skip gitleaks action for dependabot --- .github/workflows/actions.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index eefa303c9..8eb87a906 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -32,6 +32,7 @@ jobs: fetch-depth: 0 - name: Run Gitleaks + if: github.actor != 'dependabot[bot]' uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 89525e8660009eab716ac40802c30822111c424c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:14:04 +0000 Subject: [PATCH 024/186] pip dev: (deps-dev): bump black from 26.3.0 to 26.3.1 Bumps [black](https://github.com/psf/black) from 26.3.0 to 26.3.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/26.3.0...26.3.1) --- updated-dependencies: - dependency-name: black dependency-version: 26.3.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index eb70e1943..f2c27ea70 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ bandit==1.9.4 -black==26.3.0 +black==26.3.1 coverage==7.13.4 cyclonedx-python-lib==11.6.0 django-factory-boy==1.0.0 From 98d47ae44c1563271bd629bb0a373ceae10cc4c7 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 17 Mar 2026 17:15:50 +0000 Subject: [PATCH 025/186] CDD-3175: populate the Topic dropdown --- auth_content/models.py | 43 ++++++++++--- auth_content/static/js/child_theme.js | 87 ++++++++++++++++++++------- auth_content/wagtail_hooks.py | 21 ++++++- 3 files changed, 119 insertions(+), 32 deletions(-) diff --git a/auth_content/models.py b/auth_content/models.py index b06818266..301d89dfc 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -3,7 +3,8 @@ from django.db import models from wagtail.admin.panels import FieldPanel -from validation.enums.theme_and_topic_enums import ChildTheme, ParentTheme +from validation.enums.theme_and_topic_enums import ChildTheme, ParentTheme, Topic + def get_theme_child_map(): """Returns an object of all parent to child mappings @@ -21,19 +22,45 @@ def get_theme_child_map(): # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme theme_mapping[parent.value] = ChildTheme[parent.name].return_tuple_list() + print(theme_mapping) return theme_mapping + +def get_sub_theme_child_map(): + """Returns an object of all parent to child mappings + e.g. + { + infectious_disease: [vaccine_preventable, respiratory ....], + extreme_event: [weather_alert, mortality_report...] + ... + } + + """ + + sub_theme_mapping = {} + for topic in Topic: + print("item: ", topic.value) + + # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme + sub_theme_mapping[topic.name.lower( + )] = Topic[topic.name].return_tuple_list() + + print(sub_theme_mapping) + + return sub_theme_mapping + + class PermissionSet(models.Model): theme = models.CharField( - choices=[(e.value, e.name.replace("_", " ").title()) for e in ParentTheme] + choices=[(e.value, e.name.replace("_", " ").title()) + for e in ParentTheme] ) sub_theme = models.CharField(max_length=255, choices=[]) - topic = models.CharField(max_length=255) - metric = models.CharField(max_length=255) - geography_type = models.CharField(max_length=255) - geography = models.CharField(max_length=255) + topic = models.CharField(max_length=255, choices=[]) + metric = models.CharField(max_length=255, choices=[]) + geography_type = models.CharField(max_length=255, choices=[]) + geography = models.CharField(max_length=255, choices=[]) - panels = [ FieldPanel("theme"), FieldPanel("sub_theme"), @@ -42,6 +69,6 @@ class PermissionSet(models.Model): FieldPanel("geography_type"), FieldPanel("geography"), ] - + def __str__(self): return self.theme diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 3b958d499..589935019 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -1,44 +1,89 @@ -;(function () { +(function () { let theme, sub_theme, - themeMapping = {} + topic, + subThemeMapping = {}, + themeMapping = {}; function setSubTheme() { - theme = document.querySelector('select[name="theme"]') - sub_theme = document.querySelector('select[name="sub_theme"]') + theme = document.querySelector('select[name="theme"]'); + sub_theme = document.querySelector('select[name="sub_theme"]'); - if (!theme || !sub_theme) return + if (!theme || !sub_theme) return; - themeMapping = window.PERMISSIONSET_THEME_MAP + themeMapping = window.PERMISSIONSET_THEME_MAP; if (!theme.value) { - clearSubTheme() + clearSubTheme(); } else { - populateSubThemeDropDown() + populateSubThemeDropDown(); + } + } + + function setTopic() { + sub_theme = document.querySelector('select[name="sub_theme"]'); + topic = document.querySelector('select[name="topic"]'); + + if (!sub_theme || !topic) return; + + subThemeMapping = window.PERMISSIONSET_SUB_THEME_MAP; + console.log("subTheme: ", subThemeMapping); + + console.log(sub_theme.value); + + if (!sub_theme.value) { + clearTopic(); + } else { + populateTopicDropDown(); } } function populateSubThemeDropDown() { - sub_theme.disabled = false - sub_theme.innerHTML = "" - sub_theme.add(new Option("---------", "")) + sub_theme.disabled = false; + sub_theme.innerHTML = ""; + sub_theme.add(new Option("---------", "")); - const options = themeMapping[theme.value] || [] + const options = themeMapping[theme.value] || []; - options.forEach(([value, label]) => sub_theme.add(new Option(label, value))) + options.forEach(([value, label]) => + sub_theme.add(new Option(label, value)), + ); } function clearSubTheme() { - sub_theme.innerHTML = "" - sub_theme.add(new Option("---------", "")) - sub_theme.value = "" - sub_theme.disabled = true + sub_theme.innerHTML = ""; + sub_theme.add(new Option("---------", "")); + sub_theme.value = ""; + sub_theme.disabled = true; } - document.addEventListener("DOMContentLoaded", setSubTheme) + function populateTopicDropDown() { + topic.disabled = false; + topic.innerHTML = ""; + topic.add(new Option("---------", "")); + + const options = subThemeMapping[sub_theme.value] || []; + console.log("options:", subThemeMapping); + + options.forEach(([value, label]) => topic.add(new Option(label, value))); + } + + function clearTopic() { + topic.innerHTML = ""; + topic.add(new Option("---------", "")); + topic.value = ""; + topic.disabled = true; + } + + document.addEventListener("DOMContentLoaded", setSubTheme); + document.addEventListener("DOMContentLoaded", setTopic); + document.addEventListener("change", function (e) { if (e.target.name === "theme") { - setSubTheme() + setSubTheme(); + } + if (e.target.name === "sub_theme") { + setTopic(); } - }) -})() + }); +})(); diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py index 88fc433d6..cbdd67ace 100644 --- a/auth_content/wagtail_hooks.py +++ b/auth_content/wagtail_hooks.py @@ -5,7 +5,7 @@ from wagtail.admin.viewsets.model import ModelViewSetGroup from django.templatetags.static import static -from auth_content.models import PermissionSet, get_theme_child_map +from auth_content.models import PermissionSet, get_theme_child_map, get_sub_theme_child_map from django.utils.html import format_html from django.utils.safestring import mark_safe @@ -27,12 +27,27 @@ class AuthGroup(ModelViewSetGroup): def register_auth_viewset(): return AuthGroup() -# exposes the mapping of parent to child themes +# exposes the mapping of parent to child themes + + @hooks.register("insert_editor_js") def permission_set_theme_mapping(): mapping = json.dumps(get_theme_child_map()) return format_html( - "", mark_safe(mapping) + "", mark_safe( + mapping) + ) + +# exposes the mapping of parent to child themes + + +@hooks.register("insert_editor_js") +def permission_set_sub_theme_mapping(): + sub_theme_mapping = json.dumps(get_sub_theme_child_map()) + print(sub_theme_mapping) + return format_html( + "", mark_safe( + sub_theme_mapping) ) From 2c01f79fe168cec39d78c014dc830eedc2850e82 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Wed, 18 Mar 2026 15:21:25 +0000 Subject: [PATCH 026/186] Wire up geographyType model --- auth_content/models.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/auth_content/models.py b/auth_content/models.py index 301d89dfc..5806a6890 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -3,6 +3,7 @@ from django.db import models from wagtail.admin.panels import FieldPanel +from validation.enums.geographies_enums import GeographyType from validation.enums.theme_and_topic_enums import ChildTheme, ParentTheme, Topic @@ -50,6 +51,28 @@ def get_sub_theme_child_map(): return sub_theme_mapping +def get_geography_type_geographies_map(): + """Returns an object of all parent to child mappings + e.g. + { + infectious_disease: [vaccine_preventable, respiratory ....], + extreme_event: [weather_alert, mortality_report...] + ... + } + + """ + + geographies_mapping = {} + for geographyType in GeographyType: + # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme + geographies_mapping[geographyType.value] = [ + geographyType.name].return_tuple_list() + + print(geographies_mapping) + geographies_mapping = {} + return geographies_mapping + + class PermissionSet(models.Model): theme = models.CharField( choices=[(e.value, e.name.replace("_", " ").title()) @@ -58,7 +81,8 @@ class PermissionSet(models.Model): sub_theme = models.CharField(max_length=255, choices=[]) topic = models.CharField(max_length=255, choices=[]) metric = models.CharField(max_length=255, choices=[]) - geography_type = models.CharField(max_length=255, choices=[]) + geography_type = models.CharField(max_length=255, choices=[( + e.value, e.value.replace("_", " ")) for e in GeographyType]) geography = models.CharField(max_length=255, choices=[]) panels = [ From 96526f53badab15aae8db665e1824102530966e9 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Thu, 19 Mar 2026 15:05:17 +0000 Subject: [PATCH 027/186] Update theme functionality to pull available themes from the db via the metrics interface. --- auth_content/models.py | 5 +- .../field_choices_callables.py | 48 ++++++++++++++++++- cms/metrics_interface/interface.py | 11 +++++ metrics/data/managers/core_models/theme.py | 20 ++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/auth_content/models.py b/auth_content/models.py index 5806a6890..505d3a12b 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -3,6 +3,7 @@ from django.db import models from wagtail.admin.panels import FieldPanel +from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids from validation.enums.geographies_enums import GeographyType from validation.enums.theme_and_topic_enums import ChildTheme, ParentTheme, Topic @@ -75,9 +76,7 @@ def get_geography_type_geographies_map(): class PermissionSet(models.Model): theme = models.CharField( - choices=[(e.value, e.name.replace("_", " ").title()) - for e in ParentTheme] - ) + max_length=255, choices=get_all_theme_names_and_ids()) sub_theme = models.CharField(max_length=255, choices=[]) topic = models.CharField(max_length=255, choices=[]) metric = models.CharField(max_length=255, choices=[]) diff --git a/cms/metrics_interface/field_choices_callables.py b/cms/metrics_interface/field_choices_callables.py index b8cefbb06..9a0023989 100644 --- a/cms/metrics_interface/field_choices_callables.py +++ b/cms/metrics_interface/field_choices_callables.py @@ -11,6 +11,7 @@ """ from cms.metrics_interface import MetricsAPIInterface +from django.db.models import QuerySet LIST_OF_TWO_STRING_ITEM_TUPLES = list[tuple[str, str]] DICT_OF_CHART_AXIS_AND_SUB_CATEGORIES = dict[str, list[str]] @@ -24,6 +25,22 @@ def _build_two_item_tuple_choices( return [(choice, choice) for choice in choices] +def _build_id_name_tuple_choices( + *, choices: QuerySet +) -> list[tuple[int, str]]: + """Build choices from a QuerySet containing id and name fields. + + Args: + choices: QuerySet with 'id' and 'name' fields + + Returns: + A list of 2-item tuples (id, name). + Examples: + [(1, "infectious_disease"), (2, "respiratory"), ...] + """ + return [(choice['id'], choice['name']) for choice in choices] + + def get_possible_axis_choices() -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `chart axis` fields of the CMS blocks. @@ -276,8 +293,34 @@ def get_all_theme_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: [("Infectious_disease", "Infectious_disease"), ...] """ metrics_interface = MetricsAPIInterface() + theme_names = metrics_interface.get_all_theme_names() + print(theme_names) return _build_two_item_tuple_choices( - choices=metrics_interface.get_all_theme_names(), + choices=theme_names, + ) + + +def get_all_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `theme` fields of the CMS blocks. + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + This means that we don't need to create a new migration + whenever a new chart type is added. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of theme names. + Examples: + [("Infectious_disease", "Infectious_disease"), ...] + """ + metrics_interface = MetricsAPIInterface() + theme_names = metrics_interface.get_all_theme_choices() + print(theme_names) + return _build_id_name_tuple_choices( + choices=theme_names, ) @@ -576,7 +619,8 @@ def get_all_geography_choices_grouped_by_type() -> ( def get_all_subcategory_choices_grouped_by_categories() -> ( dict[ - str, LIST_OF_TWO_STRING_ITEM_TUPLES | dict[str, LIST_OF_TWO_STRING_ITEM_TUPLES] + str, LIST_OF_TWO_STRING_ITEM_TUPLES | dict[str, + LIST_OF_TWO_STRING_ITEM_TUPLES] ] ): """Callable to return all subcategory choices groups by categories. diff --git a/cms/metrics_interface/interface.py b/cms/metrics_interface/interface.py index 2c63d2871..73bd1c8c4 100644 --- a/cms/metrics_interface/interface.py +++ b/cms/metrics_interface/interface.py @@ -186,6 +186,17 @@ def get_all_theme_names(self) -> QuerySet: """ return self.theme_manager.get_all_names() + def get_all_theme_choices(self) -> QuerySet: + """Gets all available theme names as a flat list queryset. + Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual theme names. + Examples: + ``. + """ + return self.theme_manager.get_all_choices() + def get_all_sub_theme_names(self) -> QuerySet: """Gets all available sub_theme names as a flat list queryset. Note this is achieved by delegating the call to the `SubThemeManager` from the Metrics API diff --git a/metrics/data/managers/core_models/theme.py b/metrics/data/managers/core_models/theme.py index de07d2f74..b5c416167 100644 --- a/metrics/data/managers/core_models/theme.py +++ b/metrics/data/managers/core_models/theme.py @@ -21,6 +21,16 @@ def get_all_names(self) -> models.QuerySet: """ return self.all().values_list("name", flat=True) + def get_all_choices(self) -> models.QuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.all().values("id", "name") + class ThemeManager(models.Manager): """Custom model manager class for the `Theme` model.""" @@ -38,3 +48,13 @@ def get_all_names(self) -> ThemeQuerySet: """ return self.get_queryset().get_all_names() + + def get_all_choices(self) -> ThemeQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self .get_queryset().get_all_choices() From 7446975508ad2c7e8dafdfcfc04120b879b77eed Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Fri, 20 Mar 2026 11:00:05 +0000 Subject: [PATCH 028/186] CDD-3175: added endpoints for retrieving subtheme/topics/metrics and wired up javascript to call endpoints. --- auth_content/models.py | 96 +++---- auth_content/static/js/child_theme.js | 246 +++++++++++++----- auth_content/wagtail_hooks.py | 21 +- .../field_choices_callables.py | 30 ++- cms/metrics_interface/interface.py | 12 + metrics/api/urls_construction.py | 32 ++- metrics/api/views/permission_sets.py | 93 +++++++ .../data/managers/core_models/sub_theme.py | 20 ++ 8 files changed, 402 insertions(+), 148 deletions(-) create mode 100644 metrics/api/views/permission_sets.py diff --git a/auth_content/models.py b/auth_content/models.py index 505d3a12b..ecd6d9245 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -1,11 +1,11 @@ -from django.db import models +from django import forms from django.db import models from wagtail.admin.panels import FieldPanel from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids from validation.enums.geographies_enums import GeographyType -from validation.enums.theme_and_topic_enums import ChildTheme, ParentTheme, Topic +from wagtail.admin.forms import WagtailAdminModelForm def get_theme_child_map(): @@ -18,71 +18,55 @@ def get_theme_child_map(): } """ - theme_mapping = {} - for parent in ParentTheme: - # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme - theme_mapping[parent.value] = ChildTheme[parent.name].return_tuple_list() print(theme_mapping) return theme_mapping -def get_sub_theme_child_map(): - """Returns an object of all parent to child mappings - e.g. - { - infectious_disease: [vaccine_preventable, respiratory ....], - extreme_event: [weather_alert, mortality_report...] - ... - } - - """ - - sub_theme_mapping = {} - for topic in Topic: - print("item: ", topic.value) - - # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme - sub_theme_mapping[topic.name.lower( - )] = Topic[topic.name].return_tuple_list() - - print(sub_theme_mapping) - - return sub_theme_mapping - - -def get_geography_type_geographies_map(): - """Returns an object of all parent to child mappings - e.g. - { - infectious_disease: [vaccine_preventable, respiratory ....], - extreme_event: [weather_alert, mortality_report...] - ... - } - - """ - - geographies_mapping = {} - for geographyType in GeographyType: - # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme - geographies_mapping[geographyType.value] = [ - geographyType.name].return_tuple_list() - - print(geographies_mapping) - geographies_mapping = {} - return geographies_mapping +class PermissionSetForm(WagtailAdminModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Use CharField with Select widget to bypass choice validation + self.fields['sub_theme'] = forms.CharField( + required=False, + label="Sub Theme", + widget=forms.Select(choices=[("-1", "Select theme first")]) + ) + self.fields['topic'] = forms.CharField( + required=False, + label="Topic", + widget=forms.Select(choices=[("-1", "Select sub-theme first")]) + ) + self.fields['metric'] = forms.CharField( + required=False, + label="Metric", + widget=forms.Select(choices=[("-1", "Select topic first")]) + ) + self.fields['geography'] = forms.CharField( + required=False, + label="Geography", + widget=forms.Select( + choices=[("-1", "Select geography type first")]) + ) class PermissionSet(models.Model): theme = models.CharField( - max_length=255, choices=get_all_theme_names_and_ids()) - sub_theme = models.CharField(max_length=255, choices=[]) - topic = models.CharField(max_length=255, choices=[]) - metric = models.CharField(max_length=255, choices=[]) + max_length=255, choices=[("-1", "* (All themes)")] + get_all_theme_names_and_ids(), blank=True, default="-1") + sub_theme = models.CharField( + max_length=255, blank=True, default="-1") + topic = models.CharField(max_length=255, + blank=True, default="-1") + metric = models.CharField( + max_length=255, blank=True, default="-1") geography_type = models.CharField(max_length=255, choices=[( - e.value, e.value.replace("_", " ")) for e in GeographyType]) - geography = models.CharField(max_length=255, choices=[]) + e.value, e.value.replace("_", " ")) for e in GeographyType], blank=True, default="-1") + geography = models.CharField( + max_length=255, blank=True, default="-1") + + base_form_class = PermissionSetForm panels = [ FieldPanel("theme"), diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 589935019..da80575bc 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -1,89 +1,211 @@ (function () { - let theme, - sub_theme, - topic, - subThemeMapping = {}, - themeMapping = {}; + "use strict"; + + console.log("Permission set cascading script loaded"); + + let theme, subTheme, topic, metric; + + /** + * Generic function to fetch choices from the API + * @param {string} endpoint - The API endpoint (e.g., 'subthemes', 'topics', 'metrics') + * @param {string} paramValue - The ID value to pass + * @returns {Promise} Array of choices [[id, name], ...] + */ + async function fetchChoices(endpoint, paramValue) { + try { + const url = `/api/permission-set/${endpoint}/${paramValue}`; + console.log(`Fetching from: ${url}`); + + const response = await fetch(url); + + if (!response.ok) { + const errorData = await response.json(); + console.error(`API error: ${errorData.error || "Unknown error"}`); + return []; + } + + const data = await response.json(); + console.log(`Received data from ${endpoint}:`, data); + return data.choices || []; + } catch (error) { + console.error(`Error fetching ${endpoint}:`, error); + return []; + } + } - function setSubTheme() { - theme = document.querySelector('select[name="theme"]'); - sub_theme = document.querySelector('select[name="sub_theme"]'); + /** + * Generic function to populate a dropdown with choices + * @param {HTMLSelectElement} dropdown - The select element to populate + * @param {Array} choices - Array of [id, name] tuples + */ + function populateDropdown(dropdown, choices) { + dropdown.disabled = false; + dropdown.innerHTML = ""; + + choices.forEach(([id, name]) => { + const option = document.createElement("option"); + option.value = id; + option.textContent = name; + dropdown.appendChild(option); + }); + + console.log(`Populated ${dropdown.name} with ${choices.length} options`); + } - if (!theme || !sub_theme) return; + /** + * Generic function to clear and disable a dropdown + * @param {HTMLSelectElement} dropdown - The select element to clear + * @param {string} message - Message to display + */ + function clearDropdown(dropdown, message = "Select parent first") { + dropdown.innerHTML = ""; - themeMapping = window.PERMISSIONSET_THEME_MAP; + const option = document.createElement("option"); + option.value = "-1"; + option.textContent = message; + dropdown.appendChild(option); - if (!theme.value) { - clearSubTheme(); - } else { - populateSubThemeDropDown(); - } - } + dropdown.value = "-1"; + dropdown.disabled = true; - function setTopic() { - sub_theme = document.querySelector('select[name="sub_theme"]'); - topic = document.querySelector('select[name="topic"]'); - - if (!sub_theme || !topic) return; + console.log(`Cleared ${dropdown.name}: ${message}`); + } - subThemeMapping = window.PERMISSIONSET_SUB_THEME_MAP; - console.log("subTheme: ", subThemeMapping); + /** + * Handle theme selection change + */ + async function handleThemeChange() { + const themeValue = theme.value; + console.log("Theme changed to:", themeValue); + + // Clear all dependent dropdowns + clearDropdown(subTheme, "Loading..."); + clearDropdown(topic, "Select sub-theme first"); + clearDropdown(metric, "Select topic first"); + + if (themeValue === "-1") { + console.log("Wildcard theme selected"); + clearDropdown(subTheme, "Select theme first"); + return; + } - console.log(sub_theme.value); + // Fetch and populate sub-themes + const choices = await fetchChoices("subthemes", themeValue); - if (!sub_theme.value) { - clearTopic(); + if (choices.length > 0) { + populateDropdown(subTheme, choices); } else { - populateTopicDropDown(); + clearDropdown(subTheme, "No sub-themes available"); } } - function populateSubThemeDropDown() { - sub_theme.disabled = false; - sub_theme.innerHTML = ""; - sub_theme.add(new Option("---------", "")); + /** + * Handle sub-theme selection change + */ + async function handleSubThemeChange() { + const subThemeValue = subTheme.value; + console.log("Sub-theme changed to:", subThemeValue); + + // Clear dependent dropdowns + clearDropdown(topic, "Loading..."); + clearDropdown(metric, "Select topic first"); + + if (subThemeValue === "-1") { + console.log("Wildcard or no sub-theme selected"); + clearDropdown(topic, "Select sub-theme first"); + return; + } - const options = themeMapping[theme.value] || []; + // Fetch and populate topics + const choices = await fetchChoices("topics", subThemeValue); - options.forEach(([value, label]) => - sub_theme.add(new Option(label, value)), - ); + if (choices.length > 0) { + populateDropdown(topic, choices); + } else { + clearDropdown(topic, "No topics available"); + } } - function clearSubTheme() { - sub_theme.innerHTML = ""; - sub_theme.add(new Option("---------", "")); - sub_theme.value = ""; - sub_theme.disabled = true; - } + /** + * Handle topic selection change + */ + async function handleTopicChange() { + const topicValue = topic.value; + console.log("Topic changed to:", topicValue); - function populateTopicDropDown() { - topic.disabled = false; - topic.innerHTML = ""; - topic.add(new Option("---------", "")); + clearDropdown(metric, "Loading..."); - const options = subThemeMapping[sub_theme.value] || []; - console.log("options:", subThemeMapping); + if (topicValue === "-1") { + console.log("Wildcard or no topic selected"); + clearDropdown(metric, "Select topic first"); + return; + } - options.forEach(([value, label]) => topic.add(new Option(label, value))); - } + // Fetch and populate metrics + const choices = await fetchChoices("metrics", topicValue); - function clearTopic() { - topic.innerHTML = ""; - topic.add(new Option("---------", "")); - topic.value = ""; - topic.disabled = true; + if (choices.length > 0) { + populateDropdown(metric, choices); + } else { + clearDropdown(metric, "No metrics available"); + } } - document.addEventListener("DOMContentLoaded", setSubTheme); - document.addEventListener("DOMContentLoaded", setTopic); + /** + * Initialize the cascading dropdowns + */ + function initialize() { + console.log("Initializing..."); - document.addEventListener("change", function (e) { - if (e.target.name === "theme") { - setSubTheme(); + // Get dropdown elements + theme = document.querySelector('select[name="theme"]'); + subTheme = document.querySelector('select[name="sub_theme"]'); + topic = document.querySelector('select[name="topic"]'); + metric = document.querySelector('select[name="metric"]'); + + // Exit if not on permission set page + if (!theme || !subTheme || !topic || !metric) { + console.log("Permission set dropdowns not found on this page"); + return; } - if (e.target.name === "sub_theme") { - setTopic(); + + console.log("Found all dropdowns: theme, sub_theme, topic, metric"); + + // Set initial disabled state + clearDropdown(subTheme, "Select theme first"); + clearDropdown(topic, "Select sub-theme first"); + clearDropdown(metric, "Select topic first"); + + // Add event listeners + theme.addEventListener("change", handleThemeChange); + subTheme.addEventListener("change", handleSubThemeChange); + topic.addEventListener("change", handleTopicChange); + + console.log("Event listeners attached"); + + // If editing existing record, trigger cascade to repopulate + if (theme.value && theme.value !== "-1") { + console.log("Existing theme value detected:", theme.value); + handleThemeChange().then(() => { + // After sub-themes load, check if sub-theme was already selected + setTimeout(() => { + if (subTheme.value && subTheme.value !== "-1") { + console.log("Existing sub-theme value detected:", subTheme.value); + handleSubThemeChange().then(() => { + // After topics load, check if topic was already selected + setTimeout(() => { + if (topic.value && topic.value !== "-1") { + console.log("Existing topic value detected:", topic.value); + handleTopicChange(); + } + }, 300); + }); + } + }, 300); + }); } - }); + } + + // Initialize when DOM is ready + document.addEventListener("DOMContentLoaded", initialize); })(); diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py index cbdd67ace..ffbcaa6b1 100644 --- a/auth_content/wagtail_hooks.py +++ b/auth_content/wagtail_hooks.py @@ -5,7 +5,7 @@ from wagtail.admin.viewsets.model import ModelViewSetGroup from django.templatetags.static import static -from auth_content.models import PermissionSet, get_theme_child_map, get_sub_theme_child_map +from auth_content.models import PermissionSet from django.utils.html import format_html from django.utils.safestring import mark_safe @@ -29,28 +29,9 @@ def register_auth_viewset(): # exposes the mapping of parent to child themes - -@hooks.register("insert_editor_js") -def permission_set_theme_mapping(): - mapping = json.dumps(get_theme_child_map()) - return format_html( - "", mark_safe( - mapping) - ) - # exposes the mapping of parent to child themes -@hooks.register("insert_editor_js") -def permission_set_sub_theme_mapping(): - sub_theme_mapping = json.dumps(get_sub_theme_child_map()) - print(sub_theme_mapping) - return format_html( - "", mark_safe( - sub_theme_mapping) - ) - - @hooks.register("insert_editor_js") def permission_set_js(): return format_html('', static("js/child_theme.js")) diff --git a/cms/metrics_interface/field_choices_callables.py b/cms/metrics_interface/field_choices_callables.py index 9a0023989..4868bc9da 100644 --- a/cms/metrics_interface/field_choices_callables.py +++ b/cms/metrics_interface/field_choices_callables.py @@ -27,7 +27,7 @@ def _build_two_item_tuple_choices( def _build_id_name_tuple_choices( *, choices: QuerySet -) -> list[tuple[int, str]]: +) -> list[tuple[str, str]]: """Build choices from a QuerySet containing id and name fields. Args: @@ -38,7 +38,7 @@ def _build_id_name_tuple_choices( Examples: [(1, "infectious_disease"), (2, "respiratory"), ...] """ - return [(choice['id'], choice['name']) for choice in choices] + return [(str(choice['id']), choice['name']) for choice in choices] def get_possible_axis_choices() -> LIST_OF_TWO_STRING_ITEM_TUPLES: @@ -368,6 +368,32 @@ def get_all_unique_sub_theme_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: ) +def get_filtered_unique_sub_theme_names_for_parent_theme(parent_theme_id) -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `theme` fields of the CMS blocks. + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + This means that we don't need to create a new migration + whenever a new chart type is added. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of theme names. + Examples: + [("Infectious_disease", "Infectious_disease"), ...] + """ + metrics_interface = MetricsAPIInterface() + print("received parent_theme_id: ", parent_theme_id) + filtered_sub_themes = metrics_interface.get_filtered_unique_sub_theme_names_for_parent_theme( + parent_theme_id=parent_theme_id) + print("filtered_sub_themes: ", filtered_sub_themes) + return _build_id_name_tuple_choices( + choices=filtered_sub_themes, + ) + + def get_all_topic_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `topic` fields of the CMS blocks. diff --git a/cms/metrics_interface/interface.py b/cms/metrics_interface/interface.py index 73bd1c8c4..4d0d51c48 100644 --- a/cms/metrics_interface/interface.py +++ b/cms/metrics_interface/interface.py @@ -220,6 +220,18 @@ def get_all_unique_sub_theme_names(self) -> QuerySet: """ return self.sub_theme_manager.get_all_unique_names() + def get_filtered_unique_sub_theme_names_for_parent_theme(self, parent_theme_id) -> QuerySet: + """Get all unique sub_theme names as a flat list queryset. + Note this is achieved by delegating the call to the `SubThemeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual sub_theme names. + Examples: + ` + + """ + return self.sub_theme_manager.get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) + def get_all_topic_names(self) -> QuerySet: """Gets all available topic names as a flat list queryset. Note this is achieved by delegating the call to the `TopicManager` from the Metrics API diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index 4b76f90e9..e7d741d3b 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -39,6 +39,7 @@ from metrics.api.views.geographies import GeographiesView, GeographiesViewDeprecated from metrics.api.views.health import InternalHealthView from metrics.api.views.maps import MapsView +from metrics.api.views.permission_sets import MetricsByTopicView, SubThemesByThemeView, TopicsBySubThemeView from public_api import construct_url_patterns_for_public_api router = routers.DefaultRouter() @@ -81,7 +82,8 @@ def construct_cms_admin_urlpatterns( prefix: str = "" if app_mode == enums.AppMode.CMS_ADMIN.value else "cms-admin/" return [ path(prefix, include(wagtailadmin_urls)), - path("choose-page/", LinkBrowseView.as_view(), name="wagtailadmin_choose_page"), + path("choose-page/", LinkBrowseView.as_view(), + name="wagtailadmin_choose_page"), ] @@ -129,14 +131,22 @@ def construct_public_api_urlpatterns( # Headless CMS API - pages + drafts endpoints path(API_PREFIX, cms_api_router.urls), path(f"{API_PREFIX}global-banners/v2", GlobalBannerView.as_view()), + path(f"{API_PREFIX}permission-set/subthemes/", + SubThemesByThemeView.as_view(), name='get_subthemes'), + path(f"{API_PREFIX}permission-set/topics/", + TopicsBySubThemeView.as_view(), name='get_topics'), + path(f"{API_PREFIX}permission-set/metrics/", + MetricsByTopicView.as_view(), name='get_metrics'), path(f"{API_PREFIX}menus/v1", MenuView.as_view()), - path(f"{API_PREFIX}alerts/v1/heat", heat_alert_list, name="heat-alerts-list"), + path(f"{API_PREFIX}alerts/v1/heat", + heat_alert_list, name="heat-alerts-list"), path( f"{API_PREFIX}alerts/v1/heat/", heat_alert_detail, name="heat-alerts-detail", ), - path(f"{API_PREFIX}alerts/v1/cold", cold_alert_list, name="cold-alerts-list"), + path(f"{API_PREFIX}alerts/v1/cold", + cold_alert_list, name="cold-alerts-list"), path( f"{API_PREFIX}alerts/v1/cold/", cold_alert_detail, @@ -147,7 +157,8 @@ def construct_public_api_urlpatterns( re_path(f"^{API_PREFIX}charts/subplot/v1", SubplotChartsView.as_view()), re_path(f"^{API_PREFIX}downloads/v2", DownloadsView.as_view()), re_path(f"^{API_PREFIX}bulkdownloads/v1", BulkDownloadsView.as_view()), - re_path(f"^{API_PREFIX}downloads/subplot/v1", SubplotDownloadsView.as_view()), + re_path(f"^{API_PREFIX}downloads/subplot/v1", + SubplotDownloadsView.as_view()), re_path( f"^{API_PREFIX}geographies/v2/(?P[^/]+)", GeographiesViewDeprecated.as_view(), @@ -158,12 +169,15 @@ def construct_public_api_urlpatterns( re_path(f"^{API_PREFIX}tables/v4", TablesView.as_view()), re_path(f"^{API_PREFIX}tables/subplot/v1", TablesSubplotView.as_view()), re_path(f"^{API_PREFIX}trends/v3", TrendsView.as_view()), + ] # Audit API endpoints audit_api_timeseries_list = AuditAPITimeSeriesViewSet.as_view({"get": "list"}) -audit_core_timeseries_list = AuditCoreTimeseriesViewSet.as_view({"get": "list"}) -audit_api_core_headline_list = AuditCoreHeadlineViewSet.as_view({"get": "list"}) +audit_core_timeseries_list = AuditCoreTimeseriesViewSet.as_view({ + "get": "list"}) +audit_api_core_headline_list = AuditCoreHeadlineViewSet.as_view({ + "get": "list"}) audit_api_urlpatterns = [ path( @@ -183,7 +197,8 @@ def construct_public_api_urlpatterns( ), re_path(f"^{API_PREFIX}charts/v2", ChartsView.as_view()), re_path(f"^{API_PREFIX}charts/v3", EncodedChartsView.as_view()), - re_path(f"^{API_PREFIX}charts/dual-category/v1", DualCategoryChartsView.as_view()), + re_path(f"^{API_PREFIX}charts/dual-category/v1", + DualCategoryChartsView.as_view()), re_path(f"^{API_PREFIX}charts/subplot/v1", SubplotChartsView.as_view()), ] @@ -204,7 +219,8 @@ def construct_public_api_urlpatterns( ] static_urlpatterns = [ - re_path(r"^static/(?P.*)$", serve, {"document_root": settings.STATIC_ROOT}), + re_path(r"^static/(?P.*)$", serve, + {"document_root": settings.STATIC_ROOT}), ] common_urlpatterns = [ diff --git a/metrics/api/views/permission_sets.py b/metrics/api/views/permission_sets.py new file mode 100644 index 000000000..411399bee --- /dev/null +++ b/metrics/api/views/permission_sets.py @@ -0,0 +1,93 @@ +from drf_spectacular.utils import extend_schema +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + +PERMISSION_SETS_API_TAG = "data hierarchy" + + +@extend_schema(tags=[PERMISSION_SETS_API_TAG]) +class SubThemesByThemeView(APIView): + """Get sub-themes filtered by theme ID""" + permission_classes = [] + + def get(self, request, *args, **kwargs): + """API endpoint to fetch sub-themes based on selected theme.""" + theme_id = self.kwargs['theme_id'] + + if not theme_id: + return Response({'error': 'theme_id required'}, status=status.HTTP_400_BAD_REQUEST) + + if theme_id == "-1": + return Response({'choices': [["-1", "* (All sub-themes)"]]}) + + try: + # Your interface call here + return Response({ + 'choices': [ + ['1', 'Vaccine Preventable'], + ['2', 'Respiratory'], + ['3', 'Healthcare Associated Infections'] + ], + 'theme_id_received': theme_id + }) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@extend_schema(tags=[PERMISSION_SETS_API_TAG]) +class TopicsBySubThemeView(APIView): + """Get topics filtered by sub-theme ID""" + permission_classes = [] + + def get(self, request, *args, **kwargs): + """API endpoint to fetch topics based on selected sub-theme.""" + sub_theme_id = self.kwargs['sub_theme_id'] + + if not sub_theme_id: + return Response({'error': 'sub_theme_id required'}, status=status.HTTP_400_BAD_REQUEST) + + if sub_theme_id == "-1": + return Response({'choices': [["-1", "* (All topics)"]]}) + + try: + # Your interface call here + return Response({ + 'choices': [ + ['10', 'COVID-19'], + ['11', 'Influenza'], + ['12', 'Measles'] + ], + 'sub_theme_id_received': sub_theme_id + }) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@extend_schema(tags=[PERMISSION_SETS_API_TAG]) +class MetricsByTopicView(APIView): + """Get metrics filtered by topic ID""" + permission_classes = [] + + def get(self, request, *args, **kwargs): + """API endpoint to fetch metrics based on selected topic.""" + topic_id = self.kwargs['topic_id'] + + if not topic_id: + return Response({'error': 'topic_id required'}, status=status.HTTP_400_BAD_REQUEST) + + if topic_id == "-1": + return Response({'choices': [["-1", "* (All metrics)"]]}) + + try: + # Your interface call here + return Response({ + 'choices': [ + ['100', 'Cases per 100k'], + ['101', 'Deaths total'], + ['102', 'Hospital admissions'] + ], + 'topic_id_received': topic_id + }) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/metrics/data/managers/core_models/sub_theme.py b/metrics/data/managers/core_models/sub_theme.py index 97c5e371d..341b4ac34 100644 --- a/metrics/data/managers/core_models/sub_theme.py +++ b/metrics/data/managers/core_models/sub_theme.py @@ -33,6 +33,16 @@ def get_all_unique_names(self) -> models.QuerySet: """ return self.all().values_list("name", flat=True).distinct().order_by("name") + def get_filtered_unique_names_related_to_theme(self, parent_theme_id) -> models.QuerySet: + """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.filter(themeId=parent_theme_id).values("id", "name").distinct() + class SubThemeManager(models.Manager): """Custom model manager class for the `SubTheme` model.""" @@ -61,3 +71,13 @@ def get_all_unique_names(self) -> SubThemeQuerySet: `` """ return self.get_queryset().get_all_unique_names() + + def get_filtered_unique_names_related_to_theme(self, parent_theme_id: str) -> SubThemeQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.get_queryset().get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) From e4bc21addcd456a09c76f97b927d4e5a57087c2f Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Fri, 20 Mar 2026 14:08:26 +0000 Subject: [PATCH 029/186] Update the model and the permission_set javascript when handling wildcard selection --- auth_content/models.py | 24 ++++--- auth_content/static/js/child_theme.js | 97 ++++++++++++++++++--------- 2 files changed, 80 insertions(+), 41 deletions(-) diff --git a/auth_content/models.py b/auth_content/models.py index ecd6d9245..97a9ef816 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -32,17 +32,17 @@ def __init__(self, *args, **kwargs): self.fields['sub_theme'] = forms.CharField( required=False, label="Sub Theme", - widget=forms.Select(choices=[("-1", "Select theme first")]) + widget=forms.Select(choices=[("", "Select theme first")]) ) self.fields['topic'] = forms.CharField( required=False, label="Topic", - widget=forms.Select(choices=[("-1", "Select sub-theme first")]) + widget=forms.Select(choices=[("", "Select sub-theme first")]) ) self.fields['metric'] = forms.CharField( required=False, label="Metric", - widget=forms.Select(choices=[("-1", "Select topic first")]) + widget=forms.Select(choices=[("", "Select topic first")]) ) self.fields['geography'] = forms.CharField( required=False, @@ -54,17 +54,17 @@ def __init__(self, *args, **kwargs): class PermissionSet(models.Model): theme = models.CharField( - max_length=255, choices=[("-1", "* (All themes)")] + get_all_theme_names_and_ids(), blank=True, default="-1") + max_length=255, choices=[("", "---------"), ("-1", "* (All themes)")] + get_all_theme_names_and_ids(), blank=True, default="") sub_theme = models.CharField( - max_length=255, blank=True, default="-1") + max_length=255, blank=True, default="") topic = models.CharField(max_length=255, - blank=True, default="-1") + blank=True, default="") metric = models.CharField( - max_length=255, blank=True, default="-1") + max_length=255, blank=True, default="") geography_type = models.CharField(max_length=255, choices=[( - e.value, e.value.replace("_", " ")) for e in GeographyType], blank=True, default="-1") + e.value, e.value.replace("_", " ")) for e in GeographyType], blank=True, default="") geography = models.CharField( - max_length=255, blank=True, default="-1") + max_length=255, blank=True, default="") base_form_class = PermissionSetForm @@ -78,4 +78,8 @@ class PermissionSet(models.Model): ] def __str__(self): - return self.theme + if self.theme and self.theme != "" and self.theme != "-1": + return f"Permission Set - Theme {self.theme}" + elif self.theme == "-1": + return "Permission Set - All Themes" + return "Permission Set - Not Configured" diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index da80575bc..0f3ca945d 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -1,19 +1,16 @@ (function () { "use strict"; - - console.log("Permission set cascading script loaded"); - let theme, subTheme, topic, metric; /** * Generic function to fetch choices from the API * @param {string} endpoint - The API endpoint (e.g., 'subthemes', 'topics', 'metrics') - * @param {string} paramValue - The ID value to pass + * @param {string} dataItemId - The ID value to pass * @returns {Promise} Array of choices [[id, name], ...] */ - async function fetchChoices(endpoint, paramValue) { + async function fetchChoices(endpoint, dataItemId) { try { - const url = `/api/permission-set/${endpoint}/${paramValue}`; + const url = `/api/permission-set/${endpoint}/${dataItemId}`; console.log(`Fetching from: ${url}`); const response = await fetch(url); @@ -48,16 +45,30 @@ option.textContent = name; dropdown.appendChild(option); }); + } + + function clearDropdown(dropdown, message = "Select parent first") { + dropdown.innerHTML = ""; + + const option = document.createElement("option"); + option.value = ""; + option.textContent = message; + dropdown.appendChild(option); + + dropdown.value = ""; + dropdown.disabled = true; - console.log(`Populated ${dropdown.name} with ${choices.length} options`); + console.log(`Cleared ${dropdown.name}: ${message}`); } /** - * Generic function to clear and disable a dropdown - * @param {HTMLSelectElement} dropdown - The select element to clear - * @param {string} message - Message to display + * Set dropdown to wildcard and disable it + * Used when parent is wildcard, cascading "all" to children */ - function clearDropdown(dropdown, message = "Select parent first") { + function setToWildcard( + dropdown, + message = "* (All - inherited from parent)", + ) { dropdown.innerHTML = ""; const option = document.createElement("option"); @@ -68,7 +79,7 @@ dropdown.value = "-1"; dropdown.disabled = true; - console.log(`Cleared ${dropdown.name}: ${message}`); + console.log(`Set ${dropdown.name} to wildcard: ${message}`); } /** @@ -76,19 +87,28 @@ */ async function handleThemeChange() { const themeValue = theme.value; - console.log("Theme changed to:", themeValue); // Clear all dependent dropdowns - clearDropdown(subTheme, "Loading..."); - clearDropdown(topic, "Select sub-theme first"); - clearDropdown(metric, "Select topic first"); + if (!themeValue || themeValue === "") { + console.log("No theme selected - clearing all children"); + clearDropdown(subTheme, "Select theme first"); + clearDropdown(topic, "Select sub-theme first"); + clearDropdown(metric, "Select topic first"); + return; + } if (themeValue === "-1") { - console.log("Wildcard theme selected"); - clearDropdown(subTheme, "Select theme first"); + console.log("Wildcard theme selected - cascading to all children"); + setToWildcard(subTheme, "* (All sub-themes)"); + setToWildcard(topic, "* (All topics)"); + setToWildcard(metric, "* (All metrics)"); return; } + clearDropdown(subTheme, "--------"); + clearDropdown(topic, "--------"); + clearDropdown(metric, "--------"); + // Fetch and populate sub-themes const choices = await fetchChoices("subthemes", themeValue); @@ -106,16 +126,25 @@ const subThemeValue = subTheme.value; console.log("Sub-theme changed to:", subThemeValue); - // Clear dependent dropdowns - clearDropdown(topic, "Loading..."); - clearDropdown(metric, "Select topic first"); + if (!subThemeValue || subThemeValue === "") { + // No sub-theme selected - clear children + clearDropdown(topic, "Select sub-theme first"); + clearDropdown(metric, "Select topic first"); + return; + } if (subThemeValue === "-1") { - console.log("Wildcard or no sub-theme selected"); - clearDropdown(topic, "Select sub-theme first"); + // Wildcard sub-theme = cascade wildcard to children + console.log("Wildcard sub-theme selected - cascading to children"); + setToWildcard(topic, "* (All topics)"); + setToWildcard(metric, "* (All metrics)"); return; } + // Clear dependent dropdowns + clearDropdown(topic, "--------"); + clearDropdown(metric, "--------"); + // Fetch and populate topics const choices = await fetchChoices("topics", subThemeValue); @@ -133,14 +162,22 @@ const topicValue = topic.value; console.log("Topic changed to:", topicValue); - clearDropdown(metric, "Loading..."); + if (!topicValue || topicValue === "") { + // No topic selected - clear metrics + console.log("No topic selected - clearing metrics"); + clearDropdown(metric, "Select topic first"); + return; + } if (topicValue === "-1") { - console.log("Wildcard or no topic selected"); - clearDropdown(metric, "Select topic first"); + // Wildcard topic = cascade wildcard to metrics + console.log("Wildcard topic selected - cascading to metrics"); + setToWildcard(metric, "* (All metrics)"); return; } + clearDropdown(metric, "--------"); + // Fetch and populate metrics const choices = await fetchChoices("metrics", topicValue); @@ -169,12 +206,10 @@ return; } - console.log("Found all dropdowns: theme, sub_theme, topic, metric"); - // Set initial disabled state - clearDropdown(subTheme, "Select theme first"); - clearDropdown(topic, "Select sub-theme first"); - clearDropdown(metric, "Select topic first"); + clearDropdown(subTheme, "--------"); + clearDropdown(topic, "--------"); + clearDropdown(metric, "--------"); // Add event listeners theme.addEventListener("change", handleThemeChange); From 9c7ca666557c0c4820b28990e04fbbdf41e716bd Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Fri, 20 Mar 2026 15:29:36 +0000 Subject: [PATCH 030/186] Update to add serializer to handle request and response for subthemes and update to subthemes to handle querying db --- metrics/api/serializers/permission_sets.py | 83 +++++++++++++++++++ metrics/api/views/permission_sets.py | 31 ++----- .../data/managers/core_models/sub_theme.py | 2 +- 3 files changed, 93 insertions(+), 23 deletions(-) create mode 100644 metrics/api/serializers/permission_sets.py diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py new file mode 100644 index 000000000..f31e16aad --- /dev/null +++ b/metrics/api/serializers/permission_sets.py @@ -0,0 +1,83 @@ +from rest_framework import serializers + +from metrics.data.models.core_models.supporting import SubTheme +from django.db.models import QuerySet + + +class SubThemeRequestSerializer(serializers.Serializer): + """Fetches and formats sub-theme choices based on theme_id""" + theme_id = serializers.CharField(required=True) + + @property + def sub_theme_manager(self): + """ + Fetch the topic manager from the context if available. + If not get the Manager which has been declared on the `Topic` model. + """ + return self.context.get("sub_theme_manager", SubTheme.objects) + + def validate_theme_id(self, value): + """Validate theme_id is either wildcard or a valid integer""" + if value == "-1": + return value + + try: + int(value) + return value + except ValueError: + raise serializers.ValidationError( + "theme_id must be a number or '-1'") + + def data(self) -> dict: + """ + Fetch sub-themes from DB and format as response. + + Returns: + Dict with 'choices' key containing list of [id, name] pairs + """ + theme_id = self.validated_data['theme_id'] + + # Handle wildcard + if theme_id == "-1": + return {'choices': [["-1", "* (All sub-themes)"]]} + + # Fetch from interface + parent_theme_id = int(theme_id) + sub_theme_tuples = _queryset_to_id_name_tuples(self.sub_theme_manager.get_filtered_unique_names_related_to_theme( + parent_theme_id)) + + # Format response + print('sub_themes: ', sub_theme_tuples) + choices = [[str(id), name] for id, name in sub_theme_tuples] + + return {'choices': choices} + + +class PermissionSetResponseSerializer(serializers.Serializer): + """Formats the response for choice endpoints""" + choices = serializers.ListField( + child=serializers.ListField( + child=serializers.CharField(), + min_length=2, + max_length=2 + ), + help_text="List of [id, name] pairs for dropdown options" + ) + + +def _queryset_to_id_name_tuples(queryset: QuerySet) -> list[tuple[int, str]]: + """ + Convert a QuerySet with 'id' and 'name' fields to a list of tuples. + + Args: + queryset: QuerySet containing dicts with 'id' and 'name' keys + + Returns: + List of (id, name) tuples + + Examples: + >>> qs = Model.objects.values('id', 'name') + >>> queryset_to_id_name_tuples(qs) + [(1, "item1"), (2, "item2")] + """ + return [(item['id'], item['name']) for item in queryset] diff --git a/metrics/api/views/permission_sets.py b/metrics/api/views/permission_sets.py index 411399bee..ad2b179a4 100644 --- a/metrics/api/views/permission_sets.py +++ b/metrics/api/views/permission_sets.py @@ -1,38 +1,25 @@ +from http import HTTPStatus + from drf_spectacular.utils import extend_schema from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status +from metrics.api.serializers.permission_sets import PermissionSetResponseSerializer, SubThemeRequestSerializer + PERMISSION_SETS_API_TAG = "data hierarchy" -@extend_schema(tags=[PERMISSION_SETS_API_TAG]) +@extend_schema(request=PermissionSetResponseSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) class SubThemesByThemeView(APIView): """Get sub-themes filtered by theme ID""" permission_classes = [] - def get(self, request, *args, **kwargs): + def get(self, request, theme_id, *args, **kwargs): """API endpoint to fetch sub-themes based on selected theme.""" - theme_id = self.kwargs['theme_id'] - - if not theme_id: - return Response({'error': 'theme_id required'}, status=status.HTTP_400_BAD_REQUEST) - - if theme_id == "-1": - return Response({'choices': [["-1", "* (All sub-themes)"]]}) - - try: - # Your interface call here - return Response({ - 'choices': [ - ['1', 'Vaccine Preventable'], - ['2', 'Respiratory'], - ['3', 'Healthcare Associated Infections'] - ], - 'theme_id_received': theme_id - }) - except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + serializer = SubThemeRequestSerializer(data={'theme_id': theme_id}) + serializer.is_valid(raise_exception=True) + return Response(serializer.data()) @extend_schema(tags=[PERMISSION_SETS_API_TAG]) diff --git a/metrics/data/managers/core_models/sub_theme.py b/metrics/data/managers/core_models/sub_theme.py index 341b4ac34..43acffa95 100644 --- a/metrics/data/managers/core_models/sub_theme.py +++ b/metrics/data/managers/core_models/sub_theme.py @@ -41,7 +41,7 @@ def get_filtered_unique_names_related_to_theme(self, parent_theme_id) -> models. Examples: `` """ - return self.filter(themeId=parent_theme_id).values("id", "name").distinct() + return self.filter(theme_id=parent_theme_id).values('id', 'name').distinct() class SubThemeManager(models.Manager): From 5b2f370d8f73a2e4b782254a26b205b6b442a2f4 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Fri, 20 Mar 2026 15:39:52 +0000 Subject: [PATCH 031/186] CDD-3175: updated the JS to add wildcard and empty object options --- auth_content/static/js/child_theme.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 0f3ca945d..89b610ce7 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -39,6 +39,18 @@ dropdown.disabled = false; dropdown.innerHTML = ""; + //dropdown empty + const nullOption = document.createElement("option"); + nullOption.value = ""; + nullOption.textContent = "--------"; + dropdown.appendChild(nullOption); + + //dropdown wildcard choice + const wildcardOption = document.createElement("option"); + wildcardOption.value = "-1"; + wildcardOption.textContent = "* (All items)"; + dropdown.appendChild(wildcardOption); + choices.forEach(([id, name]) => { const option = document.createElement("option"); option.value = id; From b362c43d0a84a0fa31450b9ea5ec5a8c60674ad6 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Fri, 20 Mar 2026 16:08:55 +0000 Subject: [PATCH 032/186] CDD-3175: Updated the topics and metrics endpoints to retrieve data from the DB --- ..._alter_permissionset_geography_and_more.py | 71 +++++++++++++ ..._alter_permissionset_geography_and_more.py | 69 ++++++++++++ .../0005_alter_permissionset_theme.py | 27 +++++ ..._alter_permissionset_geography_and_more.py | 79 ++++++++++++++ ..._alter_permissionset_geography_and_more.py | 44 ++++++++ .../0008_alter_permissionset_topic.py | 18 ++++ ..._alter_permissionset_geography_and_more.py | 73 +++++++++++++ metrics/api/serializers/permission_sets.py | 100 +++++++++++++++++- metrics/api/views/permission_sets.py | 64 +++-------- metrics/data/managers/core_models/metric.py | 23 +++- metrics/data/managers/core_models/topic.py | 20 ++++ 11 files changed, 538 insertions(+), 50 deletions(-) create mode 100644 auth_content/migrations/0003_alter_permissionset_geography_and_more.py create mode 100644 auth_content/migrations/0004_alter_permissionset_geography_and_more.py create mode 100644 auth_content/migrations/0005_alter_permissionset_theme.py create mode 100644 auth_content/migrations/0006_alter_permissionset_geography_and_more.py create mode 100644 auth_content/migrations/0007_alter_permissionset_geography_and_more.py create mode 100644 auth_content/migrations/0008_alter_permissionset_topic.py create mode 100644 auth_content/migrations/0009_alter_permissionset_geography_and_more.py diff --git a/auth_content/migrations/0003_alter_permissionset_geography_and_more.py b/auth_content/migrations/0003_alter_permissionset_geography_and_more.py new file mode 100644 index 000000000..e5b4c5e32 --- /dev/null +++ b/auth_content/migrations/0003_alter_permissionset_geography_and_more.py @@ -0,0 +1,71 @@ +# Generated by Django 5.2.9 on 2026-03-19 17:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0002_permissionset_delete_authfeature"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography", + field=models.CharField(blank=True, default="*", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField( + blank=True, + choices=[ + ("*", "* (All)"), + ("United Kingdom", "United Kingdom"), + ("Nation", "Nation"), + ("Lower Tier Local Authority", "Lower Tier Local Authority"), + ("NHS Region", "NHS Region"), + ("NHS Trust", "NHS Trust"), + ("Upper Tier Local Authority", "Upper Tier Local Authority"), + ("UKHSA Region", "UKHSA Region"), + ("UKHSA Super-Region", "UKHSA Super-Region"), + ("Government Office Region", "Government Office Region"), + ("Integrated Care Board", "Integrated Care Board"), + ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), + ("Region", "Region"), + ], + default="*", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="metric", + field=models.IntegerField( + default=-1, help_text="Select a specific metric or * for all metrics" + ), + ), + migrations.AlterField( + model_name="permissionset", + name="sub_theme", + field=models.IntegerField( + default=-1, + help_text="Select a specific sub-theme or * for all sub-themes", + ), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.IntegerField( + default=-1, help_text="Select a specific theme or * for all themes" + ), + ), + migrations.AlterField( + model_name="permissionset", + name="topic", + field=models.IntegerField( + default=-1, help_text="Select a specific topic or * for all topics" + ), + ), + ] diff --git a/auth_content/migrations/0004_alter_permissionset_geography_and_more.py b/auth_content/migrations/0004_alter_permissionset_geography_and_more.py new file mode 100644 index 000000000..27c3e55c4 --- /dev/null +++ b/auth_content/migrations/0004_alter_permissionset_geography_and_more.py @@ -0,0 +1,69 @@ +# Generated by Django 5.2.9 on 2026-03-19 17:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0003_alter_permissionset_geography_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography", + field=models.CharField(choices=[], default="-1", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField( + choices=[ + ("United Kingdom", "United Kingdom"), + ("Nation", "Nation"), + ("Lower Tier Local Authority", "Lower Tier Local Authority"), + ("NHS Region", "NHS Region"), + ("NHS Trust", "NHS Trust"), + ("Upper Tier Local Authority", "Upper Tier Local Authority"), + ("UKHSA Region", "UKHSA Region"), + ("UKHSA Super-Region", "UKHSA Super-Region"), + ("Government Office Region", "Government Office Region"), + ("Integrated Care Board", "Integrated Care Board"), + ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), + ("Region", "Region"), + ], + default="-1", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="metric", + field=models.CharField(choices=[], default="-1", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="sub_theme", + field=models.CharField(choices=[], default="-1", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + choices=[ + (3, "extreme_event"), + (1, "immunisation"), + (2, "infectious_disease"), + (4, "non-communicable"), + ], + default="-1", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="topic", + field=models.CharField(choices=[], default="-1", max_length=255), + ), + ] diff --git a/auth_content/migrations/0005_alter_permissionset_theme.py b/auth_content/migrations/0005_alter_permissionset_theme.py new file mode 100644 index 000000000..51e01f6a3 --- /dev/null +++ b/auth_content/migrations/0005_alter_permissionset_theme.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.9 on 2026-03-19 17:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0004_alter_permissionset_geography_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + choices=[ + ("3", "extreme_event"), + ("1", "immunisation"), + ("2", "infectious_disease"), + ("4", "non-communicable"), + ], + default="-1", + max_length=255, + ), + ), + ] diff --git a/auth_content/migrations/0006_alter_permissionset_geography_and_more.py b/auth_content/migrations/0006_alter_permissionset_geography_and_more.py new file mode 100644 index 000000000..dfb05e52e --- /dev/null +++ b/auth_content/migrations/0006_alter_permissionset_geography_and_more.py @@ -0,0 +1,79 @@ +# Generated by Django 5.2.9 on 2026-03-19 17:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0005_alter_permissionset_theme"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography", + field=models.CharField( + blank=True, choices=[], default="-1", max_length=255 + ), + ), + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField( + blank=True, + choices=[ + ("United Kingdom", "United Kingdom"), + ("Nation", "Nation"), + ("Lower Tier Local Authority", "Lower Tier Local Authority"), + ("NHS Region", "NHS Region"), + ("NHS Trust", "NHS Trust"), + ("Upper Tier Local Authority", "Upper Tier Local Authority"), + ("UKHSA Region", "UKHSA Region"), + ("UKHSA Super-Region", "UKHSA Super-Region"), + ("Government Office Region", "Government Office Region"), + ("Integrated Care Board", "Integrated Care Board"), + ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), + ("Region", "Region"), + ], + default="-1", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="metric", + field=models.CharField( + blank=True, choices=[], default="-1", max_length=255 + ), + ), + migrations.AlterField( + model_name="permissionset", + name="sub_theme", + field=models.CharField( + blank=True, choices=[], default="-1", max_length=255 + ), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("3", "extreme_event"), + ("1", "immunisation"), + ("2", "infectious_disease"), + ("4", "non-communicable"), + ], + default="-1", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="topic", + field=models.CharField( + blank=True, choices=[], default="-1", max_length=255 + ), + ), + ] diff --git a/auth_content/migrations/0007_alter_permissionset_geography_and_more.py b/auth_content/migrations/0007_alter_permissionset_geography_and_more.py new file mode 100644 index 000000000..5bb2a083e --- /dev/null +++ b/auth_content/migrations/0007_alter_permissionset_geography_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.9 on 2026-03-19 17:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0006_alter_permissionset_geography_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography", + field=models.CharField(blank=True, default="-1", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="metric", + field=models.CharField(blank=True, default="-1", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="sub_theme", + field=models.CharField(blank=True, default="-1", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("-1", "* (All themes)"), + ("3", "extreme_event"), + ("1", "immunisation"), + ("2", "infectious_disease"), + ("4", "non-communicable"), + ], + default="-1", + max_length=255, + ), + ), + ] diff --git a/auth_content/migrations/0008_alter_permissionset_topic.py b/auth_content/migrations/0008_alter_permissionset_topic.py new file mode 100644 index 000000000..a1f2df380 --- /dev/null +++ b/auth_content/migrations/0008_alter_permissionset_topic.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.9 on 2026-03-19 17:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0007_alter_permissionset_geography_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="topic", + field=models.CharField(blank=True, default="-1", max_length=255), + ), + ] diff --git a/auth_content/migrations/0009_alter_permissionset_geography_and_more.py b/auth_content/migrations/0009_alter_permissionset_geography_and_more.py new file mode 100644 index 000000000..6d82ff6bb --- /dev/null +++ b/auth_content/migrations/0009_alter_permissionset_geography_and_more.py @@ -0,0 +1,73 @@ +# Generated by Django 5.2.12 on 2026-03-20 11:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0008_alter_permissionset_topic"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField( + blank=True, + choices=[ + ("United Kingdom", "United Kingdom"), + ("Nation", "Nation"), + ("Lower Tier Local Authority", "Lower Tier Local Authority"), + ("NHS Region", "NHS Region"), + ("NHS Trust", "NHS Trust"), + ("Upper Tier Local Authority", "Upper Tier Local Authority"), + ("UKHSA Region", "UKHSA Region"), + ("UKHSA Super-Region", "UKHSA Super-Region"), + ("Government Office Region", "Government Office Region"), + ("Integrated Care Board", "Integrated Care Board"), + ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), + ("Region", "Region"), + ], + default="", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="metric", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="sub_theme", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("", "---------"), + ("-1", "* (All themes)"), + ("3", "extreme_event"), + ("1", "immunisation"), + ("2", "infectious_disease"), + ("4", "non-communicable"), + ], + default="", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="topic", + field=models.CharField(blank=True, default="", max_length=255), + ), + ] diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py index f31e16aad..c985ddc29 100644 --- a/metrics/api/serializers/permission_sets.py +++ b/metrics/api/serializers/permission_sets.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from metrics.data.models.core_models.supporting import SubTheme +from metrics.data.models.core_models.supporting import SubTheme, Topic, Metric from django.db.models import QuerySet @@ -53,6 +53,104 @@ def data(self) -> dict: return {'choices': choices} +class TopicRequestSerializer(serializers.Serializer): + """Fetches and formats topic related to sub-themes based on provided parent sub_theme_id""" + sub_theme_id = serializers.CharField(required=True) + + @property + def topic_manager(self): + """ + Fetch the topic manager from the context if available. + If not get the Manager which has been declared on the `Topic` model. + """ + return self.context.get("topic_manager", Topic.objects) + + def validate_sub_theme_id(self, value): + """Validate sub_theme_id is either wildcard or a valid integer""" + if value == "-1": + return value + + try: + int(value) + return value + except ValueError: + raise serializers.ValidationError( + "sub_theme_id must be a number or '-1'") + + def data(self) -> dict: + """ + Fetch topics from DB and format as response. + + Returns: + Dict with 'choices' key containing list of [id, name] pairs + """ + sub_theme_id = self.validated_data['sub_theme_id'] + + # Handle wildcard + if sub_theme_id == "-1": + return {'choices': [["-1", "* (All sub-themes)"]]} + + # Fetch from interface + parent_sub_theme_id = int(sub_theme_id) + topic_tuples = _queryset_to_id_name_tuples(self.topic_manager.get_filtered_unique_names_related_to_sub_theme( + parent_sub_theme_id)) + + # Format response + print('sub_themes: ', topic_tuples) + choices = [[str(id), name] for id, name in topic_tuples] + + return {'choices': choices} + + +class MetricRequestSerializer(serializers.Serializer): + """Fetches and formats metrics related to topics based on provided parent topic_id""" + topic_id = serializers.CharField(required=True) + + @property + def metric_manager(self): + """ + Fetch the metric manager from the context if available. + If not get the Manager which has been declared on the `Metric` model. + """ + return self.context.get("metric_manager", Metric.objects) + + def validate_topic_id(self, value): + """Validate topic_id is either wildcard or a valid integer""" + if value == "-1": + return value + + try: + int(value) + return value + except ValueError: + raise serializers.ValidationError( + "topic_id must be a number or '-1'") + + def data(self) -> dict: + """ + Fetch topics from DB and format as response. + + Returns: + Dict with 'choices' key containing list of [id, name] pairs + """ + topic_id = self.validated_data['topic_id'] + + # Handle wildcard + if topic_id == "-1": + return {'choices': [["-1", "* (All sub-themes)"]]} + + # Fetch from interface + parent_topic_id = int(topic_id) + metric_tuples = _queryset_to_id_name_tuples(self.metric_manager.get_filtered_unique_names_related_to_parent_topic_id( + parent_topic_id)) + + # Format response + print('metrics: ', metric_tuples) + choices = [[str(id), name] for id, name in metric_tuples] + + return {'choices': choices} + + class PermissionSetResponseSerializer(serializers.Serializer): """Formats the response for choice endpoints""" choices = serializers.ListField( diff --git a/metrics/api/views/permission_sets.py b/metrics/api/views/permission_sets.py index ad2b179a4..49f233064 100644 --- a/metrics/api/views/permission_sets.py +++ b/metrics/api/views/permission_sets.py @@ -5,12 +5,12 @@ from rest_framework.response import Response from rest_framework import status -from metrics.api.serializers.permission_sets import PermissionSetResponseSerializer, SubThemeRequestSerializer +from metrics.api.serializers.permission_sets import MetricRequestSerializer, PermissionSetResponseSerializer, SubThemeRequestSerializer, TopicRequestSerializer PERMISSION_SETS_API_TAG = "data hierarchy" -@extend_schema(request=PermissionSetResponseSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) +@extend_schema(request=SubThemeRequestSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) class SubThemesByThemeView(APIView): """Get sub-themes filtered by theme ID""" permission_classes = [] @@ -22,59 +22,27 @@ def get(self, request, theme_id, *args, **kwargs): return Response(serializer.data()) -@extend_schema(tags=[PERMISSION_SETS_API_TAG]) +@extend_schema(request=TopicRequestSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) class TopicsBySubThemeView(APIView): """Get topics filtered by sub-theme ID""" permission_classes = [] - def get(self, request, *args, **kwargs): - """API endpoint to fetch topics based on selected sub-theme.""" - sub_theme_id = self.kwargs['sub_theme_id'] - - if not sub_theme_id: - return Response({'error': 'sub_theme_id required'}, status=status.HTTP_400_BAD_REQUEST) - - if sub_theme_id == "-1": - return Response({'choices': [["-1", "* (All topics)"]]}) - - try: - # Your interface call here - return Response({ - 'choices': [ - ['10', 'COVID-19'], - ['11', 'Influenza'], - ['12', 'Measles'] - ], - 'sub_theme_id_received': sub_theme_id - }) - except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + def get(self, request, sub_theme_id, *args, **kwargs): + """API endpoint to fetch sub-themes based on selected theme.""" + serializer = TopicRequestSerializer( + data={'sub_theme_id': sub_theme_id}) + serializer.is_valid(raise_exception=True) + return Response(serializer.data()) -@extend_schema(tags=[PERMISSION_SETS_API_TAG]) +@extend_schema(request=MetricRequestSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) class MetricsByTopicView(APIView): """Get metrics filtered by topic ID""" permission_classes = [] - def get(self, request, *args, **kwargs): - """API endpoint to fetch metrics based on selected topic.""" - topic_id = self.kwargs['topic_id'] - - if not topic_id: - return Response({'error': 'topic_id required'}, status=status.HTTP_400_BAD_REQUEST) - - if topic_id == "-1": - return Response({'choices': [["-1", "* (All metrics)"]]}) - - try: - # Your interface call here - return Response({ - 'choices': [ - ['100', 'Cases per 100k'], - ['101', 'Deaths total'], - ['102', 'Hospital admissions'] - ], - 'topic_id_received': topic_id - }) - except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + def get(self, request, topic_id, *args, **kwargs): + """API endpoint to fetch sub-themes based on selected theme.""" + serializer = MetricRequestSerializer( + data={'topic_id': topic_id}) + serializer.is_valid(raise_exception=True) + return Response(serializer.data()) diff --git a/metrics/data/managers/core_models/metric.py b/metrics/data/managers/core_models/metric.py index 03a713e63..26f8e8c93 100644 --- a/metrics/data/managers/core_models/metric.py +++ b/metrics/data/managers/core_models/metric.py @@ -44,7 +44,8 @@ def get_all_unique_change_type_names(self) -> models.QuerySet: `` """ return self.get_all_unique_names().filter( - models.Q(name__icontains="change") & ~models.Q(name__icontains="percent") + models.Q(name__icontains="change") & ~models.Q( + name__icontains="percent") ) def get_all_unique_percent_change_type_names(self) -> models.QuerySet: @@ -82,6 +83,16 @@ def get_all_headline_names(self) -> models.QuerySet: """ return self.get_all_unique_names().filter(metric_group__name="headline") + def get_filtered_unique_names_related_to_parent_topic_id(self, parent_topic_id) -> models.QuerySet: + """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.filter(topic_id=parent_topic_id).values('id', 'name').distinct() + class MetricManager(models.Manager): """Custom model manager class for the `Metric` model.""" @@ -155,3 +166,13 @@ def get_all_headline_names(self) -> MetricQuerySet: """ return self.get_queryset().get_all_headline_names() + + def get_filtered_unique_names_related_to_parent_topic_id(self, parent_topic_id: str) -> MetricQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.get_queryset().get_filtered_unique_names_related_to_parent_topic_id(parent_topic_id=parent_topic_id) diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index 32574d116..ce992cafa 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -45,6 +45,16 @@ def get_by_name(self, name: str) -> "Topic": """ return self.get(name=name) + def get_filtered_unique_names_related_to_sub_theme(self, parent_sub_theme_id) -> models.QuerySet: + """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.filter(sub_theme_id=parent_sub_theme_id).values('id', 'name').distinct() + class TopicManager(models.Manager): """Custom model manager class for the `Metric` model.""" @@ -93,3 +103,13 @@ def get_by_name(self, name: str) -> "Topic": """ return self.get_queryset().get_by_name(name=name) + + def get_filtered_unique_names_related_to_sub_theme(self, parent_sub_theme_id: str) -> TopicQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.get_queryset().get_filtered_unique_names_related_to_sub_theme(parent_sub_theme_id=parent_sub_theme_id) From fd8395384e04979c497131df9e92a846a06baa54 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Fri, 20 Mar 2026 16:35:41 +0000 Subject: [PATCH 033/186] CDD-3175: wired up the logic for selecting geography types --- auth_content/models.py | 4 +-- .../field_choices_callables.py | 25 +++++++++++++++++++ cms/metrics_interface/interface.py | 12 +++++++++ .../managers/core_models/geography_type.py | 23 +++++++++++++++++ metrics/data/managers/core_models/theme.py | 2 +- 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/auth_content/models.py b/auth_content/models.py index 97a9ef816..b0e87ec6a 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -3,7 +3,7 @@ from django.db import models from wagtail.admin.panels import FieldPanel -from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids +from cms.metrics_interface.field_choices_callables import get_all_geography_type_names_and_ids, get_all_theme_names_and_ids from validation.enums.geographies_enums import GeographyType from wagtail.admin.forms import WagtailAdminModelForm @@ -62,7 +62,7 @@ class PermissionSet(models.Model): metric = models.CharField( max_length=255, blank=True, default="") geography_type = models.CharField(max_length=255, choices=[( - e.value, e.value.replace("_", " ")) for e in GeographyType], blank=True, default="") + "", "---------"), ("-1", "* (All themes)")] + get_all_geography_type_names_and_ids(), blank=True, default="") geography = models.CharField( max_length=255, blank=True, default="") diff --git a/cms/metrics_interface/field_choices_callables.py b/cms/metrics_interface/field_choices_callables.py index 4868bc9da..e321065c2 100644 --- a/cms/metrics_interface/field_choices_callables.py +++ b/cms/metrics_interface/field_choices_callables.py @@ -558,6 +558,31 @@ def get_all_geography_type_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: ) +def get_all_geography_type_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `geography_type` fields of the CMS blocks on permission sets. + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + This means that we don't need to create a new migration + whenever a new `Geography` is added to that table. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of geography type names. + Examples: + [(, "Nation"), ...] + + """ + metrics_interface = MetricsAPIInterface() + geography_choices = metrics_interface.get_all_geography_type_names_and_ids() + print(geography_choices) + return _build_id_name_tuple_choices( + choices=geography_choices + ) + + def get_all_sex_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `sex` fields of the CMS blocks. diff --git a/cms/metrics_interface/interface.py b/cms/metrics_interface/interface.py index 4d0d51c48..8b96dd393 100644 --- a/cms/metrics_interface/interface.py +++ b/cms/metrics_interface/interface.py @@ -420,3 +420,15 @@ def get_geography_code_for_geography( return self.geography_manager.get_geography_code_for_geography( geography=geography, geography_type=geography_type ) + + def get_all_geography_type_names_and_ids(self) -> QuerySet: + """Gets all available geography_type names as a flat list queryset. + Note this is achieved by delegating the call to the `GeographyTypeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual geography_type names: + Examples: + `` + + """ + return self.geography_type_manager.get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/geography_type.py b/metrics/data/managers/core_models/geography_type.py index 51dec8020..87d893bc5 100644 --- a/metrics/data/managers/core_models/geography_type.py +++ b/metrics/data/managers/core_models/geography_type.py @@ -23,6 +23,18 @@ def get_all_names(self) -> models.QuerySet: """ return self.all().values_list("name", flat=True).order_by("name") + def get_all_names_and_ids(self) -> models.QuerySet: + """Gets all available geography_type names as a flat list queryset. + + Returns: + QuerySet: A queryset of the individual geography_type names + ordered in descending ordering starting from A -> Z: + Examples: + `` + + """ + return self.all().values("id", "name") + class GeographyTypeManager(models.Manager): """Custom model manager class for the `GeographyType` model.""" @@ -40,3 +52,14 @@ def get_all_names(self) -> GeographyTypeQuerySet: """ return self.get_queryset().get_all_names() + + def get_all_names_and_ids(self) -> GeographyTypeQuerySet: + """Gets all available geography_type names as a flat list queryset. + + Returns: + QuerySet: A queryset of the individual geography_type names: + Examples: + `` + + """ + return self.get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/theme.py b/metrics/data/managers/core_models/theme.py index b5c416167..d3852c010 100644 --- a/metrics/data/managers/core_models/theme.py +++ b/metrics/data/managers/core_models/theme.py @@ -55,6 +55,6 @@ def get_all_choices(self) -> ThemeQuerySet: Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ return self .get_queryset().get_all_choices() From 6d37e211b8733b0b0aca6467f1be46ab79af8f6a Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Mon, 23 Mar 2026 12:38:42 +0000 Subject: [PATCH 034/186] CDD-3175: update permission set for geographies --- auth_content/static/js/child_theme.js | 66 +++++++++++++- cms/metrics_interface/interface.py | 2 +- metrics/api/serializers/geographies.py | 87 ++++++++++++++++++- metrics/api/serializers/permission_sets.py | 1 + metrics/api/urls_construction.py | 5 +- metrics/api/views/geographies.py | 16 +++- .../data/managers/core_models/geography.py | 42 +++++++++ 7 files changed, 213 insertions(+), 6 deletions(-) diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 89b610ce7..4f0a5499f 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -1,6 +1,6 @@ (function () { "use strict"; - let theme, subTheme, topic, metric; + let theme, subTheme, topic, metric, geographyType, geography; /** * Generic function to fetch choices from the API @@ -29,6 +29,29 @@ return []; } } + async function fetchGeographies(endpoint, dataItemId) { + console.log("selected geography type: ", dataItemId); + console.log("selected endpoint: ", endpoint); + try { + const url = `/api/permission-set/${endpoint}/${dataItemId}`; + console.log(`Fetching from: ${url}`); + + const response = await fetch(url); + + if (!response.ok) { + const errorData = await response.json(); + console.error(`API error: ${errorData.error || "Unknown error"}`); + return []; + } + + const data = await response.json(); + console.log(`Received data from ${endpoint}:`, data); + return data.choices || []; + } catch (error) { + console.error(`Error fetching ${endpoint}:`, error); + return []; + } + } /** * Generic function to populate a dropdown with choices @@ -199,6 +222,34 @@ clearDropdown(metric, "No metrics available"); } } + async function handleGeographyTypeChange() { + const geographyTypeValue = geographyType.value; + console.log("geography type changed to:", geographyTypeValue); + + if (!geographyTypeValue || geographyTypeValue === "") { + // No topic selected - clear metrics + console.log("No geography type selected"); + clearDropdown(geography, "Select geography type first"); + return; + } + + if (geographyTypeValue === "-1") { + // Wildcard topic = cascade wildcard to metrics + console.log("Wildcard geography selected - cascading to metrics"); + setToWildcard(metric, "* (All geographies)"); + return; + } + clearDropdown(geography, "--------"); + + // Fetch and populate metrics + const choices = await fetchGeographies("geographies", geographyTypeValue); + + if (choices.length > 0) { + populateDropdown(geography, choices); + } else { + clearDropdown(geography, "No geographies available"); + } + } /** * Initialize the cascading dropdowns @@ -211,9 +262,18 @@ subTheme = document.querySelector('select[name="sub_theme"]'); topic = document.querySelector('select[name="topic"]'); metric = document.querySelector('select[name="metric"]'); + geographyType = document.querySelector('select[name="geography_type"]'); + geography = document.querySelector('select[name="geography"]'); // Exit if not on permission set page - if (!theme || !subTheme || !topic || !metric) { + if ( + !theme || + !subTheme || + !topic || + !metric || + !geographyType || + !geography + ) { console.log("Permission set dropdowns not found on this page"); return; } @@ -222,11 +282,13 @@ clearDropdown(subTheme, "--------"); clearDropdown(topic, "--------"); clearDropdown(metric, "--------"); + clearDropdown(geography, "--------"); // Add event listeners theme.addEventListener("change", handleThemeChange); subTheme.addEventListener("change", handleSubThemeChange); topic.addEventListener("change", handleTopicChange); + geographyType.addEventListener("change", handleGeographyTypeChange); console.log("Event listeners attached"); diff --git a/cms/metrics_interface/interface.py b/cms/metrics_interface/interface.py index 8b96dd393..f26103aa7 100644 --- a/cms/metrics_interface/interface.py +++ b/cms/metrics_interface/interface.py @@ -349,7 +349,7 @@ def get_all_geography_names_and_codes_by_geography_type( ) -> QuerySet: """Gets all geography names and codes for a particular geography type, for example `Nation` or `Government Office Region`. - Note this is achived by delegating the call to the `GeographyManager` from Metrics API + Note this is achieved by delegating the call to the `GeographyManager` from Metrics API Returns QuerySet: A queryset of the geography_code and geography_names fields as a list of tuples. diff --git a/metrics/api/serializers/geographies.py b/metrics/api/serializers/geographies.py index a459eaa62..b0e45bf83 100644 --- a/metrics/api/serializers/geographies.py +++ b/metrics/api/serializers/geographies.py @@ -2,6 +2,7 @@ from rest_framework import serializers +from metrics.api.serializers.permission_sets import _queryset_to_id_name_tuples from metrics.data.in_memory_models.geography_relationships.handlers import ( get_upstream_relationships_for_geography, ) @@ -11,6 +12,7 @@ Geography, Topic, ) +from django.db.models import QuerySet GEOGRAPHY_TYPE_RESULT = dict[str, list[dict[str, str]]] @@ -61,7 +63,8 @@ def data(self) -> list[GEOGRAPHY_TYPE_RESULT]: """ topic: str = self.validated_data["topic"] queryset: CoreTimeSeriesQuerySet = ( - self.core_time_series_manager.get_available_geographies(topic=topic) + self.core_time_series_manager.get_available_geographies( + topic=topic) ) return _serialize_queryset(queryset=queryset) @@ -186,6 +189,18 @@ class GeographiesResponseSerializer(serializers.ListSerializer): child = GeographiesResponseListSerializer() +class GeographyChoicesResponseSerializer(serializers.Serializer): + """Formats the response for choice endpoints""" + choices = serializers.ListField( + child=serializers.ListField( + child=serializers.CharField(), + min_length=2, + max_length=2 + ), + help_text="List of [id, name] pairs for dropdown options" + ) + + MISSING_FIELD_ERROR_MESSAGE = "Either 'topic' or 'geography_type' must be provided." SINGLE_FIELD_ONLY_ERROR_MESSAGE = ( "Only one of 'topic' or 'geography_type' should be provided, not both." @@ -208,3 +223,73 @@ def validate(cls, attrs: dict[str, str]) -> dict[str, str]: raise serializers.ValidationError(SINGLE_FIELD_ONLY_ERROR_MESSAGE) return attrs + + +class GeographyByGeographyTypeRequestSerializer(serializers.Serializer): + geography_type_id = serializers.CharField(required=True) + + @property + def geography_manager(self): + return self.context.get("geography_manager", Geography.objects) + + def validate_geography_type_id(self, value): + """Validate theme_id is either wildcard or a valid integer""" + if value == "-1": + return value + + try: + int(value) + return value + except ValueError: + raise serializers.ValidationError( + "Geography Type must be a number or '-1'") + + def data(self) -> dict: + """ + Fetch sub-themes from DB and format as response. + + Returns: + Dict with 'choices' key containing list of [id, name] pairs + """ + geography_type_id = self.validated_data['geography_type_id'] + + # Handle wildcard + if geography_type_id == "-1": + return {'choices': [["-1", "* (All geographies)"]]} + + # Fetch from interface + parent_geography_type_id = int(geography_type_id) + geographies = self.geography_manager.get_geography_codes_and_names_by_geography_type_id( + parent_geography_type_id) + print(geographies) + geography_names_and_codes_tuples = _queryset_to_geography_code_name_tuples( + geographies) + + # Format response + print('geography data: ', geography_names_and_codes_tuples) + choices = [[str(geography_code), name] + for geography_code, name in geography_names_and_codes_tuples] + + return {'choices': choices} + + +def _queryset_to_geography_code_name_tuples(queryset: QuerySet) -> list[tuple[str, str]]: + """ + Convert a QuerySet with 'id' and 'name' fields to a list of tuples. + + Args: + queryset: QuerySet containing dicts with 'id' and 'name' keys + + Returns: + List of (id, name) tuples + + Examples: + >>> qs = Model.objects.values('id', 'name') + >>> queryset_to_id_name_tuples(qs) + [(1, "item1"), (2, "item2")] + """ + print('received queryset: ', queryset) + + for item in queryset: + print('item: ', item) + return [(item['geography_code'], item['name']) for item in queryset] diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py index c985ddc29..f57f352b9 100644 --- a/metrics/api/serializers/permission_sets.py +++ b/metrics/api/serializers/permission_sets.py @@ -178,4 +178,5 @@ def _queryset_to_id_name_tuples(queryset: QuerySet) -> list[tuple[int, str]]: >>> queryset_to_id_name_tuples(qs) [(1, "item1"), (2, "item2")] """ + print('received queryset: ', queryset) return [(item['id'], item['name']) for item in queryset] diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index e7d741d3b..5519e23d2 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -36,7 +36,7 @@ ) from metrics.api.views.charts import DualCategoryChartsView from metrics.api.views.charts.subplot_charts import SubplotChartsView -from metrics.api.views.geographies import GeographiesView, GeographiesViewDeprecated +from metrics.api.views.geographies import GeographiesByGeographyTypeView, GeographiesView, GeographiesViewDeprecated from metrics.api.views.health import InternalHealthView from metrics.api.views.maps import MapsView from metrics.api.views.permission_sets import MetricsByTopicView, SubThemesByThemeView, TopicsBySubThemeView @@ -137,6 +137,8 @@ def construct_public_api_urlpatterns( TopicsBySubThemeView.as_view(), name='get_topics'), path(f"{API_PREFIX}permission-set/metrics/", MetricsByTopicView.as_view(), name='get_metrics'), + path(f"{API_PREFIX}permission-set/geographies/", + GeographiesByGeographyTypeView.as_view(), name='get_geographies'), path(f"{API_PREFIX}menus/v1", MenuView.as_view()), path(f"{API_PREFIX}alerts/v1/heat", heat_alert_list, name="heat-alerts-list"), @@ -164,6 +166,7 @@ def construct_public_api_urlpatterns( GeographiesViewDeprecated.as_view(), ), re_path(f"^{API_PREFIX}geographies/v3", GeographiesView.as_view()), + re_path(f"^{API_PREFIX}headlines/v3", HeadlinesView.as_view()), re_path(f"^{API_PREFIX}maps/v1", MapsView.as_view()), re_path(f"^{API_PREFIX}tables/v4", TablesView.as_view()), diff --git a/metrics/api/views/geographies.py b/metrics/api/views/geographies.py index ed3ac8397..eb90d8270 100644 --- a/metrics/api/views/geographies.py +++ b/metrics/api/views/geographies.py @@ -12,6 +12,8 @@ GeographiesRequestSerializer, GeographiesRequestSerializerDeprecated, GeographiesResponseSerializer, + GeographyByGeographyTypeRequestSerializer, + GeographyChoicesResponseSerializer, ) GEOGRAPHIES_API_TAG = "geographies" @@ -73,7 +75,8 @@ def get(self, request, *args, **kwargs) -> Response: If neither are provided **or** both are provided, then a 400 `Bad Request` 400 will be returned. """ - request_serializer = GeographiesRequestSerializer(data=request.query_params) + request_serializer = GeographiesRequestSerializer( + data=request.query_params) request_serializer.is_valid(raise_exception=True) payload = request_serializer.data @@ -104,3 +107,14 @@ def _handle_geographies_by_geography_type( serializer = GeographiesForGeographyTypeSerializer(data=payload) serializer.is_valid(raise_exception=True) return serializer.data() + + +@extend_schema(request=GeographyByGeographyTypeRequestSerializer, tags=[GEOGRAPHIES_API_TAG], responses={HTTPStatus.OK.value: GeographyChoicesResponseSerializer}) +class GeographiesByGeographyTypeView(APIView): + permission_classes = [] + + def get(self, request, geography_type_id, *args, **kwargs): + serializer = GeographyByGeographyTypeRequestSerializer( + data={'geography_type_id': geography_type_id}) + serializer.is_valid(raise_exception=True) + return Response(serializer.data()) diff --git a/metrics/data/managers/core_models/geography.py b/metrics/data/managers/core_models/geography.py index 9ff8fad09..ec1ea2a93 100644 --- a/metrics/data/managers/core_models/geography.py +++ b/metrics/data/managers/core_models/geography.py @@ -80,6 +80,28 @@ def get_geography_codes_and_names_by_geography_type( .order_by("geography_code") ) + def get_geography_codes_and_names_by_geography_type_id( + self, + geography_type_id: int, + ): + """Gets all available geography codes and names for the given `geography_type_name` + + Args: + geography_type_name: string representation of `geography_type_name` + + Returns: + QuerySet: A queryset of the individual geography codes + which are related to the given geography_type: + Examples: + `` + + """ + return ( + self.filter(geography_type_id=geography_type_id) + .values("geography_code", "name") + .order_by("geography_code") + ) + def get_geographies_by_geography_type( self, geography_type_name: str, @@ -168,6 +190,26 @@ def get_geography_codes_and_names_by_geography_type( geography_type_name=geography_type_name ) + def get_geography_codes_and_names_by_geography_type_id( + self, + geography_type_id: str, + ): + """Gets all available geography codes and names for a give `geography_type` + + Args: + geography_type_name: string representation of `geography_type_name` + + Returns: + QuerySet: A queryset of the individual geography codes + which are related to the given geography_type: + Examples: + `` + + """ + return self.get_queryset().get_geography_codes_and_names_by_geography_type_id( + geography_type_id=geography_type_id + ) + def get_geographies_by_geography_type( self, geography_type_name: str, From ad8123c13a6e97e3538baf74b9e733b90c5a2d2d Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Mon, 23 Mar 2026 14:43:58 +0000 Subject: [PATCH 035/186] CDD-3175: updates for limiting the creation of duplicate permission sets --- ...r_permissionset_geography_type_and_more.py | 46 +++++++++++++++++++ auth_content/models.py | 11 ++++- auth_content/static/js/child_theme.js | 14 +++--- 3 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py diff --git a/auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py b/auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py new file mode 100644 index 000000000..b737436f2 --- /dev/null +++ b/auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.9 on 2026-03-23 14:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0009_alter_permissionset_geography_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField( + blank=True, + choices=[ + ("", "---------"), + ("-1", "* (All geography-types)"), + ("1", "Region"), + ("2", "Upper Tier Local Authority"), + ("3", "Nation"), + ("4", "Lower Tier Local Authority"), + ("5", "Government Office Region"), + ("6", "United Kingdom"), + ], + default="", + max_length=255, + ), + ), + migrations.AddConstraint( + model_name="permissionset", + constraint=models.UniqueConstraint( + fields=( + "theme", + "sub_theme", + "topic", + "metric", + "geography_type", + "geography", + ), + name="unique_permission_set", + ), + ), + ] diff --git a/auth_content/models.py b/auth_content/models.py index b0e87ec6a..ec3543d9e 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -62,7 +62,7 @@ class PermissionSet(models.Model): metric = models.CharField( max_length=255, blank=True, default="") geography_type = models.CharField(max_length=255, choices=[( - "", "---------"), ("-1", "* (All themes)")] + get_all_geography_type_names_and_ids(), blank=True, default="") + "", "---------"), ("-1", "* (All geography-types)")] + get_all_geography_type_names_and_ids(), blank=True, default="") geography = models.CharField( max_length=255, blank=True, default="") @@ -77,6 +77,15 @@ class PermissionSet(models.Model): FieldPanel("geography"), ] + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['theme', 'sub_theme', 'topic', + 'metric', 'geography_type', 'geography'], + name='unique_permission_set' + ) + ] + def __str__(self): if self.theme and self.theme != "" and self.theme != "-1": return f"Permission Set - Theme {self.theme}" diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 4f0a5499f..2aab107c7 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -58,7 +58,7 @@ * @param {HTMLSelectElement} dropdown - The select element to populate * @param {Array} choices - Array of [id, name] tuples */ - function populateDropdown(dropdown, choices) { + function populateDropdown(dropdown, choices, wildcardValue = "* All Items") { dropdown.disabled = false; dropdown.innerHTML = ""; @@ -71,7 +71,7 @@ //dropdown wildcard choice const wildcardOption = document.createElement("option"); wildcardOption.value = "-1"; - wildcardOption.textContent = "* (All items)"; + wildcardOption.textContent = wildcardValue; dropdown.appendChild(wildcardOption); choices.forEach(([id, name]) => { @@ -148,7 +148,7 @@ const choices = await fetchChoices("subthemes", themeValue); if (choices.length > 0) { - populateDropdown(subTheme, choices); + populateDropdown(subTheme, choices, "* All sub-themes"); } else { clearDropdown(subTheme, "No sub-themes available"); } @@ -184,7 +184,7 @@ const choices = await fetchChoices("topics", subThemeValue); if (choices.length > 0) { - populateDropdown(topic, choices); + populateDropdown(topic, choices, "* All topics"); } else { clearDropdown(topic, "No topics available"); } @@ -217,7 +217,7 @@ const choices = await fetchChoices("metrics", topicValue); if (choices.length > 0) { - populateDropdown(metric, choices); + populateDropdown(metric, choices, "* All metrics"); } else { clearDropdown(metric, "No metrics available"); } @@ -236,7 +236,7 @@ if (geographyTypeValue === "-1") { // Wildcard topic = cascade wildcard to metrics console.log("Wildcard geography selected - cascading to metrics"); - setToWildcard(metric, "* (All geographies)"); + setToWildcard(geography, "* (All geographies)"); return; } clearDropdown(geography, "--------"); @@ -245,7 +245,7 @@ const choices = await fetchGeographies("geographies", geographyTypeValue); if (choices.length > 0) { - populateDropdown(geography, choices); + populateDropdown(geography, choices, "* All geographies"); } else { clearDropdown(geography, "No geographies available"); } From 60d108f27b335353480ea4bce52d6bcd943f5ca7 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Mon, 23 Mar 2026 15:56:20 +0000 Subject: [PATCH 036/186] CDD-3175: updates for handling the naming of permission sets --- .../migrations/0011_permissionset_name.py | 23 +++ ..._alter_permissionset_geography_and_more.py | 67 ++++++++ auth_content/models.py | 150 ++++++++++++++++-- auth_content/static/js/child_theme.js | 132 +++++++++++---- .../field_choices_callables.py | 74 ++++++++- cms/metrics_interface/interface.py | 48 +++++- metrics/data/managers/core_models/metric.py | 20 +++ .../data/managers/core_models/sub_theme.py | 20 +++ metrics/data/managers/core_models/theme.py | 6 +- metrics/data/managers/core_models/topic.py | 20 +++ 10 files changed, 516 insertions(+), 44 deletions(-) create mode 100644 auth_content/migrations/0011_permissionset_name.py create mode 100644 auth_content/migrations/0012_alter_permissionset_geography_and_more.py diff --git a/auth_content/migrations/0011_permissionset_name.py b/auth_content/migrations/0011_permissionset_name.py new file mode 100644 index 000000000..8aa52066e --- /dev/null +++ b/auth_content/migrations/0011_permissionset_name.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.9 on 2026-03-23 14:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0010_alter_permissionset_geography_type_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="permissionset", + name="name", + field=models.CharField( + blank=True, + editable=False, + help_text="Auto-generated display name", + max_length=500, + ), + ), + ] diff --git a/auth_content/migrations/0012_alter_permissionset_geography_and_more.py b/auth_content/migrations/0012_alter_permissionset_geography_and_more.py new file mode 100644 index 000000000..5f2cb0caa --- /dev/null +++ b/auth_content/migrations/0012_alter_permissionset_geography_and_more.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.9 on 2026-03-23 15:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0011_permissionset_name"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography", + field=models.CharField(default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField( + choices=[ + ("", "---------"), + ("-1", "* (All geography-types)"), + ("1", "Region"), + ("2", "Upper Tier Local Authority"), + ("3", "Nation"), + ("4", "Lower Tier Local Authority"), + ("5", "Government Office Region"), + ("6", "United Kingdom"), + ], + default="", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="metric", + field=models.CharField(default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="sub_theme", + field=models.CharField(default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + choices=[ + ("", "---------"), + ("-1", "* (All themes)"), + ("3", "extreme_event"), + ("1", "immunisation"), + ("2", "infectious_disease"), + ("4", "non-communicable"), + ], + default="", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="topic", + field=models.CharField(default="", max_length=255), + ), + ] diff --git a/auth_content/models.py b/auth_content/models.py index ec3543d9e..3307d848d 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -1,6 +1,7 @@ from django import forms from django.db import models +from django.core.exceptions import ValidationError from wagtail.admin.panels import FieldPanel from cms.metrics_interface.field_choices_callables import get_all_geography_type_names_and_ids, get_all_theme_names_and_ids @@ -51,20 +52,59 @@ def __init__(self, *args, **kwargs): choices=[("-1", "Select geography type first")]) ) + def clean(self): + """Validate that this permission set doesn't already exist""" + cleaned_data = super().clean() + + theme = cleaned_data.get('theme') + sub_theme = cleaned_data.get('sub_theme') + topic = cleaned_data.get('topic') + metric = cleaned_data.get('metric') + geography_type = cleaned_data.get('geography_type') + geography = cleaned_data.get('geography') + + # Check if this combination already exists (excluding current instance when editing) + queryset = PermissionSet.objects.filter( + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography_type=geography_type, + geography=geography + ) + + # Exclude current instance when editing + if self.instance.pk: + queryset = queryset.exclude(pk=self.instance.pk) + + if queryset.exists(): + raise ValidationError( + "A permission set with this exact combination already exists. " + "Please modify your selection to create a unique permission set." + ) + + return cleaned_data + class PermissionSet(models.Model): + name = models.CharField( + max_length=500, + blank=True, + editable=False, # Don't show in admin form + help_text="Auto-generated display name" + ) theme = models.CharField( - max_length=255, choices=[("", "---------"), ("-1", "* (All themes)")] + get_all_theme_names_and_ids(), blank=True, default="") + max_length=255, choices=[("", "---------"), ("-1", "* (All themes)")] + get_all_theme_names_and_ids(), blank=False, default="") sub_theme = models.CharField( - max_length=255, blank=True, default="") + max_length=255, blank=False, default="") topic = models.CharField(max_length=255, - blank=True, default="") + blank=False, default="") metric = models.CharField( - max_length=255, blank=True, default="") + max_length=255, blank=False, default="") geography_type = models.CharField(max_length=255, choices=[( - "", "---------"), ("-1", "* (All geography-types)")] + get_all_geography_type_names_and_ids(), blank=True, default="") + "", "---------"), ("-1", "* (All geography-types)")] + get_all_geography_type_names_and_ids(), blank=False, default="") geography = models.CharField( - max_length=255, blank=True, default="") + max_length=255, blank=False, default="") base_form_class = PermissionSetForm @@ -86,9 +126,97 @@ class Meta: ) ] + def save(self, *args, **kwargs): + """Generate the display name before saving""" + self.name = self._generate_display_name() + super().save(*args, **kwargs) + + def _generate_display_name(self): + """ + Generate display name using the selected dropdown labels. + This uses the form's choice labels, not database lookups. + """ + from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids + + parts = [] + + # Theme + if self.theme == "-1": + parts.append("Theme: * (All)") + elif self.theme: + theme_name = self._get_choice_label('theme', self.theme) + parts.append(f"Theme: {theme_name}") + + # Sub-theme (we'll need to store these lookups) + if self.sub_theme == "-1": + parts.append("Sub-theme: * (All)") + elif self.sub_theme: + sub_theme_name = self._get_choice_label( + 'sub-theme', self.sub_theme) + parts.append(f"Sub-theme ID: {sub_theme_name}") + + # Topic + if self.topic == "-1": + parts.append("Topic: * (All)") + elif self.topic: + topic_name = self._get_choice_label( + 'topic', self.topic) + parts.append(f"Topic: {topic_name}") + + # Metric + if self.metric == "-1": + parts.append("Metric: * (All)") + elif self.metric: + metric_name = self._get_choice_label( + 'metric', self.metric) + parts.append(f"Metric: {metric_name}") + + # Geography type (we have the label from enum) + if self.geography_type == "-1": + parts.append("Geography Type: * (All)") + elif self.geography_type: + geo_type_label = self.geography_type.replace('_', ' ').title() + parts.append(f"Geography Type: {geo_type_label}") + + # Geography + if self.geography == "-1": + parts.append("Geography: * (All)") + elif self.geography: + parts.append(f"Geography ID: {self.geography}") + + return " | ".join(parts) if parts else "Permission Set (Not Configured)" + + def _get_choice_label(self, field_name, value): + """Get the display label for a choice field""" + if field_name == 'theme': + from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids + choices = get_all_theme_names_and_ids() + for choice_value, choice_label in choices: + if choice_value == value: + return choice_label + + if field_name == 'sub-theme': + from cms.metrics_interface.field_choices_callables import get_all_sub_theme_names_and_ids + choices = get_all_sub_theme_names_and_ids() + for choice_value, choice_label in choices: + if choice_value == value: + return choice_label + + if field_name == 'topic': + from cms.metrics_interface.field_choices_callables import get_all_topic_names_and_ids + choices = get_all_topic_names_and_ids() + for choice_value, choice_label in choices: + if choice_value == value: + return choice_label + + if field_name == 'metric': + from cms.metrics_interface.field_choices_callables import get_all_metric_names_and_ids + choices = get_all_metric_names_and_ids() + for choice_value, choice_label in choices: + if choice_value == value: + return choice_label + + return value # Fallback to ID if not found + def __str__(self): - if self.theme and self.theme != "" and self.theme != "-1": - return f"Permission Set - Theme {self.theme}" - elif self.theme == "-1": - return "Permission Set - All Themes" - return "Permission Set - Not Configured" + return self.name if self.name else f"Permission Set {self.id}" diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 2aab107c7..a2e22066c 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -59,6 +59,7 @@ * @param {Array} choices - Array of [id, name] tuples */ function populateDropdown(dropdown, choices, wildcardValue = "* All Items") { + const currentValue = dropdown.value; dropdown.disabled = false; dropdown.innerHTML = ""; @@ -80,6 +81,10 @@ option.textContent = name; dropdown.appendChild(option); }); + + if (currentValue) { + dropdown.value = currentValue; + } } function clearDropdown(dropdown, message = "Select parent first") { @@ -251,6 +256,89 @@ } } + /** + * Initialize dropdowns for edit mode + * Loads the dropdown options based on saved values + */ + async function initializeEditMode() { + console.log("Initializing edit mode..."); + + // Store original values before we start manipulating dropdowns + const savedTheme = theme.value; + const savedSubTheme = subTheme.value; + const savedTopic = topic.value; + const savedMetric = metric.value; + const savedGeographyType = geographyType.value; + const savedGeography = geography.value; + + console.log("Saved values:", { + theme: savedTheme, + subTheme: savedSubTheme, + topic: savedTopic, + metric: savedMetric, + geographyType: savedGeographyType, + geography: savedGeography, + }); + + // If theme has a value (not wildcard, not empty), load sub-themes + if (savedTheme && savedTheme !== "" && savedTheme !== "-1") { + console.log(`Loading sub-themes for theme ${savedTheme}...`); + const subThemeChoices = await fetchChoices("subthemes", savedTheme); + if (subThemeChoices.length > 0) { + populateDropdown(subTheme, subThemeChoices, "* (All sub-themes)"); + subTheme.value = savedSubTheme; // Restore selection + } + + // If sub-theme has a value, load topics + if (savedSubTheme && savedSubTheme !== "" && savedSubTheme !== "-1") { + console.log(`Loading topics for sub-theme ${savedSubTheme}...`); + const topicChoices = await fetchChoices("topics", savedSubTheme); + if (topicChoices.length > 0) { + populateDropdown(topic, topicChoices, "* (All topics)"); + topic.value = savedTopic; // Restore selection + } + + // If topic has a value, load metrics + if (savedTopic && savedTopic !== "" && savedTopic !== "-1") { + console.log(`Loading metrics for topic ${savedTopic}...`); + const metricChoices = await fetchChoices("metrics", savedTopic); + if (metricChoices.length > 0) { + populateDropdown(metric, metricChoices, "* (All metrics)"); + metric.value = savedMetric; // Restore selection + } + } + } + } else if (savedTheme === "-1") { + // Theme is wildcard, cascade to children + setToWildcard(subTheme, "* (All sub-themes)"); + setToWildcard(topic, "* (All topics)"); + setToWildcard(metric, "* (All metrics)"); + } + + // Handle geography independently + if ( + savedGeographyType && + savedGeographyType !== "" && + savedGeographyType !== "-1" + ) { + console.log( + `Loading geographies for geography type ${savedGeographyType}...`, + ); + const geographyChoices = await fetchChoices( + "geographies", + savedGeographyType, + ); + if (geographyChoices.length > 0) { + populateDropdown(geography, geographyChoices, "* (All geographies)"); + geography.value = savedGeography; // Restore selection + } + } else if (savedGeographyType === "-1") { + setToWildcard(geography, "* (All geographies)"); + } + + console.log("Edit mode initialization complete"); + } + /** * Initialize the cascading dropdowns */ @@ -278,12 +366,6 @@ return; } - // Set initial disabled state - clearDropdown(subTheme, "--------"); - clearDropdown(topic, "--------"); - clearDropdown(metric, "--------"); - clearDropdown(geography, "--------"); - // Add event listeners theme.addEventListener("change", handleThemeChange); subTheme.addEventListener("change", handleSubThemeChange); @@ -291,27 +373,23 @@ geographyType.addEventListener("change", handleGeographyTypeChange); console.log("Event listeners attached"); - - // If editing existing record, trigger cascade to repopulate - if (theme.value && theme.value !== "-1") { - console.log("Existing theme value detected:", theme.value); - handleThemeChange().then(() => { - // After sub-themes load, check if sub-theme was already selected - setTimeout(() => { - if (subTheme.value && subTheme.value !== "-1") { - console.log("Existing sub-theme value detected:", subTheme.value); - handleSubThemeChange().then(() => { - // After topics load, check if topic was already selected - setTimeout(() => { - if (topic.value && topic.value !== "-1") { - console.log("Existing topic value detected:", topic.value); - handleTopicChange(); - } - }, 300); - }); - } - }, 300); - }); + const isEditMode = + theme.value || + subTheme.value || + topic.value || + metric.value || + geographyType.value || + geography.value; + + if (isEditMode) { + console.log("Edit mode detected"); + initializeEditMode(); + } else { + console.log("Create mode - setting initial state"); + clearDropdown(subTheme, "Select theme first"); + clearDropdown(topic, "Select sub-theme first"); + clearDropdown(metric, "Select topic first"); + clearDropdown(geography, "Select geography type first"); } } diff --git a/cms/metrics_interface/field_choices_callables.py b/cms/metrics_interface/field_choices_callables.py index e321065c2..48063d2d2 100644 --- a/cms/metrics_interface/field_choices_callables.py +++ b/cms/metrics_interface/field_choices_callables.py @@ -317,7 +317,7 @@ def get_all_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: [("Infectious_disease", "Infectious_disease"), ...] """ metrics_interface = MetricsAPIInterface() - theme_names = metrics_interface.get_all_theme_choices() + theme_names = metrics_interface.get_all_theme_names_and_ids() print(theme_names) return _build_id_name_tuple_choices( choices=theme_names, @@ -368,6 +368,30 @@ def get_all_unique_sub_theme_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: ) +def get_all_sub_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `theme` fields of the CMS blocks. + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + This means that we don't need to create a new migration + whenever a new chart type is added. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of theme names. + Examples: + [("Infectious_disease", "Infectious_disease"), ...] + """ + metrics_interface = MetricsAPIInterface() + sub_theme_names_and_ids = metrics_interface.get_all_sub_theme_names_and_ids() + print(sub_theme_names_and_ids) + return _build_id_name_tuple_choices( + choices=sub_theme_names_and_ids, + ) + + def get_filtered_unique_sub_theme_names_for_parent_theme(parent_theme_id) -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `theme` fields of the CMS blocks. @@ -432,6 +456,54 @@ def get_a_list_of_all_topic_names(): return list(metrics_interface.get_all_topic_names()) +def get_all_topic_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `theme` fields of the CMS blocks. + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + This means that we don't need to create a new migration + whenever a new chart type is added. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of theme names. + Examples: + [("Infectious_disease", "Infectious_disease"), ...] + """ + metrics_interface = MetricsAPIInterface() + topic_names_and_ids = metrics_interface.get_all_topic_names_and_ids() + print(topic_names_and_ids) + return _build_id_name_tuple_choices( + choices=topic_names_and_ids, + ) + + +def get_all_metric_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `theme` fields of the CMS blocks. + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + This means that we don't need to create a new migration + whenever a new chart type is added. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of theme names. + Examples: + [("Infectious_disease", "Infectious_disease"), ...] + """ + metrics_interface = MetricsAPIInterface() + metric_names_and_ids = metrics_interface.get_all_metric_names_and_ids() + print(metric_names_and_ids) + return _build_id_name_tuple_choices( + choices=metric_names_and_ids, + ) + + def get_all_unique_change_type_metric_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `metric` fields of trend number CMS blocks. diff --git a/cms/metrics_interface/interface.py b/cms/metrics_interface/interface.py index f26103aa7..9e7ab2aaa 100644 --- a/cms/metrics_interface/interface.py +++ b/cms/metrics_interface/interface.py @@ -186,7 +186,7 @@ def get_all_theme_names(self) -> QuerySet: """ return self.theme_manager.get_all_names() - def get_all_theme_choices(self) -> QuerySet: + def get_all_theme_names_and_ids(self) -> QuerySet: """Gets all available theme names as a flat list queryset. Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API @@ -195,7 +195,40 @@ def get_all_theme_choices(self) -> QuerySet: Examples: ``. """ - return self.theme_manager.get_all_choices() + return self.theme_manager.get_all_names_and_ids() + + def get_all_sub_theme_names_and_ids(self) -> QuerySet: + """Gets all available theme names as a flat list queryset. + Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual theme names. + Examples: + ``. + """ + return self.sub_theme_manager.get_all_names_and_ids() + + def get_all_topic_names_and_ids(self) -> QuerySet: + """Gets all available theme names as a flat list queryset. + Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual theme names. + Examples: + ``. + """ + return self.topic_manager.get_all_names_and_ids() + + def get_all_metric_names_and_ids(self) -> QuerySet: + """Gets all available theme names as a flat list queryset. + Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual theme names. + Examples: + ``. + """ + return self.metric_manager.get_all_names_and_ids() def get_all_sub_theme_names(self) -> QuerySet: """Gets all available sub_theme names as a flat list queryset. @@ -232,6 +265,17 @@ def get_filtered_unique_sub_theme_names_for_parent_theme(self, parent_theme_id) """ return self.sub_theme_manager.get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) + def get_all_sub_theme_names_and_ids(self) -> QuerySet: + """Gets all available theme names as a flat list queryset. + Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual theme names. + Examples: + ``. + """ + return self.sub_theme_manager.get_all_names_and_ids() + def get_all_topic_names(self) -> QuerySet: """Gets all available topic names as a flat list queryset. Note this is achieved by delegating the call to the `TopicManager` from the Metrics API diff --git a/metrics/data/managers/core_models/metric.py b/metrics/data/managers/core_models/metric.py index 26f8e8c93..abf08ad45 100644 --- a/metrics/data/managers/core_models/metric.py +++ b/metrics/data/managers/core_models/metric.py @@ -93,6 +93,16 @@ def get_filtered_unique_names_related_to_parent_topic_id(self, parent_topic_id) """ return self.filter(topic_id=parent_topic_id).values('id', 'name').distinct() + def get_all_names_and_ids(self) -> models.QuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.all().values("id", "name").distinct() + class MetricManager(models.Manager): """Custom model manager class for the `Metric` model.""" @@ -176,3 +186,13 @@ def get_filtered_unique_names_related_to_parent_topic_id(self, parent_topic_id: `` """ return self.get_queryset().get_filtered_unique_names_related_to_parent_topic_id(parent_topic_id=parent_topic_id) + + def get_all_names_and_ids(self) -> MetricQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self .get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/sub_theme.py b/metrics/data/managers/core_models/sub_theme.py index 43acffa95..42956fbdf 100644 --- a/metrics/data/managers/core_models/sub_theme.py +++ b/metrics/data/managers/core_models/sub_theme.py @@ -33,6 +33,16 @@ def get_all_unique_names(self) -> models.QuerySet: """ return self.all().values_list("name", flat=True).distinct().order_by("name") + def get_all_names_and_ids(self) -> models.QuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.all().values("id", "name").distinct() + def get_filtered_unique_names_related_to_theme(self, parent_theme_id) -> models.QuerySet: """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. @@ -81,3 +91,13 @@ def get_filtered_unique_names_related_to_theme(self, parent_theme_id: str) -> Su `` """ return self.get_queryset().get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) + + def get_all_names_and_ids(self) -> SubThemeQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self .get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/theme.py b/metrics/data/managers/core_models/theme.py index d3852c010..ec9ac54d6 100644 --- a/metrics/data/managers/core_models/theme.py +++ b/metrics/data/managers/core_models/theme.py @@ -21,7 +21,7 @@ def get_all_names(self) -> models.QuerySet: """ return self.all().values_list("name", flat=True) - def get_all_choices(self) -> models.QuerySet: + def get_all_names_and_ids(self) -> models.QuerySet: """Gets all available themes with id and name fields. Returns: @@ -49,7 +49,7 @@ def get_all_names(self) -> ThemeQuerySet: """ return self.get_queryset().get_all_names() - def get_all_choices(self) -> ThemeQuerySet: + def get_all_names_and_ids(self) -> ThemeQuerySet: """Gets all available themes with id and name fields. Returns: @@ -57,4 +57,4 @@ def get_all_choices(self) -> ThemeQuerySet: Examples: `` """ - return self .get_queryset().get_all_choices() + return self .get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index ce992cafa..0988625af 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -55,6 +55,16 @@ def get_filtered_unique_names_related_to_sub_theme(self, parent_sub_theme_id) -> """ return self.filter(sub_theme_id=parent_sub_theme_id).values('id', 'name').distinct() + def get_all_names_and_ids(self) -> models.QuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.all().values("id", "name").distinct() + class TopicManager(models.Manager): """Custom model manager class for the `Metric` model.""" @@ -113,3 +123,13 @@ def get_filtered_unique_names_related_to_sub_theme(self, parent_sub_theme_id: st `` """ return self.get_queryset().get_filtered_unique_names_related_to_sub_theme(parent_sub_theme_id=parent_sub_theme_id) + + def get_all_names_and_ids(self) -> TopicQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self .get_queryset().get_all_names_and_ids() From 57c71cb3c6718b32150c27cf5b5c03e3738350fa Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Mon, 23 Mar 2026 17:13:08 +0000 Subject: [PATCH 037/186] CDD-3085: updated validations and wildcard functionality --- auth_content/models.py | 41 ++++++++++++++++++++++++--- auth_content/static/js/child_theme.js | 3 -- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/auth_content/models.py b/auth_content/models.py index 3307d848d..86aa117ad 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -31,27 +31,60 @@ def __init__(self, *args, **kwargs): # Use CharField with Select widget to bypass choice validation self.fields['sub_theme'] = forms.CharField( - required=False, + required=True, label="Sub Theme", widget=forms.Select(choices=[("", "Select theme first")]) ) self.fields['topic'] = forms.CharField( - required=False, + required=True, label="Topic", widget=forms.Select(choices=[("", "Select sub-theme first")]) ) self.fields['metric'] = forms.CharField( - required=False, + required=True, label="Metric", widget=forms.Select(choices=[("", "Select topic first")]) ) self.fields['geography'] = forms.CharField( - required=False, + required=True, label="Geography", widget=forms.Select( choices=[("-1", "Select geography type first")]) ) + if self.instance and self.instance.pk: + # Sub-theme + if self.instance.sub_theme: + self.fields['sub_theme'].widget.choices = [ + ("", "Select theme first"), + (self.instance.sub_theme, + f"Loading... (ID: {self.instance.sub_theme})") + ] + + # Topic + if self.instance.topic: + self.fields['topic'].widget.choices = [ + ("", "Select sub-theme first"), + (self.instance.topic, + f"Loading... (ID: {self.instance.topic})") + ] + + # Metric + if self.instance.metric: + self.fields['metric'].widget.choices = [ + ("", "Select topic first"), + (self.instance.metric, + f"Loading... (ID: {self.instance.metric})") + ] + + # Geography + if self.instance.geography: + self.fields['geography'].widget.choices = [ + ("", "Select geography type first"), + (self.instance.geography, + f"Loading... (ID: {self.instance.geography})") + ] + def clean(self): """Validate that this permission set doesn't already exist""" cleaned_data = super().clean() diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index a2e22066c..18b869e4c 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -117,9 +117,6 @@ dropdown.appendChild(option); dropdown.value = "-1"; - dropdown.disabled = true; - - console.log(`Set ${dropdown.name} to wildcard: ${message}`); } /** From aec0c5c9d4f788a17162881ca134fc6899408924 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 24 Mar 2026 12:12:52 +0000 Subject: [PATCH 038/186] CDD-3175: update migrations and add tidy up javascript and validation --- auth_content/migrations/0001_initial.py | 66 +++++++++++++++- .../0002_permissionset_delete_authfeature.py | 47 ----------- ..._alter_permissionset_geography_and_more.py | 71 ----------------- ..._alter_permissionset_geography_and_more.py | 69 ---------------- .../0005_alter_permissionset_theme.py | 27 ------- ..._alter_permissionset_geography_and_more.py | 79 ------------------- ..._alter_permissionset_geography_and_more.py | 44 ----------- .../0008_alter_permissionset_topic.py | 18 ----- ..._alter_permissionset_geography_and_more.py | 73 ----------------- ...r_permissionset_geography_type_and_more.py | 46 ----------- .../migrations/0011_permissionset_name.py | 23 ------ ..._alter_permissionset_geography_and_more.py | 67 ---------------- auth_content/models.py | 25 +++++- auth_content/static/js/child_theme.js | 5 +- metrics/api/serializers/permission_sets.py | 4 +- 15 files changed, 87 insertions(+), 577 deletions(-) delete mode 100644 auth_content/migrations/0002_permissionset_delete_authfeature.py delete mode 100644 auth_content/migrations/0003_alter_permissionset_geography_and_more.py delete mode 100644 auth_content/migrations/0004_alter_permissionset_geography_and_more.py delete mode 100644 auth_content/migrations/0005_alter_permissionset_theme.py delete mode 100644 auth_content/migrations/0006_alter_permissionset_geography_and_more.py delete mode 100644 auth_content/migrations/0007_alter_permissionset_geography_and_more.py delete mode 100644 auth_content/migrations/0008_alter_permissionset_topic.py delete mode 100644 auth_content/migrations/0009_alter_permissionset_geography_and_more.py delete mode 100644 auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py delete mode 100644 auth_content/migrations/0011_permissionset_name.py delete mode 100644 auth_content/migrations/0012_alter_permissionset_geography_and_more.py diff --git a/auth_content/migrations/0001_initial.py b/auth_content/migrations/0001_initial.py index 7559ef2c4..c5b047e53 100644 --- a/auth_content/migrations/0001_initial.py +++ b/auth_content/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.12 on 2026-03-12 11:19 +# Generated by Django 5.2.12 on 2026-03-24 10:42 from django.db import migrations, models @@ -11,7 +11,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="AuthFeature", + name="PermissionSet", fields=[ ( "id", @@ -22,8 +22,66 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("title", models.CharField(max_length=255)), - ("description", models.TextField()), + ( + "name", + models.CharField( + blank=True, + editable=False, + help_text="Auto-generated display name", + max_length=500, + ), + ), + ( + "theme", + models.CharField( + choices=[ + ("", "---------"), + ("-1", "* (All themes)"), + ("3", "extreme_event"), + ("1", "immunisation"), + ("2", "infectious_disease"), + ("4", "non-communicable"), + ], + default="", + max_length=255, + ), + ), + ("sub_theme", models.CharField(default="", max_length=255)), + ("topic", models.CharField(default="", max_length=255)), + ("metric", models.CharField(default="", max_length=255)), + ( + "geography_type", + models.CharField( + choices=[ + ("", "---------"), + ("-1", "* (All geography-types)"), + ("1", "Region"), + ("2", "Upper Tier Local Authority"), + ("3", "Nation"), + ("4", "Lower Tier Local Authority"), + ("5", "Government Office Region"), + ("6", "United Kingdom"), + ], + default="", + max_length=255, + ), + ), + ("geography", models.CharField(default="", max_length=255)), ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=( + "theme", + "sub_theme", + "topic", + "metric", + "geography_type", + "geography", + ), + name="unique_permission_set", + ) + ], + }, ), ] diff --git a/auth_content/migrations/0002_permissionset_delete_authfeature.py b/auth_content/migrations/0002_permissionset_delete_authfeature.py deleted file mode 100644 index 3128e3c31..000000000 --- a/auth_content/migrations/0002_permissionset_delete_authfeature.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-13 14:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="PermissionSet", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "theme", - models.CharField( - choices=[ - ("infectious_disease", "Infectious Disease"), - ("extreme_event", "Extreme Event"), - ("non-communicable", "Non Communicable"), - ("climate_and_environment", "Climate And Environment"), - ("immunisation", "Immunisation"), - ] - ), - ), - ("sub_theme", models.CharField(choices=[], max_length=255)), - ("topic", models.CharField(max_length=255)), - ("metric", models.CharField(max_length=255)), - ("geography_type", models.CharField(max_length=255)), - ("geography", models.CharField(max_length=255)), - ], - ), - migrations.DeleteModel( - name="AuthFeature", - ), - ] diff --git a/auth_content/migrations/0003_alter_permissionset_geography_and_more.py b/auth_content/migrations/0003_alter_permissionset_geography_and_more.py deleted file mode 100644 index e5b4c5e32..000000000 --- a/auth_content/migrations/0003_alter_permissionset_geography_and_more.py +++ /dev/null @@ -1,71 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-19 17:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0002_permissionset_delete_authfeature"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography", - field=models.CharField(blank=True, default="*", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="geography_type", - field=models.CharField( - blank=True, - choices=[ - ("*", "* (All)"), - ("United Kingdom", "United Kingdom"), - ("Nation", "Nation"), - ("Lower Tier Local Authority", "Lower Tier Local Authority"), - ("NHS Region", "NHS Region"), - ("NHS Trust", "NHS Trust"), - ("Upper Tier Local Authority", "Upper Tier Local Authority"), - ("UKHSA Region", "UKHSA Region"), - ("UKHSA Super-Region", "UKHSA Super-Region"), - ("Government Office Region", "Government Office Region"), - ("Integrated Care Board", "Integrated Care Board"), - ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), - ("Region", "Region"), - ], - default="*", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="metric", - field=models.IntegerField( - default=-1, help_text="Select a specific metric or * for all metrics" - ), - ), - migrations.AlterField( - model_name="permissionset", - name="sub_theme", - field=models.IntegerField( - default=-1, - help_text="Select a specific sub-theme or * for all sub-themes", - ), - ), - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.IntegerField( - default=-1, help_text="Select a specific theme or * for all themes" - ), - ), - migrations.AlterField( - model_name="permissionset", - name="topic", - field=models.IntegerField( - default=-1, help_text="Select a specific topic or * for all topics" - ), - ), - ] diff --git a/auth_content/migrations/0004_alter_permissionset_geography_and_more.py b/auth_content/migrations/0004_alter_permissionset_geography_and_more.py deleted file mode 100644 index 27c3e55c4..000000000 --- a/auth_content/migrations/0004_alter_permissionset_geography_and_more.py +++ /dev/null @@ -1,69 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-19 17:05 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0003_alter_permissionset_geography_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography", - field=models.CharField(choices=[], default="-1", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="geography_type", - field=models.CharField( - choices=[ - ("United Kingdom", "United Kingdom"), - ("Nation", "Nation"), - ("Lower Tier Local Authority", "Lower Tier Local Authority"), - ("NHS Region", "NHS Region"), - ("NHS Trust", "NHS Trust"), - ("Upper Tier Local Authority", "Upper Tier Local Authority"), - ("UKHSA Region", "UKHSA Region"), - ("UKHSA Super-Region", "UKHSA Super-Region"), - ("Government Office Region", "Government Office Region"), - ("Integrated Care Board", "Integrated Care Board"), - ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), - ("Region", "Region"), - ], - default="-1", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="metric", - field=models.CharField(choices=[], default="-1", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="sub_theme", - field=models.CharField(choices=[], default="-1", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - choices=[ - (3, "extreme_event"), - (1, "immunisation"), - (2, "infectious_disease"), - (4, "non-communicable"), - ], - default="-1", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="topic", - field=models.CharField(choices=[], default="-1", max_length=255), - ), - ] diff --git a/auth_content/migrations/0005_alter_permissionset_theme.py b/auth_content/migrations/0005_alter_permissionset_theme.py deleted file mode 100644 index 51e01f6a3..000000000 --- a/auth_content/migrations/0005_alter_permissionset_theme.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-19 17:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0004_alter_permissionset_geography_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - choices=[ - ("3", "extreme_event"), - ("1", "immunisation"), - ("2", "infectious_disease"), - ("4", "non-communicable"), - ], - default="-1", - max_length=255, - ), - ), - ] diff --git a/auth_content/migrations/0006_alter_permissionset_geography_and_more.py b/auth_content/migrations/0006_alter_permissionset_geography_and_more.py deleted file mode 100644 index dfb05e52e..000000000 --- a/auth_content/migrations/0006_alter_permissionset_geography_and_more.py +++ /dev/null @@ -1,79 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-19 17:10 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0005_alter_permissionset_theme"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography", - field=models.CharField( - blank=True, choices=[], default="-1", max_length=255 - ), - ), - migrations.AlterField( - model_name="permissionset", - name="geography_type", - field=models.CharField( - blank=True, - choices=[ - ("United Kingdom", "United Kingdom"), - ("Nation", "Nation"), - ("Lower Tier Local Authority", "Lower Tier Local Authority"), - ("NHS Region", "NHS Region"), - ("NHS Trust", "NHS Trust"), - ("Upper Tier Local Authority", "Upper Tier Local Authority"), - ("UKHSA Region", "UKHSA Region"), - ("UKHSA Super-Region", "UKHSA Super-Region"), - ("Government Office Region", "Government Office Region"), - ("Integrated Care Board", "Integrated Care Board"), - ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), - ("Region", "Region"), - ], - default="-1", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="metric", - field=models.CharField( - blank=True, choices=[], default="-1", max_length=255 - ), - ), - migrations.AlterField( - model_name="permissionset", - name="sub_theme", - field=models.CharField( - blank=True, choices=[], default="-1", max_length=255 - ), - ), - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - blank=True, - choices=[ - ("3", "extreme_event"), - ("1", "immunisation"), - ("2", "infectious_disease"), - ("4", "non-communicable"), - ], - default="-1", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="topic", - field=models.CharField( - blank=True, choices=[], default="-1", max_length=255 - ), - ), - ] diff --git a/auth_content/migrations/0007_alter_permissionset_geography_and_more.py b/auth_content/migrations/0007_alter_permissionset_geography_and_more.py deleted file mode 100644 index 5bb2a083e..000000000 --- a/auth_content/migrations/0007_alter_permissionset_geography_and_more.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-19 17:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0006_alter_permissionset_geography_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography", - field=models.CharField(blank=True, default="-1", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="metric", - field=models.CharField(blank=True, default="-1", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="sub_theme", - field=models.CharField(blank=True, default="-1", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - blank=True, - choices=[ - ("-1", "* (All themes)"), - ("3", "extreme_event"), - ("1", "immunisation"), - ("2", "infectious_disease"), - ("4", "non-communicable"), - ], - default="-1", - max_length=255, - ), - ), - ] diff --git a/auth_content/migrations/0008_alter_permissionset_topic.py b/auth_content/migrations/0008_alter_permissionset_topic.py deleted file mode 100644 index a1f2df380..000000000 --- a/auth_content/migrations/0008_alter_permissionset_topic.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-19 17:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0007_alter_permissionset_geography_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="topic", - field=models.CharField(blank=True, default="-1", max_length=255), - ), - ] diff --git a/auth_content/migrations/0009_alter_permissionset_geography_and_more.py b/auth_content/migrations/0009_alter_permissionset_geography_and_more.py deleted file mode 100644 index 6d82ff6bb..000000000 --- a/auth_content/migrations/0009_alter_permissionset_geography_and_more.py +++ /dev/null @@ -1,73 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-20 11:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0008_alter_permissionset_topic"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography", - field=models.CharField(blank=True, default="", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="geography_type", - field=models.CharField( - blank=True, - choices=[ - ("United Kingdom", "United Kingdom"), - ("Nation", "Nation"), - ("Lower Tier Local Authority", "Lower Tier Local Authority"), - ("NHS Region", "NHS Region"), - ("NHS Trust", "NHS Trust"), - ("Upper Tier Local Authority", "Upper Tier Local Authority"), - ("UKHSA Region", "UKHSA Region"), - ("UKHSA Super-Region", "UKHSA Super-Region"), - ("Government Office Region", "Government Office Region"), - ("Integrated Care Board", "Integrated Care Board"), - ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), - ("Region", "Region"), - ], - default="", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="metric", - field=models.CharField(blank=True, default="", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="sub_theme", - field=models.CharField(blank=True, default="", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - blank=True, - choices=[ - ("", "---------"), - ("-1", "* (All themes)"), - ("3", "extreme_event"), - ("1", "immunisation"), - ("2", "infectious_disease"), - ("4", "non-communicable"), - ], - default="", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="topic", - field=models.CharField(blank=True, default="", max_length=255), - ), - ] diff --git a/auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py b/auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py deleted file mode 100644 index b737436f2..000000000 --- a/auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-23 14:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0009_alter_permissionset_geography_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography_type", - field=models.CharField( - blank=True, - choices=[ - ("", "---------"), - ("-1", "* (All geography-types)"), - ("1", "Region"), - ("2", "Upper Tier Local Authority"), - ("3", "Nation"), - ("4", "Lower Tier Local Authority"), - ("5", "Government Office Region"), - ("6", "United Kingdom"), - ], - default="", - max_length=255, - ), - ), - migrations.AddConstraint( - model_name="permissionset", - constraint=models.UniqueConstraint( - fields=( - "theme", - "sub_theme", - "topic", - "metric", - "geography_type", - "geography", - ), - name="unique_permission_set", - ), - ), - ] diff --git a/auth_content/migrations/0011_permissionset_name.py b/auth_content/migrations/0011_permissionset_name.py deleted file mode 100644 index 8aa52066e..000000000 --- a/auth_content/migrations/0011_permissionset_name.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-23 14:51 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0010_alter_permissionset_geography_type_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="permissionset", - name="name", - field=models.CharField( - blank=True, - editable=False, - help_text="Auto-generated display name", - max_length=500, - ), - ), - ] diff --git a/auth_content/migrations/0012_alter_permissionset_geography_and_more.py b/auth_content/migrations/0012_alter_permissionset_geography_and_more.py deleted file mode 100644 index 5f2cb0caa..000000000 --- a/auth_content/migrations/0012_alter_permissionset_geography_and_more.py +++ /dev/null @@ -1,67 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-23 15:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0011_permissionset_name"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography", - field=models.CharField(default="", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="geography_type", - field=models.CharField( - choices=[ - ("", "---------"), - ("-1", "* (All geography-types)"), - ("1", "Region"), - ("2", "Upper Tier Local Authority"), - ("3", "Nation"), - ("4", "Lower Tier Local Authority"), - ("5", "Government Office Region"), - ("6", "United Kingdom"), - ], - default="", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="metric", - field=models.CharField(default="", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="sub_theme", - field=models.CharField(default="", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - choices=[ - ("", "---------"), - ("-1", "* (All themes)"), - ("3", "extreme_event"), - ("1", "immunisation"), - ("2", "infectious_disease"), - ("4", "non-communicable"), - ], - default="", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="topic", - field=models.CharField(default="", max_length=255), - ), - ] diff --git a/auth_content/models.py b/auth_content/models.py index 86aa117ad..14ce5af05 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -54,36 +54,53 @@ def __init__(self, *args, **kwargs): if self.instance and self.instance.pk: # Sub-theme - if self.instance.sub_theme: + if self.instance.sub_theme and self.instance.sub_theme != "-1": self.fields['sub_theme'].widget.choices = [ ("", "Select theme first"), (self.instance.sub_theme, f"Loading... (ID: {self.instance.sub_theme})") ] + elif self.instance.sub_theme == "-1": + # Add wildcard option for initial render + self.fields['sub_theme'].widget.choices = [ + ("-1", "* (All sub-themes)") + ] # Topic - if self.instance.topic: + if self.instance.topic and self.instance.topic != "-1": self.fields['topic'].widget.choices = [ ("", "Select sub-theme first"), (self.instance.topic, f"Loading... (ID: {self.instance.topic})") ] + elif self.instance.topic == "-1": + self.fields['topic'].widget.choices = [ + ("-1", "* (All topics)") + ] # Metric - if self.instance.metric: + if self.instance.metric and self.instance.metric != "-1": self.fields['metric'].widget.choices = [ ("", "Select topic first"), (self.instance.metric, f"Loading... (ID: {self.instance.metric})") ] + elif self.instance.metric == "-1": + self.fields['metric'].widget.choices = [ + ("-1", "* (All metrics)") + ] # Geography - if self.instance.geography: + if self.instance.geography and self.instance.geography != "-1": self.fields['geography'].widget.choices = [ ("", "Select geography type first"), (self.instance.geography, f"Loading... (ID: {self.instance.geography})") ] + elif self.instance.geography == "-1": + self.fields['geography'].widget.choices = [ + ("-1", "* (All geographies)") + ] def clean(self): """Validate that this permission set doesn't already exist""" diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 18b869e4c..3c283408d 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -96,7 +96,6 @@ dropdown.appendChild(option); dropdown.value = ""; - dropdown.disabled = true; console.log(`Cleared ${dropdown.name}: ${message}`); } @@ -179,8 +178,8 @@ } // Clear dependent dropdowns - clearDropdown(topic, "--------"); - clearDropdown(metric, "--------"); + clearDropdown(topic, "Select sub-theme"); + clearDropdown(metric, "Select metric"); // Fetch and populate topics const choices = await fetchChoices("topics", subThemeValue); diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py index f57f352b9..aeb33125d 100644 --- a/metrics/api/serializers/permission_sets.py +++ b/metrics/api/serializers/permission_sets.py @@ -88,7 +88,7 @@ def data(self) -> dict: # Handle wildcard if sub_theme_id == "-1": - return {'choices': [["-1", "* (All sub-themes)"]]} + return {'choices': [["-1", "* (All topics)"]]} # Fetch from interface parent_sub_theme_id = int(sub_theme_id) @@ -137,7 +137,7 @@ def data(self) -> dict: # Handle wildcard if topic_id == "-1": - return {'choices': [["-1", "* (All sub-themes)"]]} + return {'choices': [["-1", "* (All metrics)"]]} # Fetch from interface parent_topic_id = int(topic_id) From d679e8d480cf3d24b84f7ba019b4ec0e6aeb26ee Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 24 Mar 2026 12:56:51 +0000 Subject: [PATCH 039/186] CDD-3175: remove console logs from javascript --- auth_content/static/js/child_theme.js | 44 +-------------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 3c283408d..20a9391bb 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -11,7 +11,6 @@ async function fetchChoices(endpoint, dataItemId) { try { const url = `/api/permission-set/${endpoint}/${dataItemId}`; - console.log(`Fetching from: ${url}`); const response = await fetch(url); @@ -22,7 +21,6 @@ } const data = await response.json(); - console.log(`Received data from ${endpoint}:`, data); return data.choices || []; } catch (error) { console.error(`Error fetching ${endpoint}:`, error); @@ -30,11 +28,8 @@ } } async function fetchGeographies(endpoint, dataItemId) { - console.log("selected geography type: ", dataItemId); - console.log("selected endpoint: ", endpoint); try { const url = `/api/permission-set/${endpoint}/${dataItemId}`; - console.log(`Fetching from: ${url}`); const response = await fetch(url); @@ -45,7 +40,6 @@ } const data = await response.json(); - console.log(`Received data from ${endpoint}:`, data); return data.choices || []; } catch (error) { console.error(`Error fetching ${endpoint}:`, error); @@ -96,8 +90,6 @@ dropdown.appendChild(option); dropdown.value = ""; - - console.log(`Cleared ${dropdown.name}: ${message}`); } /** @@ -126,7 +118,6 @@ // Clear all dependent dropdowns if (!themeValue || themeValue === "") { - console.log("No theme selected - clearing all children"); clearDropdown(subTheme, "Select theme first"); clearDropdown(topic, "Select sub-theme first"); clearDropdown(metric, "Select topic first"); @@ -134,7 +125,6 @@ } if (themeValue === "-1") { - console.log("Wildcard theme selected - cascading to all children"); setToWildcard(subTheme, "* (All sub-themes)"); setToWildcard(topic, "* (All topics)"); setToWildcard(metric, "* (All metrics)"); @@ -160,7 +150,6 @@ */ async function handleSubThemeChange() { const subThemeValue = subTheme.value; - console.log("Sub-theme changed to:", subThemeValue); if (!subThemeValue || subThemeValue === "") { // No sub-theme selected - clear children @@ -171,7 +160,6 @@ if (subThemeValue === "-1") { // Wildcard sub-theme = cascade wildcard to children - console.log("Wildcard sub-theme selected - cascading to children"); setToWildcard(topic, "* (All topics)"); setToWildcard(metric, "* (All metrics)"); return; @@ -196,18 +184,15 @@ */ async function handleTopicChange() { const topicValue = topic.value; - console.log("Topic changed to:", topicValue); if (!topicValue || topicValue === "") { // No topic selected - clear metrics - console.log("No topic selected - clearing metrics"); clearDropdown(metric, "Select topic first"); return; } if (topicValue === "-1") { // Wildcard topic = cascade wildcard to metrics - console.log("Wildcard topic selected - cascading to metrics"); setToWildcard(metric, "* (All metrics)"); return; } @@ -225,18 +210,15 @@ } async function handleGeographyTypeChange() { const geographyTypeValue = geographyType.value; - console.log("geography type changed to:", geographyTypeValue); if (!geographyTypeValue || geographyTypeValue === "") { // No topic selected - clear metrics - console.log("No geography type selected"); clearDropdown(geography, "Select geography type first"); return; } if (geographyTypeValue === "-1") { // Wildcard topic = cascade wildcard to metrics - console.log("Wildcard geography selected - cascading to metrics"); setToWildcard(geography, "* (All geographies)"); return; } @@ -257,8 +239,6 @@ * Loads the dropdown options based on saved values */ async function initializeEditMode() { - console.log("Initializing edit mode..."); - // Store original values before we start manipulating dropdowns const savedTheme = theme.value; const savedSubTheme = subTheme.value; @@ -267,18 +247,8 @@ const savedGeographyType = geographyType.value; const savedGeography = geography.value; - console.log("Saved values:", { - theme: savedTheme, - subTheme: savedSubTheme, - topic: savedTopic, - metric: savedMetric, - geographyType: savedGeographyType, - geography: savedGeography, - }); - // If theme has a value (not wildcard, not empty), load sub-themes if (savedTheme && savedTheme !== "" && savedTheme !== "-1") { - console.log(`Loading sub-themes for theme ${savedTheme}...`); const subThemeChoices = await fetchChoices("subthemes", savedTheme); if (subThemeChoices.length > 0) { populateDropdown(subTheme, subThemeChoices, "* (All sub-themes)"); @@ -287,7 +257,6 @@ // If sub-theme has a value, load topics if (savedSubTheme && savedSubTheme !== "" && savedSubTheme !== "-1") { - console.log(`Loading topics for sub-theme ${savedSubTheme}...`); const topicChoices = await fetchChoices("topics", savedSubTheme); if (topicChoices.length > 0) { populateDropdown(topic, topicChoices, "* (All topics)"); @@ -296,7 +265,6 @@ // If topic has a value, load metrics if (savedTopic && savedTopic !== "" && savedTopic !== "-1") { - console.log(`Loading metrics for topic ${savedTopic}...`); const metricChoices = await fetchChoices("metrics", savedTopic); if (metricChoices.length > 0) { populateDropdown(metric, metricChoices, "* (All metrics)"); @@ -317,9 +285,6 @@ savedGeographyType !== "" && savedGeographyType !== "-1" ) { - console.log( - `Loading geographies for geography type ${savedGeographyType}...`, - ); const geographyChoices = await fetchChoices( "geographies", savedGeographyType, @@ -331,16 +296,12 @@ } else if (savedGeographyType === "-1") { setToWildcard(geography, "* (All geographies)"); } - - console.log("Edit mode initialization complete"); } /** * Initialize the cascading dropdowns */ function initialize() { - console.log("Initializing..."); - // Get dropdown elements theme = document.querySelector('select[name="theme"]'); subTheme = document.querySelector('select[name="sub_theme"]'); @@ -358,7 +319,7 @@ !geographyType || !geography ) { - console.log("Permission set dropdowns not found on this page"); + console.error("Permission set dropdowns not found on this page"); return; } @@ -368,7 +329,6 @@ topic.addEventListener("change", handleTopicChange); geographyType.addEventListener("change", handleGeographyTypeChange); - console.log("Event listeners attached"); const isEditMode = theme.value || subTheme.value || @@ -378,10 +338,8 @@ geography.value; if (isEditMode) { - console.log("Edit mode detected"); initializeEditMode(); } else { - console.log("Create mode - setting initial state"); clearDropdown(subTheme, "Select theme first"); clearDropdown(topic, "Select sub-theme first"); clearDropdown(metric, "Select topic first"); From 9875ae4a1c0b1c92a15d6f816fe451801ada6037 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 24 Mar 2026 14:16:47 +0000 Subject: [PATCH 040/186] CDD-3175: Update PermissionSet model --- auth_content/models.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/auth_content/models.py b/auth_content/models.py index 14ce5af05..330f8fbf1 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -61,7 +61,6 @@ def __init__(self, *args, **kwargs): f"Loading... (ID: {self.instance.sub_theme})") ] elif self.instance.sub_theme == "-1": - # Add wildcard option for initial render self.fields['sub_theme'].widget.choices = [ ("-1", "* (All sub-themes)") ] @@ -123,7 +122,6 @@ def clean(self): geography=geography ) - # Exclude current instance when editing if self.instance.pk: queryset = queryset.exclude(pk=self.instance.pk) @@ -140,7 +138,7 @@ class PermissionSet(models.Model): name = models.CharField( max_length=500, blank=True, - editable=False, # Don't show in admin form + editable=False, help_text="Auto-generated display name" ) theme = models.CharField( @@ -197,7 +195,7 @@ def _generate_display_name(self): theme_name = self._get_choice_label('theme', self.theme) parts.append(f"Theme: {theme_name}") - # Sub-theme (we'll need to store these lookups) + # Sub-theme if self.sub_theme == "-1": parts.append("Sub-theme: * (All)") elif self.sub_theme: @@ -221,7 +219,7 @@ def _generate_display_name(self): 'metric', self.metric) parts.append(f"Metric: {metric_name}") - # Geography type (we have the label from enum) + # Geography type if self.geography_type == "-1": parts.append("Geography Type: * (All)") elif self.geography_type: @@ -266,7 +264,7 @@ def _get_choice_label(self, field_name, value): if choice_value == value: return choice_label - return value # Fallback to ID if not found + return value def __str__(self): return self.name if self.name else f"Permission Set {self.id}" From 5be16904552d392235ffcea9c6400a023755c729 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 24 Mar 2026 14:21:58 +0000 Subject: [PATCH 041/186] CDD-3175: Update wagtail hooks --- auth_content/wagtail_hooks.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py index ffbcaa6b1..f4700cce3 100644 --- a/auth_content/wagtail_hooks.py +++ b/auth_content/wagtail_hooks.py @@ -27,10 +27,6 @@ class AuthGroup(ModelViewSetGroup): def register_auth_viewset(): return AuthGroup() -# exposes the mapping of parent to child themes - -# exposes the mapping of parent to child themes - @hooks.register("insert_editor_js") def permission_set_js(): From dc45208371e1fde8803fa692761fea08df9b89c5 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 24 Mar 2026 14:31:16 +0000 Subject: [PATCH 042/186] CDD-3175: remove print statements and tidy up field_choice_callables --- .../field_choices_callables.py | 31 +++++-------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/cms/metrics_interface/field_choices_callables.py b/cms/metrics_interface/field_choices_callables.py index 48063d2d2..868ad8d5b 100644 --- a/cms/metrics_interface/field_choices_callables.py +++ b/cms/metrics_interface/field_choices_callables.py @@ -293,10 +293,8 @@ def get_all_theme_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: [("Infectious_disease", "Infectious_disease"), ...] """ metrics_interface = MetricsAPIInterface() - theme_names = metrics_interface.get_all_theme_names() - print(theme_names) return _build_two_item_tuple_choices( - choices=theme_names, + choices=metrics_interface.get_all_theme_names(), ) @@ -317,10 +315,8 @@ def get_all_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: [("Infectious_disease", "Infectious_disease"), ...] """ metrics_interface = MetricsAPIInterface() - theme_names = metrics_interface.get_all_theme_names_and_ids() - print(theme_names) return _build_id_name_tuple_choices( - choices=theme_names, + choices=metrics_interface.get_all_theme_names_and_ids() ) @@ -385,10 +381,8 @@ def get_all_sub_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: [("Infectious_disease", "Infectious_disease"), ...] """ metrics_interface = MetricsAPIInterface() - sub_theme_names_and_ids = metrics_interface.get_all_sub_theme_names_and_ids() - print(sub_theme_names_and_ids) return _build_id_name_tuple_choices( - choices=sub_theme_names_and_ids, + choices=metrics_interface.get_all_sub_theme_names_and_ids(), ) @@ -409,12 +403,9 @@ def get_filtered_unique_sub_theme_names_for_parent_theme(parent_theme_id) -> LIS [("Infectious_disease", "Infectious_disease"), ...] """ metrics_interface = MetricsAPIInterface() - print("received parent_theme_id: ", parent_theme_id) - filtered_sub_themes = metrics_interface.get_filtered_unique_sub_theme_names_for_parent_theme( - parent_theme_id=parent_theme_id) - print("filtered_sub_themes: ", filtered_sub_themes) return _build_id_name_tuple_choices( - choices=filtered_sub_themes, + choices=metrics_interface.get_filtered_unique_sub_theme_names_for_parent_theme( + parent_theme_id=parent_theme_id), ) @@ -473,10 +464,8 @@ def get_all_topic_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: [("Infectious_disease", "Infectious_disease"), ...] """ metrics_interface = MetricsAPIInterface() - topic_names_and_ids = metrics_interface.get_all_topic_names_and_ids() - print(topic_names_and_ids) return _build_id_name_tuple_choices( - choices=topic_names_and_ids, + choices=metrics_interface.get_all_topic_names_and_ids() ) @@ -497,10 +486,8 @@ def get_all_metric_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: [("Infectious_disease", "Infectious_disease"), ...] """ metrics_interface = MetricsAPIInterface() - metric_names_and_ids = metrics_interface.get_all_metric_names_and_ids() - print(metric_names_and_ids) return _build_id_name_tuple_choices( - choices=metric_names_and_ids, + choices=metrics_interface.get_all_metric_names_and_ids() ) @@ -648,10 +635,8 @@ def get_all_geography_type_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: """ metrics_interface = MetricsAPIInterface() - geography_choices = metrics_interface.get_all_geography_type_names_and_ids() - print(geography_choices) return _build_id_name_tuple_choices( - choices=geography_choices + choices=metrics_interface.get_all_geography_type_names_and_ids() ) From 8178091ae097114e003beb6ee328834f12fb5c47 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 24 Mar 2026 16:42:40 +0000 Subject: [PATCH 043/186] CDD-3175: Update method descriptions --- .../field_choices_callables.py | 26 +++++------ cms/metrics_interface/interface.py | 43 +++++++------------ 2 files changed, 26 insertions(+), 43 deletions(-) diff --git a/cms/metrics_interface/field_choices_callables.py b/cms/metrics_interface/field_choices_callables.py index 868ad8d5b..129a6b530 100644 --- a/cms/metrics_interface/field_choices_callables.py +++ b/cms/metrics_interface/field_choices_callables.py @@ -312,7 +312,7 @@ def get_all_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: Returns: A list of 2-item tuples of theme names. Examples: - [("Infectious_disease", "Infectious_disease"), ...] + [(1, "immunisation"), ...] """ metrics_interface = MetricsAPIInterface() return _build_id_name_tuple_choices( @@ -365,20 +365,18 @@ def get_all_unique_sub_theme_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: def get_all_sub_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: - """Callable for the `choices` on the `theme` fields of the CMS blocks. + """Callable for the `choices` on the `sub-theme` fields of the CMS blocks. Notes: This callable wraps the `MetricsAPIInterface` and is passed to a migration for the CMS blocks. - This means that we don't need to create a new migration - whenever a new chart type is added. Instead, the 1-off migration is pointed at this callable. So Wagtail will pull the choices by invoking this function. Returns: - A list of 2-item tuples of theme names. + A list of 2-item tuples of subtheme names and ids. Examples: - [("Infectious_disease", "Infectious_disease"), ...] + [(1, "childhood-vaccines"), ...] """ metrics_interface = MetricsAPIInterface() return _build_id_name_tuple_choices( @@ -453,15 +451,13 @@ def get_all_topic_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: Notes: This callable wraps the `MetricsAPIInterface` and is passed to a migration for the CMS blocks. - This means that we don't need to create a new migration - whenever a new chart type is added. Instead, the 1-off migration is pointed at this callable. So Wagtail will pull the choices by invoking this function. Returns: - A list of 2-item tuples of theme names. + A list of 2-item tuples of topic names and ids. Examples: - [("Infectious_disease", "Infectious_disease"), ...] + [(1, "6-in-1"), ...] """ metrics_interface = MetricsAPIInterface() return _build_id_name_tuple_choices( @@ -481,9 +477,9 @@ def get_all_metric_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: So Wagtail will pull the choices by invoking this function. Returns: - A list of 2-item tuples of theme names. + A list of 2-item tuples of metric names and ids. Examples: - [("Infectious_disease", "Infectious_disease"), ...] + [(1, "6-in-1_coverage_coverageByYear"), ...] """ metrics_interface = MetricsAPIInterface() return _build_id_name_tuple_choices( @@ -623,15 +619,13 @@ def get_all_geography_type_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: Notes: This callable wraps the `MetricsAPIInterface` and is passed to a migration for the CMS blocks. - This means that we don't need to create a new migration - whenever a new `Geography` is added to that table. Instead, the 1-off migration is pointed at this callable. So Wagtail will pull the choices by invoking this function. Returns: - A list of 2-item tuples of geography type names. + A list of 2-item tuples of geography type names and ids. Examples: - [(, "Nation"), ...] + [(1, "Nation"), ...] """ metrics_interface = MetricsAPIInterface() diff --git a/cms/metrics_interface/interface.py b/cms/metrics_interface/interface.py index 9e7ab2aaa..abb72bfef 100644 --- a/cms/metrics_interface/interface.py +++ b/cms/metrics_interface/interface.py @@ -187,46 +187,46 @@ def get_all_theme_names(self) -> QuerySet: return self.theme_manager.get_all_names() def get_all_theme_names_and_ids(self) -> QuerySet: - """Gets all available theme names as a flat list queryset. - Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + """Gets all available theme names names and ids as a list queryset. + Note this is achieved by delegating the call to the `SubThemeManager` from the Metrics API Returns: QuerySet: A queryset of the individual theme names. Examples: - ``. + ``. """ return self.theme_manager.get_all_names_and_ids() def get_all_sub_theme_names_and_ids(self) -> QuerySet: - """Gets all available theme names as a flat list queryset. - Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + """Gets all available subtheme names names and ids as a list queryset. + Note this is achieved by delegating the call to the `SubThemeManager` from the Metrics API Returns: - QuerySet: A queryset of the individual theme names. + QuerySet: A queryset of the individual subtheme names. Examples: - ``. + ``. """ return self.sub_theme_manager.get_all_names_and_ids() def get_all_topic_names_and_ids(self) -> QuerySet: - """Gets all available theme names as a flat list queryset. - Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + """Gets all available topic names names and ids as a list queryset. + Note this is achieved by delegating the call to the `TopicManager` from the Metrics API Returns: - QuerySet: A queryset of the individual theme names. + QuerySet: A queryset of the individual topic names. Examples: - ``. + ``. """ return self.topic_manager.get_all_names_and_ids() def get_all_metric_names_and_ids(self) -> QuerySet: - """Gets all available theme names as a flat list queryset. - Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + """Gets all available metric names names and ids as a list queryset. + Note this is achieved by delegating the call to the `MetricManager` from the Metrics API Returns: - QuerySet: A queryset of the individual theme names. + QuerySet: A queryset of the individual metric names. Examples: - ``. + ``. """ return self.metric_manager.get_all_names_and_ids() @@ -265,17 +265,6 @@ def get_filtered_unique_sub_theme_names_for_parent_theme(self, parent_theme_id) """ return self.sub_theme_manager.get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) - def get_all_sub_theme_names_and_ids(self) -> QuerySet: - """Gets all available theme names as a flat list queryset. - Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API - - Returns: - QuerySet: A queryset of the individual theme names. - Examples: - ``. - """ - return self.sub_theme_manager.get_all_names_and_ids() - def get_all_topic_names(self) -> QuerySet: """Gets all available topic names as a flat list queryset. Note this is achieved by delegating the call to the `TopicManager` from the Metrics API @@ -472,7 +461,7 @@ def get_all_geography_type_names_and_ids(self) -> QuerySet: Returns: QuerySet: A queryset of the individual geography_type names: Examples: - `` + `` """ return self.geography_type_manager.get_all_names_and_ids() From cc5bd4a0c74f8f9759dcdcf9f61f62d86619f679 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 24 Mar 2026 16:52:24 +0000 Subject: [PATCH 044/186] CDD-3175: tidied up the geography serializer --- metrics/api/serializers/geographies.py | 13 +++---------- metrics/api/serializers/help_texts.py | 4 +++- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/metrics/api/serializers/geographies.py b/metrics/api/serializers/geographies.py index b0e45bf83..af6b5e1a6 100644 --- a/metrics/api/serializers/geographies.py +++ b/metrics/api/serializers/geographies.py @@ -1,6 +1,7 @@ from collections import defaultdict from rest_framework import serializers +from metrics.api.serializers import help_texts from metrics.api.serializers.permission_sets import _queryset_to_id_name_tuples from metrics.data.in_memory_models.geography_relationships.handlers import ( @@ -197,7 +198,7 @@ class GeographyChoicesResponseSerializer(serializers.Serializer): min_length=2, max_length=2 ), - help_text="List of [id, name] pairs for dropdown options" + help_text=help_texts.GEOGRAPHY_TUPLE_FORMATTING ) @@ -246,7 +247,7 @@ def validate_geography_type_id(self, value): def data(self) -> dict: """ - Fetch sub-themes from DB and format as response. + Fetch geographies for specified geography type from DB and format as response. Returns: Dict with 'choices' key containing list of [id, name] pairs @@ -257,16 +258,12 @@ def data(self) -> dict: if geography_type_id == "-1": return {'choices': [["-1", "* (All geographies)"]]} - # Fetch from interface parent_geography_type_id = int(geography_type_id) geographies = self.geography_manager.get_geography_codes_and_names_by_geography_type_id( parent_geography_type_id) - print(geographies) geography_names_and_codes_tuples = _queryset_to_geography_code_name_tuples( geographies) - # Format response - print('geography data: ', geography_names_and_codes_tuples) choices = [[str(geography_code), name] for geography_code, name in geography_names_and_codes_tuples] @@ -288,8 +285,4 @@ def _queryset_to_geography_code_name_tuples(queryset: QuerySet) -> list[tuple[st >>> queryset_to_id_name_tuples(qs) [(1, "item1"), (2, "item2")] """ - print('received queryset: ', queryset) - - for item in queryset: - print('item: ', item) return [(item['geography_code'], item['name']) for item in queryset] diff --git a/metrics/api/serializers/help_texts.py b/metrics/api/serializers/help_texts.py index f04c6dfc1..518d637d4 100644 --- a/metrics/api/serializers/help_texts.py +++ b/metrics/api/serializers/help_texts.py @@ -126,7 +126,9 @@ Boolean switch to decide whether to draw splines on individual data points. If set to false, linear point-to-point lines will be drawn between points. """ - CONFIDENCE_INTERVALS: str = """ Boolean switch to decide whether to draw confidence intervals if provided """ +GEOGRAPHY_TUPLE_FORMATTING: str = """ +"List of [id, name] pairs for dropdown options" +""" From e07efdfa980dc0f6c77cd15f761efc203f803313 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 24 Mar 2026 16:55:30 +0000 Subject: [PATCH 045/186] CDD-3175: formatting --- auth_content/models.py | 167 ++++++++++-------- auth_content/wagtail_hooks.py | 9 +- cms/dashboard/wagtail_hooks.py | 2 - .../field_choices_callables.py | 19 +- cms/metrics_interface/interface.py | 8 +- metrics/api/serializers/geographies.py | 46 ++--- metrics/api/serializers/permission_sets.py | 69 ++++---- metrics/api/urls_construction.py | 66 ++++--- metrics/api/views/geographies.py | 16 +- metrics/api/views/permission_sets.py | 39 ++-- .../data/managers/core_models/geography.py | 13 +- metrics/data/managers/core_models/metric.py | 19 +- .../data/managers/core_models/sub_theme.py | 16 +- metrics/data/managers/core_models/theme.py | 2 +- metrics/data/managers/core_models/topic.py | 20 ++- validation/enums/helper_enum.py | 2 +- 16 files changed, 291 insertions(+), 222 deletions(-) diff --git a/auth_content/models.py b/auth_content/models.py index 330f8fbf1..99c6932e1 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -1,12 +1,13 @@ from django import forms - -from django.db import models from django.core.exceptions import ValidationError +from django.db import models +from wagtail.admin.forms import WagtailAdminModelForm from wagtail.admin.panels import FieldPanel -from cms.metrics_interface.field_choices_callables import get_all_geography_type_names_and_ids, get_all_theme_names_and_ids -from validation.enums.geographies_enums import GeographyType -from wagtail.admin.forms import WagtailAdminModelForm +from cms.metrics_interface.field_choices_callables import ( + get_all_geography_type_names_and_ids, + get_all_theme_names_and_ids, +) def get_theme_child_map(): @@ -20,8 +21,6 @@ def get_theme_child_map(): """ theme_mapping = {} - - print(theme_mapping) return theme_mapping @@ -30,74 +29,75 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Use CharField with Select widget to bypass choice validation - self.fields['sub_theme'] = forms.CharField( + self.fields["sub_theme"] = forms.CharField( required=True, label="Sub Theme", - widget=forms.Select(choices=[("", "Select theme first")]) + widget=forms.Select(choices=[("", "Select theme first")]), ) - self.fields['topic'] = forms.CharField( + self.fields["topic"] = forms.CharField( required=True, label="Topic", - widget=forms.Select(choices=[("", "Select sub-theme first")]) + widget=forms.Select(choices=[("", "Select sub-theme first")]), ) - self.fields['metric'] = forms.CharField( + self.fields["metric"] = forms.CharField( required=True, label="Metric", - widget=forms.Select(choices=[("", "Select topic first")]) + widget=forms.Select(choices=[("", "Select topic first")]), ) - self.fields['geography'] = forms.CharField( + self.fields["geography"] = forms.CharField( required=True, label="Geography", widget=forms.Select( - choices=[("-1", "Select geography type first")]) + choices=[("-1", "Select geography type first")]), ) if self.instance and self.instance.pk: # Sub-theme if self.instance.sub_theme and self.instance.sub_theme != "-1": - self.fields['sub_theme'].widget.choices = [ + self.fields["sub_theme"].widget.choices = [ ("", "Select theme first"), - (self.instance.sub_theme, - f"Loading... (ID: {self.instance.sub_theme})") + ( + self.instance.sub_theme, + f"Loading... (ID: {self.instance.sub_theme})", + ), ] elif self.instance.sub_theme == "-1": - self.fields['sub_theme'].widget.choices = [ - ("-1", "* (All sub-themes)") - ] + self.fields["sub_theme"].widget.choices = [ + ("-1", "* (All sub-themes)")] # Topic if self.instance.topic and self.instance.topic != "-1": - self.fields['topic'].widget.choices = [ + self.fields["topic"].widget.choices = [ ("", "Select sub-theme first"), (self.instance.topic, - f"Loading... (ID: {self.instance.topic})") + f"Loading... (ID: {self.instance.topic})"), ] elif self.instance.topic == "-1": - self.fields['topic'].widget.choices = [ - ("-1", "* (All topics)") - ] + self.fields["topic"].widget.choices = [ + ("-1", "* (All topics)")] # Metric if self.instance.metric and self.instance.metric != "-1": - self.fields['metric'].widget.choices = [ + self.fields["metric"].widget.choices = [ ("", "Select topic first"), (self.instance.metric, - f"Loading... (ID: {self.instance.metric})") + f"Loading... (ID: {self.instance.metric})"), ] elif self.instance.metric == "-1": - self.fields['metric'].widget.choices = [ - ("-1", "* (All metrics)") - ] + self.fields["metric"].widget.choices = [ + ("-1", "* (All metrics)")] # Geography if self.instance.geography and self.instance.geography != "-1": - self.fields['geography'].widget.choices = [ + self.fields["geography"].widget.choices = [ ("", "Select geography type first"), - (self.instance.geography, - f"Loading... (ID: {self.instance.geography})") + ( + self.instance.geography, + f"Loading... (ID: {self.instance.geography})", + ), ] elif self.instance.geography == "-1": - self.fields['geography'].widget.choices = [ + self.fields["geography"].widget.choices = [ ("-1", "* (All geographies)") ] @@ -105,12 +105,12 @@ def clean(self): """Validate that this permission set doesn't already exist""" cleaned_data = super().clean() - theme = cleaned_data.get('theme') - sub_theme = cleaned_data.get('sub_theme') - topic = cleaned_data.get('topic') - metric = cleaned_data.get('metric') - geography_type = cleaned_data.get('geography_type') - geography = cleaned_data.get('geography') + theme = cleaned_data.get("theme") + sub_theme = cleaned_data.get("sub_theme") + topic = cleaned_data.get("topic") + metric = cleaned_data.get("metric") + geography_type = cleaned_data.get("geography_type") + geography = cleaned_data.get("geography") # Check if this combination already exists (excluding current instance when editing) queryset = PermissionSet.objects.filter( @@ -119,7 +119,7 @@ def clean(self): topic=topic, metric=metric, geography_type=geography_type, - geography=geography + geography=geography, ) if self.instance.pk: @@ -139,20 +139,26 @@ class PermissionSet(models.Model): max_length=500, blank=True, editable=False, - help_text="Auto-generated display name" + help_text="Auto-generated display name", ) theme = models.CharField( - max_length=255, choices=[("", "---------"), ("-1", "* (All themes)")] + get_all_theme_names_and_ids(), blank=False, default="") - sub_theme = models.CharField( - max_length=255, blank=False, default="") - topic = models.CharField(max_length=255, - blank=False, default="") - metric = models.CharField( - max_length=255, blank=False, default="") - geography_type = models.CharField(max_length=255, choices=[( - "", "---------"), ("-1", "* (All geography-types)")] + get_all_geography_type_names_and_ids(), blank=False, default="") - geography = models.CharField( - max_length=255, blank=False, default="") + max_length=255, + choices=[("", "---------"), ("-1", "* (All themes)")] + + get_all_theme_names_and_ids(), + blank=False, + default="", + ) + sub_theme = models.CharField(max_length=255, blank=False, default="") + topic = models.CharField(max_length=255, blank=False, default="") + metric = models.CharField(max_length=255, blank=False, default="") + geography_type = models.CharField( + max_length=255, + choices=[("", "---------"), ("-1", "* (All geography-types)")] + + get_all_geography_type_names_and_ids(), + blank=False, + default="", + ) + geography = models.CharField(max_length=255, blank=False, default="") base_form_class = PermissionSetForm @@ -168,9 +174,15 @@ class PermissionSet(models.Model): class Meta: constraints = [ models.UniqueConstraint( - fields=['theme', 'sub_theme', 'topic', - 'metric', 'geography_type', 'geography'], - name='unique_permission_set' + fields=[ + "theme", + "sub_theme", + "topic", + "metric", + "geography_type", + "geography", + ], + name="unique_permission_set", ) ] @@ -184,7 +196,6 @@ def _generate_display_name(self): Generate display name using the selected dropdown labels. This uses the form's choice labels, not database lookups. """ - from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids parts = [] @@ -192,7 +203,7 @@ def _generate_display_name(self): if self.theme == "-1": parts.append("Theme: * (All)") elif self.theme: - theme_name = self._get_choice_label('theme', self.theme) + theme_name = self._get_choice_label("theme", self.theme) parts.append(f"Theme: {theme_name}") # Sub-theme @@ -200,30 +211,28 @@ def _generate_display_name(self): parts.append("Sub-theme: * (All)") elif self.sub_theme: sub_theme_name = self._get_choice_label( - 'sub-theme', self.sub_theme) + "sub-theme", self.sub_theme) parts.append(f"Sub-theme ID: {sub_theme_name}") # Topic if self.topic == "-1": parts.append("Topic: * (All)") elif self.topic: - topic_name = self._get_choice_label( - 'topic', self.topic) + topic_name = self._get_choice_label("topic", self.topic) parts.append(f"Topic: {topic_name}") # Metric if self.metric == "-1": parts.append("Metric: * (All)") elif self.metric: - metric_name = self._get_choice_label( - 'metric', self.metric) + metric_name = self._get_choice_label("metric", self.metric) parts.append(f"Metric: {metric_name}") # Geography type if self.geography_type == "-1": parts.append("Geography Type: * (All)") elif self.geography_type: - geo_type_label = self.geography_type.replace('_', ' ').title() + geo_type_label = self.geography_type.replace("_", " ").title() parts.append(f"Geography Type: {geo_type_label}") # Geography @@ -236,29 +245,41 @@ def _generate_display_name(self): def _get_choice_label(self, field_name, value): """Get the display label for a choice field""" - if field_name == 'theme': - from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids + if field_name == "theme": + from cms.metrics_interface.field_choices_callables import ( + get_all_theme_names_and_ids, + ) + choices = get_all_theme_names_and_ids() for choice_value, choice_label in choices: if choice_value == value: return choice_label - if field_name == 'sub-theme': - from cms.metrics_interface.field_choices_callables import get_all_sub_theme_names_and_ids + if field_name == "sub-theme": + from cms.metrics_interface.field_choices_callables import ( + get_all_sub_theme_names_and_ids, + ) + choices = get_all_sub_theme_names_and_ids() for choice_value, choice_label in choices: if choice_value == value: return choice_label - if field_name == 'topic': - from cms.metrics_interface.field_choices_callables import get_all_topic_names_and_ids + if field_name == "topic": + from cms.metrics_interface.field_choices_callables import ( + get_all_topic_names_and_ids, + ) + choices = get_all_topic_names_and_ids() for choice_value, choice_label in choices: if choice_value == value: return choice_label - if field_name == 'metric': - from cms.metrics_interface.field_choices_callables import get_all_metric_names_and_ids + if field_name == "metric": + from cms.metrics_interface.field_choices_callables import ( + get_all_metric_names_and_ids, + ) + choices = get_all_metric_names_and_ids() for choice_value, choice_label in choices: if choice_value == value: diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py index f4700cce3..eef13b3fe 100644 --- a/auth_content/wagtail_hooks.py +++ b/auth_content/wagtail_hooks.py @@ -1,13 +1,10 @@ -import json - +from django.templatetags.static import static +from django.utils.html import format_html from wagtail import hooks -from wagtail.snippets.views.snippets import SnippetViewSet from wagtail.admin.viewsets.model import ModelViewSetGroup -from django.templatetags.static import static +from wagtail.snippets.views.snippets import SnippetViewSet from auth_content.models import PermissionSet -from django.utils.html import format_html -from django.utils.safestring import mark_safe class PermissionSetViewSet(SnippetViewSet): diff --git a/cms/dashboard/wagtail_hooks.py b/cms/dashboard/wagtail_hooks.py index 2507444bd..e20b3d7fc 100644 --- a/cms/dashboard/wagtail_hooks.py +++ b/cms/dashboard/wagtail_hooks.py @@ -8,8 +8,6 @@ from wagtail.admin.site_summary import PagesSummaryItem, SummaryItem from wagtail.models import Page from wagtail.whitelist import check_url -from wagtail import hooks - @hooks.register("insert_global_admin_css") diff --git a/cms/metrics_interface/field_choices_callables.py b/cms/metrics_interface/field_choices_callables.py index 129a6b530..88be7b3a2 100644 --- a/cms/metrics_interface/field_choices_callables.py +++ b/cms/metrics_interface/field_choices_callables.py @@ -10,9 +10,10 @@ And allowing the CMS to provide the content creator with access to the `latest` data after the point of ingestion. """ -from cms.metrics_interface import MetricsAPIInterface from django.db.models import QuerySet +from cms.metrics_interface import MetricsAPIInterface + LIST_OF_TWO_STRING_ITEM_TUPLES = list[tuple[str, str]] DICT_OF_CHART_AXIS_AND_SUB_CATEGORIES = dict[str, list[str]] GEOGRAPHY_TYPE_NAME_FOR_ALERTS = "Government Office Region" @@ -25,9 +26,7 @@ def _build_two_item_tuple_choices( return [(choice, choice) for choice in choices] -def _build_id_name_tuple_choices( - *, choices: QuerySet -) -> list[tuple[str, str]]: +def _build_id_name_tuple_choices(*, choices: QuerySet) -> list[tuple[str, str]]: """Build choices from a QuerySet containing id and name fields. Args: @@ -38,7 +37,7 @@ def _build_id_name_tuple_choices( Examples: [(1, "infectious_disease"), (2, "respiratory"), ...] """ - return [(str(choice['id']), choice['name']) for choice in choices] + return [(str(choice["id"]), choice["name"]) for choice in choices] def get_possible_axis_choices() -> LIST_OF_TWO_STRING_ITEM_TUPLES: @@ -384,7 +383,9 @@ def get_all_sub_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: ) -def get_filtered_unique_sub_theme_names_for_parent_theme(parent_theme_id) -> LIST_OF_TWO_STRING_ITEM_TUPLES: +def get_filtered_unique_sub_theme_names_for_parent_theme( + parent_theme_id, +) -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `theme` fields of the CMS blocks. Notes: @@ -403,7 +404,8 @@ def get_filtered_unique_sub_theme_names_for_parent_theme(parent_theme_id) -> LIS metrics_interface = MetricsAPIInterface() return _build_id_name_tuple_choices( choices=metrics_interface.get_filtered_unique_sub_theme_names_for_parent_theme( - parent_theme_id=parent_theme_id), + parent_theme_id=parent_theme_id + ), ) @@ -721,8 +723,7 @@ def get_all_geography_choices_grouped_by_type() -> ( def get_all_subcategory_choices_grouped_by_categories() -> ( dict[ - str, LIST_OF_TWO_STRING_ITEM_TUPLES | dict[str, - LIST_OF_TWO_STRING_ITEM_TUPLES] + str, LIST_OF_TWO_STRING_ITEM_TUPLES | dict[str, LIST_OF_TWO_STRING_ITEM_TUPLES] ] ): """Callable to return all subcategory choices groups by categories. diff --git a/cms/metrics_interface/interface.py b/cms/metrics_interface/interface.py index abb72bfef..fe2fe8a6d 100644 --- a/cms/metrics_interface/interface.py +++ b/cms/metrics_interface/interface.py @@ -253,7 +253,9 @@ def get_all_unique_sub_theme_names(self) -> QuerySet: """ return self.sub_theme_manager.get_all_unique_names() - def get_filtered_unique_sub_theme_names_for_parent_theme(self, parent_theme_id) -> QuerySet: + def get_filtered_unique_sub_theme_names_for_parent_theme( + self, parent_theme_id + ) -> QuerySet: """Get all unique sub_theme names as a flat list queryset. Note this is achieved by delegating the call to the `SubThemeManager` from the Metrics API @@ -263,7 +265,9 @@ def get_filtered_unique_sub_theme_names_for_parent_theme(self, parent_theme_id) ` """ - return self.sub_theme_manager.get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) + return self.sub_theme_manager.get_filtered_unique_names_related_to_theme( + parent_theme_id=parent_theme_id + ) def get_all_topic_names(self) -> QuerySet: """Gets all available topic names as a flat list queryset. diff --git a/metrics/api/serializers/geographies.py b/metrics/api/serializers/geographies.py index af6b5e1a6..d7eb27782 100644 --- a/metrics/api/serializers/geographies.py +++ b/metrics/api/serializers/geographies.py @@ -1,9 +1,9 @@ from collections import defaultdict +from django.db.models import QuerySet from rest_framework import serializers -from metrics.api.serializers import help_texts -from metrics.api.serializers.permission_sets import _queryset_to_id_name_tuples +from metrics.api.serializers import help_texts from metrics.data.in_memory_models.geography_relationships.handlers import ( get_upstream_relationships_for_geography, ) @@ -13,7 +13,6 @@ Geography, Topic, ) -from django.db.models import QuerySet GEOGRAPHY_TYPE_RESULT = dict[str, list[dict[str, str]]] @@ -64,8 +63,7 @@ def data(self) -> list[GEOGRAPHY_TYPE_RESULT]: """ topic: str = self.validated_data["topic"] queryset: CoreTimeSeriesQuerySet = ( - self.core_time_series_manager.get_available_geographies( - topic=topic) + self.core_time_series_manager.get_available_geographies(topic=topic) ) return _serialize_queryset(queryset=queryset) @@ -192,13 +190,12 @@ class GeographiesResponseSerializer(serializers.ListSerializer): class GeographyChoicesResponseSerializer(serializers.Serializer): """Formats the response for choice endpoints""" + choices = serializers.ListField( child=serializers.ListField( - child=serializers.CharField(), - min_length=2, - max_length=2 + child=serializers.CharField(), min_length=2, max_length=2 ), - help_text=help_texts.GEOGRAPHY_TUPLE_FORMATTING + help_text=help_texts.GEOGRAPHY_TUPLE_FORMATTING, ) @@ -242,8 +239,7 @@ def validate_geography_type_id(self, value): int(value) return value except ValueError: - raise serializers.ValidationError( - "Geography Type must be a number or '-1'") + raise serializers.ValidationError("Geography Type must be a number or '-1'") def data(self) -> dict: """ @@ -252,25 +248,33 @@ def data(self) -> dict: Returns: Dict with 'choices' key containing list of [id, name] pairs """ - geography_type_id = self.validated_data['geography_type_id'] + geography_type_id = self.validated_data["geography_type_id"] # Handle wildcard if geography_type_id == "-1": - return {'choices': [["-1", "* (All geographies)"]]} + return {"choices": [["-1", "* (All geographies)"]]} parent_geography_type_id = int(geography_type_id) - geographies = self.geography_manager.get_geography_codes_and_names_by_geography_type_id( - parent_geography_type_id) + geographies = ( + self.geography_manager.get_geography_codes_and_names_by_geography_type_id( + parent_geography_type_id + ) + ) geography_names_and_codes_tuples = _queryset_to_geography_code_name_tuples( - geographies) + geographies + ) - choices = [[str(geography_code), name] - for geography_code, name in geography_names_and_codes_tuples] + choices = [ + [str(geography_code), name] + for geography_code, name in geography_names_and_codes_tuples + ] - return {'choices': choices} + return {"choices": choices} -def _queryset_to_geography_code_name_tuples(queryset: QuerySet) -> list[tuple[str, str]]: +def _queryset_to_geography_code_name_tuples( + queryset: QuerySet, +) -> list[tuple[str, str]]: """ Convert a QuerySet with 'id' and 'name' fields to a list of tuples. @@ -285,4 +289,4 @@ def _queryset_to_geography_code_name_tuples(queryset: QuerySet) -> list[tuple[st >>> queryset_to_id_name_tuples(qs) [(1, "item1"), (2, "item2")] """ - return [(item['geography_code'], item['name']) for item in queryset] + return [(item["geography_code"], item["name"]) for item in queryset] diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py index aeb33125d..c14ffd11f 100644 --- a/metrics/api/serializers/permission_sets.py +++ b/metrics/api/serializers/permission_sets.py @@ -1,11 +1,12 @@ +from django.db.models import QuerySet from rest_framework import serializers -from metrics.data.models.core_models.supporting import SubTheme, Topic, Metric -from django.db.models import QuerySet +from metrics.data.models.core_models.supporting import Metric, SubTheme, Topic class SubThemeRequestSerializer(serializers.Serializer): """Fetches and formats sub-theme choices based on theme_id""" + theme_id = serializers.CharField(required=True) @property @@ -35,26 +36,25 @@ def data(self) -> dict: Returns: Dict with 'choices' key containing list of [id, name] pairs """ - theme_id = self.validated_data['theme_id'] + theme_id = self.validated_data["theme_id"] - # Handle wildcard if theme_id == "-1": - return {'choices': [["-1", "* (All sub-themes)"]]} + return {"choices": [["-1", "* (All sub-themes)"]]} - # Fetch from interface parent_theme_id = int(theme_id) - sub_theme_tuples = _queryset_to_id_name_tuples(self.sub_theme_manager.get_filtered_unique_names_related_to_theme( - parent_theme_id)) - - # Format response - print('sub_themes: ', sub_theme_tuples) + sub_theme_tuples = _queryset_to_id_name_tuples( + self.sub_theme_manager.get_filtered_unique_names_related_to_theme( + parent_theme_id + ) + ) choices = [[str(id), name] for id, name in sub_theme_tuples] - return {'choices': choices} + return {"choices": choices} class TopicRequestSerializer(serializers.Serializer): """Fetches and formats topic related to sub-themes based on provided parent sub_theme_id""" + sub_theme_id = serializers.CharField(required=True) @property @@ -84,26 +84,26 @@ def data(self) -> dict: Returns: Dict with 'choices' key containing list of [id, name] pairs """ - sub_theme_id = self.validated_data['sub_theme_id'] + sub_theme_id = self.validated_data["sub_theme_id"] - # Handle wildcard if sub_theme_id == "-1": - return {'choices': [["-1", "* (All topics)"]]} + return {"choices": [["-1", "* (All topics)"]]} - # Fetch from interface parent_sub_theme_id = int(sub_theme_id) - topic_tuples = _queryset_to_id_name_tuples(self.topic_manager.get_filtered_unique_names_related_to_sub_theme( - parent_sub_theme_id)) + topic_tuples = _queryset_to_id_name_tuples( + self.topic_manager.get_filtered_unique_names_related_to_sub_theme( + parent_sub_theme_id + ) + ) - # Format response - print('sub_themes: ', topic_tuples) choices = [[str(id), name] for id, name in topic_tuples] - return {'choices': choices} + return {"choices": choices} class MetricRequestSerializer(serializers.Serializer): """Fetches and formats metrics related to topics based on provided parent topic_id""" + topic_id = serializers.CharField(required=True) @property @@ -133,33 +133,31 @@ def data(self) -> dict: Returns: Dict with 'choices' key containing list of [id, name] pairs """ - topic_id = self.validated_data['topic_id'] + topic_id = self.validated_data["topic_id"] - # Handle wildcard if topic_id == "-1": - return {'choices': [["-1", "* (All metrics)"]]} + return {"choices": [["-1", "* (All metrics)"]]} - # Fetch from interface parent_topic_id = int(topic_id) - metric_tuples = _queryset_to_id_name_tuples(self.metric_manager.get_filtered_unique_names_related_to_parent_topic_id( - parent_topic_id)) + metric_tuples = _queryset_to_id_name_tuples( + self.metric_manager.get_filtered_unique_names_related_to_parent_topic_id( + parent_topic_id + ) + ) - # Format response - print('metrics: ', metric_tuples) choices = [[str(id), name] for id, name in metric_tuples] - return {'choices': choices} + return {"choices": choices} class PermissionSetResponseSerializer(serializers.Serializer): """Formats the response for choice endpoints""" + choices = serializers.ListField( child=serializers.ListField( - child=serializers.CharField(), - min_length=2, - max_length=2 + child=serializers.CharField(), min_length=2, max_length=2 ), - help_text="List of [id, name] pairs for dropdown options" + help_text="List of [id, name] pairs for dropdown options", ) @@ -178,5 +176,4 @@ def _queryset_to_id_name_tuples(queryset: QuerySet) -> list[tuple[int, str]]: >>> queryset_to_id_name_tuples(qs) [(1, "item1"), (2, "item2")] """ - print('received queryset: ', queryset) - return [(item['id'], item['name']) for item in queryset] + return [(item["id"], item["name"]) for item in queryset] diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index 5519e23d2..0af934f35 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -36,10 +36,18 @@ ) from metrics.api.views.charts import DualCategoryChartsView from metrics.api.views.charts.subplot_charts import SubplotChartsView -from metrics.api.views.geographies import GeographiesByGeographyTypeView, GeographiesView, GeographiesViewDeprecated +from metrics.api.views.geographies import ( + GeographiesByGeographyTypeView, + GeographiesView, + GeographiesViewDeprecated, +) from metrics.api.views.health import InternalHealthView from metrics.api.views.maps import MapsView -from metrics.api.views.permission_sets import MetricsByTopicView, SubThemesByThemeView, TopicsBySubThemeView +from metrics.api.views.permission_sets import ( + MetricsByTopicView, + SubThemesByThemeView, + TopicsBySubThemeView, +) from public_api import construct_url_patterns_for_public_api router = routers.DefaultRouter() @@ -82,8 +90,7 @@ def construct_cms_admin_urlpatterns( prefix: str = "" if app_mode == enums.AppMode.CMS_ADMIN.value else "cms-admin/" return [ path(prefix, include(wagtailadmin_urls)), - path("choose-page/", LinkBrowseView.as_view(), - name="wagtailadmin_choose_page"), + path("choose-page/", LinkBrowseView.as_view(), name="wagtailadmin_choose_page"), ] @@ -131,24 +138,34 @@ def construct_public_api_urlpatterns( # Headless CMS API - pages + drafts endpoints path(API_PREFIX, cms_api_router.urls), path(f"{API_PREFIX}global-banners/v2", GlobalBannerView.as_view()), - path(f"{API_PREFIX}permission-set/subthemes/", - SubThemesByThemeView.as_view(), name='get_subthemes'), - path(f"{API_PREFIX}permission-set/topics/", - TopicsBySubThemeView.as_view(), name='get_topics'), - path(f"{API_PREFIX}permission-set/metrics/", - MetricsByTopicView.as_view(), name='get_metrics'), - path(f"{API_PREFIX}permission-set/geographies/", - GeographiesByGeographyTypeView.as_view(), name='get_geographies'), + path( + f"{API_PREFIX}permission-set/subthemes/", + SubThemesByThemeView.as_view(), + name="get_subthemes", + ), + path( + f"{API_PREFIX}permission-set/topics/", + TopicsBySubThemeView.as_view(), + name="get_topics", + ), + path( + f"{API_PREFIX}permission-set/metrics/", + MetricsByTopicView.as_view(), + name="get_metrics", + ), + path( + f"{API_PREFIX}permission-set/geographies/", + GeographiesByGeographyTypeView.as_view(), + name="get_geographies", + ), path(f"{API_PREFIX}menus/v1", MenuView.as_view()), - path(f"{API_PREFIX}alerts/v1/heat", - heat_alert_list, name="heat-alerts-list"), + path(f"{API_PREFIX}alerts/v1/heat", heat_alert_list, name="heat-alerts-list"), path( f"{API_PREFIX}alerts/v1/heat/", heat_alert_detail, name="heat-alerts-detail", ), - path(f"{API_PREFIX}alerts/v1/cold", - cold_alert_list, name="cold-alerts-list"), + path(f"{API_PREFIX}alerts/v1/cold", cold_alert_list, name="cold-alerts-list"), path( f"{API_PREFIX}alerts/v1/cold/", cold_alert_detail, @@ -159,28 +176,23 @@ def construct_public_api_urlpatterns( re_path(f"^{API_PREFIX}charts/subplot/v1", SubplotChartsView.as_view()), re_path(f"^{API_PREFIX}downloads/v2", DownloadsView.as_view()), re_path(f"^{API_PREFIX}bulkdownloads/v1", BulkDownloadsView.as_view()), - re_path(f"^{API_PREFIX}downloads/subplot/v1", - SubplotDownloadsView.as_view()), + re_path(f"^{API_PREFIX}downloads/subplot/v1", SubplotDownloadsView.as_view()), re_path( f"^{API_PREFIX}geographies/v2/(?P[^/]+)", GeographiesViewDeprecated.as_view(), ), re_path(f"^{API_PREFIX}geographies/v3", GeographiesView.as_view()), - re_path(f"^{API_PREFIX}headlines/v3", HeadlinesView.as_view()), re_path(f"^{API_PREFIX}maps/v1", MapsView.as_view()), re_path(f"^{API_PREFIX}tables/v4", TablesView.as_view()), re_path(f"^{API_PREFIX}tables/subplot/v1", TablesSubplotView.as_view()), re_path(f"^{API_PREFIX}trends/v3", TrendsView.as_view()), - ] # Audit API endpoints audit_api_timeseries_list = AuditAPITimeSeriesViewSet.as_view({"get": "list"}) -audit_core_timeseries_list = AuditCoreTimeseriesViewSet.as_view({ - "get": "list"}) -audit_api_core_headline_list = AuditCoreHeadlineViewSet.as_view({ - "get": "list"}) +audit_core_timeseries_list = AuditCoreTimeseriesViewSet.as_view({"get": "list"}) +audit_api_core_headline_list = AuditCoreHeadlineViewSet.as_view({"get": "list"}) audit_api_urlpatterns = [ path( @@ -200,8 +212,7 @@ def construct_public_api_urlpatterns( ), re_path(f"^{API_PREFIX}charts/v2", ChartsView.as_view()), re_path(f"^{API_PREFIX}charts/v3", EncodedChartsView.as_view()), - re_path(f"^{API_PREFIX}charts/dual-category/v1", - DualCategoryChartsView.as_view()), + re_path(f"^{API_PREFIX}charts/dual-category/v1", DualCategoryChartsView.as_view()), re_path(f"^{API_PREFIX}charts/subplot/v1", SubplotChartsView.as_view()), ] @@ -222,8 +233,7 @@ def construct_public_api_urlpatterns( ] static_urlpatterns = [ - re_path(r"^static/(?P.*)$", serve, - {"document_root": settings.STATIC_ROOT}), + re_path(r"^static/(?P.*)$", serve, {"document_root": settings.STATIC_ROOT}), ] common_urlpatterns = [ diff --git a/metrics/api/views/geographies.py b/metrics/api/views/geographies.py index eb90d8270..f9b9d485c 100644 --- a/metrics/api/views/geographies.py +++ b/metrics/api/views/geographies.py @@ -66,17 +66,12 @@ class GeographiesView(APIView): ) def get(self, request, *args, **kwargs) -> Response: """This endpoint returns a list of geography types along with an aggregated list of their geographies. - --- - # Main errors - A query parameter of either `topic` or `geography_type` must be provided. If neither are provided **or** both are provided, then a 400 `Bad Request` 400 will be returned. - """ - request_serializer = GeographiesRequestSerializer( - data=request.query_params) + request_serializer = GeographiesRequestSerializer(data=request.query_params) request_serializer.is_valid(raise_exception=True) payload = request_serializer.data @@ -109,12 +104,17 @@ def _handle_geographies_by_geography_type( return serializer.data() -@extend_schema(request=GeographyByGeographyTypeRequestSerializer, tags=[GEOGRAPHIES_API_TAG], responses={HTTPStatus.OK.value: GeographyChoicesResponseSerializer}) +@extend_schema( + request=GeographyByGeographyTypeRequestSerializer, + tags=[GEOGRAPHIES_API_TAG], + responses={HTTPStatus.OK.value: GeographyChoicesResponseSerializer}, +) class GeographiesByGeographyTypeView(APIView): permission_classes = [] def get(self, request, geography_type_id, *args, **kwargs): serializer = GeographyByGeographyTypeRequestSerializer( - data={'geography_type_id': geography_type_id}) + data={"geography_type_id": geography_type_id} + ) serializer.is_valid(raise_exception=True) return Response(serializer.data()) diff --git a/metrics/api/views/permission_sets.py b/metrics/api/views/permission_sets.py index 49f233064..99bb9ce38 100644 --- a/metrics/api/views/permission_sets.py +++ b/metrics/api/views/permission_sets.py @@ -1,48 +1,65 @@ from http import HTTPStatus from drf_spectacular.utils import extend_schema -from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework import status +from rest_framework.views import APIView -from metrics.api.serializers.permission_sets import MetricRequestSerializer, PermissionSetResponseSerializer, SubThemeRequestSerializer, TopicRequestSerializer +from metrics.api.serializers.permission_sets import ( + MetricRequestSerializer, + PermissionSetResponseSerializer, + SubThemeRequestSerializer, + TopicRequestSerializer, +) PERMISSION_SETS_API_TAG = "data hierarchy" -@extend_schema(request=SubThemeRequestSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) +@extend_schema( + request=SubThemeRequestSerializer, + tags=[PERMISSION_SETS_API_TAG], + responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}, +) class SubThemesByThemeView(APIView): """Get sub-themes filtered by theme ID""" + permission_classes = [] def get(self, request, theme_id, *args, **kwargs): """API endpoint to fetch sub-themes based on selected theme.""" - serializer = SubThemeRequestSerializer(data={'theme_id': theme_id}) + serializer = SubThemeRequestSerializer(data={"theme_id": theme_id}) serializer.is_valid(raise_exception=True) return Response(serializer.data()) -@extend_schema(request=TopicRequestSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) +@extend_schema( + request=TopicRequestSerializer, + tags=[PERMISSION_SETS_API_TAG], + responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}, +) class TopicsBySubThemeView(APIView): """Get topics filtered by sub-theme ID""" + permission_classes = [] def get(self, request, sub_theme_id, *args, **kwargs): """API endpoint to fetch sub-themes based on selected theme.""" - serializer = TopicRequestSerializer( - data={'sub_theme_id': sub_theme_id}) + serializer = TopicRequestSerializer(data={"sub_theme_id": sub_theme_id}) serializer.is_valid(raise_exception=True) return Response(serializer.data()) -@extend_schema(request=MetricRequestSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) +@extend_schema( + request=MetricRequestSerializer, + tags=[PERMISSION_SETS_API_TAG], + responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}, +) class MetricsByTopicView(APIView): """Get metrics filtered by topic ID""" + permission_classes = [] def get(self, request, topic_id, *args, **kwargs): """API endpoint to fetch sub-themes based on selected theme.""" - serializer = MetricRequestSerializer( - data={'topic_id': topic_id}) + serializer = MetricRequestSerializer(data={"topic_id": topic_id}) serializer.is_valid(raise_exception=True) return Response(serializer.data()) diff --git a/metrics/data/managers/core_models/geography.py b/metrics/data/managers/core_models/geography.py index ec1ea2a93..675b99712 100644 --- a/metrics/data/managers/core_models/geography.py +++ b/metrics/data/managers/core_models/geography.py @@ -84,14 +84,14 @@ def get_geography_codes_and_names_by_geography_type_id( self, geography_type_id: int, ): - """Gets all available geography codes and names for the given `geography_type_name` + """Gets all available geography codes and names for the given `geography_type_id` Args: - geography_type_name: string representation of `geography_type_name` + geography_type_id: string representation of `geography_type_id` Returns: - QuerySet: A queryset of the individual geography codes - which are related to the given geography_type: + QuerySet: A queryset of the individual geography codes and geography names + which are related to the given geography_type_id: Examples: `` @@ -197,11 +197,10 @@ def get_geography_codes_and_names_by_geography_type_id( """Gets all available geography codes and names for a give `geography_type` Args: - geography_type_name: string representation of `geography_type_name` + geography_type_id: string representation of `geography_type_id` Returns: - QuerySet: A queryset of the individual geography codes - which are related to the given geography_type: + QuerySet: A queryset of the individual geography codes and names which are related to the given geography_type: Examples: `` diff --git a/metrics/data/managers/core_models/metric.py b/metrics/data/managers/core_models/metric.py index abf08ad45..eddd7c6d0 100644 --- a/metrics/data/managers/core_models/metric.py +++ b/metrics/data/managers/core_models/metric.py @@ -44,8 +44,7 @@ def get_all_unique_change_type_names(self) -> models.QuerySet: `` """ return self.get_all_unique_names().filter( - models.Q(name__icontains="change") & ~models.Q( - name__icontains="percent") + models.Q(name__icontains="change") & ~models.Q(name__icontains="percent") ) def get_all_unique_percent_change_type_names(self) -> models.QuerySet: @@ -83,7 +82,9 @@ def get_all_headline_names(self) -> models.QuerySet: """ return self.get_all_unique_names().filter(metric_group__name="headline") - def get_filtered_unique_names_related_to_parent_topic_id(self, parent_topic_id) -> models.QuerySet: + def get_filtered_unique_names_related_to_parent_topic_id( + self, parent_topic_id + ) -> models.QuerySet: """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. Returns: @@ -91,7 +92,7 @@ def get_filtered_unique_names_related_to_parent_topic_id(self, parent_topic_id) Examples: `` """ - return self.filter(topic_id=parent_topic_id).values('id', 'name').distinct() + return self.filter(topic_id=parent_topic_id).values("id", "name").distinct() def get_all_names_and_ids(self) -> models.QuerySet: """Gets all available themes with id and name fields. @@ -177,7 +178,9 @@ def get_all_headline_names(self) -> MetricQuerySet: """ return self.get_queryset().get_all_headline_names() - def get_filtered_unique_names_related_to_parent_topic_id(self, parent_topic_id: str) -> MetricQuerySet: + def get_filtered_unique_names_related_to_parent_topic_id( + self, parent_topic_id: str + ) -> MetricQuerySet: """Gets all available themes with id and name fields. Returns: @@ -185,7 +188,9 @@ def get_filtered_unique_names_related_to_parent_topic_id(self, parent_topic_id: Examples: `` """ - return self.get_queryset().get_filtered_unique_names_related_to_parent_topic_id(parent_topic_id=parent_topic_id) + return self.get_queryset().get_filtered_unique_names_related_to_parent_topic_id( + parent_topic_id=parent_topic_id + ) def get_all_names_and_ids(self) -> MetricQuerySet: """Gets all available themes with id and name fields. @@ -195,4 +200,4 @@ def get_all_names_and_ids(self) -> MetricQuerySet: Examples: `` """ - return self .get_queryset().get_all_names_and_ids() + return self.get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/sub_theme.py b/metrics/data/managers/core_models/sub_theme.py index 42956fbdf..17ede43df 100644 --- a/metrics/data/managers/core_models/sub_theme.py +++ b/metrics/data/managers/core_models/sub_theme.py @@ -43,7 +43,9 @@ def get_all_names_and_ids(self) -> models.QuerySet: """ return self.all().values("id", "name").distinct() - def get_filtered_unique_names_related_to_theme(self, parent_theme_id) -> models.QuerySet: + def get_filtered_unique_names_related_to_theme( + self, parent_theme_id + ) -> models.QuerySet: """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. Returns: @@ -51,7 +53,7 @@ def get_filtered_unique_names_related_to_theme(self, parent_theme_id) -> models. Examples: `` """ - return self.filter(theme_id=parent_theme_id).values('id', 'name').distinct() + return self.filter(theme_id=parent_theme_id).values("id", "name").distinct() class SubThemeManager(models.Manager): @@ -82,7 +84,9 @@ def get_all_unique_names(self) -> SubThemeQuerySet: """ return self.get_queryset().get_all_unique_names() - def get_filtered_unique_names_related_to_theme(self, parent_theme_id: str) -> SubThemeQuerySet: + def get_filtered_unique_names_related_to_theme( + self, parent_theme_id: str + ) -> SubThemeQuerySet: """Gets all available themes with id and name fields. Returns: @@ -90,7 +94,9 @@ def get_filtered_unique_names_related_to_theme(self, parent_theme_id: str) -> Su Examples: `` """ - return self.get_queryset().get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) + return self.get_queryset().get_filtered_unique_names_related_to_theme( + parent_theme_id=parent_theme_id + ) def get_all_names_and_ids(self) -> SubThemeQuerySet: """Gets all available themes with id and name fields. @@ -100,4 +106,4 @@ def get_all_names_and_ids(self) -> SubThemeQuerySet: Examples: `` """ - return self .get_queryset().get_all_names_and_ids() + return self.get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/theme.py b/metrics/data/managers/core_models/theme.py index ec9ac54d6..7980a5076 100644 --- a/metrics/data/managers/core_models/theme.py +++ b/metrics/data/managers/core_models/theme.py @@ -57,4 +57,4 @@ def get_all_names_and_ids(self) -> ThemeQuerySet: Examples: `` """ - return self .get_queryset().get_all_names_and_ids() + return self.get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index 0988625af..81018fc52 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -45,7 +45,9 @@ def get_by_name(self, name: str) -> "Topic": """ return self.get(name=name) - def get_filtered_unique_names_related_to_sub_theme(self, parent_sub_theme_id) -> models.QuerySet: + def get_filtered_unique_names_related_to_sub_theme( + self, parent_sub_theme_id + ) -> models.QuerySet: """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. Returns: @@ -53,7 +55,11 @@ def get_filtered_unique_names_related_to_sub_theme(self, parent_sub_theme_id) -> Examples: `` """ - return self.filter(sub_theme_id=parent_sub_theme_id).values('id', 'name').distinct() + return ( + self.filter(sub_theme_id=parent_sub_theme_id) + .values("id", "name") + .distinct() + ) def get_all_names_and_ids(self) -> models.QuerySet: """Gets all available themes with id and name fields. @@ -114,7 +120,9 @@ def get_by_name(self, name: str) -> "Topic": """ return self.get_queryset().get_by_name(name=name) - def get_filtered_unique_names_related_to_sub_theme(self, parent_sub_theme_id: str) -> TopicQuerySet: + def get_filtered_unique_names_related_to_sub_theme( + self, parent_sub_theme_id: str + ) -> TopicQuerySet: """Gets all available themes with id and name fields. Returns: @@ -122,7 +130,9 @@ def get_filtered_unique_names_related_to_sub_theme(self, parent_sub_theme_id: st Examples: `` """ - return self.get_queryset().get_filtered_unique_names_related_to_sub_theme(parent_sub_theme_id=parent_sub_theme_id) + return self.get_queryset().get_filtered_unique_names_related_to_sub_theme( + parent_sub_theme_id=parent_sub_theme_id + ) def get_all_names_and_ids(self) -> TopicQuerySet: """Gets all available themes with id and name fields. @@ -132,4 +142,4 @@ def get_all_names_and_ids(self) -> TopicQuerySet: Examples: `` """ - return self .get_queryset().get_all_names_and_ids() + return self.get_queryset().get_all_names_and_ids() diff --git a/validation/enums/helper_enum.py b/validation/enums/helper_enum.py index d46ddc25d..545ef2e9a 100644 --- a/validation/enums/helper_enum.py +++ b/validation/enums/helper_enum.py @@ -7,6 +7,6 @@ def return_list(self): def return_name_list(self): return [e.name for e in self.value] - + def return_tuple_list(self): return [(e.value, e.name.replace("_", " ").title()) for e in self.value] From 6995b9e353a32bcd2530f52df0528d9906880061 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 24 Mar 2026 17:27:44 +0000 Subject: [PATCH 046/186] Update documentation --- metrics/data/managers/core_models/metric.py | 19 ++++++++++--------- .../data/managers/core_models/sub_theme.py | 10 +++++----- metrics/data/managers/core_models/topic.py | 14 +++++++------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/metrics/data/managers/core_models/metric.py b/metrics/data/managers/core_models/metric.py index eddd7c6d0..6201adf9e 100644 --- a/metrics/data/managers/core_models/metric.py +++ b/metrics/data/managers/core_models/metric.py @@ -44,7 +44,8 @@ def get_all_unique_change_type_names(self) -> models.QuerySet: `` """ return self.get_all_unique_names().filter( - models.Q(name__icontains="change") & ~models.Q(name__icontains="percent") + models.Q(name__icontains="change") & ~models.Q( + name__icontains="percent") ) def get_all_unique_percent_change_type_names(self) -> models.QuerySet: @@ -85,22 +86,22 @@ def get_all_headline_names(self) -> models.QuerySet: def get_filtered_unique_names_related_to_parent_topic_id( self, parent_topic_id ) -> models.QuerySet: - """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. + """Gets all available unique metrics with id and name fields that are related to the parent topic ID. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ return self.filter(topic_id=parent_topic_id).values("id", "name").distinct() def get_all_names_and_ids(self) -> models.QuerySet: - """Gets all available themes with id and name fields. + """Gets all available metrics with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ return self.all().values("id", "name").distinct() @@ -181,23 +182,23 @@ def get_all_headline_names(self) -> MetricQuerySet: def get_filtered_unique_names_related_to_parent_topic_id( self, parent_topic_id: str ) -> MetricQuerySet: - """Gets all available themes with id and name fields. + """Gets all available metrics with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ return self.get_queryset().get_filtered_unique_names_related_to_parent_topic_id( parent_topic_id=parent_topic_id ) def get_all_names_and_ids(self) -> MetricQuerySet: - """Gets all available themes with id and name fields. + """Gets all available metrics with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ return self.get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/sub_theme.py b/metrics/data/managers/core_models/sub_theme.py index 17ede43df..7fb72fea4 100644 --- a/metrics/data/managers/core_models/sub_theme.py +++ b/metrics/data/managers/core_models/sub_theme.py @@ -34,7 +34,7 @@ def get_all_unique_names(self) -> models.QuerySet: return self.all().values_list("name", flat=True).distinct().order_by("name") def get_all_names_and_ids(self) -> models.QuerySet: - """Gets all available themes with id and name fields. + """Gets all available sub_themes with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: @@ -46,7 +46,7 @@ def get_all_names_and_ids(self) -> models.QuerySet: def get_filtered_unique_names_related_to_theme( self, parent_theme_id ) -> models.QuerySet: - """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. + """Gets all available unique sub_themes with id and name fields that are related to the parent theme ID. Returns: QuerySet: A queryset containing dictionaries with id and name: @@ -87,7 +87,7 @@ def get_all_unique_names(self) -> SubThemeQuerySet: def get_filtered_unique_names_related_to_theme( self, parent_theme_id: str ) -> SubThemeQuerySet: - """Gets all available themes with id and name fields. + """Gets all available sub_themes with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: @@ -99,11 +99,11 @@ def get_filtered_unique_names_related_to_theme( ) def get_all_names_and_ids(self) -> SubThemeQuerySet: - """Gets all available themes with id and name fields. + """Gets all available sub_themes with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ return self.get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index 81018fc52..26633dc5f 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -48,12 +48,12 @@ def get_by_name(self, name: str) -> "Topic": def get_filtered_unique_names_related_to_sub_theme( self, parent_sub_theme_id ) -> models.QuerySet: - """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. + """Gets all available topics with id and name fields that are related to the parent sub_theme ID. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ return ( self.filter(sub_theme_id=parent_sub_theme_id) @@ -62,12 +62,12 @@ def get_filtered_unique_names_related_to_sub_theme( ) def get_all_names_and_ids(self) -> models.QuerySet: - """Gets all available themes with id and name fields. + """Gets all available topics with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ return self.all().values("id", "name").distinct() @@ -123,12 +123,12 @@ def get_by_name(self, name: str) -> "Topic": def get_filtered_unique_names_related_to_sub_theme( self, parent_sub_theme_id: str ) -> TopicQuerySet: - """Gets all available themes with id and name fields. + """Gets all available topics with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ return self.get_queryset().get_filtered_unique_names_related_to_sub_theme( parent_sub_theme_id=parent_sub_theme_id @@ -140,6 +140,6 @@ def get_all_names_and_ids(self) -> TopicQuerySet: Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ return self.get_queryset().get_all_names_and_ids() From 843f34048402b392dc41d290586d8e9b263069da Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Wed, 25 Mar 2026 11:11:47 +0000 Subject: [PATCH 047/186] CDD-3175: linting --- auth_content/models.py | 203 +++++++++----------- metrics/api/serializers/geographies.py | 14 +- metrics/api/serializers/permission_sets.py | 36 ++-- metrics/api/views/geographies.py | 2 +- metrics/api/views/permission_sets.py | 6 +- metrics/data/managers/core_models/metric.py | 3 +- 6 files changed, 127 insertions(+), 137 deletions(-) diff --git a/auth_content/models.py b/auth_content/models.py index 99c6932e1..5388721d8 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -1,3 +1,5 @@ +from itertools import starmap + from django import forms from django.core.exceptions import ValidationError from django.db import models @@ -6,7 +8,10 @@ from cms.metrics_interface.field_choices_callables import ( get_all_geography_type_names_and_ids, + get_all_metric_names_and_ids, + get_all_sub_theme_names_and_ids, get_all_theme_names_and_ids, + get_all_topic_names_and_ids, ) @@ -20,8 +25,7 @@ def get_theme_child_map(): } """ - theme_mapping = {} - return theme_mapping + return {} class PermissionSetForm(WagtailAdminModelForm): @@ -29,6 +33,13 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Use CharField with Select widget to bypass choice validation + self.fields["theme"] = forms.CharField( + widget=forms.Select( + choices=[("", "---------"), ("-1", "* (All themes)")] + + get_all_theme_names_and_ids() + ), + required=True, + ) self.fields["sub_theme"] = forms.CharField( required=True, label="Sub Theme", @@ -44,11 +55,17 @@ def __init__(self, *args, **kwargs): label="Metric", widget=forms.Select(choices=[("", "Select topic first")]), ) + self.fields["geography_type"] = forms.CharField( + widget=forms.Select( + choices=[("", "---------"), ("-1", "* (All geography-types)")] + + get_all_geography_type_names_and_ids() + ), + required=True, + ) self.fields["geography"] = forms.CharField( required=True, label="Geography", - widget=forms.Select( - choices=[("-1", "Select geography type first")]), + widget=forms.Select(choices=[("-1", "Select geography type first")]), ) if self.instance and self.instance.pk: @@ -62,30 +79,25 @@ def __init__(self, *args, **kwargs): ), ] elif self.instance.sub_theme == "-1": - self.fields["sub_theme"].widget.choices = [ - ("-1", "* (All sub-themes)")] + self.fields["sub_theme"].widget.choices = [("-1", "* (All sub-themes)")] # Topic if self.instance.topic and self.instance.topic != "-1": self.fields["topic"].widget.choices = [ ("", "Select sub-theme first"), - (self.instance.topic, - f"Loading... (ID: {self.instance.topic})"), + (self.instance.topic, f"Loading... (ID: {self.instance.topic})"), ] elif self.instance.topic == "-1": - self.fields["topic"].widget.choices = [ - ("-1", "* (All topics)")] + self.fields["topic"].widget.choices = [("-1", "* (All topics)")] # Metric if self.instance.metric and self.instance.metric != "-1": self.fields["metric"].widget.choices = [ ("", "Select topic first"), - (self.instance.metric, - f"Loading... (ID: {self.instance.metric})"), + (self.instance.metric, f"Loading... (ID: {self.instance.metric})"), ] elif self.instance.metric == "-1": - self.fields["metric"].widget.choices = [ - ("-1", "* (All metrics)")] + self.fields["metric"].widget.choices = [("-1", "* (All metrics)")] # Geography if self.instance.geography and self.instance.geography != "-1": @@ -127,8 +139,7 @@ def clean(self): if queryset.exists(): raise ValidationError( - "A permission set with this exact combination already exists. " - "Please modify your selection to create a unique permission set." + message="A permission set with this exact combination already exists. Please modify your selection to create a unique permission set." ) return cleaned_data @@ -141,23 +152,11 @@ class PermissionSet(models.Model): editable=False, help_text="Auto-generated display name", ) - theme = models.CharField( - max_length=255, - choices=[("", "---------"), ("-1", "* (All themes)")] - + get_all_theme_names_and_ids(), - blank=False, - default="", - ) + theme = models.CharField(max_length=255, blank=False, default="") sub_theme = models.CharField(max_length=255, blank=False, default="") topic = models.CharField(max_length=255, blank=False, default="") metric = models.CharField(max_length=255, blank=False, default="") - geography_type = models.CharField( - max_length=255, - choices=[("", "---------"), ("-1", "* (All geography-types)")] - + get_all_geography_type_names_and_ids(), - blank=False, - default="", - ) + geography_type = models.CharField(max_length=255, blank=False, default="") geography = models.CharField(max_length=255, blank=False, default="") base_form_class = PermissionSetForm @@ -197,95 +196,81 @@ def _generate_display_name(self): This uses the form's choice labels, not database lookups. """ - parts = [] - - # Theme - if self.theme == "-1": - parts.append("Theme: * (All)") - elif self.theme: - theme_name = self._get_choice_label("theme", self.theme) - parts.append(f"Theme: {theme_name}") - - # Sub-theme - if self.sub_theme == "-1": - parts.append("Sub-theme: * (All)") - elif self.sub_theme: - sub_theme_name = self._get_choice_label( - "sub-theme", self.sub_theme) - parts.append(f"Sub-theme ID: {sub_theme_name}") - - # Topic - if self.topic == "-1": - parts.append("Topic: * (All)") - elif self.topic: - topic_name = self._get_choice_label("topic", self.topic) - parts.append(f"Topic: {topic_name}") - - # Metric - if self.metric == "-1": - parts.append("Metric: * (All)") - elif self.metric: - metric_name = self._get_choice_label("metric", self.metric) - parts.append(f"Metric: {metric_name}") - - # Geography type - if self.geography_type == "-1": - parts.append("Geography Type: * (All)") - elif self.geography_type: - geo_type_label = self.geography_type.replace("_", " ").title() - parts.append(f"Geography Type: {geo_type_label}") - - # Geography - if self.geography == "-1": - parts.append("Geography: * (All)") - elif self.geography: - parts.append(f"Geography ID: {self.geography}") + def format_field(field_name: str, field_value: str, label: str) -> str | None: + """ + Format a single field for display. + + Args: + field_name: The field identifier (e.g., "theme", "sub-theme") + field_value: The stored value (ID or "-1") + label: The display label (e.g., "Theme", "Sub-theme") + + Returns: + Formatted string or None if field is empty + """ + if not field_value: + return None + + if field_value == "-1": + return f"{label}: * (All)" + + # Special case for geography_type - format the enum value + if field_name == "geography_type": + formatted_value = field_value.replace("_", " ").title() + return f"{label}: {formatted_value}" + + # For other fields, use choice label lookup + choice_label = self._get_choice_label(field_name, field_value) + return f"{label}: {choice_label}" + + fields = [ + ("theme", self.theme, "Theme"), + ("sub-theme", self.sub_theme, "Sub-theme"), + ("topic", self.topic, "Topic"), + ("metric", self.metric, "Metric"), + ("geography_type", self.geography_type, "Geography Type"), + ("geography", self.geography, "Geography"), + ] + + parts = [p for p in starmap(format_field, fields) if p is not None] return " | ".join(parts) if parts else "Permission Set (Not Configured)" - def _get_choice_label(self, field_name, value): + def _get_choice_label(self, field_name: str, value: str) -> str: """Get the display label for a choice field""" - if field_name == "theme": - from cms.metrics_interface.field_choices_callables import ( - get_all_theme_names_and_ids, - ) - choices = get_all_theme_names_and_ids() - for choice_value, choice_label in choices: - if choice_value == value: - return choice_label + field_lookup_map = { + "theme": get_all_theme_names_and_ids, + "sub-theme": get_all_sub_theme_names_and_ids, + "topic": get_all_topic_names_and_ids, + "metric": get_all_metric_names_and_ids, + } - if field_name == "sub-theme": - from cms.metrics_interface.field_choices_callables import ( - get_all_sub_theme_names_and_ids, - ) - - choices = get_all_sub_theme_names_and_ids() - for choice_value, choice_label in choices: - if choice_value == value: - return choice_label + # Get the appropriate lookup function + lookup_func = field_lookup_map.get(field_name) - if field_name == "topic": - from cms.metrics_interface.field_choices_callables import ( - get_all_topic_names_and_ids, - ) + if lookup_func: + choices = lookup_func() + return self._find_label_in_choices(choices, value) - choices = get_all_topic_names_and_ids() - for choice_value, choice_label in choices: - if choice_value == value: - return choice_label + return value - if field_name == "metric": - from cms.metrics_interface.field_choices_callables import ( - get_all_metric_names_and_ids, - ) + @staticmethod + def _find_label_in_choices(choices: list[tuple], value: str) -> str: + """ + Find the label for a given value in a list of (value, label) tuples. - choices = get_all_metric_names_and_ids() - for choice_value, choice_label in choices: - if choice_value == value: - return choice_label + Args: + choices: List of (value, label) tuples + value: The value to look up - return value + Returns: + The label if found, otherwise the original value + """ + return next( + (label for choice_value, label in choices if choice_value == value), + value, # default if not found + ) def __str__(self): - return self.name if self.name else f"Permission Set {self.id}" + return self.name or f"Permission Set {self.id}" diff --git a/metrics/api/serializers/geographies.py b/metrics/api/serializers/geographies.py index d7eb27782..b4f7024c9 100644 --- a/metrics/api/serializers/geographies.py +++ b/metrics/api/serializers/geographies.py @@ -230,16 +230,17 @@ class GeographyByGeographyTypeRequestSerializer(serializers.Serializer): def geography_manager(self): return self.context.get("geography_manager", Geography.objects) - def validate_geography_type_id(self, value): - """Validate theme_id is either wildcard or a valid integer""" + @staticmethod + def validate_geography_type_id(value): + """Validate geography_type_id is either wildcard or a valid integer""" if value == "-1": return value try: - int(value) - return value - except ValueError: - raise serializers.ValidationError("Geography Type must be a number or '-1'") + return int(value) + except ValueError as err: + message = "Geography Type must be a number or '-1'" + raise serializers.ValidationError(message) from err def data(self) -> dict: """ @@ -268,7 +269,6 @@ def data(self) -> dict: [str(geography_code), name] for geography_code, name in geography_names_and_codes_tuples ] - return {"choices": choices} diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py index c14ffd11f..8db852c79 100644 --- a/metrics/api/serializers/permission_sets.py +++ b/metrics/api/serializers/permission_sets.py @@ -17,17 +17,19 @@ def sub_theme_manager(self): """ return self.context.get("sub_theme_manager", SubTheme.objects) - def validate_theme_id(self, value): + @staticmethod + def validate_theme_id(value): """Validate theme_id is either wildcard or a valid integer""" if value == "-1": return value try: int(value) + except ValueError as err: + msg = "theme_id must be a number or '-1'" + raise serializers.ValidationError(msg) from err + else: return value - except ValueError: - raise serializers.ValidationError( - "theme_id must be a number or '-1'") def data(self) -> dict: """ @@ -47,7 +49,7 @@ def data(self) -> dict: parent_theme_id ) ) - choices = [[str(id), name] for id, name in sub_theme_tuples] + choices = [[str(item_id), name] for item_id, name in sub_theme_tuples] return {"choices": choices} @@ -65,17 +67,19 @@ def topic_manager(self): """ return self.context.get("topic_manager", Topic.objects) - def validate_sub_theme_id(self, value): + @staticmethod + def validate_sub_theme_id(value): """Validate sub_theme_id is either wildcard or a valid integer""" if value == "-1": return value try: int(value) + except ValueError as err: + msg = "sub_theme_id must be a number or '-1'" + raise serializers.ValidationError(msg) from err + else: return value - except ValueError: - raise serializers.ValidationError( - "sub_theme_id must be a number or '-1'") def data(self) -> dict: """ @@ -96,7 +100,7 @@ def data(self) -> dict: ) ) - choices = [[str(id), name] for id, name in topic_tuples] + choices = [[str(item_id), name] for item_id, name in topic_tuples] return {"choices": choices} @@ -114,17 +118,19 @@ def metric_manager(self): """ return self.context.get("metric_manager", Metric.objects) - def validate_topic_id(self, value): + @staticmethod + def validate_topic_id(value): """Validate topic_id is either wildcard or a valid integer""" if value == "-1": return value try: int(value) + except ValueError as err: + msg = "topic_id must be a number or '-1'" + raise serializers.ValidationError(msg) from err + else: return value - except ValueError: - raise serializers.ValidationError( - "topic_id must be a number or '-1'") def data(self) -> dict: """ @@ -145,7 +151,7 @@ def data(self) -> dict: ) ) - choices = [[str(id), name] for id, name in metric_tuples] + choices = [[str(item_id), name] for item_id, name in metric_tuples] return {"choices": choices} diff --git a/metrics/api/views/geographies.py b/metrics/api/views/geographies.py index f9b9d485c..07f38d435 100644 --- a/metrics/api/views/geographies.py +++ b/metrics/api/views/geographies.py @@ -112,7 +112,7 @@ def _handle_geographies_by_geography_type( class GeographiesByGeographyTypeView(APIView): permission_classes = [] - def get(self, request, geography_type_id, *args, **kwargs): + def get(self, request, geography_type_id, *args, **kwargs): # noqa: PLR6301 serializer = GeographyByGeographyTypeRequestSerializer( data={"geography_type_id": geography_type_id} ) diff --git a/metrics/api/views/permission_sets.py b/metrics/api/views/permission_sets.py index 99bb9ce38..7a76eaf8f 100644 --- a/metrics/api/views/permission_sets.py +++ b/metrics/api/views/permission_sets.py @@ -24,7 +24,7 @@ class SubThemesByThemeView(APIView): permission_classes = [] - def get(self, request, theme_id, *args, **kwargs): + def get(self, request, theme_id, *args, **kwargs): # noqa: PLR6301 """API endpoint to fetch sub-themes based on selected theme.""" serializer = SubThemeRequestSerializer(data={"theme_id": theme_id}) serializer.is_valid(raise_exception=True) @@ -41,7 +41,7 @@ class TopicsBySubThemeView(APIView): permission_classes = [] - def get(self, request, sub_theme_id, *args, **kwargs): + def get(self, request, sub_theme_id, *args, **kwargs): # noqa: PLR6301 """API endpoint to fetch sub-themes based on selected theme.""" serializer = TopicRequestSerializer(data={"sub_theme_id": sub_theme_id}) serializer.is_valid(raise_exception=True) @@ -58,7 +58,7 @@ class MetricsByTopicView(APIView): permission_classes = [] - def get(self, request, topic_id, *args, **kwargs): + def get(self, request, topic_id, *args, **kwargs): # noqa: PLR6301 """API endpoint to fetch sub-themes based on selected theme.""" serializer = MetricRequestSerializer(data={"topic_id": topic_id}) serializer.is_valid(raise_exception=True) diff --git a/metrics/data/managers/core_models/metric.py b/metrics/data/managers/core_models/metric.py index 6201adf9e..c2e289782 100644 --- a/metrics/data/managers/core_models/metric.py +++ b/metrics/data/managers/core_models/metric.py @@ -44,8 +44,7 @@ def get_all_unique_change_type_names(self) -> models.QuerySet: `` """ return self.get_all_unique_names().filter( - models.Q(name__icontains="change") & ~models.Q( - name__icontains="percent") + models.Q(name__icontains="change") & ~models.Q(name__icontains="percent") ) def get_all_unique_percent_change_type_names(self) -> models.QuerySet: From 7b910901e4fbd6365cd7ce6ec715f1c11d004746 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Wed, 25 Mar 2026 14:07:22 +0000 Subject: [PATCH 048/186] CDD-3175: tests --- .../field_choices_callables.py | 28 +- cms/metrics_interface/interface.py | 16 - tests/factories/metrics/metric.py | 13 +- tests/factories/metrics/sub_theme.py | 13 +- tests/factories/metrics/topic.py | 13 +- .../metrics/api/views/test_geographies.py | 107 +++- .../metrics/api/views/test_permission_sets.py | 226 ++++++++ .../test_field_choices_callables.py | 146 ++++- .../cms/metrics_interface/test_interface.py | 75 +++ .../api/serializers/test_geographies.py | 364 ++++++++++++ .../api/serializers/test_permission_sets.py | 517 ++++++++++++++++++ .../managers/core_models/test_geography.py | 26 + .../managers/core_models/test_sub_theme.py | 16 + .../data/managers/core_models/test_theme.py | 18 + 14 files changed, 1522 insertions(+), 56 deletions(-) create mode 100644 tests/integration/metrics/api/views/test_permission_sets.py create mode 100644 tests/unit/metrics/api/serializers/test_permission_sets.py diff --git a/cms/metrics_interface/field_choices_callables.py b/cms/metrics_interface/field_choices_callables.py index 88be7b3a2..0c606e095 100644 --- a/cms/metrics_interface/field_choices_callables.py +++ b/cms/metrics_interface/field_choices_callables.py @@ -314,6 +314,7 @@ def get_all_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: [(1, "immunisation"), ...] """ metrics_interface = MetricsAPIInterface() + return _build_id_name_tuple_choices( choices=metrics_interface.get_all_theme_names_and_ids() ) @@ -383,32 +384,6 @@ def get_all_sub_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: ) -def get_filtered_unique_sub_theme_names_for_parent_theme( - parent_theme_id, -) -> LIST_OF_TWO_STRING_ITEM_TUPLES: - """Callable for the `choices` on the `theme` fields of the CMS blocks. - - Notes: - This callable wraps the `MetricsAPIInterface` - and is passed to a migration for the CMS blocks. - This means that we don't need to create a new migration - whenever a new chart type is added. - Instead, the 1-off migration is pointed at this callable. - So Wagtail will pull the choices by invoking this function. - - Returns: - A list of 2-item tuples of theme names. - Examples: - [("Infectious_disease", "Infectious_disease"), ...] - """ - metrics_interface = MetricsAPIInterface() - return _build_id_name_tuple_choices( - choices=metrics_interface.get_filtered_unique_sub_theme_names_for_parent_theme( - parent_theme_id=parent_theme_id - ), - ) - - def get_all_topic_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `topic` fields of the CMS blocks. @@ -462,6 +437,7 @@ def get_all_topic_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: [(1, "6-in-1"), ...] """ metrics_interface = MetricsAPIInterface() + return _build_id_name_tuple_choices( choices=metrics_interface.get_all_topic_names_and_ids() ) diff --git a/cms/metrics_interface/interface.py b/cms/metrics_interface/interface.py index fe2fe8a6d..dc5bc606c 100644 --- a/cms/metrics_interface/interface.py +++ b/cms/metrics_interface/interface.py @@ -253,22 +253,6 @@ def get_all_unique_sub_theme_names(self) -> QuerySet: """ return self.sub_theme_manager.get_all_unique_names() - def get_filtered_unique_sub_theme_names_for_parent_theme( - self, parent_theme_id - ) -> QuerySet: - """Get all unique sub_theme names as a flat list queryset. - Note this is achieved by delegating the call to the `SubThemeManager` from the Metrics API - - Returns: - QuerySet: A queryset of the individual sub_theme names. - Examples: - ` - - """ - return self.sub_theme_manager.get_filtered_unique_names_related_to_theme( - parent_theme_id=parent_theme_id - ) - def get_all_topic_names(self) -> QuerySet: """Gets all available topic names as a flat list queryset. Note this is achieved by delegating the call to the `TopicManager` from the Metrics API diff --git a/tests/factories/metrics/metric.py b/tests/factories/metrics/metric.py index acce07bb2..0d678ba95 100644 --- a/tests/factories/metrics/metric.py +++ b/tests/factories/metrics/metric.py @@ -1,6 +1,6 @@ import factory -from metrics.data.models.core_models import Metric +from metrics.data.models.core_models import Metric, Topic class MetricFactory(factory.django.DjangoModelFactory): @@ -10,3 +10,14 @@ class MetricFactory(factory.django.DjangoModelFactory): class Meta: model = Metric + + @classmethod + def create_with_topic( + cls, name: str, topic: str + ): + topic, _ = Topic.objects.get_or_create(name=topic) + + return cls.create( + name=name, + topic=topic, + ) diff --git a/tests/factories/metrics/sub_theme.py b/tests/factories/metrics/sub_theme.py index e965e0eda..3b703fdbf 100644 --- a/tests/factories/metrics/sub_theme.py +++ b/tests/factories/metrics/sub_theme.py @@ -1,6 +1,6 @@ import factory -from metrics.data.models.core_models import SubTheme +from metrics.data.models.core_models import SubTheme, Theme class SubThemeFactory(factory.django.DjangoModelFactory): @@ -10,3 +10,14 @@ class SubThemeFactory(factory.django.DjangoModelFactory): class Meta: model = SubTheme + + @classmethod + def create_with_theme( + cls, name: str, theme: str + ): + theme, _ = Theme.objects.get_or_create(name=theme) + + return cls.create( + name=name, + theme=theme, + ) diff --git a/tests/factories/metrics/topic.py b/tests/factories/metrics/topic.py index 0f43b49bb..dbc9957f1 100644 --- a/tests/factories/metrics/topic.py +++ b/tests/factories/metrics/topic.py @@ -1,6 +1,6 @@ import factory -from metrics.data.models.core_models import Topic +from metrics.data.models.core_models import Topic, SubTheme class TopicFactory(factory.django.DjangoModelFactory): @@ -10,3 +10,14 @@ class TopicFactory(factory.django.DjangoModelFactory): class Meta: model = Topic + + @classmethod + def create_with_sub_theme( + cls, name: str, sub_theme: str + ): + sub_theme, _ = SubTheme.objects.get_or_create(name=sub_theme) + + return cls.create( + name=name, + sub_theme=sub_theme, + ) diff --git a/tests/integration/metrics/api/views/test_geographies.py b/tests/integration/metrics/api/views/test_geographies.py index cfceaa65b..845474d91 100644 --- a/tests/integration/metrics/api/views/test_geographies.py +++ b/tests/integration/metrics/api/views/test_geographies.py @@ -171,7 +171,8 @@ def test_get_returns_correct_results_for_topic(self): # When query_params = {"topic": topic} - response: Response = client.get(path=self.path, query_params=query_params) + response: Response = client.get( + path=self.path, query_params=query_params) # Then # Geographies are returned in descending alphabetical order @@ -244,7 +245,8 @@ def test_get_returns_correct_results_for_geography_type(self): # When query_params = {"geography_type": ltla} - response: Response = client.get(path=self.path, query_params=query_params) + response: Response = client.get( + path=self.path, query_params=query_params) # Then # Geographies are returned in descending alphabetical order @@ -265,3 +267,104 @@ def test_get_returns_correct_results_for_geography_type(self): assert result["geographies"][2]["geography_code"] == hackney.geography_code assert len(result["geographies"]) == 3 + + +class TestGeographiesByGeographyTypeView: + @property + def path(self) -> str: + return "/api/permission-set/geographies" + + @pytest.mark.django_db + def test_get_geographies_by_geography_type_id_should_return_geographies(self): + + client = APIClient() + ltla = "Lower Tier Local Authority" + + bexley = GeographyFactory.create_with_geography_type( + name="Bexley", geography_code="E09000004", geography_type=ltla + ) + arun = GeographyFactory.create_with_geography_type( + name="Arun", geography_code="E07000224", geography_type=ltla + ) + hackney = GeographyFactory.create_with_geography_type( + name="Hackney", geography_code="E09000012", geography_type=ltla + ) + GeographyFactory.create_with_geography_type( + name="England", geography_code="E92000001", geography_type="Nation" + ) + + geographyTypeId = 1 + path = f"{self.path}/{geographyTypeId}" + response: Response = client.get(path=path) + result = response.data + assert len(response.data["choices"]) == 3 + assert result["choices"][0][0] == arun.geography_code + assert result["choices"][0][1] == arun.name + + assert result["choices"][1][0] == bexley.geography_code + assert result["choices"][1][1] == bexley.name + + assert result["choices"][2][0] == hackney.geography_code + assert result["choices"][2][1] == hackney.name + + @pytest.mark.django_db + def test_get_geographies_by_geography_type_id_should_return_wildcard(self): + + client = APIClient() + ltla = "Lower Tier Local Authority" + + bexley = GeographyFactory.create_with_geography_type( + name="Bexley", geography_code="E09000004", geography_type=ltla + ) + arun = GeographyFactory.create_with_geography_type( + name="Arun", geography_code="E07000224", geography_type=ltla + ) + hackney = GeographyFactory.create_with_geography_type( + name="Hackney", geography_code="E09000012", geography_type=ltla + ) + GeographyFactory.create_with_geography_type( + name="England", geography_code="E92000001", geography_type="Nation" + ) + + geographyTypeId = -1 + path = f"{self.path}/{geographyTypeId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == "-1" + assert result["choices"][0][1] == "* (All geographies)" + + @pytest.mark.django_db + def test_get_geographies_by_geography_type_id_should_return_an_error(self): + + client = APIClient() + ltla = "Lower Tier Local Authority" + + bexley = GeographyFactory.create_with_geography_type( + name="Bexley", geography_code="E09000004", geography_type=ltla + ) + arun = GeographyFactory.create_with_geography_type( + name="Arun", geography_code="E07000224", geography_type=ltla + ) + hackney = GeographyFactory.create_with_geography_type( + name="Hackney", geography_code="E09000012", geography_type=ltla + ) + GeographyFactory.create_with_geography_type( + name="England", geography_code="E92000001", geography_type="Nation" + ) + + geographyTypeId = "string" + path = f"{self.path}/{geographyTypeId}" + response: Response = client.get(path=path) + result = response.data + + assert response.status_code == HTTPStatus.BAD_REQUEST + + # data should contain error + + assert str(result["geography_type_id"][0] + ) == "Geography Type must be a number or '-1'" diff --git a/tests/integration/metrics/api/views/test_permission_sets.py b/tests/integration/metrics/api/views/test_permission_sets.py new file mode 100644 index 000000000..19e4992a8 --- /dev/null +++ b/tests/integration/metrics/api/views/test_permission_sets.py @@ -0,0 +1,226 @@ +from http import HTTPStatus + +import pytest +from rest_framework.response import Response +from rest_framework.test import APIClient + +from tests.factories.metrics.metric import MetricFactory +from tests.factories.metrics.sub_theme import SubThemeFactory +from tests.factories.metrics.topic import TopicFactory + + +class TestSubThemeByThemeView: + @property + def path(self) -> str: + return "/api/permission-set/subthemes" + + @pytest.mark.django_db + def test_get_sub_themes_by_theme_id_should_return_tuple_of_id_and_name(self): + + client = APIClient() + + # create subthemes + respiratorySubTheme = SubThemeFactory.create_with_theme( + name="respiratory", theme="infectious_disease") + childhoodVaccinesSubtheme = SubThemeFactory.create_with_theme( + name="immunisation", theme="childhood_vaccines") + + # Retrieve the subthemes + themeId = 1 + path = f"{self.path}/{themeId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == str(respiratorySubTheme.id) + assert result["choices"][0][1] == respiratorySubTheme.name + + @pytest.mark.django_db + def test_get_sub_themes_by_theme_id_should_return_wildcard(self): + + client = APIClient() + + # create subthemes + respiratorySubtheme = SubThemeFactory.create_with_theme( + name="respiratory", theme="infectious_disease") + childhoodVaccinesSubtheme = SubThemeFactory.create_with_theme( + name="immunisation", theme="childhood_vaccines") + + # Retrieve the subthemes + themeId = -1 + path = f"{self.path}/{themeId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == "-1" + assert result["choices"][0][1] == "* (All sub-themes)" + + @pytest.mark.django_db + def test_get_sub_themes_by_theme_id_should_return_an_error(self): + + client = APIClient() + + # create subthemes + respiratorySubtheme = SubThemeFactory.create_with_theme( + name="respiratory", theme="infectious_disease") + childhoodVaccinesSubtheme = SubThemeFactory.create_with_theme( + name="immunisation", theme="childhood_vaccines") + + # Retrieve the subthemes + themeId = "string" + path = f"{self.path}/{themeId}" + response: Response = client.get(path=path) + result = response.data + + assert response.status_code == HTTPStatus.BAD_REQUEST + + # data should contain error + assert str(result["theme_id"][0] + ) == "theme_id must be a number or '-1'" + + +class TestTopicBySubThemeView: + @property + def path(self) -> str: + return "/api/permission-set/topics" + + @pytest.mark.django_db + def test_get_topics_by_sub_theme_id_should_return_tuple_of_id_and_name(self): + + client = APIClient() + + # create subthemes + covid19Topic = TopicFactory.create_with_sub_theme( + name="COVID-19", sub_theme="respiratory") + + # Retrieve the topics + subThemeId = 1 + path = f"{self.path}/{subThemeId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == str(covid19Topic.id) + assert result["choices"][0][1] == covid19Topic.name + + @pytest.mark.django_db + def test_get_topics_by_sub_theme_id_should_return_wildcard(self): + + client = APIClient() + + covid19Topic = TopicFactory.create_with_sub_theme( + name="COVID-19", sub_theme="respiratory") + + # Retrieve the subthemes + themeId = -1 + path = f"{self.path}/{themeId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == "-1" + assert result["choices"][0][1] == "* (All topics)" + + @pytest.mark.django_db + def test_get_topics_by_sub_theme_id_should_return_an_error(self): + + client = APIClient() + + # create subthemes + covid19Topic = TopicFactory.create_with_sub_theme( + name="COVID-19", sub_theme="respiratory") + + # Retrieve the subthemes + themeId = "string" + path = f"{self.path}/{themeId}" + response: Response = client.get(path=path) + result = response.data + + assert response.status_code == HTTPStatus.BAD_REQUEST + + # data should contain error + assert str(result["sub_theme_id"][0] + ) == "sub_theme_id must be a number or '-1'" + + +class TestMetricByTopicView: + @property + def path(self) -> str: + return "/api/permission-set/metrics" + + @pytest.mark.django_db + def test_get_metric_by_topic_id_should_return_tuple_of_id_and_name(self): + + client = APIClient() + + # create subthemes + covid19metric = MetricFactory.create_with_topic( + name="COVID-19_cases_rateRollingMean", topic="COVID-19") + + # Retrieve the topics + topicId = 1 + path = f"{self.path}/{topicId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == str(covid19metric.id) + assert result["choices"][0][1] == covid19metric.name + + @pytest.mark.django_db + def test_get_metric_by_topic_id_should_return_wildcard(self): + + client = APIClient() + + covid19metric = MetricFactory.create_with_topic( + name="COVID-19_cases_rateRollingMean", topic="COVID-19") + + # Retrieve the subthemes + topicId = -1 + path = f"{self.path}/{topicId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == "-1" + assert result["choices"][0][1] == "* (All metrics)" + + @pytest.mark.django_db + def test_get_metric_by_topic_id_should_return_an_error(self): + + client = APIClient() + + # create subthemes + covid19metric = MetricFactory.create_with_topic( + name="COVID-19_cases_rateRollingMean", topic="COVID-19") + + # Retrieve the subthemes + topicId = "string" + path = f"{self.path}/{topicId}" + response: Response = client.get(path=path) + result = response.data + + assert response.status_code == HTTPStatus.BAD_REQUEST + + # data should contain error + assert str(result["topic_id"][0] + ) == "topic_id must be a number or '-1'" diff --git a/tests/unit/cms/metrics_interface/test_field_choices_callables.py b/tests/unit/cms/metrics_interface/test_field_choices_callables.py index 9bd55d59d..112157b71 100644 --- a/tests/unit/cms/metrics_interface/test_field_choices_callables.py +++ b/tests/unit/cms/metrics_interface/test_field_choices_callables.py @@ -32,7 +32,8 @@ def test_delegates_call_correctly( unique_metric_names = field_choices_callables.get_all_unique_metric_names() # Then - assert unique_metric_names == [(x, x) for x in retrieved_unique_metric_names] + assert unique_metric_names == [(x, x) + for x in retrieved_unique_metric_names] class TestGetAlLTimeSeriesMetricNames: @@ -55,7 +56,8 @@ def test_delegates_calls_correctly( unique_metric_names = field_choices_callables.get_all_timeseries_metric_names() # Then - assert unique_metric_names == [(x, x) for x in retrieved_unique_metric_names] + assert unique_metric_names == [(x, x) + for x in retrieved_unique_metric_names] class TestGetAllHeadlineMetricNames: @@ -68,7 +70,8 @@ def test_delegates_calls_correctly( When `get_all_headline_metric_names()` is called Then the headline metric names are returned as a list of 2-item tuples """ - retrieved_headline_metric_names = ["COVID-19_headline_cases_7DayTotals"] + retrieved_headline_metric_names = [ + "COVID-19_headline_cases_7DayTotals"] mocked_get_all_headline_metric_names.return_value = ( retrieved_headline_metric_names ) @@ -95,7 +98,8 @@ def test_delegates_call_correctly( Then the unique metric names are returned as a list of 2-item tuples """ # Given - retrieved_unique_change_type_metric_names = ["COVID-19_deaths_ONSRollingMean"] + retrieved_unique_change_type_metric_names = [ + "COVID-19_deaths_ONSRollingMean"] mocked_get_all_unique_change_type_metric_names.return_value = ( retrieved_unique_change_type_metric_names ) @@ -433,7 +437,8 @@ def test_delegates_call_correctly( geography_type_names = field_choices_callables.get_all_geography_type_names() # Then - assert geography_type_names == [(x, x) for x in retrieved_geography_type_names] + assert geography_type_names == [(x, x) + for x in retrieved_geography_type_names] class TestGetAllSexNames: @@ -495,6 +500,34 @@ def test_delegates_call_correctly(self, mocked_get_all_theme_names: mock.MagicMo assert all_theme_names == [(x, x) for x in retrieved_theme_names] +class TestGetAllThemeNamesAndIds: + @mock.patch.object(interface.MetricsAPIInterface, "get_all_theme_names_and_ids") + def test_delegates_call_correctly( + self, mocked_get_all_theme_names_and_ids: mock.MagicMock + ): + """ + Given an instance of the `MetricsAPIInterface` which returns theme names + When `get_all_theme_names()` is called + Then the theme names are returned as a list of 2-item tuples + """ + # Given + retrieved_theme_names = [ + {"id": 3, "name": "extreme_event"}, + {"id": 1, "name": "immunisation"}, + {"id": 2, "name": "infectious_disease"}, + {"id": 4, "name": "non-communicable"}, + ] + mocked_get_all_theme_names_and_ids.return_value = retrieved_theme_names + + # When + all_theme_names_and_ids = field_choices_callables.get_all_theme_names_and_ids() + + # Then + assert all_theme_names_and_ids == [ + (str(x["id"]), x["name"]) for x in retrieved_theme_names + ] + + class TestGetAllSubThemeNames: @mock.patch.object(interface.MetricsAPIInterface, "get_all_sub_theme_names") def test_delegates_call_correctly( @@ -516,7 +549,38 @@ def test_delegates_call_correctly( all_sub_theme_names = field_choices_callables.get_all_sub_theme_names() # Then - assert all_sub_theme_names == [(x, x) for x in retrieved_sub_theme_names] + assert all_sub_theme_names == [(x, x) + for x in retrieved_sub_theme_names] + + +class TestGetAllSubThemeNamesAndIds: + @mock.patch.object(interface.MetricsAPIInterface, "get_all_sub_theme_names_and_ids") + def test_delegates_call_correctly( + self, mocked_get_all_sub_theme_names_and_ids: mock.MagicMock + ): + """ + Given an instance of the `MetricsAPIInterface` which returns theme names + When `get_all_theme_names()` is called + Then the theme names are returned as a list of 2-item tuples + """ + # Given + retrieved_sub_theme_names = [ + {"id": 3, "name": "extreme_event"}, + {"id": 1, "name": "immunisation"}, + {"id": 2, "name": "infectious_disease"}, + {"id": 4, "name": "non-communicable"}, + ] + mocked_get_all_sub_theme_names_and_ids.return_value = retrieved_sub_theme_names + + # When + all_sub_theme_names_and_ids = ( + field_choices_callables.get_all_sub_theme_names_and_ids() + ) + + # Then + assert all_sub_theme_names_and_ids == [ + (str(x["id"]), x["name"]) for x in retrieved_sub_theme_names + ] class TestGetAllUniqueSubThemeNames: @@ -549,6 +613,67 @@ def test_delegates_call_correctly( ] +class TestGetAllTopicNamesAndIds: + @mock.patch.object(interface.MetricsAPIInterface, "get_all_topic_names_and_ids") + def test_delegates_call_correctly( + self, mocked_get_all_topic_names_and_ids: mock.MagicMock + ): + """f + Given an instance of the `MetricsAPIInterface` which returns unique sub theme names + When `get_all_topic_names_and_ids()` is called + Then the topic names and ids are returned as a list of 2-item tuples + """ + # Given + retrieved_topic_names_and_ids = [ + {"id": 1, "name": "6-in-1"}, + {"id": 2, "name": "MMR1"}, + {"id": 3, "name": "COVID-19"}, + ] + mocked_get_all_topic_names_and_ids.return_value = retrieved_topic_names_and_ids + + # When + all_topic_names_and_ids = field_choices_callables.get_all_topic_names_and_ids() + + # Then + assert all_topic_names_and_ids == [ + (str(x["id"]), x["name"]) for x in retrieved_topic_names_and_ids + ] + + +class TestGetAllMetricNamesAndIds: + @mock.patch.object(interface.MetricsAPIInterface, "get_all_metric_names_and_ids") + def test_delegates_call_correctly( + self, mocked_get_all_metric_names_and_ids: mock.MagicMock + ): + """f + Given an instance of the `MetricsAPIInterface` which metric names and ids + When `get_all_metric_names_and_ids()` is called + Then the metric names and ids are returned as a list of 2-item tuples + """ + # Given + retrieved_metric_names_and_ids = [ + {"id": 1, "name": "6-in-1_coverage_coverageByYear"}, + { + "id": 58, + "name": "COVID-19-like_syndromic_emergencyDepartment_countsByDay", + }, + {"id": 46, "name": "COVID-19_cases_casesByDay"}, + ] + mocked_get_all_metric_names_and_ids.return_value = ( + retrieved_metric_names_and_ids + ) + + # When + all_metric_names_and_ids = ( + field_choices_callables.get_all_metric_names_and_ids() + ) + + # Then + assert all_metric_names_and_ids == [ + (str(x["id"]), x["name"]) for x in retrieved_metric_names_and_ids + ] + + class TestSimplifiedChartTypes: @mock.patch.object(interface.MetricsAPIInterface, "get_simplified_chart_types") def test_delegates_call_correctly( @@ -589,7 +714,8 @@ def test_delegates_call_correctly( mocked_get_all_sex_names = ["all", "f", "m"] mocked_get_all_age_names.return_value = ["00-04", "05-11"] mocked_get_all_stratum_names.return_value = ["default"] - mocked_get_all_geography_names.return_value = ["London", "Yorkshire and Humber"] + mocked_get_all_geography_names.return_value = [ + "London", "Yorkshire and Humber"] # When retrieved_subcategory_choices = ( @@ -679,8 +805,10 @@ def test_receives_subcategory_choices_grouped_by_category( Then a dictionary is returned containing the subcategory choices grouped by category """ # Given - mocked_get_all_age_names.return_value = [("00-04", "00-04"), ("05-11", "05-11")] - mocked_get_all_sex_names.return_value = [("all", "all"), ("m", "m"), ("f", "f")] + mocked_get_all_age_names.return_value = [ + ("00-04", "00-04"), ("05-11", "05-11")] + mocked_get_all_sex_names.return_value = [ + ("all", "all"), ("m", "m"), ("f", "f")] mocked_get_all_stratum_names.return_value = [("default", "default")] mocked_all_geography_choices_grouped_by_type.return_value = [ ("London", "London"), diff --git a/tests/unit/cms/metrics_interface/test_interface.py b/tests/unit/cms/metrics_interface/test_interface.py index e7f2b95fb..e7fe413e0 100644 --- a/tests/unit/cms/metrics_interface/test_interface.py +++ b/tests/unit/cms/metrics_interface/test_interface.py @@ -519,3 +519,78 @@ def test_get_geography_code_for_geography_delegates_call_correctly( spy_geography_manager.get_geography_code_for_geography.assert_called_once_with( geography=mock_geography, geography_type=mock_geography_type ) + + def test_get_all_theme_names_and_ids_delegates_call_correctly(self): + """ + Given a `ThemeManager` from the Metrics API app + When `get_all_names_and_ids()` is called from that object + Then the call is delegated to the correct method on the `ThemeManager` + """ + # Given + spy_theme_manager = mock.Mock() + metrics_api_interface = interface.MetricsAPIInterface( + theme_manager=spy_theme_manager, + metric_manager=mock.MagicMock(), + ) + + # When + all_theme_names_and_ids = metrics_api_interface.get_all_theme_names_and_ids() + + # Then + assert all_theme_names_and_ids == spy_theme_manager.get_all_names_and_ids() + + def test_get_all_sub_theme_names_and_ids_delegates_call_correctly(self): + """ + Given a `SubThemeManager` from the Metrics API app + When `get_all_names_and_ids()` is called from that object + Then the call is delegated to the correct method on the `SubThemeManager` + """ + # Given + spy_sub_theme_manager = mock.Mock() + metrics_api_interface = interface.MetricsAPIInterface( + sub_theme_manager=spy_sub_theme_manager, + metric_manager=mock.MagicMock(), + ) + + # When + all_sub_theme_names_and_ids = metrics_api_interface.get_all_sub_theme_names_and_ids() + + # Then + assert all_sub_theme_names_and_ids == spy_sub_theme_manager.get_all_names_and_ids() + + def test_get_all_topic_names_and_ids_delegates_call_correctly(self): + """ + Given a `SubThemeManager` from the Metrics API app + When `get_all_names_and_ids()` is called from that object + Then the call is delegated to the correct method on the `SubThemeManager` + """ + # Given + spy_topic_manager = mock.Mock() + metrics_api_interface = interface.MetricsAPIInterface( + topic_manager=spy_topic_manager, + metric_manager=mock.MagicMock(), + ) + + # When + all_topic_names_and_ids = metrics_api_interface.get_all_topic_names_and_ids() + + # Then + assert all_topic_names_and_ids == spy_topic_manager.get_all_names_and_ids() + + def test_get_all_metric_names_and_ids_delegates_call_correctly(self): + """ + Given a `SubThemeManager` from the Metrics API app + When `get_all_names_and_ids()` is called from that object + Then the call is delegated to the correct method on the `SubThemeManager` + """ + # Given + spy_metric_manager = mock.Mock() + metrics_api_interface = interface.MetricsAPIInterface( + metric_manager=spy_metric_manager, + ) + + # When + all_metric_names_and_ids = metrics_api_interface.get_all_metric_names_and_ids() + + # Then + assert all_metric_names_and_ids == spy_metric_manager.get_all_names_and_ids() diff --git a/tests/unit/metrics/api/serializers/test_geographies.py b/tests/unit/metrics/api/serializers/test_geographies.py index 246723971..55142fe21 100644 --- a/tests/unit/metrics/api/serializers/test_geographies.py +++ b/tests/unit/metrics/api/serializers/test_geographies.py @@ -4,11 +4,14 @@ from rest_framework.exceptions import ValidationError +from metrics.data.models.core_models.supporting import Geography from validation.geography_code import UNITED_KINGDOM_GEOGRAPHY_CODE from metrics.api.serializers.geographies import ( GeographiesForTopicSerializer, _serialize_queryset, GeographiesRequestSerializer, + GeographyByGeographyTypeRequestSerializer, + _queryset_to_geography_code_name_tuples ) from tests.fakes.factories.metrics.core_time_series_factory import ( FakeCoreTimeSeriesFactory, @@ -278,3 +281,364 @@ def test_raises_error_when_multiple_fields_are_provided(self): error.value.detail["non_field_errors"][0] == "Only one of 'topic' or 'geography_type' should be provided, not both." ) + + +class TestGeographyByGeographyTypeRequestSerializer: + """Tests for GeographyByGeographyTypeRequestSerializer""" + + def test_validates_wildcard_geography_type_id(self): + """ + Given a wildcard geography_type_id value of "-1" + When the value is validated + Then the wildcard is accepted + """ + # Given + data = {"geography_type_id": "-1"} + + # When + serializer = GeographyByGeographyTypeRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["geography_type_id"] == "-1" + + def test_validates_numeric_geography_type_id(self): + """ + Given a valid numeric geography_type_id + When the value is validated + Then the numeric value is converted to an integer + """ + # Given + data = {"geography_type_id": "3"} + + # When + serializer = GeographyByGeographyTypeRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["geography_type_id"] == 3 + + def test_rejects_invalid_geography_type_id(self): + """ + Given an invalid geography_type_id (not a number or wildcard) + When the value is validated + Then a ValidationError is raised + """ + # Given + data = {"geography_type_id": "invalid_value"} + + # When + serializer = GeographyByGeographyTypeRequestSerializer(data=data) + + # Then + assert not serializer.is_valid() + assert "geography_type_id" in serializer.errors + assert "Geography Type must be a number or '-1'" in str( + serializer.errors["geography_type_id"] + ) + + def test_validation_error_has_chained_exception(self): + """ + Given an invalid geography_type_id + When validation fails + Then the ValidationError is chained from the original ValueError + """ + # Given + data = {"geography_type_id": "not_a_number"} + + # When + serializer = GeographyByGeographyTypeRequestSerializer(data=data) + + # Then + with pytest.raises(ValidationError) as error: + serializer.is_valid(raise_exception=True) + + assert ( + error.value.detail["geography_type_id"][0] + == "Geography Type must be a number or '-1'" + ) + + def test_data_returns_wildcard_response_for_wildcard_geography_type_id(self): + """ + Given a wildcard geography_type_id of "-1" + When data() is called + Then a wildcard response is returned + """ + # Given + data = {"geography_type_id": "-1"} + serializer = GeographyByGeographyTypeRequestSerializer(data=data) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + assert response == {"choices": [["-1", "* (All geographies)"]]} + + def test_data_fetches_geographies_for_valid_geography_type_id(self): + """ + Given a valid numeric geography_type_id + When data() is called + Then geographies are fetched from the manager and formatted correctly + """ + # Given + mock_manager = mock.MagicMock() + mock_queryset = [ + {"geography_code": "E92000001", "name": "England"}, + {"geography_code": "E12000001", "name": "North East"}, + ] + mock_manager.get_geography_codes_and_names_by_geography_type_id.return_value = ( + mock_queryset + ) + + data = {"geography_type_id": "2"} + serializer = GeographyByGeographyTypeRequestSerializer( + data=data, context={"geography_manager": mock_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + mock_manager.get_geography_codes_and_names_by_geography_type_id.assert_called_once_with( + 2 + ) + assert response == { + "choices": [ + ["E92000001", "England"], + ["E12000001", "North East"], + ] + } + + def test_data_handles_empty_geography_queryset(self): + """ + Given a valid geography_type_id that returns no geographies + When data() is called + Then an empty choices list is returned + """ + # Given + mock_manager = mock.MagicMock() + mock_manager.get_geography_codes_and_names_by_geography_type_id.return_value = [] + + data = {"geography_type_id": "999"} + serializer = GeographyByGeographyTypeRequestSerializer( + data=data, context={"geography_manager": mock_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + assert response == {"choices": []} + + def test_data_converts_geography_codes_to_strings(self): + """ + Given geographies with various geography_code formats + When data() is called + Then all geography codes are converted to strings + """ + # Given + mock_manager = mock.MagicMock() + mock_queryset = [ + {"geography_code": "E92000001", "name": "England"}, + {"geography_code": 12345, "name": "Numeric Code Area"}, + ] + mock_manager.get_geography_codes_and_names_by_geography_type_id.return_value = ( + mock_queryset + ) + + data = {"geography_type_id": "1"} + serializer = GeographyByGeographyTypeRequestSerializer( + data=data, context={"geography_manager": mock_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + assert response == { + "choices": [ + ["E92000001", "England"], + ["12345", "Numeric Code Area"], + ] + } + # Verify all codes are strings + for choice in response["choices"]: + assert isinstance(choice[0], str) + + def test_geography_manager_uses_context_when_available(self): + """ + Given a geography_manager in the context + When the property is accessed + Then the context manager is returned + """ + # Given + mock_manager = mock.MagicMock() + serializer = GeographyByGeographyTypeRequestSerializer( + data={"geography_type_id": "1"}, context={"geography_manager": mock_manager} + ) + + # When / Then + assert serializer.geography_manager == mock_manager + + def test_geography_manager_falls_back_to_default(self): + """ + Given no geography_manager in the context + When the property is accessed + Then the default Geography.objects manager is returned + """ + # Given + serializer = GeographyByGeographyTypeRequestSerializer( + data={"geography_type_id": "1"} + ) + + # When / Then + assert serializer.geography_manager == Geography.objects + + def test_requires_geography_type_id_field(self): + """ + Given data without geography_type_id + When the serializer is validated + Then a validation error is raised + """ + # Given + data = {} + + # When + serializer = GeographyByGeographyTypeRequestSerializer(data=data) + + # Then + assert not serializer.is_valid() + assert "geography_type_id" in serializer.errors + + def test_data_calls_helper_function_to_convert_queryset(self): + """ + Given a valid geography_type_id + When data() is called + Then the helper function is used to convert the queryset + """ + # Given + mock_manager = mock.MagicMock() + mock_queryset = [ + {"geography_code": "S92000003", "name": "Scotland"}, + ] + mock_manager.get_geography_codes_and_names_by_geography_type_id.return_value = ( + mock_queryset + ) + + data = {"geography_type_id": "1"} + serializer = GeographyByGeographyTypeRequestSerializer( + data=data, context={"geography_manager": mock_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + # The helper function should have been used (implicitly tested by correct output) + assert response == {"choices": [["S92000003", "Scotland"]]} + + +class TestQuerysetToGeographyCodeNameTuples: + """Tests for the _queryset_to_geography_code_name_tuples helper function""" + + def test_converts_queryset_to_tuples(self): + """ + Given a QuerySet with geography_code and name fields + When converted using _queryset_to_geography_code_name_tuples + Then a list of (geography_code, name) tuples is returned + """ + # Given + mock_queryset = [ + {"geography_code": "E92000001", "name": "England"}, + {"geography_code": "W92000004", "name": "Wales"}, + {"geography_code": "S92000003", "name": "Scotland"}, + ] + + # When + result = _queryset_to_geography_code_name_tuples(mock_queryset) + + # Then + assert result == [ + ("E92000001", "England"), + ("W92000004", "Wales"), + ("S92000003", "Scotland"), + ] + + def test_handles_empty_queryset(self): + """ + Given an empty QuerySet + When converted using _queryset_to_geography_code_name_tuples + Then an empty list is returned + """ + # Given + mock_queryset = [] + + # When + result = _queryset_to_geography_code_name_tuples(mock_queryset) + + # Then + assert result == [] + + def test_preserves_geography_code_types(self): + """ + Given a QuerySet with various geography_code types + When converted using _queryset_to_geography_code_name_tuples + Then the geography_code types are preserved + """ + # Given + mock_queryset = [ + {"geography_code": "E12000001", "name": "North East"}, + {"geography_code": 12345, "name": "Numeric Code"}, + ] + + # When + result = _queryset_to_geography_code_name_tuples(mock_queryset) + + # Then + assert result[0] == ("E12000001", "North East") + assert result[1] == (12345, "Numeric Code") + assert isinstance(result[0][0], str) + assert isinstance(result[1][0], int) + + def test_handles_single_item_queryset(self): + """ + Given a QuerySet with a single item + When converted using _queryset_to_geography_code_name_tuples + Then a list with one tuple is returned + """ + # Given + mock_queryset = [ + {"geography_code": "N92000002", "name": "Northern Ireland"}] + + # When + result = _queryset_to_geography_code_name_tuples(mock_queryset) + + # Then + assert result == [("N92000002", "Northern Ireland")] + assert len(result) == 1 + + def test_handles_special_characters_in_names(self): + """ + Given a QuerySet with special characters in geography names + When converted using _queryset_to_geography_code_name_tuples + Then the special characters are preserved + """ + # Given + mock_queryset = [ + {"geography_code": "E06000001", "name": "Hartlepool & District"}, + {"geography_code": "E06000002", "name": "St. Albans"}, + ] + + # When + result = _queryset_to_geography_code_name_tuples(mock_queryset) + + # Then + assert result == [ + ("E06000001", "Hartlepool & District"), + ("E06000002", "St. Albans"), + ] diff --git a/tests/unit/metrics/api/serializers/test_permission_sets.py b/tests/unit/metrics/api/serializers/test_permission_sets.py new file mode 100644 index 000000000..8180c6765 --- /dev/null +++ b/tests/unit/metrics/api/serializers/test_permission_sets.py @@ -0,0 +1,517 @@ +from unittest import mock + +import pytest +from rest_framework import serializers as drf_serializers + +from metrics.api.serializers.permission_sets import ( + MetricRequestSerializer, + PermissionSetResponseSerializer, + SubThemeRequestSerializer, + TopicRequestSerializer, + _queryset_to_id_name_tuples, +) +from metrics.data.models.core_models.supporting import Metric, SubTheme, Topic + + +class TestSubThemeRequestSerializer: + """Tests for SubThemeRequestSerializer""" + + def test_validates_wildcard_theme_id(self): + """ + Given a wildcard theme_id value of "-1" + When the value is validated + Then the wildcard is accepted + """ + # Given + data = {"theme_id": "-1"} + + # When + serializer = SubThemeRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["theme_id"] == "-1" + + def test_validates_numeric_theme_id(self): + """ + Given a valid numeric theme_id + When the value is validated + Then the numeric value is accepted + """ + # Given + data = {"theme_id": "123"} + + # When + serializer = SubThemeRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["theme_id"] == "123" + + def test_rejects_invalid_theme_id(self): + """ + Given an invalid theme_id (not a number or wildcard) + When the value is validated + Then a ValidationError is raised + """ + # Given + data = {"theme_id": "invalid"} + + # When + serializer = SubThemeRequestSerializer(data=data) + + # Then + assert not serializer.is_valid() + assert "theme_id" in serializer.errors + assert "theme_id must be a number or '-1'" in str( + serializer.errors["theme_id"]) + + def test_data_returns_wildcard_response_for_wildcard_theme_id(self): + """ + Given a wildcard theme_id of "-1" + When data() is called + Then a wildcard response is returned + """ + # Given + data = {"theme_id": "-1"} + serializer = SubThemeRequestSerializer(data=data) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + assert response == {"choices": [["-1", "* (All sub-themes)"]]} + + def test_data_fetches_sub_themes_for_valid_theme_id(self): + """ + Given a valid numeric theme_id + When data() is called + Then sub-themes are fetched from the manager and formatted correctly + """ + # Given + metrics_manager = mock.MagicMock() + mock_queryset = [ + {"id": 1, "name": "respiratory"}, + {"id": 2, "name": "gastrointestinal"}, + ] + metrics_manager.get_filtered_unique_names_related_to_theme.return_value = ( + mock_queryset + ) + + data = {"theme_id": "5"} + serializer = SubThemeRequestSerializer( + data=data, context={"sub_theme_manager": metrics_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + metrics_manager.get_filtered_unique_names_related_to_theme.assert_called_once_with( + 5 + ) + assert response == { + "choices": [["1", "respiratory"], ["2", "gastrointestinal"]] + } + + def test_sub_theme_manager_uses_context_when_available(self): + """ + Given a sub_theme_manager in the context + When the property is accessed + Then the context manager is returned + """ + # Given + metrics_manager = mock.MagicMock() + serializer = SubThemeRequestSerializer( + data={"theme_id": "1"}, context={"sub_theme_manager": metrics_manager} + ) + + # When / Then + assert serializer.sub_theme_manager == metrics_manager + + def test_sub_theme_manager_falls_back_to_default(self): + """ + Given no sub_theme_manager in the context + When the property is accessed + Then the default SubTheme.objects manager is returned + """ + # Given + serializer = SubThemeRequestSerializer(data={"theme_id": "1"}) + + # When / Then + assert serializer.sub_theme_manager == SubTheme.objects + + +class TestTopicRequestSerializer: + """Tests for TopicRequestSerializer""" + + def test_validates_wildcard_sub_theme_id(self): + """ + Given a wildcard sub_theme_id value of "-1" + When the value is validated + Then the wildcard is accepted + """ + # Given + data = {"sub_theme_id": "-1"} + + # When + serializer = TopicRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["sub_theme_id"] == "-1" + + def test_validates_numeric_sub_theme_id(self): + """ + Given a valid numeric sub_theme_id + When the value is validated + Then the numeric value is accepted + """ + # Given + data = {"sub_theme_id": "456"} + + # When + serializer = TopicRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["sub_theme_id"] == "456" + + def test_rejects_invalid_sub_theme_id(self): + """ + Given an invalid sub_theme_id (not a number or wildcard) + When the value is validated + Then a ValidationError is raised + """ + # Given + data = {"sub_theme_id": "not_a_number"} + + # When + serializer = TopicRequestSerializer(data=data) + + # Then + assert not serializer.is_valid() + assert "sub_theme_id" in serializer.errors + assert "sub_theme_id must be a number or '-1'" in str( + serializer.errors["sub_theme_id"] + ) + + def test_data_returns_wildcard_response_for_wildcard_sub_theme_id(self): + """ + Given a wildcard sub_theme_id of "-1" + When data() is called + Then a wildcard response is returned + """ + # Given + data = {"sub_theme_id": "-1"} + serializer = TopicRequestSerializer(data=data) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + assert response == {"choices": [["-1", "* (All topics)"]]} + + def test_data_fetches_topics_for_valid_sub_theme_id(self): + """ + Given a valid numeric sub_theme_id + When data() is called + Then topics are fetched from the manager and formatted correctly + """ + # Given + metrics_manager = mock.MagicMock() + mock_queryset = [ + {"id": 10, "name": "COVID-19"}, + {"id": 11, "name": "Influenza"}, + ] + metrics_manager.get_filtered_unique_names_related_to_sub_theme.return_value = ( + mock_queryset + ) + + data = {"sub_theme_id": "3"} + serializer = TopicRequestSerializer( + data=data, context={"topic_manager": metrics_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + metrics_manager.get_filtered_unique_names_related_to_sub_theme.assert_called_once_with( + 3 + ) + assert response == {"choices": [ + ["10", "COVID-19"], ["11", "Influenza"]]} + + def test_topic_manager_uses_context_when_available(self): + """ + Given a topic_manager in the context + When the property is accessed + Then the context manager is returned + """ + # Given + metrics_manager = mock.MagicMock() + serializer = TopicRequestSerializer( + data={"sub_theme_id": "1"}, context={"topic_manager": metrics_manager} + ) + + # When / Then + assert serializer.topic_manager == metrics_manager + + def test_topic_manager_falls_back_to_default(self): + """ + Given no topic_manager in the context + When the property is accessed + Then the default Topic.objects manager is returned + """ + # Given + serializer = TopicRequestSerializer(data={"sub_theme_id": "1"}) + + # When / Then + assert serializer.topic_manager == Topic.objects + + +class TestMetricRequestSerializer: + """Tests for MetricRequestSerializer""" + + def test_validates_wildcard_topic_id(self): + """ + Given a wildcard topic_id value of "-1" + When the value is validated + Then the wildcard is accepted + """ + # Given + data = {"topic_id": "-1"} + + # When + serializer = MetricRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["topic_id"] == "-1" + + def test_validates_numeric_topic_id(self): + """ + Given a valid numeric topic_id + When the value is validated + Then the numeric value is accepted + """ + # Given + data = {"topic_id": "789"} + + # When + serializer = MetricRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["topic_id"] == "789" + + def test_rejects_invalid_topic_id(self): + """ + Given an invalid topic_id (not a number or wildcard) + When the value is validated + Then a ValidationError is raised + """ + # Given + data = {"topic_id": "abc123"} + + # When + serializer = MetricRequestSerializer(data=data) + + # Then + assert not serializer.is_valid() + assert "topic_id" in serializer.errors + assert "topic_id must be a number or '-1'" in str( + serializer.errors["topic_id"]) + + def test_data_returns_wildcard_response_for_wildcard_topic_id(self): + """ + Given a wildcard topic_id of "-1" + When data() is called + Then a wildcard response is returned + """ + # Given + data = {"topic_id": "-1"} + serializer = MetricRequestSerializer(data=data) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + assert response == {"choices": [["-1", "* (All metrics)"]]} + + def test_data_fetches_metrics_for_valid_topic_id(self): + """ + Given a valid numeric topic_id + When data() is called + Then metrics are fetched from the manager and formatted correctly + """ + # Given + metrics_manager = mock.MagicMock() + mock_queryset = [ + {"id": 100, "name": "COVID-19_cases_rate"}, + {"id": 101, "name": "COVID-19_deaths_rate"}, + ] + metrics_manager.get_filtered_unique_names_related_to_parent_topic_id.return_value = ( + mock_queryset + ) + + data = {"topic_id": "15"} + serializer = MetricRequestSerializer( + data=data, context={"metric_manager": metrics_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + metrics_manager.get_filtered_unique_names_related_to_parent_topic_id.assert_called_once_with( + 15 + ) + assert response == { + "choices": [ + ["100", "COVID-19_cases_rate"], + ["101", "COVID-19_deaths_rate"], + ] + } + + def test_metric_manager_uses_context_when_available(self): + """ + Given a metric_manager in the context + When the property is accessed + Then the context manager is returned + """ + # Given + metrics_manager = mock.MagicMock() + serializer = MetricRequestSerializer( + data={"topic_id": "1"}, context={"metric_manager": metrics_manager} + ) + + # When / Then + assert serializer.metric_manager == metrics_manager + + def test_metric_manager_falls_back_to_default(self): + """ + Given no metric_manager in the context + When the property is accessed + Then the default Metric.objects manager is returned + """ + # Given + serializer = MetricRequestSerializer(data={"topic_id": "1"}) + + # When / Then + assert serializer.metric_manager == Metric.objects + + +class TestPermissionSetResponseSerializer: + """Tests for PermissionSetResponseSerializer""" + + def test_serializes_valid_choices_structure(self): + """ + Given a valid choices structure + When the data is serialized + Then the serializer validates successfully + """ + # Given + data = {"choices": [["1", "Option 1"], ["2", "Option 2"]]} + + # When + serializer = PermissionSetResponseSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data == data + + def test_rejects_invalid_choice_structure(self): + """ + Given an invalid choices structure (not pairs) + When the data is serialized + Then validation fails + """ + # Given + data = {"choices": [["1", "Option 1", "Extra"], ["2"]]} + + # When + serializer = PermissionSetResponseSerializer(data=data) + + # Then + assert not serializer.is_valid() + + def test_choices_field_has_correct_help_text(self): + """ + Given the PermissionSetResponseSerializer + When the fields are inspected + Then the choices field has the expected help text + """ + # Given + serializer = PermissionSetResponseSerializer() + + # When + choices_field = serializer.fields["choices"] + + # Then + assert choices_field.help_text == "List of [id, name] pairs for dropdown options" + + +class TestQuerysetToIdNameTuples: + """Tests for the _queryset_to_id_name_tuples helper function""" + + def test_converts_queryset_to_tuples(self): + """ + Given a QuerySet with id and name fields + When converted using _queryset_to_id_name_tuples + Then a list of (id, name) tuples is returned + """ + # Given + mock_queryset = [ + {"id": 1, "name": "First"}, + {"id": 2, "name": "Second"}, + {"id": 3, "name": "Third"}, + ] + + # When + result = _queryset_to_id_name_tuples(mock_queryset) + + # Then + assert result == [(1, "First"), (2, "Second"), (3, "Third")] + + def test_handles_empty_queryset(self): + """ + Given an empty QuerySet + When converted using _queryset_to_id_name_tuples + Then an empty list is returned + """ + # Given + mock_queryset = [] + + # When + result = _queryset_to_id_name_tuples(mock_queryset) + + # Then + assert result == [] + + def test_preserves_id_types(self): + """ + Given a QuerySet with various id types + When converted using _queryset_to_id_name_tuples + Then the id types are preserved + """ + # Given + mock_queryset = [ + {"id": 999, "name": "Large ID"}, + {"id": 1, "name": "Small ID"}, + ] + + # When + result = _queryset_to_id_name_tuples(mock_queryset) + + # Then + assert result[0][0] == 999 + assert result[1][0] == 1 + assert isinstance(result[0][0], int) 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 42d7a9ac0..e6385c4da 100644 --- a/tests/unit/metrics/data/managers/core_models/test_geography.py +++ b/tests/unit/metrics/data/managers/core_models/test_geography.py @@ -30,3 +30,29 @@ def test_get_all_geography_names_by_type( spy_get_all_geography_names_by_type.assert_called_with( geography_type_name=fake_geography_type, ) + + @mock.patch.object( + GeographyQuerySet, "get_geography_codes_and_names_by_geography_type_id" + ) + def test_get_geography_codes_and_names_by_geography_type_id( + self, spy_get_geography_codes_and_names_by_geography_type_id: mock.MagicMock + ): + """ + Given a payload containing the required field + When `get_all_geography_names_by_type` is called, + Then it delegates call to `GeographyQuerySet`. + """ + # Given + fake_geography_type_id = 1 + geography_manager = GeographyManager() + + # When + GeographyManager.get_geography_codes_and_names_by_geography_type_id( + geography_manager, + geography_type_id=fake_geography_type_id, + ) + + # Then + spy_get_geography_codes_and_names_by_geography_type_id.assert_called_with( + geography_type_id=fake_geography_type_id, + ) diff --git a/tests/unit/metrics/data/managers/core_models/test_sub_theme.py b/tests/unit/metrics/data/managers/core_models/test_sub_theme.py index 4b74fa4b9..437836d06 100644 --- a/tests/unit/metrics/data/managers/core_models/test_sub_theme.py +++ b/tests/unit/metrics/data/managers/core_models/test_sub_theme.py @@ -39,3 +39,19 @@ def test_get_all_unique_names(self, spy_get_all_unique_names: mock.MagicMock): # Then spy_get_all_unique_names.assert_called_once_with() + + @mock.patch.object(SubThemeQuerySet, "get_all_names_and_ids") + def test_get_all_sub_theme_names(self, spy_get_all_names_and_ids: mock.MagicMock): + """ + Given an instance of a `SubThemeManager` + When `get_all_names` is called + Then it delegates call to `SubThemeQuerySet`. + """ + # Given + sub_theme_manager = SubThemeManager() + + # When + sub_theme_manager.get_all_names_and_ids() + + # Then + spy_get_all_names_and_ids.assert_called_once_with() diff --git a/tests/unit/metrics/data/managers/core_models/test_theme.py b/tests/unit/metrics/data/managers/core_models/test_theme.py index 0bdeb7064..fb52fe10f 100644 --- a/tests/unit/metrics/data/managers/core_models/test_theme.py +++ b/tests/unit/metrics/data/managers/core_models/test_theme.py @@ -23,3 +23,21 @@ def test_get_all_theme_names(self, spy_get_all_names: mock.MagicMock): # Then spy_get_all_names.assert_called_once() + + @mock.patch.object(ThemeQuerySet, "get_all_names_and_ids") + def test_get_all_theme_names_and_ids( + self, spy_get_all_names_and_ids: mock.MagicMock + ): + """ + Given an instance of a `ThemeManager` + When `get_all_names` is called + Then it delegates call to `ThemeQuerySet`. + """ + # Given + theme_manager = ThemeManager() + + # When + theme_manager.get_all_names_and_ids() + + # Then + spy_get_all_names_and_ids.assert_called_once() From 3ee2a70d8f8064a681100cb55d53dcc2e51688cc Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 26 Mar 2026 15:46:20 +0000 Subject: [PATCH 049/186] CDD-3176: Add initial model --- auth_content/models.py | 22 ++++++++++++++++++++++ auth_content/wagtail_hooks.py | 10 +++++++--- cms/dynamic_content/sections.py | 5 +++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/auth_content/models.py b/auth_content/models.py index 5388721d8..903ed7a98 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -5,6 +5,9 @@ from django.db import models from wagtail.admin.forms import WagtailAdminModelForm from wagtail.admin.panels import FieldPanel +from django.core.validators import RegexValidator +from wagtail.fields import StreamField +from cms.dynamic_content import sections from cms.metrics_interface.field_choices_callables import ( get_all_geography_type_names_and_ids, @@ -274,3 +277,22 @@ def _find_label_in_choices(choices: list[tuple], value: str) -> str: def __str__(self): return self.name or f"Permission Set {self.id}" + +class Users(models.Model): + user_entra_id = models.CharField( + validators=[ + RegexValidator( + regex="^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$" + ) + ], + ) + permission_set = StreamField( + [ + ("section", sections.DropdownSection()), + ], + use_json_field=True, +) + panels = [ + FieldPanel("user_entra_id"), + FieldPanel("permission_set"), + ] diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py index eef13b3fe..cc66ab3a8 100644 --- a/auth_content/wagtail_hooks.py +++ b/auth_content/wagtail_hooks.py @@ -4,17 +4,21 @@ from wagtail.admin.viewsets.model import ModelViewSetGroup from wagtail.snippets.views.snippets import SnippetViewSet -from auth_content.models import PermissionSet +from auth_content.models import PermissionSet, Users class PermissionSetViewSet(SnippetViewSet): model = PermissionSet menu_label = "Permission Sets" icon = "key" - + +class UserViewSet(SnippetViewSet): + model = Users + menu_label = "Users" + icon = "user" class AuthGroup(ModelViewSetGroup): - items = (PermissionSetViewSet,) + items = (PermissionSetViewSet, UserViewSet) menu_label = "Auth" menu_icon = "lock" menu_order = 300 diff --git a/cms/dynamic_content/sections.py b/cms/dynamic_content/sections.py index 2bdd5368a..7285946bb 100644 --- a/cms/dynamic_content/sections.py +++ b/cms/dynamic_content/sections.py @@ -1,4 +1,5 @@ from wagtail.blocks import ( + ChoiceBlock, RichTextBlock, StreamBlock, StructBlock, @@ -68,6 +69,10 @@ class TextSection(StructBlock): body = RichTextBlock(help_text=help_texts.REQUIRED_BODY_FIELD, required=True) +class DropdownSection(StructBlock): + choice = ChoiceBlock(choices=[("1", "one"), ("2", "two")]) + + class CodeExample(StructBlock): heading = TextBlock(help_text=help_texts.HEADING_BLOCK, required=False) content = blocks.CodeBlock(help_text=help_texts.CODE_EXAMPLE, required=True) From 60173958b1f65cd77e454d46f01a483fbcec5bb2 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 12 Mar 2026 11:22:37 +0000 Subject: [PATCH 050/186] CDD-3175: Initial Commit --- auth_content/__init__.py | 0 auth_content/admin.py | 3 +++ auth_content/apps.py | 6 +++++ auth_content/migrations/0001_initial.py | 29 +++++++++++++++++++++++++ auth_content/migrations/__init__.py | 0 auth_content/models.py | 19 ++++++++++++++++ auth_content/tests.py | 3 +++ auth_content/views.py | 3 +++ cms/dashboard/wagtail_hooks.py | 23 ++++++++++++++++++++ metrics/api/settings/default.py | 1 + 10 files changed, 87 insertions(+) create mode 100644 auth_content/__init__.py create mode 100644 auth_content/admin.py create mode 100644 auth_content/apps.py create mode 100644 auth_content/migrations/0001_initial.py create mode 100644 auth_content/migrations/__init__.py create mode 100644 auth_content/models.py create mode 100644 auth_content/tests.py create mode 100644 auth_content/views.py diff --git a/auth_content/__init__.py b/auth_content/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/auth_content/admin.py b/auth_content/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/auth_content/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/auth_content/apps.py b/auth_content/apps.py new file mode 100644 index 000000000..c7f2ba7ca --- /dev/null +++ b/auth_content/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthContentConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "auth_content" diff --git a/auth_content/migrations/0001_initial.py b/auth_content/migrations/0001_initial.py new file mode 100644 index 000000000..7559ef2c4 --- /dev/null +++ b/auth_content/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.12 on 2026-03-12 11:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="AuthFeature", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("description", models.TextField()), + ], + ), + ] diff --git a/auth_content/migrations/__init__.py b/auth_content/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/auth_content/models.py b/auth_content/models.py new file mode 100644 index 000000000..1744cb5d4 --- /dev/null +++ b/auth_content/models.py @@ -0,0 +1,19 @@ +from django.db import models + +# Create your models here. +from django.db import models +from wagtail.admin.panels import FieldPanel +from wagtail.snippets.models import register_snippet + + +class AuthFeature(models.Model): + title = models.CharField(max_length=255) + description = models.TextField() + + panels = [ + FieldPanel('title'), + FieldPanel('description'), + ] + + def __str__(self): + return self.title \ No newline at end of file diff --git a/auth_content/tests.py b/auth_content/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/auth_content/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/auth_content/views.py b/auth_content/views.py new file mode 100644 index 000000000..91ea44a21 --- /dev/null +++ b/auth_content/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/cms/dashboard/wagtail_hooks.py b/cms/dashboard/wagtail_hooks.py index e20b3d7fc..0d631ca2e 100644 --- a/cms/dashboard/wagtail_hooks.py +++ b/cms/dashboard/wagtail_hooks.py @@ -8,6 +8,11 @@ from wagtail.admin.site_summary import PagesSummaryItem, SummaryItem from wagtail.models import Page from wagtail.whitelist import check_url +from wagtail import hooks +from wagtail.snippets.views.snippets import SnippetViewSet +from wagtail.admin.viewsets.model import ModelViewSetGroup + +from auth_content.models import AuthFeature @hooks.register("insert_global_admin_css") @@ -124,3 +129,21 @@ def register_link_props(features): rule = features.converter_rules_by_converter["contentstate"]["link"] rule["to_database_format"]["entity_decorators"]["LINK"] = link_entity_with_href features.register_converter_rule("contentstate", "link", rule) + +# Initial feature to test out new menu section +class AuthFeatureViewSet(SnippetViewSet): + model = AuthFeature + menu_label = "Features" + icon = "key" + + +class AuthGroup(ModelViewSetGroup): + items = (AuthFeatureViewSet,) + menu_label = "Auth" + menu_icon = "lock" + menu_order = 300 + + +@hooks.register("register_admin_viewset") +def register_auth_viewset(): + return AuthGroup() diff --git a/metrics/api/settings/default.py b/metrics/api/settings/default.py index a7c1714f6..4787eb094 100644 --- a/metrics/api/settings/default.py +++ b/metrics/api/settings/default.py @@ -77,6 +77,7 @@ "wagtail_trash", "modelcluster", "taggit", + "auth_content", ] MIDDLEWARE = [ From 0b2c039938043e83c83f7d285faa5e55655d9445 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 12 Mar 2026 14:19:12 +0000 Subject: [PATCH 051/186] Create initial permission set --- .../0002_permissionset_delete_authfeature.py | 66 +++++++++++++++++++ auth_content/models.py | 26 ++++++-- auth_content/tests.py | 3 - cms/dashboard/wagtail_hooks.py | 10 +-- validation/enums/helper_enum.py | 3 + 5 files changed, 94 insertions(+), 14 deletions(-) create mode 100644 auth_content/migrations/0002_permissionset_delete_authfeature.py delete mode 100644 auth_content/tests.py diff --git a/auth_content/migrations/0002_permissionset_delete_authfeature.py b/auth_content/migrations/0002_permissionset_delete_authfeature.py new file mode 100644 index 000000000..1c1376a5d --- /dev/null +++ b/auth_content/migrations/0002_permissionset_delete_authfeature.py @@ -0,0 +1,66 @@ +# Generated by Django 5.2.12 on 2026-03-12 16:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="PermissionSet", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "theme", + models.CharField( + choices=[ + ("infectious_disease", "Infectious Disease"), + ("extreme_event", "Extreme Event"), + ("non-communicable", "Non Communicable"), + ("climate_and_environment", "Climate And Environment"), + ("immunisation", "Immunisation"), + ] + ), + ), + ( + "sub_theme", + models.CharField( + choices=[ + ("vaccine_preventable", "Vaccine Preventable"), + ("respiratory", "Respiratory"), + ("bloodstream_infection", "Bloodstream Infection"), + ("bloodborne", "Bloodborne"), + ("gastrointestinal", "Gastrointestinal"), + ("antimicrobial_resistance", "Antimicrobial Resistance"), + ("contact", "Contact"), + ("childhood_illness", "Childhood Illness"), + ( + "invasive_bacterial_infections", + "Invasive Bacterial Infections", + ), + ("vector_borne", "Vector Borne"), + ] + ), + ), + ("topic", models.CharField(max_length=255)), + ("metric", models.CharField(max_length=255)), + ("geography_type", models.CharField(max_length=255)), + ("geography", models.CharField(max_length=255)), + ], + ), + migrations.DeleteModel( + name="AuthFeature", + ), + ] diff --git a/auth_content/models.py b/auth_content/models.py index 1744cb5d4..f96ea431a 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -3,16 +3,30 @@ # Create your models here. from django.db import models from wagtail.admin.panels import FieldPanel -from wagtail.snippets.models import register_snippet +from validation.enums.theme_and_topic_enums import ChildTheme, ParentTheme -class AuthFeature(models.Model): - title = models.CharField(max_length=255) - description = models.TextField() + +class PermissionSet(models.Model): + theme = models.CharField( + choices=[(e.value, e.name.replace("_", " ").title()) for e in ParentTheme] + ) + sub_theme = models.CharField( + choices=ChildTheme["INFECTIOUS_DISEASE"].return_tuple_list() + ) + topic = models.CharField(max_length=255) + metric = models.CharField(max_length=255) + geography_type = models.CharField(max_length=255) + geography = models.CharField(max_length=255) + panels = [ - FieldPanel('title'), - FieldPanel('description'), + FieldPanel('theme'), + FieldPanel('sub_theme'), + FieldPanel('topic'), + FieldPanel('metric'), + FieldPanel('geography_type'), + FieldPanel('geography'), ] def __str__(self): diff --git a/auth_content/tests.py b/auth_content/tests.py deleted file mode 100644 index 7ce503c2d..000000000 --- a/auth_content/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/cms/dashboard/wagtail_hooks.py b/cms/dashboard/wagtail_hooks.py index 0d631ca2e..028b3bfb5 100644 --- a/cms/dashboard/wagtail_hooks.py +++ b/cms/dashboard/wagtail_hooks.py @@ -12,7 +12,7 @@ from wagtail.snippets.views.snippets import SnippetViewSet from wagtail.admin.viewsets.model import ModelViewSetGroup -from auth_content.models import AuthFeature +from auth_content.models import PermissionSet @hooks.register("insert_global_admin_css") @@ -131,14 +131,14 @@ def register_link_props(features): features.register_converter_rule("contentstate", "link", rule) # Initial feature to test out new menu section -class AuthFeatureViewSet(SnippetViewSet): - model = AuthFeature - menu_label = "Features" +class PermissionSetViewSet(SnippetViewSet): + model = PermissionSet + menu_label = "Permission Sets" icon = "key" class AuthGroup(ModelViewSetGroup): - items = (AuthFeatureViewSet,) + items = (PermissionSetViewSet,) menu_label = "Auth" menu_icon = "lock" menu_order = 300 diff --git a/validation/enums/helper_enum.py b/validation/enums/helper_enum.py index 6cc7d4095..d46ddc25d 100644 --- a/validation/enums/helper_enum.py +++ b/validation/enums/helper_enum.py @@ -7,3 +7,6 @@ def return_list(self): def return_name_list(self): return [e.name for e in self.value] + + def return_tuple_list(self): + return [(e.value, e.name.replace("_", " ").title()) for e in self.value] From 527b15f7f86f16254ae7a505286a3117142667fd Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Fri, 13 Mar 2026 14:29:03 +0000 Subject: [PATCH 052/186] Add conditional sub_theme dropdown --- auth_content/admin.py | 3 -- auth_content/apps.py | 6 --- .../0002_permissionset_delete_authfeature.py | 23 +-------- auth_content/models.py | 40 +++++++++++----- auth_content/static/js/child_theme.js | 47 +++++++++++++++++++ auth_content/views.py | 3 -- auth_content/wagtail_hooks.py | 41 ++++++++++++++++ cms/dashboard/wagtail_hooks.py | 21 --------- 8 files changed, 117 insertions(+), 67 deletions(-) delete mode 100644 auth_content/admin.py delete mode 100644 auth_content/apps.py create mode 100644 auth_content/static/js/child_theme.js delete mode 100644 auth_content/views.py create mode 100644 auth_content/wagtail_hooks.py diff --git a/auth_content/admin.py b/auth_content/admin.py deleted file mode 100644 index 8c38f3f3d..000000000 --- a/auth_content/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/auth_content/apps.py b/auth_content/apps.py deleted file mode 100644 index c7f2ba7ca..000000000 --- a/auth_content/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class AuthContentConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "auth_content" diff --git a/auth_content/migrations/0002_permissionset_delete_authfeature.py b/auth_content/migrations/0002_permissionset_delete_authfeature.py index 1c1376a5d..6900e77a6 100644 --- a/auth_content/migrations/0002_permissionset_delete_authfeature.py +++ b/auth_content/migrations/0002_permissionset_delete_authfeature.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.12 on 2026-03-12 16:13 +# Generated by Django 5.2.12 on 2026-03-13 14:23 from django.db import migrations, models @@ -34,26 +34,7 @@ class Migration(migrations.Migration): ] ), ), - ( - "sub_theme", - models.CharField( - choices=[ - ("vaccine_preventable", "Vaccine Preventable"), - ("respiratory", "Respiratory"), - ("bloodstream_infection", "Bloodstream Infection"), - ("bloodborne", "Bloodborne"), - ("gastrointestinal", "Gastrointestinal"), - ("antimicrobial_resistance", "Antimicrobial Resistance"), - ("contact", "Contact"), - ("childhood_illness", "Childhood Illness"), - ( - "invasive_bacterial_infections", - "Invasive Bacterial Infections", - ), - ("vector_borne", "Vector Borne"), - ] - ), - ), + ("sub_theme", models.CharField(choices=[], max_length=255)), ("topic", models.CharField(max_length=255)), ("metric", models.CharField(max_length=255)), ("geography_type", models.CharField(max_length=255)), diff --git a/auth_content/models.py b/auth_content/models.py index f96ea431a..b06818266 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -1,33 +1,47 @@ from django.db import models -# Create your models here. from django.db import models from wagtail.admin.panels import FieldPanel from validation.enums.theme_and_topic_enums import ChildTheme, ParentTheme +def get_theme_child_map(): + """Returns an object of all parent to child mappings + e.g. + { + infectious_disease: [vaccine_preventable, respiratory ....], + extreme_event: [weather_alert, mortality_report...] + ... + } + + """ + + theme_mapping = {} + for parent in ParentTheme: + # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme + theme_mapping[parent.value] = ChildTheme[parent.name].return_tuple_list() + + return theme_mapping class PermissionSet(models.Model): theme = models.CharField( choices=[(e.value, e.name.replace("_", " ").title()) for e in ParentTheme] ) - sub_theme = models.CharField( - choices=ChildTheme["INFECTIOUS_DISEASE"].return_tuple_list() - ) + sub_theme = models.CharField(max_length=255, choices=[]) topic = models.CharField(max_length=255) metric = models.CharField(max_length=255) geography_type = models.CharField(max_length=255) geography = models.CharField(max_length=255) - + panels = [ - FieldPanel('theme'), - FieldPanel('sub_theme'), - FieldPanel('topic'), - FieldPanel('metric'), - FieldPanel('geography_type'), - FieldPanel('geography'), + FieldPanel("theme"), + FieldPanel("sub_theme"), + FieldPanel("topic"), + FieldPanel("metric"), + FieldPanel("geography_type"), + FieldPanel("geography"), ] - + def __str__(self): - return self.title \ No newline at end of file + return self.theme diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js new file mode 100644 index 000000000..3060e7979 --- /dev/null +++ b/auth_content/static/js/child_theme.js @@ -0,0 +1,47 @@ +;(function () { + let theme, + sub_theme, + themeMapping = {} + + function setSubTheme() { + theme = document.querySelector('select[name="theme"]') + sub_theme = document.querySelector('select[name="sub_theme"]') + + if (!theme || !sub_theme) return + + try { + themeMapping = window.PERMISSIONSET_THEME_MAP + } catch (e) { + console.error("Invalid theme map") + } + + if (!theme.value) { + clearSubTheme() + } else { + populateSubThemeDropDown() + } + } + function populateSubThemeDropDown() { + sub_theme.disabled = false + sub_theme.innerHTML = "" + sub_theme.add(new Option("---------", "")) + + const options = themeMapping[theme.value] || [] + + options.forEach(([value, label]) => sub_theme.add(new Option(label, value))) + } + + function clearSubTheme() { + sub_theme.innerHTML = "" + sub_theme.add(new Option("---------", "")) + sub_theme.value = "" + sub_theme.disabled = true + } + + document.addEventListener("DOMContentLoaded", setSubTheme) + document.addEventListener("change", function (e) { + if (e.target.name === "theme") { + setSubTheme() + } + }) +})() diff --git a/auth_content/views.py b/auth_content/views.py deleted file mode 100644 index 91ea44a21..000000000 --- a/auth_content/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py new file mode 100644 index 000000000..88fc433d6 --- /dev/null +++ b/auth_content/wagtail_hooks.py @@ -0,0 +1,41 @@ +import json + +from wagtail import hooks +from wagtail.snippets.views.snippets import SnippetViewSet +from wagtail.admin.viewsets.model import ModelViewSetGroup +from django.templatetags.static import static + +from auth_content.models import PermissionSet, get_theme_child_map +from django.utils.html import format_html +from django.utils.safestring import mark_safe + + +class PermissionSetViewSet(SnippetViewSet): + model = PermissionSet + menu_label = "Permission Sets" + icon = "key" + + +class AuthGroup(ModelViewSetGroup): + items = (PermissionSetViewSet,) + menu_label = "Auth" + menu_icon = "lock" + menu_order = 300 + + +@hooks.register("register_admin_viewset") +def register_auth_viewset(): + return AuthGroup() + +# exposes the mapping of parent to child themes +@hooks.register("insert_editor_js") +def permission_set_theme_mapping(): + mapping = json.dumps(get_theme_child_map()) + return format_html( + "", mark_safe(mapping) + ) + + +@hooks.register("insert_editor_js") +def permission_set_js(): + return format_html('', static("js/child_theme.js")) diff --git a/cms/dashboard/wagtail_hooks.py b/cms/dashboard/wagtail_hooks.py index 028b3bfb5..2507444bd 100644 --- a/cms/dashboard/wagtail_hooks.py +++ b/cms/dashboard/wagtail_hooks.py @@ -9,10 +9,7 @@ from wagtail.models import Page from wagtail.whitelist import check_url from wagtail import hooks -from wagtail.snippets.views.snippets import SnippetViewSet -from wagtail.admin.viewsets.model import ModelViewSetGroup -from auth_content.models import PermissionSet @hooks.register("insert_global_admin_css") @@ -129,21 +126,3 @@ def register_link_props(features): rule = features.converter_rules_by_converter["contentstate"]["link"] rule["to_database_format"]["entity_decorators"]["LINK"] = link_entity_with_href features.register_converter_rule("contentstate", "link", rule) - -# Initial feature to test out new menu section -class PermissionSetViewSet(SnippetViewSet): - model = PermissionSet - menu_label = "Permission Sets" - icon = "key" - - -class AuthGroup(ModelViewSetGroup): - items = (PermissionSetViewSet,) - menu_label = "Auth" - menu_icon = "lock" - menu_order = 300 - - -@hooks.register("register_admin_viewset") -def register_auth_viewset(): - return AuthGroup() From 058e52939dcc21ae42f39a9f38e346379a7493fb Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Fri, 13 Mar 2026 14:42:07 +0000 Subject: [PATCH 053/186] Update migration file and tidy up child_theme.js --- .../migrations/0002_permissionset_delete_authfeature.py | 2 +- auth_content/static/js/child_theme.js | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/auth_content/migrations/0002_permissionset_delete_authfeature.py b/auth_content/migrations/0002_permissionset_delete_authfeature.py index 6900e77a6..3128e3c31 100644 --- a/auth_content/migrations/0002_permissionset_delete_authfeature.py +++ b/auth_content/migrations/0002_permissionset_delete_authfeature.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.12 on 2026-03-13 14:23 +# Generated by Django 5.2.12 on 2026-03-13 14:40 from django.db import migrations, models diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 3060e7979..3b958d499 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -9,11 +9,7 @@ if (!theme || !sub_theme) return - try { - themeMapping = window.PERMISSIONSET_THEME_MAP - } catch (e) { - console.error("Invalid theme map") - } + themeMapping = window.PERMISSIONSET_THEME_MAP if (!theme.value) { clearSubTheme() @@ -21,6 +17,7 @@ populateSubThemeDropDown() } } + function populateSubThemeDropDown() { sub_theme.disabled = false sub_theme.innerHTML = "" From d0041f19d2c876560178fe9be0c818289643579b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 06:13:41 +0000 Subject: [PATCH 054/186] 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] From db3eed6239a1c9e9557a44feed5f97f101eab8a1 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Wed, 25 Feb 2026 12:48:21 +0000 Subject: [PATCH 055/186] Testing dummy secret with gitleaks --- .github/workflows/secret-scan.yaml | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/secret-scan.yaml diff --git a/.github/workflows/secret-scan.yaml b/.github/workflows/secret-scan.yaml new file mode 100644 index 000000000..02e37af92 --- /dev/null +++ b/.github/workflows/secret-scan.yaml @@ -0,0 +1,41 @@ +name: Secret Scan + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + gitleaks: + name: Gitleaks Secret Scan + runs-on: ubuntu-latest + + steps: + # Checkout PR HEAD commit (not the merge commit) + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + # Install latest Gitleaks + - name: Install Gitleaks + run: | + VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') + curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz -o gitleaks.tar.gz + tar -xzf gitleaks.tar.gz + sudo mv gitleaks /usr/local/bin/ + gitleaks version + + # Fetch base branch commit + - name: Fetch base branch + run: | + git fetch origin ${{ github.event.pull_request.base.sha }} + + # Scan ONLY commits introduced in this PR + - name: Run Gitleaks (PR commits only) + run: | + echo "Scanning commits from base -> PR head" + gitleaks detect \ + --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" \ + --exit-code 1 \ + --verbose \ No newline at end of file From 2edb76e1ea1516b8093a6a1b7ef80b0752426127 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Wed, 25 Feb 2026 12:51:41 +0000 Subject: [PATCH 056/186] Testing dummy secret with gitleaks --- .github/workflows/secret-scan.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/secret-scan.yaml b/.github/workflows/secret-scan.yaml index 02e37af92..240b69d25 100644 --- a/.github/workflows/secret-scan.yaml +++ b/.github/workflows/secret-scan.yaml @@ -10,7 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - # Checkout PR HEAD commit (not the merge commit) - name: Checkout PR head uses: actions/checkout@v4 with: From d818ec132c3662c65c5654b5f5d8ef0ceac1f82f Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Wed, 25 Feb 2026 13:32:11 +0000 Subject: [PATCH 057/186] Added secret scan to the existing action.yaml --- .github/workflows/secret-scan.yaml | 40 ------------------------------ 1 file changed, 40 deletions(-) delete mode 100644 .github/workflows/secret-scan.yaml diff --git a/.github/workflows/secret-scan.yaml b/.github/workflows/secret-scan.yaml deleted file mode 100644 index 240b69d25..000000000 --- a/.github/workflows/secret-scan.yaml +++ /dev/null @@ -1,40 +0,0 @@ -name: Secret Scan - -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - gitleaks: - name: Gitleaks Secret Scan - runs-on: ubuntu-latest - - steps: - - name: Checkout PR head - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - # Install latest Gitleaks - - name: Install Gitleaks - run: | - VERSION=$(curl -s https://api.github.com/repos/gitleaks/gitleaks/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') - curl -sSL https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz -o gitleaks.tar.gz - tar -xzf gitleaks.tar.gz - sudo mv gitleaks /usr/local/bin/ - gitleaks version - - # Fetch base branch commit - - name: Fetch base branch - run: | - git fetch origin ${{ github.event.pull_request.base.sha }} - - # Scan ONLY commits introduced in this PR - - name: Run Gitleaks (PR commits only) - run: | - echo "Scanning commits from base -> PR head" - gitleaks detect \ - --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" \ - --exit-code 1 \ - --verbose \ No newline at end of file From 7082593666b90a2738a0f142d0dc237c1d215e04 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Wed, 4 Mar 2026 13:33:13 +0000 Subject: [PATCH 058/186] Reverted to script installation of gitleaks --- .github/workflows/actions.yml | 83 ++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 76a460a04..3528df297 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -1,6 +1,6 @@ name: Pipeline -on: +on: pull_request: types: [opened, synchronize, reopened] push: @@ -17,7 +17,6 @@ permissions: contents: read # This is required for actions/checkout jobs: - ############################################################################### # Secret Scan ############################################################################### @@ -27,8 +26,11 @@ jobs: runs-on: ubuntu-22.04-arm steps: - - uses: actions/checkout@v4 + # Checkout PR HEAD commit (not the merge commit) + - name: Checkout PR head + uses: actions/checkout@v4 with: + ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Run Gitleaks @@ -37,7 +39,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} - + ############################################################################### # Install dependencies & build packages ############################################################################### @@ -56,7 +58,7 @@ jobs: dependency-checks: name: Dependency checks - needs: [ build ] + needs: [build] runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v4 @@ -72,7 +74,7 @@ jobs: vulnerability-checks: name: Vulnerability checks - needs: [ build ] + needs: [build] runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v4 @@ -89,7 +91,7 @@ jobs: quality-checks: name: Linting - needs: [ build ] + needs: [build] runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v4 @@ -106,7 +108,7 @@ jobs: architecture-checks: name: Architecture checks - needs: [ build ] + needs: [build] runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v4 @@ -123,7 +125,7 @@ jobs: unit-tests: name: Unit tests - needs: [ build ] + needs: [build] runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v4 @@ -140,7 +142,7 @@ jobs: integration-tests: name: Integration tests - needs: [ build ] + needs: [build] runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v4 @@ -157,7 +159,7 @@ jobs: system-tests: name: System tests - needs: [ build ] + needs: [build] runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v4 @@ -174,7 +176,7 @@ jobs: migration-tests: name: Migration tests - needs: [ build ] + needs: [build] runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v4 @@ -191,7 +193,7 @@ jobs: test-coverage: name: Test coverage - needs: [ build ] + needs: [build] runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v4 @@ -263,18 +265,19 @@ jobs: publish-main-image: name: Publish main image to central ECR - needs: [ - quality-checks, - unit-tests, - integration-tests, - system-tests, - migration-tests, - test-coverage, - dependency-checks, - vulnerability-checks, - architecture-checks, - docker-build-check - ] + needs: + [ + quality-checks, + unit-tests, + integration-tests, + system-tests, + migration-tests, + test-coverage, + dependency-checks, + vulnerability-checks, + architecture-checks, + docker-build-check, + ] runs-on: ubuntu-22.04-arm if: ${{ github.ref == 'refs/heads/main' }} steps: @@ -289,18 +292,19 @@ jobs: publish-ingestion-image: name: Publish ingestion image to central ECR - needs: [ - quality-checks, - unit-tests, - integration-tests, - system-tests, - migration-tests, - test-coverage, - dependency-checks, - vulnerability-checks, - architecture-checks, - docker-build-check - ] + needs: + [ + quality-checks, + unit-tests, + integration-tests, + system-tests, + migration-tests, + test-coverage, + dependency-checks, + vulnerability-checks, + architecture-checks, + docker-build-check, + ] runs-on: ubuntu-22.04-arm if: ${{ github.ref == 'refs/heads/main' }} @@ -321,10 +325,7 @@ jobs: trigger-deployments: name: Trigger deployments - needs: [ - publish-main-image, - publish-ingestion-image - ] + needs: [publish-main-image, publish-ingestion-image] runs-on: ubuntu-22.04-arm if: ${{ github.ref == 'refs/heads/main' }} # Only deploy if the changes are being pushed to the `main` branch From 1a7b2489b9c00ceeed6bce7ff17c39235a839087 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Wed, 4 Mar 2026 14:13:27 +0000 Subject: [PATCH 059/186] Changed job name --- .github/workflows/actions.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 3528df297..2aa20eacc 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -26,7 +26,6 @@ jobs: runs-on: ubuntu-22.04-arm steps: - # Checkout PR HEAD commit (not the merge commit) - name: Checkout PR head uses: actions/checkout@v4 with: From 9938b77754688d92cdb18e34ec204c632d71b0da Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Thu, 5 Mar 2026 12:57:26 +0000 Subject: [PATCH 060/186] Changed ubuntu version From f62bbad3a4c21399934a130345282d25440966e0 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Fri, 6 Mar 2026 11:43:50 +0000 Subject: [PATCH 061/186] Using official gitleaks action --- .github/workflows/actions.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 2aa20eacc..71d094d07 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -23,13 +23,11 @@ jobs: secret-scan: name: Gitleaks Secret Scan - runs-on: ubuntu-22.04-arm + runs-on: ubuntu-latest steps: - - name: Checkout PR head - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Run Gitleaks From ea9e6e49beb25d217a18b2be43bb99edd201928c Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Fri, 6 Mar 2026 13:51:13 +0000 Subject: [PATCH 062/186] Updated ubuntu version --- .github/workflows/actions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index 71d094d07..e68aa6952 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -23,7 +23,7 @@ jobs: secret-scan: name: Gitleaks Secret Scan - runs-on: ubuntu-latest + runs-on: ubuntu-22.04-arm steps: - uses: actions/checkout@v4 From f8206507fdedee5a35c4d10326184b05be6b57e0 Mon Sep 17 00:00:00 2001 From: abdihakim92x1 Date: Tue, 10 Mar 2026 12:34:28 +0000 Subject: [PATCH 063/186] Gitleaks arg removed --- .github/workflows/actions.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index e68aa6952..cb4ea6324 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -36,7 +36,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} - ############################################################################### # Install dependencies & build packages ############################################################################### From ed11e835d62ab11d3a5d73403ea7863bc890a145 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 17 Mar 2026 17:15:50 +0000 Subject: [PATCH 064/186] CDD-3175: populate the Topic dropdown --- auth_content/models.py | 67 ++++++++++++++++++--- auth_content/static/js/child_theme.js | 87 ++++++++++++++++++++------- auth_content/wagtail_hooks.py | 21 ++++++- 3 files changed, 143 insertions(+), 32 deletions(-) diff --git a/auth_content/models.py b/auth_content/models.py index b06818266..5806a6890 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -3,7 +3,9 @@ from django.db import models from wagtail.admin.panels import FieldPanel -from validation.enums.theme_and_topic_enums import ChildTheme, ParentTheme +from validation.enums.geographies_enums import GeographyType +from validation.enums.theme_and_topic_enums import ChildTheme, ParentTheme, Topic + def get_theme_child_map(): """Returns an object of all parent to child mappings @@ -21,19 +23,68 @@ def get_theme_child_map(): # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme theme_mapping[parent.value] = ChildTheme[parent.name].return_tuple_list() + print(theme_mapping) return theme_mapping + +def get_sub_theme_child_map(): + """Returns an object of all parent to child mappings + e.g. + { + infectious_disease: [vaccine_preventable, respiratory ....], + extreme_event: [weather_alert, mortality_report...] + ... + } + + """ + + sub_theme_mapping = {} + for topic in Topic: + print("item: ", topic.value) + + # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme + sub_theme_mapping[topic.name.lower( + )] = Topic[topic.name].return_tuple_list() + + print(sub_theme_mapping) + + return sub_theme_mapping + + +def get_geography_type_geographies_map(): + """Returns an object of all parent to child mappings + e.g. + { + infectious_disease: [vaccine_preventable, respiratory ....], + extreme_event: [weather_alert, mortality_report...] + ... + } + + """ + + geographies_mapping = {} + for geographyType in GeographyType: + # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme + geographies_mapping[geographyType.value] = [ + geographyType.name].return_tuple_list() + + print(geographies_mapping) + geographies_mapping = {} + return geographies_mapping + + class PermissionSet(models.Model): theme = models.CharField( - choices=[(e.value, e.name.replace("_", " ").title()) for e in ParentTheme] + choices=[(e.value, e.name.replace("_", " ").title()) + for e in ParentTheme] ) sub_theme = models.CharField(max_length=255, choices=[]) - topic = models.CharField(max_length=255) - metric = models.CharField(max_length=255) - geography_type = models.CharField(max_length=255) - geography = models.CharField(max_length=255) + topic = models.CharField(max_length=255, choices=[]) + metric = models.CharField(max_length=255, choices=[]) + geography_type = models.CharField(max_length=255, choices=[( + e.value, e.value.replace("_", " ")) for e in GeographyType]) + geography = models.CharField(max_length=255, choices=[]) - panels = [ FieldPanel("theme"), FieldPanel("sub_theme"), @@ -42,6 +93,6 @@ class PermissionSet(models.Model): FieldPanel("geography_type"), FieldPanel("geography"), ] - + def __str__(self): return self.theme diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 3b958d499..589935019 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -1,44 +1,89 @@ -;(function () { +(function () { let theme, sub_theme, - themeMapping = {} + topic, + subThemeMapping = {}, + themeMapping = {}; function setSubTheme() { - theme = document.querySelector('select[name="theme"]') - sub_theme = document.querySelector('select[name="sub_theme"]') + theme = document.querySelector('select[name="theme"]'); + sub_theme = document.querySelector('select[name="sub_theme"]'); - if (!theme || !sub_theme) return + if (!theme || !sub_theme) return; - themeMapping = window.PERMISSIONSET_THEME_MAP + themeMapping = window.PERMISSIONSET_THEME_MAP; if (!theme.value) { - clearSubTheme() + clearSubTheme(); } else { - populateSubThemeDropDown() + populateSubThemeDropDown(); + } + } + + function setTopic() { + sub_theme = document.querySelector('select[name="sub_theme"]'); + topic = document.querySelector('select[name="topic"]'); + + if (!sub_theme || !topic) return; + + subThemeMapping = window.PERMISSIONSET_SUB_THEME_MAP; + console.log("subTheme: ", subThemeMapping); + + console.log(sub_theme.value); + + if (!sub_theme.value) { + clearTopic(); + } else { + populateTopicDropDown(); } } function populateSubThemeDropDown() { - sub_theme.disabled = false - sub_theme.innerHTML = "" - sub_theme.add(new Option("---------", "")) + sub_theme.disabled = false; + sub_theme.innerHTML = ""; + sub_theme.add(new Option("---------", "")); - const options = themeMapping[theme.value] || [] + const options = themeMapping[theme.value] || []; - options.forEach(([value, label]) => sub_theme.add(new Option(label, value))) + options.forEach(([value, label]) => + sub_theme.add(new Option(label, value)), + ); } function clearSubTheme() { - sub_theme.innerHTML = "" - sub_theme.add(new Option("---------", "")) - sub_theme.value = "" - sub_theme.disabled = true + sub_theme.innerHTML = ""; + sub_theme.add(new Option("---------", "")); + sub_theme.value = ""; + sub_theme.disabled = true; } - document.addEventListener("DOMContentLoaded", setSubTheme) + function populateTopicDropDown() { + topic.disabled = false; + topic.innerHTML = ""; + topic.add(new Option("---------", "")); + + const options = subThemeMapping[sub_theme.value] || []; + console.log("options:", subThemeMapping); + + options.forEach(([value, label]) => topic.add(new Option(label, value))); + } + + function clearTopic() { + topic.innerHTML = ""; + topic.add(new Option("---------", "")); + topic.value = ""; + topic.disabled = true; + } + + document.addEventListener("DOMContentLoaded", setSubTheme); + document.addEventListener("DOMContentLoaded", setTopic); + document.addEventListener("change", function (e) { if (e.target.name === "theme") { - setSubTheme() + setSubTheme(); + } + if (e.target.name === "sub_theme") { + setTopic(); } - }) -})() + }); +})(); diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py index 88fc433d6..cbdd67ace 100644 --- a/auth_content/wagtail_hooks.py +++ b/auth_content/wagtail_hooks.py @@ -5,7 +5,7 @@ from wagtail.admin.viewsets.model import ModelViewSetGroup from django.templatetags.static import static -from auth_content.models import PermissionSet, get_theme_child_map +from auth_content.models import PermissionSet, get_theme_child_map, get_sub_theme_child_map from django.utils.html import format_html from django.utils.safestring import mark_safe @@ -27,12 +27,27 @@ class AuthGroup(ModelViewSetGroup): def register_auth_viewset(): return AuthGroup() -# exposes the mapping of parent to child themes +# exposes the mapping of parent to child themes + + @hooks.register("insert_editor_js") def permission_set_theme_mapping(): mapping = json.dumps(get_theme_child_map()) return format_html( - "", mark_safe(mapping) + "", mark_safe( + mapping) + ) + +# exposes the mapping of parent to child themes + + +@hooks.register("insert_editor_js") +def permission_set_sub_theme_mapping(): + sub_theme_mapping = json.dumps(get_sub_theme_child_map()) + print(sub_theme_mapping) + return format_html( + "", mark_safe( + sub_theme_mapping) ) From d38cc2dbc03db9517c844041dce67985576e9d1c Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Thu, 19 Mar 2026 15:05:17 +0000 Subject: [PATCH 065/186] Update theme functionality to pull available themes from the db via the metrics interface. --- auth_content/models.py | 105 +++---- auth_content/static/js/child_theme.js | 277 ++++++++++++++---- auth_content/wagtail_hooks.py | 21 +- .../field_choices_callables.py | 74 ++++- cms/metrics_interface/interface.py | 23 ++ metrics/api/urls_construction.py | 32 +- metrics/api/views/permission_sets.py | 93 ++++++ .../data/managers/core_models/sub_theme.py | 20 ++ metrics/data/managers/core_models/theme.py | 20 ++ 9 files changed, 516 insertions(+), 149 deletions(-) create mode 100644 metrics/api/views/permission_sets.py diff --git a/auth_content/models.py b/auth_content/models.py index 5806a6890..97a9ef816 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -1,10 +1,11 @@ -from django.db import models +from django import forms from django.db import models from wagtail.admin.panels import FieldPanel +from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids from validation.enums.geographies_enums import GeographyType -from validation.enums.theme_and_topic_enums import ChildTheme, ParentTheme, Topic +from wagtail.admin.forms import WagtailAdminModelForm def get_theme_child_map(): @@ -17,73 +18,55 @@ def get_theme_child_map(): } """ - theme_mapping = {} - for parent in ParentTheme: - # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme - theme_mapping[parent.value] = ChildTheme[parent.name].return_tuple_list() print(theme_mapping) return theme_mapping -def get_sub_theme_child_map(): - """Returns an object of all parent to child mappings - e.g. - { - infectious_disease: [vaccine_preventable, respiratory ....], - extreme_event: [weather_alert, mortality_report...] - ... - } - - """ - - sub_theme_mapping = {} - for topic in Topic: - print("item: ", topic.value) - - # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme - sub_theme_mapping[topic.name.lower( - )] = Topic[topic.name].return_tuple_list() - - print(sub_theme_mapping) - - return sub_theme_mapping - - -def get_geography_type_geographies_map(): - """Returns an object of all parent to child mappings - e.g. - { - infectious_disease: [vaccine_preventable, respiratory ....], - extreme_event: [weather_alert, mortality_report...] - ... - } - - """ - - geographies_mapping = {} - for geographyType in GeographyType: - # It has been assumed for now that validation and ingestion will catch if any parent name are not used in ChildTheme - geographies_mapping[geographyType.value] = [ - geographyType.name].return_tuple_list() - - print(geographies_mapping) - geographies_mapping = {} - return geographies_mapping +class PermissionSetForm(WagtailAdminModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Use CharField with Select widget to bypass choice validation + self.fields['sub_theme'] = forms.CharField( + required=False, + label="Sub Theme", + widget=forms.Select(choices=[("", "Select theme first")]) + ) + self.fields['topic'] = forms.CharField( + required=False, + label="Topic", + widget=forms.Select(choices=[("", "Select sub-theme first")]) + ) + self.fields['metric'] = forms.CharField( + required=False, + label="Metric", + widget=forms.Select(choices=[("", "Select topic first")]) + ) + self.fields['geography'] = forms.CharField( + required=False, + label="Geography", + widget=forms.Select( + choices=[("-1", "Select geography type first")]) + ) class PermissionSet(models.Model): theme = models.CharField( - choices=[(e.value, e.name.replace("_", " ").title()) - for e in ParentTheme] - ) - sub_theme = models.CharField(max_length=255, choices=[]) - topic = models.CharField(max_length=255, choices=[]) - metric = models.CharField(max_length=255, choices=[]) + max_length=255, choices=[("", "---------"), ("-1", "* (All themes)")] + get_all_theme_names_and_ids(), blank=True, default="") + sub_theme = models.CharField( + max_length=255, blank=True, default="") + topic = models.CharField(max_length=255, + blank=True, default="") + metric = models.CharField( + max_length=255, blank=True, default="") geography_type = models.CharField(max_length=255, choices=[( - e.value, e.value.replace("_", " ")) for e in GeographyType]) - geography = models.CharField(max_length=255, choices=[]) + e.value, e.value.replace("_", " ")) for e in GeographyType], blank=True, default="") + geography = models.CharField( + max_length=255, blank=True, default="") + + base_form_class = PermissionSetForm panels = [ FieldPanel("theme"), @@ -95,4 +78,8 @@ class PermissionSet(models.Model): ] def __str__(self): - return self.theme + if self.theme and self.theme != "" and self.theme != "-1": + return f"Permission Set - Theme {self.theme}" + elif self.theme == "-1": + return "Permission Set - All Themes" + return "Permission Set - Not Configured" diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 589935019..0f3ca945d 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -1,89 +1,246 @@ (function () { - let theme, - sub_theme, - topic, - subThemeMapping = {}, - themeMapping = {}; + "use strict"; + let theme, subTheme, topic, metric; + + /** + * Generic function to fetch choices from the API + * @param {string} endpoint - The API endpoint (e.g., 'subthemes', 'topics', 'metrics') + * @param {string} dataItemId - The ID value to pass + * @returns {Promise} Array of choices [[id, name], ...] + */ + async function fetchChoices(endpoint, dataItemId) { + try { + const url = `/api/permission-set/${endpoint}/${dataItemId}`; + console.log(`Fetching from: ${url}`); + + const response = await fetch(url); + + if (!response.ok) { + const errorData = await response.json(); + console.error(`API error: ${errorData.error || "Unknown error"}`); + return []; + } + + const data = await response.json(); + console.log(`Received data from ${endpoint}:`, data); + return data.choices || []; + } catch (error) { + console.error(`Error fetching ${endpoint}:`, error); + return []; + } + } - function setSubTheme() { - theme = document.querySelector('select[name="theme"]'); - sub_theme = document.querySelector('select[name="sub_theme"]'); + /** + * Generic function to populate a dropdown with choices + * @param {HTMLSelectElement} dropdown - The select element to populate + * @param {Array} choices - Array of [id, name] tuples + */ + function populateDropdown(dropdown, choices) { + dropdown.disabled = false; + dropdown.innerHTML = ""; + + choices.forEach(([id, name]) => { + const option = document.createElement("option"); + option.value = id; + option.textContent = name; + dropdown.appendChild(option); + }); + } - if (!theme || !sub_theme) return; + function clearDropdown(dropdown, message = "Select parent first") { + dropdown.innerHTML = ""; - themeMapping = window.PERMISSIONSET_THEME_MAP; + const option = document.createElement("option"); + option.value = ""; + option.textContent = message; + dropdown.appendChild(option); - if (!theme.value) { - clearSubTheme(); - } else { - populateSubThemeDropDown(); - } + dropdown.value = ""; + dropdown.disabled = true; + + console.log(`Cleared ${dropdown.name}: ${message}`); } - function setTopic() { - sub_theme = document.querySelector('select[name="sub_theme"]'); - topic = document.querySelector('select[name="topic"]'); + /** + * Set dropdown to wildcard and disable it + * Used when parent is wildcard, cascading "all" to children + */ + function setToWildcard( + dropdown, + message = "* (All - inherited from parent)", + ) { + dropdown.innerHTML = ""; + + const option = document.createElement("option"); + option.value = "-1"; + option.textContent = message; + dropdown.appendChild(option); + + dropdown.value = "-1"; + dropdown.disabled = true; + + console.log(`Set ${dropdown.name} to wildcard: ${message}`); + } + + /** + * Handle theme selection change + */ + async function handleThemeChange() { + const themeValue = theme.value; + + // Clear all dependent dropdowns + if (!themeValue || themeValue === "") { + console.log("No theme selected - clearing all children"); + clearDropdown(subTheme, "Select theme first"); + clearDropdown(topic, "Select sub-theme first"); + clearDropdown(metric, "Select topic first"); + return; + } - if (!sub_theme || !topic) return; + if (themeValue === "-1") { + console.log("Wildcard theme selected - cascading to all children"); + setToWildcard(subTheme, "* (All sub-themes)"); + setToWildcard(topic, "* (All topics)"); + setToWildcard(metric, "* (All metrics)"); + return; + } - subThemeMapping = window.PERMISSIONSET_SUB_THEME_MAP; - console.log("subTheme: ", subThemeMapping); + clearDropdown(subTheme, "--------"); + clearDropdown(topic, "--------"); + clearDropdown(metric, "--------"); - console.log(sub_theme.value); + // Fetch and populate sub-themes + const choices = await fetchChoices("subthemes", themeValue); - if (!sub_theme.value) { - clearTopic(); + if (choices.length > 0) { + populateDropdown(subTheme, choices); } else { - populateTopicDropDown(); + clearDropdown(subTheme, "No sub-themes available"); } } - function populateSubThemeDropDown() { - sub_theme.disabled = false; - sub_theme.innerHTML = ""; - sub_theme.add(new Option("---------", "")); + /** + * Handle sub-theme selection change + */ + async function handleSubThemeChange() { + const subThemeValue = subTheme.value; + console.log("Sub-theme changed to:", subThemeValue); + + if (!subThemeValue || subThemeValue === "") { + // No sub-theme selected - clear children + clearDropdown(topic, "Select sub-theme first"); + clearDropdown(metric, "Select topic first"); + return; + } - const options = themeMapping[theme.value] || []; + if (subThemeValue === "-1") { + // Wildcard sub-theme = cascade wildcard to children + console.log("Wildcard sub-theme selected - cascading to children"); + setToWildcard(topic, "* (All topics)"); + setToWildcard(metric, "* (All metrics)"); + return; + } - options.forEach(([value, label]) => - sub_theme.add(new Option(label, value)), - ); - } + // Clear dependent dropdowns + clearDropdown(topic, "--------"); + clearDropdown(metric, "--------"); - function clearSubTheme() { - sub_theme.innerHTML = ""; - sub_theme.add(new Option("---------", "")); - sub_theme.value = ""; - sub_theme.disabled = true; + // Fetch and populate topics + const choices = await fetchChoices("topics", subThemeValue); + + if (choices.length > 0) { + populateDropdown(topic, choices); + } else { + clearDropdown(topic, "No topics available"); + } } - function populateTopicDropDown() { - topic.disabled = false; - topic.innerHTML = ""; - topic.add(new Option("---------", "")); + /** + * Handle topic selection change + */ + async function handleTopicChange() { + const topicValue = topic.value; + console.log("Topic changed to:", topicValue); + + if (!topicValue || topicValue === "") { + // No topic selected - clear metrics + console.log("No topic selected - clearing metrics"); + clearDropdown(metric, "Select topic first"); + return; + } + + if (topicValue === "-1") { + // Wildcard topic = cascade wildcard to metrics + console.log("Wildcard topic selected - cascading to metrics"); + setToWildcard(metric, "* (All metrics)"); + return; + } - const options = subThemeMapping[sub_theme.value] || []; - console.log("options:", subThemeMapping); + clearDropdown(metric, "--------"); - options.forEach(([value, label]) => topic.add(new Option(label, value))); - } + // Fetch and populate metrics + const choices = await fetchChoices("metrics", topicValue); - function clearTopic() { - topic.innerHTML = ""; - topic.add(new Option("---------", "")); - topic.value = ""; - topic.disabled = true; + if (choices.length > 0) { + populateDropdown(metric, choices); + } else { + clearDropdown(metric, "No metrics available"); + } } - document.addEventListener("DOMContentLoaded", setSubTheme); - document.addEventListener("DOMContentLoaded", setTopic); + /** + * Initialize the cascading dropdowns + */ + function initialize() { + console.log("Initializing..."); + + // Get dropdown elements + theme = document.querySelector('select[name="theme"]'); + subTheme = document.querySelector('select[name="sub_theme"]'); + topic = document.querySelector('select[name="topic"]'); + metric = document.querySelector('select[name="metric"]'); - document.addEventListener("change", function (e) { - if (e.target.name === "theme") { - setSubTheme(); + // Exit if not on permission set page + if (!theme || !subTheme || !topic || !metric) { + console.log("Permission set dropdowns not found on this page"); + return; } - if (e.target.name === "sub_theme") { - setTopic(); + + // Set initial disabled state + clearDropdown(subTheme, "--------"); + clearDropdown(topic, "--------"); + clearDropdown(metric, "--------"); + + // Add event listeners + theme.addEventListener("change", handleThemeChange); + subTheme.addEventListener("change", handleSubThemeChange); + topic.addEventListener("change", handleTopicChange); + + console.log("Event listeners attached"); + + // If editing existing record, trigger cascade to repopulate + if (theme.value && theme.value !== "-1") { + console.log("Existing theme value detected:", theme.value); + handleThemeChange().then(() => { + // After sub-themes load, check if sub-theme was already selected + setTimeout(() => { + if (subTheme.value && subTheme.value !== "-1") { + console.log("Existing sub-theme value detected:", subTheme.value); + handleSubThemeChange().then(() => { + // After topics load, check if topic was already selected + setTimeout(() => { + if (topic.value && topic.value !== "-1") { + console.log("Existing topic value detected:", topic.value); + handleTopicChange(); + } + }, 300); + }); + } + }, 300); + }); } - }); + } + + // Initialize when DOM is ready + document.addEventListener("DOMContentLoaded", initialize); })(); diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py index cbdd67ace..ffbcaa6b1 100644 --- a/auth_content/wagtail_hooks.py +++ b/auth_content/wagtail_hooks.py @@ -5,7 +5,7 @@ from wagtail.admin.viewsets.model import ModelViewSetGroup from django.templatetags.static import static -from auth_content.models import PermissionSet, get_theme_child_map, get_sub_theme_child_map +from auth_content.models import PermissionSet from django.utils.html import format_html from django.utils.safestring import mark_safe @@ -29,28 +29,9 @@ def register_auth_viewset(): # exposes the mapping of parent to child themes - -@hooks.register("insert_editor_js") -def permission_set_theme_mapping(): - mapping = json.dumps(get_theme_child_map()) - return format_html( - "", mark_safe( - mapping) - ) - # exposes the mapping of parent to child themes -@hooks.register("insert_editor_js") -def permission_set_sub_theme_mapping(): - sub_theme_mapping = json.dumps(get_sub_theme_child_map()) - print(sub_theme_mapping) - return format_html( - "", mark_safe( - sub_theme_mapping) - ) - - @hooks.register("insert_editor_js") def permission_set_js(): return format_html('', static("js/child_theme.js")) diff --git a/cms/metrics_interface/field_choices_callables.py b/cms/metrics_interface/field_choices_callables.py index b8cefbb06..4868bc9da 100644 --- a/cms/metrics_interface/field_choices_callables.py +++ b/cms/metrics_interface/field_choices_callables.py @@ -11,6 +11,7 @@ """ from cms.metrics_interface import MetricsAPIInterface +from django.db.models import QuerySet LIST_OF_TWO_STRING_ITEM_TUPLES = list[tuple[str, str]] DICT_OF_CHART_AXIS_AND_SUB_CATEGORIES = dict[str, list[str]] @@ -24,6 +25,22 @@ def _build_two_item_tuple_choices( return [(choice, choice) for choice in choices] +def _build_id_name_tuple_choices( + *, choices: QuerySet +) -> list[tuple[str, str]]: + """Build choices from a QuerySet containing id and name fields. + + Args: + choices: QuerySet with 'id' and 'name' fields + + Returns: + A list of 2-item tuples (id, name). + Examples: + [(1, "infectious_disease"), (2, "respiratory"), ...] + """ + return [(str(choice['id']), choice['name']) for choice in choices] + + def get_possible_axis_choices() -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `chart axis` fields of the CMS blocks. @@ -276,8 +293,34 @@ def get_all_theme_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: [("Infectious_disease", "Infectious_disease"), ...] """ metrics_interface = MetricsAPIInterface() + theme_names = metrics_interface.get_all_theme_names() + print(theme_names) return _build_two_item_tuple_choices( - choices=metrics_interface.get_all_theme_names(), + choices=theme_names, + ) + + +def get_all_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `theme` fields of the CMS blocks. + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + This means that we don't need to create a new migration + whenever a new chart type is added. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of theme names. + Examples: + [("Infectious_disease", "Infectious_disease"), ...] + """ + metrics_interface = MetricsAPIInterface() + theme_names = metrics_interface.get_all_theme_choices() + print(theme_names) + return _build_id_name_tuple_choices( + choices=theme_names, ) @@ -325,6 +368,32 @@ def get_all_unique_sub_theme_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: ) +def get_filtered_unique_sub_theme_names_for_parent_theme(parent_theme_id) -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `theme` fields of the CMS blocks. + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + This means that we don't need to create a new migration + whenever a new chart type is added. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of theme names. + Examples: + [("Infectious_disease", "Infectious_disease"), ...] + """ + metrics_interface = MetricsAPIInterface() + print("received parent_theme_id: ", parent_theme_id) + filtered_sub_themes = metrics_interface.get_filtered_unique_sub_theme_names_for_parent_theme( + parent_theme_id=parent_theme_id) + print("filtered_sub_themes: ", filtered_sub_themes) + return _build_id_name_tuple_choices( + choices=filtered_sub_themes, + ) + + def get_all_topic_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `topic` fields of the CMS blocks. @@ -576,7 +645,8 @@ def get_all_geography_choices_grouped_by_type() -> ( def get_all_subcategory_choices_grouped_by_categories() -> ( dict[ - str, LIST_OF_TWO_STRING_ITEM_TUPLES | dict[str, LIST_OF_TWO_STRING_ITEM_TUPLES] + str, LIST_OF_TWO_STRING_ITEM_TUPLES | dict[str, + LIST_OF_TWO_STRING_ITEM_TUPLES] ] ): """Callable to return all subcategory choices groups by categories. diff --git a/cms/metrics_interface/interface.py b/cms/metrics_interface/interface.py index 2c63d2871..4d0d51c48 100644 --- a/cms/metrics_interface/interface.py +++ b/cms/metrics_interface/interface.py @@ -186,6 +186,17 @@ def get_all_theme_names(self) -> QuerySet: """ return self.theme_manager.get_all_names() + def get_all_theme_choices(self) -> QuerySet: + """Gets all available theme names as a flat list queryset. + Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual theme names. + Examples: + ``. + """ + return self.theme_manager.get_all_choices() + def get_all_sub_theme_names(self) -> QuerySet: """Gets all available sub_theme names as a flat list queryset. Note this is achieved by delegating the call to the `SubThemeManager` from the Metrics API @@ -209,6 +220,18 @@ def get_all_unique_sub_theme_names(self) -> QuerySet: """ return self.sub_theme_manager.get_all_unique_names() + def get_filtered_unique_sub_theme_names_for_parent_theme(self, parent_theme_id) -> QuerySet: + """Get all unique sub_theme names as a flat list queryset. + Note this is achieved by delegating the call to the `SubThemeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual sub_theme names. + Examples: + ` + + """ + return self.sub_theme_manager.get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) + def get_all_topic_names(self) -> QuerySet: """Gets all available topic names as a flat list queryset. Note this is achieved by delegating the call to the `TopicManager` from the Metrics API diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index 4b76f90e9..e7d741d3b 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -39,6 +39,7 @@ from metrics.api.views.geographies import GeographiesView, GeographiesViewDeprecated from metrics.api.views.health import InternalHealthView from metrics.api.views.maps import MapsView +from metrics.api.views.permission_sets import MetricsByTopicView, SubThemesByThemeView, TopicsBySubThemeView from public_api import construct_url_patterns_for_public_api router = routers.DefaultRouter() @@ -81,7 +82,8 @@ def construct_cms_admin_urlpatterns( prefix: str = "" if app_mode == enums.AppMode.CMS_ADMIN.value else "cms-admin/" return [ path(prefix, include(wagtailadmin_urls)), - path("choose-page/", LinkBrowseView.as_view(), name="wagtailadmin_choose_page"), + path("choose-page/", LinkBrowseView.as_view(), + name="wagtailadmin_choose_page"), ] @@ -129,14 +131,22 @@ def construct_public_api_urlpatterns( # Headless CMS API - pages + drafts endpoints path(API_PREFIX, cms_api_router.urls), path(f"{API_PREFIX}global-banners/v2", GlobalBannerView.as_view()), + path(f"{API_PREFIX}permission-set/subthemes/", + SubThemesByThemeView.as_view(), name='get_subthemes'), + path(f"{API_PREFIX}permission-set/topics/", + TopicsBySubThemeView.as_view(), name='get_topics'), + path(f"{API_PREFIX}permission-set/metrics/", + MetricsByTopicView.as_view(), name='get_metrics'), path(f"{API_PREFIX}menus/v1", MenuView.as_view()), - path(f"{API_PREFIX}alerts/v1/heat", heat_alert_list, name="heat-alerts-list"), + path(f"{API_PREFIX}alerts/v1/heat", + heat_alert_list, name="heat-alerts-list"), path( f"{API_PREFIX}alerts/v1/heat/", heat_alert_detail, name="heat-alerts-detail", ), - path(f"{API_PREFIX}alerts/v1/cold", cold_alert_list, name="cold-alerts-list"), + path(f"{API_PREFIX}alerts/v1/cold", + cold_alert_list, name="cold-alerts-list"), path( f"{API_PREFIX}alerts/v1/cold/", cold_alert_detail, @@ -147,7 +157,8 @@ def construct_public_api_urlpatterns( re_path(f"^{API_PREFIX}charts/subplot/v1", SubplotChartsView.as_view()), re_path(f"^{API_PREFIX}downloads/v2", DownloadsView.as_view()), re_path(f"^{API_PREFIX}bulkdownloads/v1", BulkDownloadsView.as_view()), - re_path(f"^{API_PREFIX}downloads/subplot/v1", SubplotDownloadsView.as_view()), + re_path(f"^{API_PREFIX}downloads/subplot/v1", + SubplotDownloadsView.as_view()), re_path( f"^{API_PREFIX}geographies/v2/(?P[^/]+)", GeographiesViewDeprecated.as_view(), @@ -158,12 +169,15 @@ def construct_public_api_urlpatterns( re_path(f"^{API_PREFIX}tables/v4", TablesView.as_view()), re_path(f"^{API_PREFIX}tables/subplot/v1", TablesSubplotView.as_view()), re_path(f"^{API_PREFIX}trends/v3", TrendsView.as_view()), + ] # Audit API endpoints audit_api_timeseries_list = AuditAPITimeSeriesViewSet.as_view({"get": "list"}) -audit_core_timeseries_list = AuditCoreTimeseriesViewSet.as_view({"get": "list"}) -audit_api_core_headline_list = AuditCoreHeadlineViewSet.as_view({"get": "list"}) +audit_core_timeseries_list = AuditCoreTimeseriesViewSet.as_view({ + "get": "list"}) +audit_api_core_headline_list = AuditCoreHeadlineViewSet.as_view({ + "get": "list"}) audit_api_urlpatterns = [ path( @@ -183,7 +197,8 @@ def construct_public_api_urlpatterns( ), re_path(f"^{API_PREFIX}charts/v2", ChartsView.as_view()), re_path(f"^{API_PREFIX}charts/v3", EncodedChartsView.as_view()), - re_path(f"^{API_PREFIX}charts/dual-category/v1", DualCategoryChartsView.as_view()), + re_path(f"^{API_PREFIX}charts/dual-category/v1", + DualCategoryChartsView.as_view()), re_path(f"^{API_PREFIX}charts/subplot/v1", SubplotChartsView.as_view()), ] @@ -204,7 +219,8 @@ def construct_public_api_urlpatterns( ] static_urlpatterns = [ - re_path(r"^static/(?P.*)$", serve, {"document_root": settings.STATIC_ROOT}), + re_path(r"^static/(?P.*)$", serve, + {"document_root": settings.STATIC_ROOT}), ] common_urlpatterns = [ diff --git a/metrics/api/views/permission_sets.py b/metrics/api/views/permission_sets.py new file mode 100644 index 000000000..411399bee --- /dev/null +++ b/metrics/api/views/permission_sets.py @@ -0,0 +1,93 @@ +from drf_spectacular.utils import extend_schema +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + +PERMISSION_SETS_API_TAG = "data hierarchy" + + +@extend_schema(tags=[PERMISSION_SETS_API_TAG]) +class SubThemesByThemeView(APIView): + """Get sub-themes filtered by theme ID""" + permission_classes = [] + + def get(self, request, *args, **kwargs): + """API endpoint to fetch sub-themes based on selected theme.""" + theme_id = self.kwargs['theme_id'] + + if not theme_id: + return Response({'error': 'theme_id required'}, status=status.HTTP_400_BAD_REQUEST) + + if theme_id == "-1": + return Response({'choices': [["-1", "* (All sub-themes)"]]}) + + try: + # Your interface call here + return Response({ + 'choices': [ + ['1', 'Vaccine Preventable'], + ['2', 'Respiratory'], + ['3', 'Healthcare Associated Infections'] + ], + 'theme_id_received': theme_id + }) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@extend_schema(tags=[PERMISSION_SETS_API_TAG]) +class TopicsBySubThemeView(APIView): + """Get topics filtered by sub-theme ID""" + permission_classes = [] + + def get(self, request, *args, **kwargs): + """API endpoint to fetch topics based on selected sub-theme.""" + sub_theme_id = self.kwargs['sub_theme_id'] + + if not sub_theme_id: + return Response({'error': 'sub_theme_id required'}, status=status.HTTP_400_BAD_REQUEST) + + if sub_theme_id == "-1": + return Response({'choices': [["-1", "* (All topics)"]]}) + + try: + # Your interface call here + return Response({ + 'choices': [ + ['10', 'COVID-19'], + ['11', 'Influenza'], + ['12', 'Measles'] + ], + 'sub_theme_id_received': sub_theme_id + }) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@extend_schema(tags=[PERMISSION_SETS_API_TAG]) +class MetricsByTopicView(APIView): + """Get metrics filtered by topic ID""" + permission_classes = [] + + def get(self, request, *args, **kwargs): + """API endpoint to fetch metrics based on selected topic.""" + topic_id = self.kwargs['topic_id'] + + if not topic_id: + return Response({'error': 'topic_id required'}, status=status.HTTP_400_BAD_REQUEST) + + if topic_id == "-1": + return Response({'choices': [["-1", "* (All metrics)"]]}) + + try: + # Your interface call here + return Response({ + 'choices': [ + ['100', 'Cases per 100k'], + ['101', 'Deaths total'], + ['102', 'Hospital admissions'] + ], + 'topic_id_received': topic_id + }) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/metrics/data/managers/core_models/sub_theme.py b/metrics/data/managers/core_models/sub_theme.py index 97c5e371d..341b4ac34 100644 --- a/metrics/data/managers/core_models/sub_theme.py +++ b/metrics/data/managers/core_models/sub_theme.py @@ -33,6 +33,16 @@ def get_all_unique_names(self) -> models.QuerySet: """ return self.all().values_list("name", flat=True).distinct().order_by("name") + def get_filtered_unique_names_related_to_theme(self, parent_theme_id) -> models.QuerySet: + """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.filter(themeId=parent_theme_id).values("id", "name").distinct() + class SubThemeManager(models.Manager): """Custom model manager class for the `SubTheme` model.""" @@ -61,3 +71,13 @@ def get_all_unique_names(self) -> SubThemeQuerySet: `` """ return self.get_queryset().get_all_unique_names() + + def get_filtered_unique_names_related_to_theme(self, parent_theme_id: str) -> SubThemeQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.get_queryset().get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) diff --git a/metrics/data/managers/core_models/theme.py b/metrics/data/managers/core_models/theme.py index de07d2f74..b5c416167 100644 --- a/metrics/data/managers/core_models/theme.py +++ b/metrics/data/managers/core_models/theme.py @@ -21,6 +21,16 @@ def get_all_names(self) -> models.QuerySet: """ return self.all().values_list("name", flat=True) + def get_all_choices(self) -> models.QuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.all().values("id", "name") + class ThemeManager(models.Manager): """Custom model manager class for the `Theme` model.""" @@ -38,3 +48,13 @@ def get_all_names(self) -> ThemeQuerySet: """ return self.get_queryset().get_all_names() + + def get_all_choices(self) -> ThemeQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self .get_queryset().get_all_choices() From 2b2bfa67af51ff41be58a7f89bf16472040d1608 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Fri, 20 Mar 2026 15:29:36 +0000 Subject: [PATCH 066/186] Update to add serializer to handle request and response for subthemes and update to subthemes to handle querying db --- metrics/api/serializers/permission_sets.py | 83 +++++++++++++++++++ metrics/api/views/permission_sets.py | 31 ++----- .../data/managers/core_models/sub_theme.py | 2 +- 3 files changed, 93 insertions(+), 23 deletions(-) create mode 100644 metrics/api/serializers/permission_sets.py diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py new file mode 100644 index 000000000..f31e16aad --- /dev/null +++ b/metrics/api/serializers/permission_sets.py @@ -0,0 +1,83 @@ +from rest_framework import serializers + +from metrics.data.models.core_models.supporting import SubTheme +from django.db.models import QuerySet + + +class SubThemeRequestSerializer(serializers.Serializer): + """Fetches and formats sub-theme choices based on theme_id""" + theme_id = serializers.CharField(required=True) + + @property + def sub_theme_manager(self): + """ + Fetch the topic manager from the context if available. + If not get the Manager which has been declared on the `Topic` model. + """ + return self.context.get("sub_theme_manager", SubTheme.objects) + + def validate_theme_id(self, value): + """Validate theme_id is either wildcard or a valid integer""" + if value == "-1": + return value + + try: + int(value) + return value + except ValueError: + raise serializers.ValidationError( + "theme_id must be a number or '-1'") + + def data(self) -> dict: + """ + Fetch sub-themes from DB and format as response. + + Returns: + Dict with 'choices' key containing list of [id, name] pairs + """ + theme_id = self.validated_data['theme_id'] + + # Handle wildcard + if theme_id == "-1": + return {'choices': [["-1", "* (All sub-themes)"]]} + + # Fetch from interface + parent_theme_id = int(theme_id) + sub_theme_tuples = _queryset_to_id_name_tuples(self.sub_theme_manager.get_filtered_unique_names_related_to_theme( + parent_theme_id)) + + # Format response + print('sub_themes: ', sub_theme_tuples) + choices = [[str(id), name] for id, name in sub_theme_tuples] + + return {'choices': choices} + + +class PermissionSetResponseSerializer(serializers.Serializer): + """Formats the response for choice endpoints""" + choices = serializers.ListField( + child=serializers.ListField( + child=serializers.CharField(), + min_length=2, + max_length=2 + ), + help_text="List of [id, name] pairs for dropdown options" + ) + + +def _queryset_to_id_name_tuples(queryset: QuerySet) -> list[tuple[int, str]]: + """ + Convert a QuerySet with 'id' and 'name' fields to a list of tuples. + + Args: + queryset: QuerySet containing dicts with 'id' and 'name' keys + + Returns: + List of (id, name) tuples + + Examples: + >>> qs = Model.objects.values('id', 'name') + >>> queryset_to_id_name_tuples(qs) + [(1, "item1"), (2, "item2")] + """ + return [(item['id'], item['name']) for item in queryset] diff --git a/metrics/api/views/permission_sets.py b/metrics/api/views/permission_sets.py index 411399bee..ad2b179a4 100644 --- a/metrics/api/views/permission_sets.py +++ b/metrics/api/views/permission_sets.py @@ -1,38 +1,25 @@ +from http import HTTPStatus + from drf_spectacular.utils import extend_schema from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status +from metrics.api.serializers.permission_sets import PermissionSetResponseSerializer, SubThemeRequestSerializer + PERMISSION_SETS_API_TAG = "data hierarchy" -@extend_schema(tags=[PERMISSION_SETS_API_TAG]) +@extend_schema(request=PermissionSetResponseSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) class SubThemesByThemeView(APIView): """Get sub-themes filtered by theme ID""" permission_classes = [] - def get(self, request, *args, **kwargs): + def get(self, request, theme_id, *args, **kwargs): """API endpoint to fetch sub-themes based on selected theme.""" - theme_id = self.kwargs['theme_id'] - - if not theme_id: - return Response({'error': 'theme_id required'}, status=status.HTTP_400_BAD_REQUEST) - - if theme_id == "-1": - return Response({'choices': [["-1", "* (All sub-themes)"]]}) - - try: - # Your interface call here - return Response({ - 'choices': [ - ['1', 'Vaccine Preventable'], - ['2', 'Respiratory'], - ['3', 'Healthcare Associated Infections'] - ], - 'theme_id_received': theme_id - }) - except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + serializer = SubThemeRequestSerializer(data={'theme_id': theme_id}) + serializer.is_valid(raise_exception=True) + return Response(serializer.data()) @extend_schema(tags=[PERMISSION_SETS_API_TAG]) diff --git a/metrics/data/managers/core_models/sub_theme.py b/metrics/data/managers/core_models/sub_theme.py index 341b4ac34..43acffa95 100644 --- a/metrics/data/managers/core_models/sub_theme.py +++ b/metrics/data/managers/core_models/sub_theme.py @@ -41,7 +41,7 @@ def get_filtered_unique_names_related_to_theme(self, parent_theme_id) -> models. Examples: `` """ - return self.filter(themeId=parent_theme_id).values("id", "name").distinct() + return self.filter(theme_id=parent_theme_id).values('id', 'name').distinct() class SubThemeManager(models.Manager): From a69ab5e5dc63b330c42dc1ea85a219066ef6680f Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Fri, 20 Mar 2026 15:39:52 +0000 Subject: [PATCH 067/186] CDD-3175: updated the JS to add wildcard and empty object options --- auth_content/static/js/child_theme.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 0f3ca945d..89b610ce7 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -39,6 +39,18 @@ dropdown.disabled = false; dropdown.innerHTML = ""; + //dropdown empty + const nullOption = document.createElement("option"); + nullOption.value = ""; + nullOption.textContent = "--------"; + dropdown.appendChild(nullOption); + + //dropdown wildcard choice + const wildcardOption = document.createElement("option"); + wildcardOption.value = "-1"; + wildcardOption.textContent = "* (All items)"; + dropdown.appendChild(wildcardOption); + choices.forEach(([id, name]) => { const option = document.createElement("option"); option.value = id; From b09b509a5d7d80d785c2b282a4eec6a96af689d9 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Fri, 20 Mar 2026 16:08:55 +0000 Subject: [PATCH 068/186] CDD-3175: Updated the topics and metrics endpoints to retrieve data from the DB --- ..._alter_permissionset_geography_and_more.py | 71 ++++++ ..._alter_permissionset_geography_and_more.py | 69 ++++++ .../0005_alter_permissionset_theme.py | 27 +++ ..._alter_permissionset_geography_and_more.py | 79 +++++++ ..._alter_permissionset_geography_and_more.py | 44 ++++ .../0008_alter_permissionset_topic.py | 18 ++ ..._alter_permissionset_geography_and_more.py | 73 +++++++ ...r_permissionset_geography_type_and_more.py | 46 ++++ .../migrations/0011_permissionset_name.py | 23 ++ ..._alter_permissionset_geography_and_more.py | 67 ++++++ auth_content/models.py | 161 +++++++++++++- auth_content/static/js/child_theme.js | 206 +++++++++++++++--- .../field_choices_callables.py | 99 ++++++++- cms/metrics_interface/interface.py | 62 +++++- metrics/api/serializers/geographies.py | 87 +++++++- metrics/api/serializers/permission_sets.py | 101 ++++++++- metrics/api/urls_construction.py | 5 +- metrics/api/views/geographies.py | 16 +- metrics/api/views/permission_sets.py | 64 ++---- .../data/managers/core_models/geography.py | 42 ++++ .../managers/core_models/geography_type.py | 23 ++ metrics/data/managers/core_models/metric.py | 43 +++- .../data/managers/core_models/sub_theme.py | 20 ++ metrics/data/managers/core_models/theme.py | 8 +- metrics/data/managers/core_models/topic.py | 40 ++++ 25 files changed, 1388 insertions(+), 106 deletions(-) create mode 100644 auth_content/migrations/0003_alter_permissionset_geography_and_more.py create mode 100644 auth_content/migrations/0004_alter_permissionset_geography_and_more.py create mode 100644 auth_content/migrations/0005_alter_permissionset_theme.py create mode 100644 auth_content/migrations/0006_alter_permissionset_geography_and_more.py create mode 100644 auth_content/migrations/0007_alter_permissionset_geography_and_more.py create mode 100644 auth_content/migrations/0008_alter_permissionset_topic.py create mode 100644 auth_content/migrations/0009_alter_permissionset_geography_and_more.py create mode 100644 auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py create mode 100644 auth_content/migrations/0011_permissionset_name.py create mode 100644 auth_content/migrations/0012_alter_permissionset_geography_and_more.py diff --git a/auth_content/migrations/0003_alter_permissionset_geography_and_more.py b/auth_content/migrations/0003_alter_permissionset_geography_and_more.py new file mode 100644 index 000000000..e5b4c5e32 --- /dev/null +++ b/auth_content/migrations/0003_alter_permissionset_geography_and_more.py @@ -0,0 +1,71 @@ +# Generated by Django 5.2.9 on 2026-03-19 17:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0002_permissionset_delete_authfeature"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography", + field=models.CharField(blank=True, default="*", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField( + blank=True, + choices=[ + ("*", "* (All)"), + ("United Kingdom", "United Kingdom"), + ("Nation", "Nation"), + ("Lower Tier Local Authority", "Lower Tier Local Authority"), + ("NHS Region", "NHS Region"), + ("NHS Trust", "NHS Trust"), + ("Upper Tier Local Authority", "Upper Tier Local Authority"), + ("UKHSA Region", "UKHSA Region"), + ("UKHSA Super-Region", "UKHSA Super-Region"), + ("Government Office Region", "Government Office Region"), + ("Integrated Care Board", "Integrated Care Board"), + ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), + ("Region", "Region"), + ], + default="*", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="metric", + field=models.IntegerField( + default=-1, help_text="Select a specific metric or * for all metrics" + ), + ), + migrations.AlterField( + model_name="permissionset", + name="sub_theme", + field=models.IntegerField( + default=-1, + help_text="Select a specific sub-theme or * for all sub-themes", + ), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.IntegerField( + default=-1, help_text="Select a specific theme or * for all themes" + ), + ), + migrations.AlterField( + model_name="permissionset", + name="topic", + field=models.IntegerField( + default=-1, help_text="Select a specific topic or * for all topics" + ), + ), + ] diff --git a/auth_content/migrations/0004_alter_permissionset_geography_and_more.py b/auth_content/migrations/0004_alter_permissionset_geography_and_more.py new file mode 100644 index 000000000..27c3e55c4 --- /dev/null +++ b/auth_content/migrations/0004_alter_permissionset_geography_and_more.py @@ -0,0 +1,69 @@ +# Generated by Django 5.2.9 on 2026-03-19 17:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0003_alter_permissionset_geography_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography", + field=models.CharField(choices=[], default="-1", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField( + choices=[ + ("United Kingdom", "United Kingdom"), + ("Nation", "Nation"), + ("Lower Tier Local Authority", "Lower Tier Local Authority"), + ("NHS Region", "NHS Region"), + ("NHS Trust", "NHS Trust"), + ("Upper Tier Local Authority", "Upper Tier Local Authority"), + ("UKHSA Region", "UKHSA Region"), + ("UKHSA Super-Region", "UKHSA Super-Region"), + ("Government Office Region", "Government Office Region"), + ("Integrated Care Board", "Integrated Care Board"), + ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), + ("Region", "Region"), + ], + default="-1", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="metric", + field=models.CharField(choices=[], default="-1", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="sub_theme", + field=models.CharField(choices=[], default="-1", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + choices=[ + (3, "extreme_event"), + (1, "immunisation"), + (2, "infectious_disease"), + (4, "non-communicable"), + ], + default="-1", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="topic", + field=models.CharField(choices=[], default="-1", max_length=255), + ), + ] diff --git a/auth_content/migrations/0005_alter_permissionset_theme.py b/auth_content/migrations/0005_alter_permissionset_theme.py new file mode 100644 index 000000000..51e01f6a3 --- /dev/null +++ b/auth_content/migrations/0005_alter_permissionset_theme.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.9 on 2026-03-19 17:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0004_alter_permissionset_geography_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + choices=[ + ("3", "extreme_event"), + ("1", "immunisation"), + ("2", "infectious_disease"), + ("4", "non-communicable"), + ], + default="-1", + max_length=255, + ), + ), + ] diff --git a/auth_content/migrations/0006_alter_permissionset_geography_and_more.py b/auth_content/migrations/0006_alter_permissionset_geography_and_more.py new file mode 100644 index 000000000..dfb05e52e --- /dev/null +++ b/auth_content/migrations/0006_alter_permissionset_geography_and_more.py @@ -0,0 +1,79 @@ +# Generated by Django 5.2.9 on 2026-03-19 17:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0005_alter_permissionset_theme"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography", + field=models.CharField( + blank=True, choices=[], default="-1", max_length=255 + ), + ), + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField( + blank=True, + choices=[ + ("United Kingdom", "United Kingdom"), + ("Nation", "Nation"), + ("Lower Tier Local Authority", "Lower Tier Local Authority"), + ("NHS Region", "NHS Region"), + ("NHS Trust", "NHS Trust"), + ("Upper Tier Local Authority", "Upper Tier Local Authority"), + ("UKHSA Region", "UKHSA Region"), + ("UKHSA Super-Region", "UKHSA Super-Region"), + ("Government Office Region", "Government Office Region"), + ("Integrated Care Board", "Integrated Care Board"), + ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), + ("Region", "Region"), + ], + default="-1", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="metric", + field=models.CharField( + blank=True, choices=[], default="-1", max_length=255 + ), + ), + migrations.AlterField( + model_name="permissionset", + name="sub_theme", + field=models.CharField( + blank=True, choices=[], default="-1", max_length=255 + ), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("3", "extreme_event"), + ("1", "immunisation"), + ("2", "infectious_disease"), + ("4", "non-communicable"), + ], + default="-1", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="topic", + field=models.CharField( + blank=True, choices=[], default="-1", max_length=255 + ), + ), + ] diff --git a/auth_content/migrations/0007_alter_permissionset_geography_and_more.py b/auth_content/migrations/0007_alter_permissionset_geography_and_more.py new file mode 100644 index 000000000..5bb2a083e --- /dev/null +++ b/auth_content/migrations/0007_alter_permissionset_geography_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.9 on 2026-03-19 17:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0006_alter_permissionset_geography_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography", + field=models.CharField(blank=True, default="-1", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="metric", + field=models.CharField(blank=True, default="-1", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="sub_theme", + field=models.CharField(blank=True, default="-1", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("-1", "* (All themes)"), + ("3", "extreme_event"), + ("1", "immunisation"), + ("2", "infectious_disease"), + ("4", "non-communicable"), + ], + default="-1", + max_length=255, + ), + ), + ] diff --git a/auth_content/migrations/0008_alter_permissionset_topic.py b/auth_content/migrations/0008_alter_permissionset_topic.py new file mode 100644 index 000000000..a1f2df380 --- /dev/null +++ b/auth_content/migrations/0008_alter_permissionset_topic.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.9 on 2026-03-19 17:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0007_alter_permissionset_geography_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="topic", + field=models.CharField(blank=True, default="-1", max_length=255), + ), + ] diff --git a/auth_content/migrations/0009_alter_permissionset_geography_and_more.py b/auth_content/migrations/0009_alter_permissionset_geography_and_more.py new file mode 100644 index 000000000..6d82ff6bb --- /dev/null +++ b/auth_content/migrations/0009_alter_permissionset_geography_and_more.py @@ -0,0 +1,73 @@ +# Generated by Django 5.2.12 on 2026-03-20 11:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0008_alter_permissionset_topic"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField( + blank=True, + choices=[ + ("United Kingdom", "United Kingdom"), + ("Nation", "Nation"), + ("Lower Tier Local Authority", "Lower Tier Local Authority"), + ("NHS Region", "NHS Region"), + ("NHS Trust", "NHS Trust"), + ("Upper Tier Local Authority", "Upper Tier Local Authority"), + ("UKHSA Region", "UKHSA Region"), + ("UKHSA Super-Region", "UKHSA Super-Region"), + ("Government Office Region", "Government Office Region"), + ("Integrated Care Board", "Integrated Care Board"), + ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), + ("Region", "Region"), + ], + default="", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="metric", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="sub_theme", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + blank=True, + choices=[ + ("", "---------"), + ("-1", "* (All themes)"), + ("3", "extreme_event"), + ("1", "immunisation"), + ("2", "infectious_disease"), + ("4", "non-communicable"), + ], + default="", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="topic", + field=models.CharField(blank=True, default="", max_length=255), + ), + ] diff --git a/auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py b/auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py new file mode 100644 index 000000000..b737436f2 --- /dev/null +++ b/auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.9 on 2026-03-23 14:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0009_alter_permissionset_geography_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField( + blank=True, + choices=[ + ("", "---------"), + ("-1", "* (All geography-types)"), + ("1", "Region"), + ("2", "Upper Tier Local Authority"), + ("3", "Nation"), + ("4", "Lower Tier Local Authority"), + ("5", "Government Office Region"), + ("6", "United Kingdom"), + ], + default="", + max_length=255, + ), + ), + migrations.AddConstraint( + model_name="permissionset", + constraint=models.UniqueConstraint( + fields=( + "theme", + "sub_theme", + "topic", + "metric", + "geography_type", + "geography", + ), + name="unique_permission_set", + ), + ), + ] diff --git a/auth_content/migrations/0011_permissionset_name.py b/auth_content/migrations/0011_permissionset_name.py new file mode 100644 index 000000000..8aa52066e --- /dev/null +++ b/auth_content/migrations/0011_permissionset_name.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.9 on 2026-03-23 14:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0010_alter_permissionset_geography_type_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="permissionset", + name="name", + field=models.CharField( + blank=True, + editable=False, + help_text="Auto-generated display name", + max_length=500, + ), + ), + ] diff --git a/auth_content/migrations/0012_alter_permissionset_geography_and_more.py b/auth_content/migrations/0012_alter_permissionset_geography_and_more.py new file mode 100644 index 000000000..5f2cb0caa --- /dev/null +++ b/auth_content/migrations/0012_alter_permissionset_geography_and_more.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.9 on 2026-03-23 15:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0011_permissionset_name"), + ] + + operations = [ + migrations.AlterField( + model_name="permissionset", + name="geography", + field=models.CharField(default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField( + choices=[ + ("", "---------"), + ("-1", "* (All geography-types)"), + ("1", "Region"), + ("2", "Upper Tier Local Authority"), + ("3", "Nation"), + ("4", "Lower Tier Local Authority"), + ("5", "Government Office Region"), + ("6", "United Kingdom"), + ], + default="", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="metric", + field=models.CharField(default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="sub_theme", + field=models.CharField(default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField( + choices=[ + ("", "---------"), + ("-1", "* (All themes)"), + ("3", "extreme_event"), + ("1", "immunisation"), + ("2", "infectious_disease"), + ("4", "non-communicable"), + ], + default="", + max_length=255, + ), + ), + migrations.AlterField( + model_name="permissionset", + name="topic", + field=models.CharField(default="", max_length=255), + ), + ] diff --git a/auth_content/models.py b/auth_content/models.py index 97a9ef816..3307d848d 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -1,9 +1,10 @@ from django import forms from django.db import models +from django.core.exceptions import ValidationError from wagtail.admin.panels import FieldPanel -from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids +from cms.metrics_interface.field_choices_callables import get_all_geography_type_names_and_ids, get_all_theme_names_and_ids from validation.enums.geographies_enums import GeographyType from wagtail.admin.forms import WagtailAdminModelForm @@ -51,20 +52,59 @@ def __init__(self, *args, **kwargs): choices=[("-1", "Select geography type first")]) ) + def clean(self): + """Validate that this permission set doesn't already exist""" + cleaned_data = super().clean() + + theme = cleaned_data.get('theme') + sub_theme = cleaned_data.get('sub_theme') + topic = cleaned_data.get('topic') + metric = cleaned_data.get('metric') + geography_type = cleaned_data.get('geography_type') + geography = cleaned_data.get('geography') + + # Check if this combination already exists (excluding current instance when editing) + queryset = PermissionSet.objects.filter( + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography_type=geography_type, + geography=geography + ) + + # Exclude current instance when editing + if self.instance.pk: + queryset = queryset.exclude(pk=self.instance.pk) + + if queryset.exists(): + raise ValidationError( + "A permission set with this exact combination already exists. " + "Please modify your selection to create a unique permission set." + ) + + return cleaned_data + class PermissionSet(models.Model): + name = models.CharField( + max_length=500, + blank=True, + editable=False, # Don't show in admin form + help_text="Auto-generated display name" + ) theme = models.CharField( - max_length=255, choices=[("", "---------"), ("-1", "* (All themes)")] + get_all_theme_names_and_ids(), blank=True, default="") + max_length=255, choices=[("", "---------"), ("-1", "* (All themes)")] + get_all_theme_names_and_ids(), blank=False, default="") sub_theme = models.CharField( - max_length=255, blank=True, default="") + max_length=255, blank=False, default="") topic = models.CharField(max_length=255, - blank=True, default="") + blank=False, default="") metric = models.CharField( - max_length=255, blank=True, default="") + max_length=255, blank=False, default="") geography_type = models.CharField(max_length=255, choices=[( - e.value, e.value.replace("_", " ")) for e in GeographyType], blank=True, default="") + "", "---------"), ("-1", "* (All geography-types)")] + get_all_geography_type_names_and_ids(), blank=False, default="") geography = models.CharField( - max_length=255, blank=True, default="") + max_length=255, blank=False, default="") base_form_class = PermissionSetForm @@ -77,9 +117,106 @@ class PermissionSet(models.Model): FieldPanel("geography"), ] + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['theme', 'sub_theme', 'topic', + 'metric', 'geography_type', 'geography'], + name='unique_permission_set' + ) + ] + + def save(self, *args, **kwargs): + """Generate the display name before saving""" + self.name = self._generate_display_name() + super().save(*args, **kwargs) + + def _generate_display_name(self): + """ + Generate display name using the selected dropdown labels. + This uses the form's choice labels, not database lookups. + """ + from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids + + parts = [] + + # Theme + if self.theme == "-1": + parts.append("Theme: * (All)") + elif self.theme: + theme_name = self._get_choice_label('theme', self.theme) + parts.append(f"Theme: {theme_name}") + + # Sub-theme (we'll need to store these lookups) + if self.sub_theme == "-1": + parts.append("Sub-theme: * (All)") + elif self.sub_theme: + sub_theme_name = self._get_choice_label( + 'sub-theme', self.sub_theme) + parts.append(f"Sub-theme ID: {sub_theme_name}") + + # Topic + if self.topic == "-1": + parts.append("Topic: * (All)") + elif self.topic: + topic_name = self._get_choice_label( + 'topic', self.topic) + parts.append(f"Topic: {topic_name}") + + # Metric + if self.metric == "-1": + parts.append("Metric: * (All)") + elif self.metric: + metric_name = self._get_choice_label( + 'metric', self.metric) + parts.append(f"Metric: {metric_name}") + + # Geography type (we have the label from enum) + if self.geography_type == "-1": + parts.append("Geography Type: * (All)") + elif self.geography_type: + geo_type_label = self.geography_type.replace('_', ' ').title() + parts.append(f"Geography Type: {geo_type_label}") + + # Geography + if self.geography == "-1": + parts.append("Geography: * (All)") + elif self.geography: + parts.append(f"Geography ID: {self.geography}") + + return " | ".join(parts) if parts else "Permission Set (Not Configured)" + + def _get_choice_label(self, field_name, value): + """Get the display label for a choice field""" + if field_name == 'theme': + from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids + choices = get_all_theme_names_and_ids() + for choice_value, choice_label in choices: + if choice_value == value: + return choice_label + + if field_name == 'sub-theme': + from cms.metrics_interface.field_choices_callables import get_all_sub_theme_names_and_ids + choices = get_all_sub_theme_names_and_ids() + for choice_value, choice_label in choices: + if choice_value == value: + return choice_label + + if field_name == 'topic': + from cms.metrics_interface.field_choices_callables import get_all_topic_names_and_ids + choices = get_all_topic_names_and_ids() + for choice_value, choice_label in choices: + if choice_value == value: + return choice_label + + if field_name == 'metric': + from cms.metrics_interface.field_choices_callables import get_all_metric_names_and_ids + choices = get_all_metric_names_and_ids() + for choice_value, choice_label in choices: + if choice_value == value: + return choice_label + + return value # Fallback to ID if not found + def __str__(self): - if self.theme and self.theme != "" and self.theme != "-1": - return f"Permission Set - Theme {self.theme}" - elif self.theme == "-1": - return "Permission Set - All Themes" - return "Permission Set - Not Configured" + return self.name if self.name else f"Permission Set {self.id}" diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 89b610ce7..a2e22066c 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -1,6 +1,6 @@ (function () { "use strict"; - let theme, subTheme, topic, metric; + let theme, subTheme, topic, metric, geographyType, geography; /** * Generic function to fetch choices from the API @@ -29,13 +29,37 @@ return []; } } + async function fetchGeographies(endpoint, dataItemId) { + console.log("selected geography type: ", dataItemId); + console.log("selected endpoint: ", endpoint); + try { + const url = `/api/permission-set/${endpoint}/${dataItemId}`; + console.log(`Fetching from: ${url}`); + + const response = await fetch(url); + + if (!response.ok) { + const errorData = await response.json(); + console.error(`API error: ${errorData.error || "Unknown error"}`); + return []; + } + + const data = await response.json(); + console.log(`Received data from ${endpoint}:`, data); + return data.choices || []; + } catch (error) { + console.error(`Error fetching ${endpoint}:`, error); + return []; + } + } /** * Generic function to populate a dropdown with choices * @param {HTMLSelectElement} dropdown - The select element to populate * @param {Array} choices - Array of [id, name] tuples */ - function populateDropdown(dropdown, choices) { + function populateDropdown(dropdown, choices, wildcardValue = "* All Items") { + const currentValue = dropdown.value; dropdown.disabled = false; dropdown.innerHTML = ""; @@ -48,7 +72,7 @@ //dropdown wildcard choice const wildcardOption = document.createElement("option"); wildcardOption.value = "-1"; - wildcardOption.textContent = "* (All items)"; + wildcardOption.textContent = wildcardValue; dropdown.appendChild(wildcardOption); choices.forEach(([id, name]) => { @@ -57,6 +81,10 @@ option.textContent = name; dropdown.appendChild(option); }); + + if (currentValue) { + dropdown.value = currentValue; + } } function clearDropdown(dropdown, message = "Select parent first") { @@ -125,7 +153,7 @@ const choices = await fetchChoices("subthemes", themeValue); if (choices.length > 0) { - populateDropdown(subTheme, choices); + populateDropdown(subTheme, choices, "* All sub-themes"); } else { clearDropdown(subTheme, "No sub-themes available"); } @@ -161,7 +189,7 @@ const choices = await fetchChoices("topics", subThemeValue); if (choices.length > 0) { - populateDropdown(topic, choices); + populateDropdown(topic, choices, "* All topics"); } else { clearDropdown(topic, "No topics available"); } @@ -194,11 +222,122 @@ const choices = await fetchChoices("metrics", topicValue); if (choices.length > 0) { - populateDropdown(metric, choices); + populateDropdown(metric, choices, "* All metrics"); } else { clearDropdown(metric, "No metrics available"); } } + async function handleGeographyTypeChange() { + const geographyTypeValue = geographyType.value; + console.log("geography type changed to:", geographyTypeValue); + + if (!geographyTypeValue || geographyTypeValue === "") { + // No topic selected - clear metrics + console.log("No geography type selected"); + clearDropdown(geography, "Select geography type first"); + return; + } + + if (geographyTypeValue === "-1") { + // Wildcard topic = cascade wildcard to metrics + console.log("Wildcard geography selected - cascading to metrics"); + setToWildcard(geography, "* (All geographies)"); + return; + } + clearDropdown(geography, "--------"); + + // Fetch and populate metrics + const choices = await fetchGeographies("geographies", geographyTypeValue); + + if (choices.length > 0) { + populateDropdown(geography, choices, "* All geographies"); + } else { + clearDropdown(geography, "No geographies available"); + } + } + + /** + * Initialize dropdowns for edit mode + * Loads the dropdown options based on saved values + */ + async function initializeEditMode() { + console.log("Initializing edit mode..."); + + // Store original values before we start manipulating dropdowns + const savedTheme = theme.value; + const savedSubTheme = subTheme.value; + const savedTopic = topic.value; + const savedMetric = metric.value; + const savedGeographyType = geographyType.value; + const savedGeography = geography.value; + + console.log("Saved values:", { + theme: savedTheme, + subTheme: savedSubTheme, + topic: savedTopic, + metric: savedMetric, + geographyType: savedGeographyType, + geography: savedGeography, + }); + + // If theme has a value (not wildcard, not empty), load sub-themes + if (savedTheme && savedTheme !== "" && savedTheme !== "-1") { + console.log(`Loading sub-themes for theme ${savedTheme}...`); + const subThemeChoices = await fetchChoices("subthemes", savedTheme); + if (subThemeChoices.length > 0) { + populateDropdown(subTheme, subThemeChoices, "* (All sub-themes)"); + subTheme.value = savedSubTheme; // Restore selection + } + + // If sub-theme has a value, load topics + if (savedSubTheme && savedSubTheme !== "" && savedSubTheme !== "-1") { + console.log(`Loading topics for sub-theme ${savedSubTheme}...`); + const topicChoices = await fetchChoices("topics", savedSubTheme); + if (topicChoices.length > 0) { + populateDropdown(topic, topicChoices, "* (All topics)"); + topic.value = savedTopic; // Restore selection + } + + // If topic has a value, load metrics + if (savedTopic && savedTopic !== "" && savedTopic !== "-1") { + console.log(`Loading metrics for topic ${savedTopic}...`); + const metricChoices = await fetchChoices("metrics", savedTopic); + if (metricChoices.length > 0) { + populateDropdown(metric, metricChoices, "* (All metrics)"); + metric.value = savedMetric; // Restore selection + } + } + } + } else if (savedTheme === "-1") { + // Theme is wildcard, cascade to children + setToWildcard(subTheme, "* (All sub-themes)"); + setToWildcard(topic, "* (All topics)"); + setToWildcard(metric, "* (All metrics)"); + } + + // Handle geography independently + if ( + savedGeographyType && + savedGeographyType !== "" && + savedGeographyType !== "-1" + ) { + console.log( + `Loading geographies for geography type ${savedGeographyType}...`, + ); + const geographyChoices = await fetchChoices( + "geographies", + savedGeographyType, + ); + if (geographyChoices.length > 0) { + populateDropdown(geography, geographyChoices, "* (All geographies)"); + geography.value = savedGeography; // Restore selection + } + } else if (savedGeographyType === "-1") { + setToWildcard(geography, "* (All geographies)"); + } + + console.log("Edit mode initialization complete"); + } /** * Initialize the cascading dropdowns @@ -211,45 +350,46 @@ subTheme = document.querySelector('select[name="sub_theme"]'); topic = document.querySelector('select[name="topic"]'); metric = document.querySelector('select[name="metric"]'); + geographyType = document.querySelector('select[name="geography_type"]'); + geography = document.querySelector('select[name="geography"]'); // Exit if not on permission set page - if (!theme || !subTheme || !topic || !metric) { + if ( + !theme || + !subTheme || + !topic || + !metric || + !geographyType || + !geography + ) { console.log("Permission set dropdowns not found on this page"); return; } - // Set initial disabled state - clearDropdown(subTheme, "--------"); - clearDropdown(topic, "--------"); - clearDropdown(metric, "--------"); - // Add event listeners theme.addEventListener("change", handleThemeChange); subTheme.addEventListener("change", handleSubThemeChange); topic.addEventListener("change", handleTopicChange); + geographyType.addEventListener("change", handleGeographyTypeChange); console.log("Event listeners attached"); - - // If editing existing record, trigger cascade to repopulate - if (theme.value && theme.value !== "-1") { - console.log("Existing theme value detected:", theme.value); - handleThemeChange().then(() => { - // After sub-themes load, check if sub-theme was already selected - setTimeout(() => { - if (subTheme.value && subTheme.value !== "-1") { - console.log("Existing sub-theme value detected:", subTheme.value); - handleSubThemeChange().then(() => { - // After topics load, check if topic was already selected - setTimeout(() => { - if (topic.value && topic.value !== "-1") { - console.log("Existing topic value detected:", topic.value); - handleTopicChange(); - } - }, 300); - }); - } - }, 300); - }); + const isEditMode = + theme.value || + subTheme.value || + topic.value || + metric.value || + geographyType.value || + geography.value; + + if (isEditMode) { + console.log("Edit mode detected"); + initializeEditMode(); + } else { + console.log("Create mode - setting initial state"); + clearDropdown(subTheme, "Select theme first"); + clearDropdown(topic, "Select sub-theme first"); + clearDropdown(metric, "Select topic first"); + clearDropdown(geography, "Select geography type first"); } } diff --git a/cms/metrics_interface/field_choices_callables.py b/cms/metrics_interface/field_choices_callables.py index 4868bc9da..48063d2d2 100644 --- a/cms/metrics_interface/field_choices_callables.py +++ b/cms/metrics_interface/field_choices_callables.py @@ -317,7 +317,7 @@ def get_all_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: [("Infectious_disease", "Infectious_disease"), ...] """ metrics_interface = MetricsAPIInterface() - theme_names = metrics_interface.get_all_theme_choices() + theme_names = metrics_interface.get_all_theme_names_and_ids() print(theme_names) return _build_id_name_tuple_choices( choices=theme_names, @@ -368,6 +368,30 @@ def get_all_unique_sub_theme_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: ) +def get_all_sub_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `theme` fields of the CMS blocks. + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + This means that we don't need to create a new migration + whenever a new chart type is added. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of theme names. + Examples: + [("Infectious_disease", "Infectious_disease"), ...] + """ + metrics_interface = MetricsAPIInterface() + sub_theme_names_and_ids = metrics_interface.get_all_sub_theme_names_and_ids() + print(sub_theme_names_and_ids) + return _build_id_name_tuple_choices( + choices=sub_theme_names_and_ids, + ) + + def get_filtered_unique_sub_theme_names_for_parent_theme(parent_theme_id) -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `theme` fields of the CMS blocks. @@ -432,6 +456,54 @@ def get_a_list_of_all_topic_names(): return list(metrics_interface.get_all_topic_names()) +def get_all_topic_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `theme` fields of the CMS blocks. + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + This means that we don't need to create a new migration + whenever a new chart type is added. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of theme names. + Examples: + [("Infectious_disease", "Infectious_disease"), ...] + """ + metrics_interface = MetricsAPIInterface() + topic_names_and_ids = metrics_interface.get_all_topic_names_and_ids() + print(topic_names_and_ids) + return _build_id_name_tuple_choices( + choices=topic_names_and_ids, + ) + + +def get_all_metric_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `theme` fields of the CMS blocks. + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + This means that we don't need to create a new migration + whenever a new chart type is added. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of theme names. + Examples: + [("Infectious_disease", "Infectious_disease"), ...] + """ + metrics_interface = MetricsAPIInterface() + metric_names_and_ids = metrics_interface.get_all_metric_names_and_ids() + print(metric_names_and_ids) + return _build_id_name_tuple_choices( + choices=metric_names_and_ids, + ) + + def get_all_unique_change_type_metric_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `metric` fields of trend number CMS blocks. @@ -558,6 +630,31 @@ def get_all_geography_type_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: ) +def get_all_geography_type_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `geography_type` fields of the CMS blocks on permission sets. + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + This means that we don't need to create a new migration + whenever a new `Geography` is added to that table. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of geography type names. + Examples: + [(, "Nation"), ...] + + """ + metrics_interface = MetricsAPIInterface() + geography_choices = metrics_interface.get_all_geography_type_names_and_ids() + print(geography_choices) + return _build_id_name_tuple_choices( + choices=geography_choices + ) + + def get_all_sex_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `sex` fields of the CMS blocks. diff --git a/cms/metrics_interface/interface.py b/cms/metrics_interface/interface.py index 4d0d51c48..9e7ab2aaa 100644 --- a/cms/metrics_interface/interface.py +++ b/cms/metrics_interface/interface.py @@ -186,7 +186,7 @@ def get_all_theme_names(self) -> QuerySet: """ return self.theme_manager.get_all_names() - def get_all_theme_choices(self) -> QuerySet: + def get_all_theme_names_and_ids(self) -> QuerySet: """Gets all available theme names as a flat list queryset. Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API @@ -195,7 +195,40 @@ def get_all_theme_choices(self) -> QuerySet: Examples: ``. """ - return self.theme_manager.get_all_choices() + return self.theme_manager.get_all_names_and_ids() + + def get_all_sub_theme_names_and_ids(self) -> QuerySet: + """Gets all available theme names as a flat list queryset. + Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual theme names. + Examples: + ``. + """ + return self.sub_theme_manager.get_all_names_and_ids() + + def get_all_topic_names_and_ids(self) -> QuerySet: + """Gets all available theme names as a flat list queryset. + Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual theme names. + Examples: + ``. + """ + return self.topic_manager.get_all_names_and_ids() + + def get_all_metric_names_and_ids(self) -> QuerySet: + """Gets all available theme names as a flat list queryset. + Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual theme names. + Examples: + ``. + """ + return self.metric_manager.get_all_names_and_ids() def get_all_sub_theme_names(self) -> QuerySet: """Gets all available sub_theme names as a flat list queryset. @@ -232,6 +265,17 @@ def get_filtered_unique_sub_theme_names_for_parent_theme(self, parent_theme_id) """ return self.sub_theme_manager.get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) + def get_all_sub_theme_names_and_ids(self) -> QuerySet: + """Gets all available theme names as a flat list queryset. + Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual theme names. + Examples: + ``. + """ + return self.sub_theme_manager.get_all_names_and_ids() + def get_all_topic_names(self) -> QuerySet: """Gets all available topic names as a flat list queryset. Note this is achieved by delegating the call to the `TopicManager` from the Metrics API @@ -349,7 +393,7 @@ def get_all_geography_names_and_codes_by_geography_type( ) -> QuerySet: """Gets all geography names and codes for a particular geography type, for example `Nation` or `Government Office Region`. - Note this is achived by delegating the call to the `GeographyManager` from Metrics API + Note this is achieved by delegating the call to the `GeographyManager` from Metrics API Returns QuerySet: A queryset of the geography_code and geography_names fields as a list of tuples. @@ -420,3 +464,15 @@ def get_geography_code_for_geography( return self.geography_manager.get_geography_code_for_geography( geography=geography, geography_type=geography_type ) + + def get_all_geography_type_names_and_ids(self) -> QuerySet: + """Gets all available geography_type names as a flat list queryset. + Note this is achieved by delegating the call to the `GeographyTypeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual geography_type names: + Examples: + `` + + """ + return self.geography_type_manager.get_all_names_and_ids() diff --git a/metrics/api/serializers/geographies.py b/metrics/api/serializers/geographies.py index a459eaa62..b0e45bf83 100644 --- a/metrics/api/serializers/geographies.py +++ b/metrics/api/serializers/geographies.py @@ -2,6 +2,7 @@ from rest_framework import serializers +from metrics.api.serializers.permission_sets import _queryset_to_id_name_tuples from metrics.data.in_memory_models.geography_relationships.handlers import ( get_upstream_relationships_for_geography, ) @@ -11,6 +12,7 @@ Geography, Topic, ) +from django.db.models import QuerySet GEOGRAPHY_TYPE_RESULT = dict[str, list[dict[str, str]]] @@ -61,7 +63,8 @@ def data(self) -> list[GEOGRAPHY_TYPE_RESULT]: """ topic: str = self.validated_data["topic"] queryset: CoreTimeSeriesQuerySet = ( - self.core_time_series_manager.get_available_geographies(topic=topic) + self.core_time_series_manager.get_available_geographies( + topic=topic) ) return _serialize_queryset(queryset=queryset) @@ -186,6 +189,18 @@ class GeographiesResponseSerializer(serializers.ListSerializer): child = GeographiesResponseListSerializer() +class GeographyChoicesResponseSerializer(serializers.Serializer): + """Formats the response for choice endpoints""" + choices = serializers.ListField( + child=serializers.ListField( + child=serializers.CharField(), + min_length=2, + max_length=2 + ), + help_text="List of [id, name] pairs for dropdown options" + ) + + MISSING_FIELD_ERROR_MESSAGE = "Either 'topic' or 'geography_type' must be provided." SINGLE_FIELD_ONLY_ERROR_MESSAGE = ( "Only one of 'topic' or 'geography_type' should be provided, not both." @@ -208,3 +223,73 @@ def validate(cls, attrs: dict[str, str]) -> dict[str, str]: raise serializers.ValidationError(SINGLE_FIELD_ONLY_ERROR_MESSAGE) return attrs + + +class GeographyByGeographyTypeRequestSerializer(serializers.Serializer): + geography_type_id = serializers.CharField(required=True) + + @property + def geography_manager(self): + return self.context.get("geography_manager", Geography.objects) + + def validate_geography_type_id(self, value): + """Validate theme_id is either wildcard or a valid integer""" + if value == "-1": + return value + + try: + int(value) + return value + except ValueError: + raise serializers.ValidationError( + "Geography Type must be a number or '-1'") + + def data(self) -> dict: + """ + Fetch sub-themes from DB and format as response. + + Returns: + Dict with 'choices' key containing list of [id, name] pairs + """ + geography_type_id = self.validated_data['geography_type_id'] + + # Handle wildcard + if geography_type_id == "-1": + return {'choices': [["-1", "* (All geographies)"]]} + + # Fetch from interface + parent_geography_type_id = int(geography_type_id) + geographies = self.geography_manager.get_geography_codes_and_names_by_geography_type_id( + parent_geography_type_id) + print(geographies) + geography_names_and_codes_tuples = _queryset_to_geography_code_name_tuples( + geographies) + + # Format response + print('geography data: ', geography_names_and_codes_tuples) + choices = [[str(geography_code), name] + for geography_code, name in geography_names_and_codes_tuples] + + return {'choices': choices} + + +def _queryset_to_geography_code_name_tuples(queryset: QuerySet) -> list[tuple[str, str]]: + """ + Convert a QuerySet with 'id' and 'name' fields to a list of tuples. + + Args: + queryset: QuerySet containing dicts with 'id' and 'name' keys + + Returns: + List of (id, name) tuples + + Examples: + >>> qs = Model.objects.values('id', 'name') + >>> queryset_to_id_name_tuples(qs) + [(1, "item1"), (2, "item2")] + """ + print('received queryset: ', queryset) + + for item in queryset: + print('item: ', item) + return [(item['geography_code'], item['name']) for item in queryset] diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py index f31e16aad..f57f352b9 100644 --- a/metrics/api/serializers/permission_sets.py +++ b/metrics/api/serializers/permission_sets.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from metrics.data.models.core_models.supporting import SubTheme +from metrics.data.models.core_models.supporting import SubTheme, Topic, Metric from django.db.models import QuerySet @@ -53,6 +53,104 @@ def data(self) -> dict: return {'choices': choices} +class TopicRequestSerializer(serializers.Serializer): + """Fetches and formats topic related to sub-themes based on provided parent sub_theme_id""" + sub_theme_id = serializers.CharField(required=True) + + @property + def topic_manager(self): + """ + Fetch the topic manager from the context if available. + If not get the Manager which has been declared on the `Topic` model. + """ + return self.context.get("topic_manager", Topic.objects) + + def validate_sub_theme_id(self, value): + """Validate sub_theme_id is either wildcard or a valid integer""" + if value == "-1": + return value + + try: + int(value) + return value + except ValueError: + raise serializers.ValidationError( + "sub_theme_id must be a number or '-1'") + + def data(self) -> dict: + """ + Fetch topics from DB and format as response. + + Returns: + Dict with 'choices' key containing list of [id, name] pairs + """ + sub_theme_id = self.validated_data['sub_theme_id'] + + # Handle wildcard + if sub_theme_id == "-1": + return {'choices': [["-1", "* (All sub-themes)"]]} + + # Fetch from interface + parent_sub_theme_id = int(sub_theme_id) + topic_tuples = _queryset_to_id_name_tuples(self.topic_manager.get_filtered_unique_names_related_to_sub_theme( + parent_sub_theme_id)) + + # Format response + print('sub_themes: ', topic_tuples) + choices = [[str(id), name] for id, name in topic_tuples] + + return {'choices': choices} + + +class MetricRequestSerializer(serializers.Serializer): + """Fetches and formats metrics related to topics based on provided parent topic_id""" + topic_id = serializers.CharField(required=True) + + @property + def metric_manager(self): + """ + Fetch the metric manager from the context if available. + If not get the Manager which has been declared on the `Metric` model. + """ + return self.context.get("metric_manager", Metric.objects) + + def validate_topic_id(self, value): + """Validate topic_id is either wildcard or a valid integer""" + if value == "-1": + return value + + try: + int(value) + return value + except ValueError: + raise serializers.ValidationError( + "topic_id must be a number or '-1'") + + def data(self) -> dict: + """ + Fetch topics from DB and format as response. + + Returns: + Dict with 'choices' key containing list of [id, name] pairs + """ + topic_id = self.validated_data['topic_id'] + + # Handle wildcard + if topic_id == "-1": + return {'choices': [["-1", "* (All sub-themes)"]]} + + # Fetch from interface + parent_topic_id = int(topic_id) + metric_tuples = _queryset_to_id_name_tuples(self.metric_manager.get_filtered_unique_names_related_to_parent_topic_id( + parent_topic_id)) + + # Format response + print('metrics: ', metric_tuples) + choices = [[str(id), name] for id, name in metric_tuples] + + return {'choices': choices} + + class PermissionSetResponseSerializer(serializers.Serializer): """Formats the response for choice endpoints""" choices = serializers.ListField( @@ -80,4 +178,5 @@ def _queryset_to_id_name_tuples(queryset: QuerySet) -> list[tuple[int, str]]: >>> queryset_to_id_name_tuples(qs) [(1, "item1"), (2, "item2")] """ + print('received queryset: ', queryset) return [(item['id'], item['name']) for item in queryset] diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index e7d741d3b..5519e23d2 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -36,7 +36,7 @@ ) from metrics.api.views.charts import DualCategoryChartsView from metrics.api.views.charts.subplot_charts import SubplotChartsView -from metrics.api.views.geographies import GeographiesView, GeographiesViewDeprecated +from metrics.api.views.geographies import GeographiesByGeographyTypeView, GeographiesView, GeographiesViewDeprecated from metrics.api.views.health import InternalHealthView from metrics.api.views.maps import MapsView from metrics.api.views.permission_sets import MetricsByTopicView, SubThemesByThemeView, TopicsBySubThemeView @@ -137,6 +137,8 @@ def construct_public_api_urlpatterns( TopicsBySubThemeView.as_view(), name='get_topics'), path(f"{API_PREFIX}permission-set/metrics/", MetricsByTopicView.as_view(), name='get_metrics'), + path(f"{API_PREFIX}permission-set/geographies/", + GeographiesByGeographyTypeView.as_view(), name='get_geographies'), path(f"{API_PREFIX}menus/v1", MenuView.as_view()), path(f"{API_PREFIX}alerts/v1/heat", heat_alert_list, name="heat-alerts-list"), @@ -164,6 +166,7 @@ def construct_public_api_urlpatterns( GeographiesViewDeprecated.as_view(), ), re_path(f"^{API_PREFIX}geographies/v3", GeographiesView.as_view()), + re_path(f"^{API_PREFIX}headlines/v3", HeadlinesView.as_view()), re_path(f"^{API_PREFIX}maps/v1", MapsView.as_view()), re_path(f"^{API_PREFIX}tables/v4", TablesView.as_view()), diff --git a/metrics/api/views/geographies.py b/metrics/api/views/geographies.py index ed3ac8397..eb90d8270 100644 --- a/metrics/api/views/geographies.py +++ b/metrics/api/views/geographies.py @@ -12,6 +12,8 @@ GeographiesRequestSerializer, GeographiesRequestSerializerDeprecated, GeographiesResponseSerializer, + GeographyByGeographyTypeRequestSerializer, + GeographyChoicesResponseSerializer, ) GEOGRAPHIES_API_TAG = "geographies" @@ -73,7 +75,8 @@ def get(self, request, *args, **kwargs) -> Response: If neither are provided **or** both are provided, then a 400 `Bad Request` 400 will be returned. """ - request_serializer = GeographiesRequestSerializer(data=request.query_params) + request_serializer = GeographiesRequestSerializer( + data=request.query_params) request_serializer.is_valid(raise_exception=True) payload = request_serializer.data @@ -104,3 +107,14 @@ def _handle_geographies_by_geography_type( serializer = GeographiesForGeographyTypeSerializer(data=payload) serializer.is_valid(raise_exception=True) return serializer.data() + + +@extend_schema(request=GeographyByGeographyTypeRequestSerializer, tags=[GEOGRAPHIES_API_TAG], responses={HTTPStatus.OK.value: GeographyChoicesResponseSerializer}) +class GeographiesByGeographyTypeView(APIView): + permission_classes = [] + + def get(self, request, geography_type_id, *args, **kwargs): + serializer = GeographyByGeographyTypeRequestSerializer( + data={'geography_type_id': geography_type_id}) + serializer.is_valid(raise_exception=True) + return Response(serializer.data()) diff --git a/metrics/api/views/permission_sets.py b/metrics/api/views/permission_sets.py index ad2b179a4..49f233064 100644 --- a/metrics/api/views/permission_sets.py +++ b/metrics/api/views/permission_sets.py @@ -5,12 +5,12 @@ from rest_framework.response import Response from rest_framework import status -from metrics.api.serializers.permission_sets import PermissionSetResponseSerializer, SubThemeRequestSerializer +from metrics.api.serializers.permission_sets import MetricRequestSerializer, PermissionSetResponseSerializer, SubThemeRequestSerializer, TopicRequestSerializer PERMISSION_SETS_API_TAG = "data hierarchy" -@extend_schema(request=PermissionSetResponseSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) +@extend_schema(request=SubThemeRequestSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) class SubThemesByThemeView(APIView): """Get sub-themes filtered by theme ID""" permission_classes = [] @@ -22,59 +22,27 @@ def get(self, request, theme_id, *args, **kwargs): return Response(serializer.data()) -@extend_schema(tags=[PERMISSION_SETS_API_TAG]) +@extend_schema(request=TopicRequestSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) class TopicsBySubThemeView(APIView): """Get topics filtered by sub-theme ID""" permission_classes = [] - def get(self, request, *args, **kwargs): - """API endpoint to fetch topics based on selected sub-theme.""" - sub_theme_id = self.kwargs['sub_theme_id'] - - if not sub_theme_id: - return Response({'error': 'sub_theme_id required'}, status=status.HTTP_400_BAD_REQUEST) - - if sub_theme_id == "-1": - return Response({'choices': [["-1", "* (All topics)"]]}) - - try: - # Your interface call here - return Response({ - 'choices': [ - ['10', 'COVID-19'], - ['11', 'Influenza'], - ['12', 'Measles'] - ], - 'sub_theme_id_received': sub_theme_id - }) - except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + def get(self, request, sub_theme_id, *args, **kwargs): + """API endpoint to fetch sub-themes based on selected theme.""" + serializer = TopicRequestSerializer( + data={'sub_theme_id': sub_theme_id}) + serializer.is_valid(raise_exception=True) + return Response(serializer.data()) -@extend_schema(tags=[PERMISSION_SETS_API_TAG]) +@extend_schema(request=MetricRequestSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) class MetricsByTopicView(APIView): """Get metrics filtered by topic ID""" permission_classes = [] - def get(self, request, *args, **kwargs): - """API endpoint to fetch metrics based on selected topic.""" - topic_id = self.kwargs['topic_id'] - - if not topic_id: - return Response({'error': 'topic_id required'}, status=status.HTTP_400_BAD_REQUEST) - - if topic_id == "-1": - return Response({'choices': [["-1", "* (All metrics)"]]}) - - try: - # Your interface call here - return Response({ - 'choices': [ - ['100', 'Cases per 100k'], - ['101', 'Deaths total'], - ['102', 'Hospital admissions'] - ], - 'topic_id_received': topic_id - }) - except Exception as e: - return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + def get(self, request, topic_id, *args, **kwargs): + """API endpoint to fetch sub-themes based on selected theme.""" + serializer = MetricRequestSerializer( + data={'topic_id': topic_id}) + serializer.is_valid(raise_exception=True) + return Response(serializer.data()) diff --git a/metrics/data/managers/core_models/geography.py b/metrics/data/managers/core_models/geography.py index 9ff8fad09..ec1ea2a93 100644 --- a/metrics/data/managers/core_models/geography.py +++ b/metrics/data/managers/core_models/geography.py @@ -80,6 +80,28 @@ def get_geography_codes_and_names_by_geography_type( .order_by("geography_code") ) + def get_geography_codes_and_names_by_geography_type_id( + self, + geography_type_id: int, + ): + """Gets all available geography codes and names for the given `geography_type_name` + + Args: + geography_type_name: string representation of `geography_type_name` + + Returns: + QuerySet: A queryset of the individual geography codes + which are related to the given geography_type: + Examples: + `` + + """ + return ( + self.filter(geography_type_id=geography_type_id) + .values("geography_code", "name") + .order_by("geography_code") + ) + def get_geographies_by_geography_type( self, geography_type_name: str, @@ -168,6 +190,26 @@ def get_geography_codes_and_names_by_geography_type( geography_type_name=geography_type_name ) + def get_geography_codes_and_names_by_geography_type_id( + self, + geography_type_id: str, + ): + """Gets all available geography codes and names for a give `geography_type` + + Args: + geography_type_name: string representation of `geography_type_name` + + Returns: + QuerySet: A queryset of the individual geography codes + which are related to the given geography_type: + Examples: + `` + + """ + return self.get_queryset().get_geography_codes_and_names_by_geography_type_id( + geography_type_id=geography_type_id + ) + def get_geographies_by_geography_type( self, geography_type_name: str, diff --git a/metrics/data/managers/core_models/geography_type.py b/metrics/data/managers/core_models/geography_type.py index 51dec8020..87d893bc5 100644 --- a/metrics/data/managers/core_models/geography_type.py +++ b/metrics/data/managers/core_models/geography_type.py @@ -23,6 +23,18 @@ def get_all_names(self) -> models.QuerySet: """ return self.all().values_list("name", flat=True).order_by("name") + def get_all_names_and_ids(self) -> models.QuerySet: + """Gets all available geography_type names as a flat list queryset. + + Returns: + QuerySet: A queryset of the individual geography_type names + ordered in descending ordering starting from A -> Z: + Examples: + `` + + """ + return self.all().values("id", "name") + class GeographyTypeManager(models.Manager): """Custom model manager class for the `GeographyType` model.""" @@ -40,3 +52,14 @@ def get_all_names(self) -> GeographyTypeQuerySet: """ return self.get_queryset().get_all_names() + + def get_all_names_and_ids(self) -> GeographyTypeQuerySet: + """Gets all available geography_type names as a flat list queryset. + + Returns: + QuerySet: A queryset of the individual geography_type names: + Examples: + `` + + """ + return self.get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/metric.py b/metrics/data/managers/core_models/metric.py index 03a713e63..abf08ad45 100644 --- a/metrics/data/managers/core_models/metric.py +++ b/metrics/data/managers/core_models/metric.py @@ -44,7 +44,8 @@ def get_all_unique_change_type_names(self) -> models.QuerySet: `` """ return self.get_all_unique_names().filter( - models.Q(name__icontains="change") & ~models.Q(name__icontains="percent") + models.Q(name__icontains="change") & ~models.Q( + name__icontains="percent") ) def get_all_unique_percent_change_type_names(self) -> models.QuerySet: @@ -82,6 +83,26 @@ def get_all_headline_names(self) -> models.QuerySet: """ return self.get_all_unique_names().filter(metric_group__name="headline") + def get_filtered_unique_names_related_to_parent_topic_id(self, parent_topic_id) -> models.QuerySet: + """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.filter(topic_id=parent_topic_id).values('id', 'name').distinct() + + def get_all_names_and_ids(self) -> models.QuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.all().values("id", "name").distinct() + class MetricManager(models.Manager): """Custom model manager class for the `Metric` model.""" @@ -155,3 +176,23 @@ def get_all_headline_names(self) -> MetricQuerySet: """ return self.get_queryset().get_all_headline_names() + + def get_filtered_unique_names_related_to_parent_topic_id(self, parent_topic_id: str) -> MetricQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.get_queryset().get_filtered_unique_names_related_to_parent_topic_id(parent_topic_id=parent_topic_id) + + def get_all_names_and_ids(self) -> MetricQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self .get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/sub_theme.py b/metrics/data/managers/core_models/sub_theme.py index 43acffa95..42956fbdf 100644 --- a/metrics/data/managers/core_models/sub_theme.py +++ b/metrics/data/managers/core_models/sub_theme.py @@ -33,6 +33,16 @@ def get_all_unique_names(self) -> models.QuerySet: """ return self.all().values_list("name", flat=True).distinct().order_by("name") + def get_all_names_and_ids(self) -> models.QuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.all().values("id", "name").distinct() + def get_filtered_unique_names_related_to_theme(self, parent_theme_id) -> models.QuerySet: """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. @@ -81,3 +91,13 @@ def get_filtered_unique_names_related_to_theme(self, parent_theme_id: str) -> Su `` """ return self.get_queryset().get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) + + def get_all_names_and_ids(self) -> SubThemeQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self .get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/theme.py b/metrics/data/managers/core_models/theme.py index b5c416167..ec9ac54d6 100644 --- a/metrics/data/managers/core_models/theme.py +++ b/metrics/data/managers/core_models/theme.py @@ -21,7 +21,7 @@ def get_all_names(self) -> models.QuerySet: """ return self.all().values_list("name", flat=True) - def get_all_choices(self) -> models.QuerySet: + def get_all_names_and_ids(self) -> models.QuerySet: """Gets all available themes with id and name fields. Returns: @@ -49,12 +49,12 @@ def get_all_names(self) -> ThemeQuerySet: """ return self.get_queryset().get_all_names() - def get_all_choices(self) -> ThemeQuerySet: + def get_all_names_and_ids(self) -> ThemeQuerySet: """Gets all available themes with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ - return self .get_queryset().get_all_choices() + return self .get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index 32574d116..0988625af 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -45,6 +45,26 @@ def get_by_name(self, name: str) -> "Topic": """ return self.get(name=name) + def get_filtered_unique_names_related_to_sub_theme(self, parent_sub_theme_id) -> models.QuerySet: + """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.filter(sub_theme_id=parent_sub_theme_id).values('id', 'name').distinct() + + def get_all_names_and_ids(self) -> models.QuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.all().values("id", "name").distinct() + class TopicManager(models.Manager): """Custom model manager class for the `Metric` model.""" @@ -93,3 +113,23 @@ def get_by_name(self, name: str) -> "Topic": """ return self.get_queryset().get_by_name(name=name) + + def get_filtered_unique_names_related_to_sub_theme(self, parent_sub_theme_id: str) -> TopicQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self.get_queryset().get_filtered_unique_names_related_to_sub_theme(parent_sub_theme_id=parent_sub_theme_id) + + def get_all_names_and_ids(self) -> TopicQuerySet: + """Gets all available themes with id and name fields. + + Returns: + QuerySet: A queryset containing dictionaries with id and name: + Examples: + `` + """ + return self .get_queryset().get_all_names_and_ids() From 817dfb8a5aebff95df04f1eb79e050395559061a Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Mon, 23 Mar 2026 17:13:08 +0000 Subject: [PATCH 069/186] CDD-3085: updated validations and wildcard functionality --- auth_content/migrations/0001_initial.py | 66 ++- .../0002_permissionset_delete_authfeature.py | 47 -- ..._alter_permissionset_geography_and_more.py | 71 --- ..._alter_permissionset_geography_and_more.py | 69 --- .../0005_alter_permissionset_theme.py | 27 - ..._alter_permissionset_geography_and_more.py | 79 --- ..._alter_permissionset_geography_and_more.py | 44 -- .../0008_alter_permissionset_topic.py | 18 - ..._alter_permissionset_geography_and_more.py | 73 --- ...r_permissionset_geography_type_and_more.py | 46 -- .../migrations/0011_permissionset_name.py | 23 - ..._alter_permissionset_geography_and_more.py | 67 --- auth_content/models.py | 308 ++++++----- auth_content/static/js/child_theme.js | 52 +- auth_content/wagtail_hooks.py | 13 +- cms/dashboard/wagtail_hooks.py | 2 - .../field_choices_callables.py | 90 +-- cms/metrics_interface/interface.py | 55 +- metrics/api/serializers/geographies.py | 67 ++- metrics/api/serializers/help_texts.py | 4 +- metrics/api/serializers/permission_sets.py | 105 ++-- metrics/api/urls_construction.py | 66 ++- metrics/api/views/geographies.py | 18 +- metrics/api/views/permission_sets.py | 45 +- .../data/managers/core_models/geography.py | 13 +- metrics/data/managers/core_models/metric.py | 35 +- .../data/managers/core_models/sub_theme.py | 26 +- metrics/data/managers/core_models/theme.py | 2 +- metrics/data/managers/core_models/topic.py | 34 +- tests/factories/metrics/metric.py | 11 +- tests/factories/metrics/sub_theme.py | 11 +- tests/factories/metrics/theme.py | 12 + tests/factories/metrics/topic.py | 11 +- .../metrics/api/views/test_geographies.py | 103 ++++ .../metrics/api/views/test_permission_sets.py | 235 ++++++++ .../managers/core_models/test_geography.py | 43 +- .../core_models/test_geography_types.py | 43 ++ .../data/managers/core_models/test_metric.py | 30 + .../managers/core_models/test_sub_theme.py | 22 + .../data/managers/core_models/test_theme.py | 28 + .../data/managers/core_models/test_topic.py | 22 + .../metrics/data/managers/test_geography.py | 43 ++ .../test_field_choices_callables.py | 152 ++++++ .../cms/metrics_interface/test_interface.py | 104 ++++ .../api/serializers/test_geographies.py | 365 +++++++++++++ .../api/serializers/test_permission_sets.py | 516 ++++++++++++++++++ .../managers/core_models/test_geography.py | 26 + .../core_models/test_geography_type.py | 0 .../data/managers/core_models/test_metric.py | 27 + .../managers/core_models/test_sub_theme.py | 16 + .../data/managers/core_models/test_theme.py | 36 ++ .../data/managers/core_models/test_topic.py | 27 + validation/enums/helper_enum.py | 2 +- 53 files changed, 2383 insertions(+), 1067 deletions(-) delete mode 100644 auth_content/migrations/0002_permissionset_delete_authfeature.py delete mode 100644 auth_content/migrations/0003_alter_permissionset_geography_and_more.py delete mode 100644 auth_content/migrations/0004_alter_permissionset_geography_and_more.py delete mode 100644 auth_content/migrations/0005_alter_permissionset_theme.py delete mode 100644 auth_content/migrations/0006_alter_permissionset_geography_and_more.py delete mode 100644 auth_content/migrations/0007_alter_permissionset_geography_and_more.py delete mode 100644 auth_content/migrations/0008_alter_permissionset_topic.py delete mode 100644 auth_content/migrations/0009_alter_permissionset_geography_and_more.py delete mode 100644 auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py delete mode 100644 auth_content/migrations/0011_permissionset_name.py delete mode 100644 auth_content/migrations/0012_alter_permissionset_geography_and_more.py create mode 100644 tests/factories/metrics/theme.py create mode 100644 tests/integration/metrics/api/views/test_permission_sets.py create mode 100644 tests/integration/metrics/data/managers/core_models/test_geography_types.py create mode 100644 tests/integration/metrics/data/managers/core_models/test_theme.py create mode 100644 tests/integration/metrics/data/managers/test_geography.py create mode 100644 tests/unit/metrics/api/serializers/test_permission_sets.py create mode 100644 tests/unit/metrics/data/managers/core_models/test_geography_type.py create mode 100644 tests/unit/metrics/data/managers/core_models/test_metric.py create mode 100644 tests/unit/metrics/data/managers/core_models/test_topic.py diff --git a/auth_content/migrations/0001_initial.py b/auth_content/migrations/0001_initial.py index 7559ef2c4..c5b047e53 100644 --- a/auth_content/migrations/0001_initial.py +++ b/auth_content/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.12 on 2026-03-12 11:19 +# Generated by Django 5.2.12 on 2026-03-24 10:42 from django.db import migrations, models @@ -11,7 +11,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="AuthFeature", + name="PermissionSet", fields=[ ( "id", @@ -22,8 +22,66 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("title", models.CharField(max_length=255)), - ("description", models.TextField()), + ( + "name", + models.CharField( + blank=True, + editable=False, + help_text="Auto-generated display name", + max_length=500, + ), + ), + ( + "theme", + models.CharField( + choices=[ + ("", "---------"), + ("-1", "* (All themes)"), + ("3", "extreme_event"), + ("1", "immunisation"), + ("2", "infectious_disease"), + ("4", "non-communicable"), + ], + default="", + max_length=255, + ), + ), + ("sub_theme", models.CharField(default="", max_length=255)), + ("topic", models.CharField(default="", max_length=255)), + ("metric", models.CharField(default="", max_length=255)), + ( + "geography_type", + models.CharField( + choices=[ + ("", "---------"), + ("-1", "* (All geography-types)"), + ("1", "Region"), + ("2", "Upper Tier Local Authority"), + ("3", "Nation"), + ("4", "Lower Tier Local Authority"), + ("5", "Government Office Region"), + ("6", "United Kingdom"), + ], + default="", + max_length=255, + ), + ), + ("geography", models.CharField(default="", max_length=255)), ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=( + "theme", + "sub_theme", + "topic", + "metric", + "geography_type", + "geography", + ), + name="unique_permission_set", + ) + ], + }, ), ] diff --git a/auth_content/migrations/0002_permissionset_delete_authfeature.py b/auth_content/migrations/0002_permissionset_delete_authfeature.py deleted file mode 100644 index 3128e3c31..000000000 --- a/auth_content/migrations/0002_permissionset_delete_authfeature.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-13 14:40 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="PermissionSet", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "theme", - models.CharField( - choices=[ - ("infectious_disease", "Infectious Disease"), - ("extreme_event", "Extreme Event"), - ("non-communicable", "Non Communicable"), - ("climate_and_environment", "Climate And Environment"), - ("immunisation", "Immunisation"), - ] - ), - ), - ("sub_theme", models.CharField(choices=[], max_length=255)), - ("topic", models.CharField(max_length=255)), - ("metric", models.CharField(max_length=255)), - ("geography_type", models.CharField(max_length=255)), - ("geography", models.CharField(max_length=255)), - ], - ), - migrations.DeleteModel( - name="AuthFeature", - ), - ] diff --git a/auth_content/migrations/0003_alter_permissionset_geography_and_more.py b/auth_content/migrations/0003_alter_permissionset_geography_and_more.py deleted file mode 100644 index e5b4c5e32..000000000 --- a/auth_content/migrations/0003_alter_permissionset_geography_and_more.py +++ /dev/null @@ -1,71 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-19 17:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0002_permissionset_delete_authfeature"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography", - field=models.CharField(blank=True, default="*", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="geography_type", - field=models.CharField( - blank=True, - choices=[ - ("*", "* (All)"), - ("United Kingdom", "United Kingdom"), - ("Nation", "Nation"), - ("Lower Tier Local Authority", "Lower Tier Local Authority"), - ("NHS Region", "NHS Region"), - ("NHS Trust", "NHS Trust"), - ("Upper Tier Local Authority", "Upper Tier Local Authority"), - ("UKHSA Region", "UKHSA Region"), - ("UKHSA Super-Region", "UKHSA Super-Region"), - ("Government Office Region", "Government Office Region"), - ("Integrated Care Board", "Integrated Care Board"), - ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), - ("Region", "Region"), - ], - default="*", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="metric", - field=models.IntegerField( - default=-1, help_text="Select a specific metric or * for all metrics" - ), - ), - migrations.AlterField( - model_name="permissionset", - name="sub_theme", - field=models.IntegerField( - default=-1, - help_text="Select a specific sub-theme or * for all sub-themes", - ), - ), - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.IntegerField( - default=-1, help_text="Select a specific theme or * for all themes" - ), - ), - migrations.AlterField( - model_name="permissionset", - name="topic", - field=models.IntegerField( - default=-1, help_text="Select a specific topic or * for all topics" - ), - ), - ] diff --git a/auth_content/migrations/0004_alter_permissionset_geography_and_more.py b/auth_content/migrations/0004_alter_permissionset_geography_and_more.py deleted file mode 100644 index 27c3e55c4..000000000 --- a/auth_content/migrations/0004_alter_permissionset_geography_and_more.py +++ /dev/null @@ -1,69 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-19 17:05 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0003_alter_permissionset_geography_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography", - field=models.CharField(choices=[], default="-1", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="geography_type", - field=models.CharField( - choices=[ - ("United Kingdom", "United Kingdom"), - ("Nation", "Nation"), - ("Lower Tier Local Authority", "Lower Tier Local Authority"), - ("NHS Region", "NHS Region"), - ("NHS Trust", "NHS Trust"), - ("Upper Tier Local Authority", "Upper Tier Local Authority"), - ("UKHSA Region", "UKHSA Region"), - ("UKHSA Super-Region", "UKHSA Super-Region"), - ("Government Office Region", "Government Office Region"), - ("Integrated Care Board", "Integrated Care Board"), - ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), - ("Region", "Region"), - ], - default="-1", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="metric", - field=models.CharField(choices=[], default="-1", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="sub_theme", - field=models.CharField(choices=[], default="-1", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - choices=[ - (3, "extreme_event"), - (1, "immunisation"), - (2, "infectious_disease"), - (4, "non-communicable"), - ], - default="-1", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="topic", - field=models.CharField(choices=[], default="-1", max_length=255), - ), - ] diff --git a/auth_content/migrations/0005_alter_permissionset_theme.py b/auth_content/migrations/0005_alter_permissionset_theme.py deleted file mode 100644 index 51e01f6a3..000000000 --- a/auth_content/migrations/0005_alter_permissionset_theme.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-19 17:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0004_alter_permissionset_geography_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - choices=[ - ("3", "extreme_event"), - ("1", "immunisation"), - ("2", "infectious_disease"), - ("4", "non-communicable"), - ], - default="-1", - max_length=255, - ), - ), - ] diff --git a/auth_content/migrations/0006_alter_permissionset_geography_and_more.py b/auth_content/migrations/0006_alter_permissionset_geography_and_more.py deleted file mode 100644 index dfb05e52e..000000000 --- a/auth_content/migrations/0006_alter_permissionset_geography_and_more.py +++ /dev/null @@ -1,79 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-19 17:10 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0005_alter_permissionset_theme"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography", - field=models.CharField( - blank=True, choices=[], default="-1", max_length=255 - ), - ), - migrations.AlterField( - model_name="permissionset", - name="geography_type", - field=models.CharField( - blank=True, - choices=[ - ("United Kingdom", "United Kingdom"), - ("Nation", "Nation"), - ("Lower Tier Local Authority", "Lower Tier Local Authority"), - ("NHS Region", "NHS Region"), - ("NHS Trust", "NHS Trust"), - ("Upper Tier Local Authority", "Upper Tier Local Authority"), - ("UKHSA Region", "UKHSA Region"), - ("UKHSA Super-Region", "UKHSA Super-Region"), - ("Government Office Region", "Government Office Region"), - ("Integrated Care Board", "Integrated Care Board"), - ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), - ("Region", "Region"), - ], - default="-1", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="metric", - field=models.CharField( - blank=True, choices=[], default="-1", max_length=255 - ), - ), - migrations.AlterField( - model_name="permissionset", - name="sub_theme", - field=models.CharField( - blank=True, choices=[], default="-1", max_length=255 - ), - ), - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - blank=True, - choices=[ - ("3", "extreme_event"), - ("1", "immunisation"), - ("2", "infectious_disease"), - ("4", "non-communicable"), - ], - default="-1", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="topic", - field=models.CharField( - blank=True, choices=[], default="-1", max_length=255 - ), - ), - ] diff --git a/auth_content/migrations/0007_alter_permissionset_geography_and_more.py b/auth_content/migrations/0007_alter_permissionset_geography_and_more.py deleted file mode 100644 index 5bb2a083e..000000000 --- a/auth_content/migrations/0007_alter_permissionset_geography_and_more.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-19 17:18 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0006_alter_permissionset_geography_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography", - field=models.CharField(blank=True, default="-1", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="metric", - field=models.CharField(blank=True, default="-1", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="sub_theme", - field=models.CharField(blank=True, default="-1", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - blank=True, - choices=[ - ("-1", "* (All themes)"), - ("3", "extreme_event"), - ("1", "immunisation"), - ("2", "infectious_disease"), - ("4", "non-communicable"), - ], - default="-1", - max_length=255, - ), - ), - ] diff --git a/auth_content/migrations/0008_alter_permissionset_topic.py b/auth_content/migrations/0008_alter_permissionset_topic.py deleted file mode 100644 index a1f2df380..000000000 --- a/auth_content/migrations/0008_alter_permissionset_topic.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-19 17:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0007_alter_permissionset_geography_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="topic", - field=models.CharField(blank=True, default="-1", max_length=255), - ), - ] diff --git a/auth_content/migrations/0009_alter_permissionset_geography_and_more.py b/auth_content/migrations/0009_alter_permissionset_geography_and_more.py deleted file mode 100644 index 6d82ff6bb..000000000 --- a/auth_content/migrations/0009_alter_permissionset_geography_and_more.py +++ /dev/null @@ -1,73 +0,0 @@ -# Generated by Django 5.2.12 on 2026-03-20 11:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0008_alter_permissionset_topic"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography", - field=models.CharField(blank=True, default="", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="geography_type", - field=models.CharField( - blank=True, - choices=[ - ("United Kingdom", "United Kingdom"), - ("Nation", "Nation"), - ("Lower Tier Local Authority", "Lower Tier Local Authority"), - ("NHS Region", "NHS Region"), - ("NHS Trust", "NHS Trust"), - ("Upper Tier Local Authority", "Upper Tier Local Authority"), - ("UKHSA Region", "UKHSA Region"), - ("UKHSA Super-Region", "UKHSA Super-Region"), - ("Government Office Region", "Government Office Region"), - ("Integrated Care Board", "Integrated Care Board"), - ("Sub-Integrated Care Board", "Sub-Integrated Care Board"), - ("Region", "Region"), - ], - default="", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="metric", - field=models.CharField(blank=True, default="", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="sub_theme", - field=models.CharField(blank=True, default="", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - blank=True, - choices=[ - ("", "---------"), - ("-1", "* (All themes)"), - ("3", "extreme_event"), - ("1", "immunisation"), - ("2", "infectious_disease"), - ("4", "non-communicable"), - ], - default="", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="topic", - field=models.CharField(blank=True, default="", max_length=255), - ), - ] diff --git a/auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py b/auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py deleted file mode 100644 index b737436f2..000000000 --- a/auth_content/migrations/0010_alter_permissionset_geography_type_and_more.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-23 14:42 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0009_alter_permissionset_geography_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography_type", - field=models.CharField( - blank=True, - choices=[ - ("", "---------"), - ("-1", "* (All geography-types)"), - ("1", "Region"), - ("2", "Upper Tier Local Authority"), - ("3", "Nation"), - ("4", "Lower Tier Local Authority"), - ("5", "Government Office Region"), - ("6", "United Kingdom"), - ], - default="", - max_length=255, - ), - ), - migrations.AddConstraint( - model_name="permissionset", - constraint=models.UniqueConstraint( - fields=( - "theme", - "sub_theme", - "topic", - "metric", - "geography_type", - "geography", - ), - name="unique_permission_set", - ), - ), - ] diff --git a/auth_content/migrations/0011_permissionset_name.py b/auth_content/migrations/0011_permissionset_name.py deleted file mode 100644 index 8aa52066e..000000000 --- a/auth_content/migrations/0011_permissionset_name.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-23 14:51 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0010_alter_permissionset_geography_type_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="permissionset", - name="name", - field=models.CharField( - blank=True, - editable=False, - help_text="Auto-generated display name", - max_length=500, - ), - ), - ] diff --git a/auth_content/migrations/0012_alter_permissionset_geography_and_more.py b/auth_content/migrations/0012_alter_permissionset_geography_and_more.py deleted file mode 100644 index 5f2cb0caa..000000000 --- a/auth_content/migrations/0012_alter_permissionset_geography_and_more.py +++ /dev/null @@ -1,67 +0,0 @@ -# Generated by Django 5.2.9 on 2026-03-23 15:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("auth_content", "0011_permissionset_name"), - ] - - operations = [ - migrations.AlterField( - model_name="permissionset", - name="geography", - field=models.CharField(default="", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="geography_type", - field=models.CharField( - choices=[ - ("", "---------"), - ("-1", "* (All geography-types)"), - ("1", "Region"), - ("2", "Upper Tier Local Authority"), - ("3", "Nation"), - ("4", "Lower Tier Local Authority"), - ("5", "Government Office Region"), - ("6", "United Kingdom"), - ], - default="", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="metric", - field=models.CharField(default="", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="sub_theme", - field=models.CharField(default="", max_length=255), - ), - migrations.AlterField( - model_name="permissionset", - name="theme", - field=models.CharField( - choices=[ - ("", "---------"), - ("-1", "* (All themes)"), - ("3", "extreme_event"), - ("1", "immunisation"), - ("2", "infectious_disease"), - ("4", "non-communicable"), - ], - default="", - max_length=255, - ), - ), - migrations.AlterField( - model_name="permissionset", - name="topic", - field=models.CharField(default="", max_length=255), - ), - ] diff --git a/auth_content/models.py b/auth_content/models.py index 3307d848d..5388721d8 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -1,12 +1,18 @@ -from django import forms +from itertools import starmap -from django.db import models +from django import forms from django.core.exceptions import ValidationError +from django.db import models +from wagtail.admin.forms import WagtailAdminModelForm from wagtail.admin.panels import FieldPanel -from cms.metrics_interface.field_choices_callables import get_all_geography_type_names_and_ids, get_all_theme_names_and_ids -from validation.enums.geographies_enums import GeographyType -from wagtail.admin.forms import WagtailAdminModelForm +from cms.metrics_interface.field_choices_callables import ( + get_all_geography_type_names_and_ids, + get_all_metric_names_and_ids, + get_all_sub_theme_names_and_ids, + get_all_theme_names_and_ids, + get_all_topic_names_and_ids, +) def get_theme_child_map(): @@ -19,10 +25,7 @@ def get_theme_child_map(): } """ - theme_mapping = {} - - print(theme_mapping) - return theme_mapping + return {} class PermissionSetForm(WagtailAdminModelForm): @@ -30,38 +33,96 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Use CharField with Select widget to bypass choice validation - self.fields['sub_theme'] = forms.CharField( - required=False, + self.fields["theme"] = forms.CharField( + widget=forms.Select( + choices=[("", "---------"), ("-1", "* (All themes)")] + + get_all_theme_names_and_ids() + ), + required=True, + ) + self.fields["sub_theme"] = forms.CharField( + required=True, label="Sub Theme", - widget=forms.Select(choices=[("", "Select theme first")]) + widget=forms.Select(choices=[("", "Select theme first")]), ) - self.fields['topic'] = forms.CharField( - required=False, + self.fields["topic"] = forms.CharField( + required=True, label="Topic", - widget=forms.Select(choices=[("", "Select sub-theme first")]) + widget=forms.Select(choices=[("", "Select sub-theme first")]), ) - self.fields['metric'] = forms.CharField( - required=False, + self.fields["metric"] = forms.CharField( + required=True, label="Metric", - widget=forms.Select(choices=[("", "Select topic first")]) + widget=forms.Select(choices=[("", "Select topic first")]), ) - self.fields['geography'] = forms.CharField( - required=False, - label="Geography", + self.fields["geography_type"] = forms.CharField( widget=forms.Select( - choices=[("-1", "Select geography type first")]) + choices=[("", "---------"), ("-1", "* (All geography-types)")] + + get_all_geography_type_names_and_ids() + ), + required=True, ) + self.fields["geography"] = forms.CharField( + required=True, + label="Geography", + widget=forms.Select(choices=[("-1", "Select geography type first")]), + ) + + if self.instance and self.instance.pk: + # Sub-theme + if self.instance.sub_theme and self.instance.sub_theme != "-1": + self.fields["sub_theme"].widget.choices = [ + ("", "Select theme first"), + ( + self.instance.sub_theme, + f"Loading... (ID: {self.instance.sub_theme})", + ), + ] + elif self.instance.sub_theme == "-1": + self.fields["sub_theme"].widget.choices = [("-1", "* (All sub-themes)")] + + # Topic + if self.instance.topic and self.instance.topic != "-1": + self.fields["topic"].widget.choices = [ + ("", "Select sub-theme first"), + (self.instance.topic, f"Loading... (ID: {self.instance.topic})"), + ] + elif self.instance.topic == "-1": + self.fields["topic"].widget.choices = [("-1", "* (All topics)")] + + # Metric + if self.instance.metric and self.instance.metric != "-1": + self.fields["metric"].widget.choices = [ + ("", "Select topic first"), + (self.instance.metric, f"Loading... (ID: {self.instance.metric})"), + ] + elif self.instance.metric == "-1": + self.fields["metric"].widget.choices = [("-1", "* (All metrics)")] + + # Geography + if self.instance.geography and self.instance.geography != "-1": + self.fields["geography"].widget.choices = [ + ("", "Select geography type first"), + ( + self.instance.geography, + f"Loading... (ID: {self.instance.geography})", + ), + ] + elif self.instance.geography == "-1": + self.fields["geography"].widget.choices = [ + ("-1", "* (All geographies)") + ] def clean(self): """Validate that this permission set doesn't already exist""" cleaned_data = super().clean() - theme = cleaned_data.get('theme') - sub_theme = cleaned_data.get('sub_theme') - topic = cleaned_data.get('topic') - metric = cleaned_data.get('metric') - geography_type = cleaned_data.get('geography_type') - geography = cleaned_data.get('geography') + theme = cleaned_data.get("theme") + sub_theme = cleaned_data.get("sub_theme") + topic = cleaned_data.get("topic") + metric = cleaned_data.get("metric") + geography_type = cleaned_data.get("geography_type") + geography = cleaned_data.get("geography") # Check if this combination already exists (excluding current instance when editing) queryset = PermissionSet.objects.filter( @@ -70,17 +131,15 @@ def clean(self): topic=topic, metric=metric, geography_type=geography_type, - geography=geography + geography=geography, ) - # Exclude current instance when editing if self.instance.pk: queryset = queryset.exclude(pk=self.instance.pk) if queryset.exists(): raise ValidationError( - "A permission set with this exact combination already exists. " - "Please modify your selection to create a unique permission set." + message="A permission set with this exact combination already exists. Please modify your selection to create a unique permission set." ) return cleaned_data @@ -90,21 +149,15 @@ class PermissionSet(models.Model): name = models.CharField( max_length=500, blank=True, - editable=False, # Don't show in admin form - help_text="Auto-generated display name" + editable=False, + help_text="Auto-generated display name", ) - theme = models.CharField( - max_length=255, choices=[("", "---------"), ("-1", "* (All themes)")] + get_all_theme_names_and_ids(), blank=False, default="") - sub_theme = models.CharField( - max_length=255, blank=False, default="") - topic = models.CharField(max_length=255, - blank=False, default="") - metric = models.CharField( - max_length=255, blank=False, default="") - geography_type = models.CharField(max_length=255, choices=[( - "", "---------"), ("-1", "* (All geography-types)")] + get_all_geography_type_names_and_ids(), blank=False, default="") - geography = models.CharField( - max_length=255, blank=False, default="") + theme = models.CharField(max_length=255, blank=False, default="") + sub_theme = models.CharField(max_length=255, blank=False, default="") + topic = models.CharField(max_length=255, blank=False, default="") + metric = models.CharField(max_length=255, blank=False, default="") + geography_type = models.CharField(max_length=255, blank=False, default="") + geography = models.CharField(max_length=255, blank=False, default="") base_form_class = PermissionSetForm @@ -120,9 +173,15 @@ class PermissionSet(models.Model): class Meta: constraints = [ models.UniqueConstraint( - fields=['theme', 'sub_theme', 'topic', - 'metric', 'geography_type', 'geography'], - name='unique_permission_set' + fields=[ + "theme", + "sub_theme", + "topic", + "metric", + "geography_type", + "geography", + ], + name="unique_permission_set", ) ] @@ -136,87 +195,82 @@ def _generate_display_name(self): Generate display name using the selected dropdown labels. This uses the form's choice labels, not database lookups. """ - from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids - - parts = [] - - # Theme - if self.theme == "-1": - parts.append("Theme: * (All)") - elif self.theme: - theme_name = self._get_choice_label('theme', self.theme) - parts.append(f"Theme: {theme_name}") - - # Sub-theme (we'll need to store these lookups) - if self.sub_theme == "-1": - parts.append("Sub-theme: * (All)") - elif self.sub_theme: - sub_theme_name = self._get_choice_label( - 'sub-theme', self.sub_theme) - parts.append(f"Sub-theme ID: {sub_theme_name}") - - # Topic - if self.topic == "-1": - parts.append("Topic: * (All)") - elif self.topic: - topic_name = self._get_choice_label( - 'topic', self.topic) - parts.append(f"Topic: {topic_name}") - - # Metric - if self.metric == "-1": - parts.append("Metric: * (All)") - elif self.metric: - metric_name = self._get_choice_label( - 'metric', self.metric) - parts.append(f"Metric: {metric_name}") - - # Geography type (we have the label from enum) - if self.geography_type == "-1": - parts.append("Geography Type: * (All)") - elif self.geography_type: - geo_type_label = self.geography_type.replace('_', ' ').title() - parts.append(f"Geography Type: {geo_type_label}") - - # Geography - if self.geography == "-1": - parts.append("Geography: * (All)") - elif self.geography: - parts.append(f"Geography ID: {self.geography}") + + def format_field(field_name: str, field_value: str, label: str) -> str | None: + """ + Format a single field for display. + + Args: + field_name: The field identifier (e.g., "theme", "sub-theme") + field_value: The stored value (ID or "-1") + label: The display label (e.g., "Theme", "Sub-theme") + + Returns: + Formatted string or None if field is empty + """ + if not field_value: + return None + + if field_value == "-1": + return f"{label}: * (All)" + + # Special case for geography_type - format the enum value + if field_name == "geography_type": + formatted_value = field_value.replace("_", " ").title() + return f"{label}: {formatted_value}" + + # For other fields, use choice label lookup + choice_label = self._get_choice_label(field_name, field_value) + return f"{label}: {choice_label}" + + fields = [ + ("theme", self.theme, "Theme"), + ("sub-theme", self.sub_theme, "Sub-theme"), + ("topic", self.topic, "Topic"), + ("metric", self.metric, "Metric"), + ("geography_type", self.geography_type, "Geography Type"), + ("geography", self.geography, "Geography"), + ] + + parts = [p for p in starmap(format_field, fields) if p is not None] return " | ".join(parts) if parts else "Permission Set (Not Configured)" - def _get_choice_label(self, field_name, value): + def _get_choice_label(self, field_name: str, value: str) -> str: """Get the display label for a choice field""" - if field_name == 'theme': - from cms.metrics_interface.field_choices_callables import get_all_theme_names_and_ids - choices = get_all_theme_names_and_ids() - for choice_value, choice_label in choices: - if choice_value == value: - return choice_label - - if field_name == 'sub-theme': - from cms.metrics_interface.field_choices_callables import get_all_sub_theme_names_and_ids - choices = get_all_sub_theme_names_and_ids() - for choice_value, choice_label in choices: - if choice_value == value: - return choice_label - - if field_name == 'topic': - from cms.metrics_interface.field_choices_callables import get_all_topic_names_and_ids - choices = get_all_topic_names_and_ids() - for choice_value, choice_label in choices: - if choice_value == value: - return choice_label - - if field_name == 'metric': - from cms.metrics_interface.field_choices_callables import get_all_metric_names_and_ids - choices = get_all_metric_names_and_ids() - for choice_value, choice_label in choices: - if choice_value == value: - return choice_label - - return value # Fallback to ID if not found + + field_lookup_map = { + "theme": get_all_theme_names_and_ids, + "sub-theme": get_all_sub_theme_names_and_ids, + "topic": get_all_topic_names_and_ids, + "metric": get_all_metric_names_and_ids, + } + + # Get the appropriate lookup function + lookup_func = field_lookup_map.get(field_name) + + if lookup_func: + choices = lookup_func() + return self._find_label_in_choices(choices, value) + + return value + + @staticmethod + def _find_label_in_choices(choices: list[tuple], value: str) -> str: + """ + Find the label for a given value in a list of (value, label) tuples. + + Args: + choices: List of (value, label) tuples + value: The value to look up + + Returns: + The label if found, otherwise the original value + """ + return next( + (label for choice_value, label in choices if choice_value == value), + value, # default if not found + ) def __str__(self): - return self.name if self.name else f"Permission Set {self.id}" + return self.name or f"Permission Set {self.id}" diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index a2e22066c..20a9391bb 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -11,7 +11,6 @@ async function fetchChoices(endpoint, dataItemId) { try { const url = `/api/permission-set/${endpoint}/${dataItemId}`; - console.log(`Fetching from: ${url}`); const response = await fetch(url); @@ -22,7 +21,6 @@ } const data = await response.json(); - console.log(`Received data from ${endpoint}:`, data); return data.choices || []; } catch (error) { console.error(`Error fetching ${endpoint}:`, error); @@ -30,11 +28,8 @@ } } async function fetchGeographies(endpoint, dataItemId) { - console.log("selected geography type: ", dataItemId); - console.log("selected endpoint: ", endpoint); try { const url = `/api/permission-set/${endpoint}/${dataItemId}`; - console.log(`Fetching from: ${url}`); const response = await fetch(url); @@ -45,7 +40,6 @@ } const data = await response.json(); - console.log(`Received data from ${endpoint}:`, data); return data.choices || []; } catch (error) { console.error(`Error fetching ${endpoint}:`, error); @@ -96,9 +90,6 @@ dropdown.appendChild(option); dropdown.value = ""; - dropdown.disabled = true; - - console.log(`Cleared ${dropdown.name}: ${message}`); } /** @@ -117,9 +108,6 @@ dropdown.appendChild(option); dropdown.value = "-1"; - dropdown.disabled = true; - - console.log(`Set ${dropdown.name} to wildcard: ${message}`); } /** @@ -130,7 +118,6 @@ // Clear all dependent dropdowns if (!themeValue || themeValue === "") { - console.log("No theme selected - clearing all children"); clearDropdown(subTheme, "Select theme first"); clearDropdown(topic, "Select sub-theme first"); clearDropdown(metric, "Select topic first"); @@ -138,7 +125,6 @@ } if (themeValue === "-1") { - console.log("Wildcard theme selected - cascading to all children"); setToWildcard(subTheme, "* (All sub-themes)"); setToWildcard(topic, "* (All topics)"); setToWildcard(metric, "* (All metrics)"); @@ -164,7 +150,6 @@ */ async function handleSubThemeChange() { const subThemeValue = subTheme.value; - console.log("Sub-theme changed to:", subThemeValue); if (!subThemeValue || subThemeValue === "") { // No sub-theme selected - clear children @@ -175,15 +160,14 @@ if (subThemeValue === "-1") { // Wildcard sub-theme = cascade wildcard to children - console.log("Wildcard sub-theme selected - cascading to children"); setToWildcard(topic, "* (All topics)"); setToWildcard(metric, "* (All metrics)"); return; } // Clear dependent dropdowns - clearDropdown(topic, "--------"); - clearDropdown(metric, "--------"); + clearDropdown(topic, "Select sub-theme"); + clearDropdown(metric, "Select metric"); // Fetch and populate topics const choices = await fetchChoices("topics", subThemeValue); @@ -200,18 +184,15 @@ */ async function handleTopicChange() { const topicValue = topic.value; - console.log("Topic changed to:", topicValue); if (!topicValue || topicValue === "") { // No topic selected - clear metrics - console.log("No topic selected - clearing metrics"); clearDropdown(metric, "Select topic first"); return; } if (topicValue === "-1") { // Wildcard topic = cascade wildcard to metrics - console.log("Wildcard topic selected - cascading to metrics"); setToWildcard(metric, "* (All metrics)"); return; } @@ -229,18 +210,15 @@ } async function handleGeographyTypeChange() { const geographyTypeValue = geographyType.value; - console.log("geography type changed to:", geographyTypeValue); if (!geographyTypeValue || geographyTypeValue === "") { // No topic selected - clear metrics - console.log("No geography type selected"); clearDropdown(geography, "Select geography type first"); return; } if (geographyTypeValue === "-1") { // Wildcard topic = cascade wildcard to metrics - console.log("Wildcard geography selected - cascading to metrics"); setToWildcard(geography, "* (All geographies)"); return; } @@ -261,8 +239,6 @@ * Loads the dropdown options based on saved values */ async function initializeEditMode() { - console.log("Initializing edit mode..."); - // Store original values before we start manipulating dropdowns const savedTheme = theme.value; const savedSubTheme = subTheme.value; @@ -271,18 +247,8 @@ const savedGeographyType = geographyType.value; const savedGeography = geography.value; - console.log("Saved values:", { - theme: savedTheme, - subTheme: savedSubTheme, - topic: savedTopic, - metric: savedMetric, - geographyType: savedGeographyType, - geography: savedGeography, - }); - // If theme has a value (not wildcard, not empty), load sub-themes if (savedTheme && savedTheme !== "" && savedTheme !== "-1") { - console.log(`Loading sub-themes for theme ${savedTheme}...`); const subThemeChoices = await fetchChoices("subthemes", savedTheme); if (subThemeChoices.length > 0) { populateDropdown(subTheme, subThemeChoices, "* (All sub-themes)"); @@ -291,7 +257,6 @@ // If sub-theme has a value, load topics if (savedSubTheme && savedSubTheme !== "" && savedSubTheme !== "-1") { - console.log(`Loading topics for sub-theme ${savedSubTheme}...`); const topicChoices = await fetchChoices("topics", savedSubTheme); if (topicChoices.length > 0) { populateDropdown(topic, topicChoices, "* (All topics)"); @@ -300,7 +265,6 @@ // If topic has a value, load metrics if (savedTopic && savedTopic !== "" && savedTopic !== "-1") { - console.log(`Loading metrics for topic ${savedTopic}...`); const metricChoices = await fetchChoices("metrics", savedTopic); if (metricChoices.length > 0) { populateDropdown(metric, metricChoices, "* (All metrics)"); @@ -321,9 +285,6 @@ savedGeographyType !== "" && savedGeographyType !== "-1" ) { - console.log( - `Loading geographies for geography type ${savedGeographyType}...`, - ); const geographyChoices = await fetchChoices( "geographies", savedGeographyType, @@ -335,16 +296,12 @@ } else if (savedGeographyType === "-1") { setToWildcard(geography, "* (All geographies)"); } - - console.log("Edit mode initialization complete"); } /** * Initialize the cascading dropdowns */ function initialize() { - console.log("Initializing..."); - // Get dropdown elements theme = document.querySelector('select[name="theme"]'); subTheme = document.querySelector('select[name="sub_theme"]'); @@ -362,7 +319,7 @@ !geographyType || !geography ) { - console.log("Permission set dropdowns not found on this page"); + console.error("Permission set dropdowns not found on this page"); return; } @@ -372,7 +329,6 @@ topic.addEventListener("change", handleTopicChange); geographyType.addEventListener("change", handleGeographyTypeChange); - console.log("Event listeners attached"); const isEditMode = theme.value || subTheme.value || @@ -382,10 +338,8 @@ geography.value; if (isEditMode) { - console.log("Edit mode detected"); initializeEditMode(); } else { - console.log("Create mode - setting initial state"); clearDropdown(subTheme, "Select theme first"); clearDropdown(topic, "Select sub-theme first"); clearDropdown(metric, "Select topic first"); diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py index ffbcaa6b1..eef13b3fe 100644 --- a/auth_content/wagtail_hooks.py +++ b/auth_content/wagtail_hooks.py @@ -1,13 +1,10 @@ -import json - +from django.templatetags.static import static +from django.utils.html import format_html from wagtail import hooks -from wagtail.snippets.views.snippets import SnippetViewSet from wagtail.admin.viewsets.model import ModelViewSetGroup -from django.templatetags.static import static +from wagtail.snippets.views.snippets import SnippetViewSet from auth_content.models import PermissionSet -from django.utils.html import format_html -from django.utils.safestring import mark_safe class PermissionSetViewSet(SnippetViewSet): @@ -27,10 +24,6 @@ class AuthGroup(ModelViewSetGroup): def register_auth_viewset(): return AuthGroup() -# exposes the mapping of parent to child themes - -# exposes the mapping of parent to child themes - @hooks.register("insert_editor_js") def permission_set_js(): diff --git a/cms/dashboard/wagtail_hooks.py b/cms/dashboard/wagtail_hooks.py index 2507444bd..e20b3d7fc 100644 --- a/cms/dashboard/wagtail_hooks.py +++ b/cms/dashboard/wagtail_hooks.py @@ -8,8 +8,6 @@ from wagtail.admin.site_summary import PagesSummaryItem, SummaryItem from wagtail.models import Page from wagtail.whitelist import check_url -from wagtail import hooks - @hooks.register("insert_global_admin_css") diff --git a/cms/metrics_interface/field_choices_callables.py b/cms/metrics_interface/field_choices_callables.py index 48063d2d2..0c606e095 100644 --- a/cms/metrics_interface/field_choices_callables.py +++ b/cms/metrics_interface/field_choices_callables.py @@ -10,9 +10,10 @@ And allowing the CMS to provide the content creator with access to the `latest` data after the point of ingestion. """ -from cms.metrics_interface import MetricsAPIInterface from django.db.models import QuerySet +from cms.metrics_interface import MetricsAPIInterface + LIST_OF_TWO_STRING_ITEM_TUPLES = list[tuple[str, str]] DICT_OF_CHART_AXIS_AND_SUB_CATEGORIES = dict[str, list[str]] GEOGRAPHY_TYPE_NAME_FOR_ALERTS = "Government Office Region" @@ -25,9 +26,7 @@ def _build_two_item_tuple_choices( return [(choice, choice) for choice in choices] -def _build_id_name_tuple_choices( - *, choices: QuerySet -) -> list[tuple[str, str]]: +def _build_id_name_tuple_choices(*, choices: QuerySet) -> list[tuple[str, str]]: """Build choices from a QuerySet containing id and name fields. Args: @@ -38,7 +37,7 @@ def _build_id_name_tuple_choices( Examples: [(1, "infectious_disease"), (2, "respiratory"), ...] """ - return [(str(choice['id']), choice['name']) for choice in choices] + return [(str(choice["id"]), choice["name"]) for choice in choices] def get_possible_axis_choices() -> LIST_OF_TWO_STRING_ITEM_TUPLES: @@ -293,10 +292,8 @@ def get_all_theme_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: [("Infectious_disease", "Infectious_disease"), ...] """ metrics_interface = MetricsAPIInterface() - theme_names = metrics_interface.get_all_theme_names() - print(theme_names) return _build_two_item_tuple_choices( - choices=theme_names, + choices=metrics_interface.get_all_theme_names(), ) @@ -314,13 +311,12 @@ def get_all_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: Returns: A list of 2-item tuples of theme names. Examples: - [("Infectious_disease", "Infectious_disease"), ...] + [(1, "immunisation"), ...] """ metrics_interface = MetricsAPIInterface() - theme_names = metrics_interface.get_all_theme_names_and_ids() - print(theme_names) + return _build_id_name_tuple_choices( - choices=theme_names, + choices=metrics_interface.get_all_theme_names_and_ids() ) @@ -369,52 +365,22 @@ def get_all_unique_sub_theme_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: def get_all_sub_theme_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: - """Callable for the `choices` on the `theme` fields of the CMS blocks. - - Notes: - This callable wraps the `MetricsAPIInterface` - and is passed to a migration for the CMS blocks. - This means that we don't need to create a new migration - whenever a new chart type is added. - Instead, the 1-off migration is pointed at this callable. - So Wagtail will pull the choices by invoking this function. - - Returns: - A list of 2-item tuples of theme names. - Examples: - [("Infectious_disease", "Infectious_disease"), ...] - """ - metrics_interface = MetricsAPIInterface() - sub_theme_names_and_ids = metrics_interface.get_all_sub_theme_names_and_ids() - print(sub_theme_names_and_ids) - return _build_id_name_tuple_choices( - choices=sub_theme_names_and_ids, - ) - - -def get_filtered_unique_sub_theme_names_for_parent_theme(parent_theme_id) -> LIST_OF_TWO_STRING_ITEM_TUPLES: - """Callable for the `choices` on the `theme` fields of the CMS blocks. + """Callable for the `choices` on the `sub-theme` fields of the CMS blocks. Notes: This callable wraps the `MetricsAPIInterface` and is passed to a migration for the CMS blocks. - This means that we don't need to create a new migration - whenever a new chart type is added. Instead, the 1-off migration is pointed at this callable. So Wagtail will pull the choices by invoking this function. Returns: - A list of 2-item tuples of theme names. + A list of 2-item tuples of subtheme names and ids. Examples: - [("Infectious_disease", "Infectious_disease"), ...] + [(1, "childhood-vaccines"), ...] """ metrics_interface = MetricsAPIInterface() - print("received parent_theme_id: ", parent_theme_id) - filtered_sub_themes = metrics_interface.get_filtered_unique_sub_theme_names_for_parent_theme( - parent_theme_id=parent_theme_id) - print("filtered_sub_themes: ", filtered_sub_themes) return _build_id_name_tuple_choices( - choices=filtered_sub_themes, + choices=metrics_interface.get_all_sub_theme_names_and_ids(), ) @@ -462,21 +428,18 @@ def get_all_topic_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: Notes: This callable wraps the `MetricsAPIInterface` and is passed to a migration for the CMS blocks. - This means that we don't need to create a new migration - whenever a new chart type is added. Instead, the 1-off migration is pointed at this callable. So Wagtail will pull the choices by invoking this function. Returns: - A list of 2-item tuples of theme names. + A list of 2-item tuples of topic names and ids. Examples: - [("Infectious_disease", "Infectious_disease"), ...] + [(1, "6-in-1"), ...] """ metrics_interface = MetricsAPIInterface() - topic_names_and_ids = metrics_interface.get_all_topic_names_and_ids() - print(topic_names_and_ids) + return _build_id_name_tuple_choices( - choices=topic_names_and_ids, + choices=metrics_interface.get_all_topic_names_and_ids() ) @@ -492,15 +455,13 @@ def get_all_metric_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: So Wagtail will pull the choices by invoking this function. Returns: - A list of 2-item tuples of theme names. + A list of 2-item tuples of metric names and ids. Examples: - [("Infectious_disease", "Infectious_disease"), ...] + [(1, "6-in-1_coverage_coverageByYear"), ...] """ metrics_interface = MetricsAPIInterface() - metric_names_and_ids = metrics_interface.get_all_metric_names_and_ids() - print(metric_names_and_ids) return _build_id_name_tuple_choices( - choices=metric_names_and_ids, + choices=metrics_interface.get_all_metric_names_and_ids() ) @@ -636,22 +597,18 @@ def get_all_geography_type_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: Notes: This callable wraps the `MetricsAPIInterface` and is passed to a migration for the CMS blocks. - This means that we don't need to create a new migration - whenever a new `Geography` is added to that table. Instead, the 1-off migration is pointed at this callable. So Wagtail will pull the choices by invoking this function. Returns: - A list of 2-item tuples of geography type names. + A list of 2-item tuples of geography type names and ids. Examples: - [(, "Nation"), ...] + [(1, "Nation"), ...] """ metrics_interface = MetricsAPIInterface() - geography_choices = metrics_interface.get_all_geography_type_names_and_ids() - print(geography_choices) return _build_id_name_tuple_choices( - choices=geography_choices + choices=metrics_interface.get_all_geography_type_names_and_ids() ) @@ -742,8 +699,7 @@ def get_all_geography_choices_grouped_by_type() -> ( def get_all_subcategory_choices_grouped_by_categories() -> ( dict[ - str, LIST_OF_TWO_STRING_ITEM_TUPLES | dict[str, - LIST_OF_TWO_STRING_ITEM_TUPLES] + str, LIST_OF_TWO_STRING_ITEM_TUPLES | dict[str, LIST_OF_TWO_STRING_ITEM_TUPLES] ] ): """Callable to return all subcategory choices groups by categories. diff --git a/cms/metrics_interface/interface.py b/cms/metrics_interface/interface.py index 9e7ab2aaa..dc5bc606c 100644 --- a/cms/metrics_interface/interface.py +++ b/cms/metrics_interface/interface.py @@ -187,46 +187,46 @@ def get_all_theme_names(self) -> QuerySet: return self.theme_manager.get_all_names() def get_all_theme_names_and_ids(self) -> QuerySet: - """Gets all available theme names as a flat list queryset. - Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + """Gets all available theme names names and ids as a list queryset. + Note this is achieved by delegating the call to the `SubThemeManager` from the Metrics API Returns: QuerySet: A queryset of the individual theme names. Examples: - ``. + ``. """ return self.theme_manager.get_all_names_and_ids() def get_all_sub_theme_names_and_ids(self) -> QuerySet: - """Gets all available theme names as a flat list queryset. - Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + """Gets all available subtheme names names and ids as a list queryset. + Note this is achieved by delegating the call to the `SubThemeManager` from the Metrics API Returns: - QuerySet: A queryset of the individual theme names. + QuerySet: A queryset of the individual subtheme names. Examples: - ``. + ``. """ return self.sub_theme_manager.get_all_names_and_ids() def get_all_topic_names_and_ids(self) -> QuerySet: - """Gets all available theme names as a flat list queryset. - Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + """Gets all available topic names names and ids as a list queryset. + Note this is achieved by delegating the call to the `TopicManager` from the Metrics API Returns: - QuerySet: A queryset of the individual theme names. + QuerySet: A queryset of the individual topic names. Examples: - ``. + ``. """ return self.topic_manager.get_all_names_and_ids() def get_all_metric_names_and_ids(self) -> QuerySet: - """Gets all available theme names as a flat list queryset. - Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API + """Gets all available metric names names and ids as a list queryset. + Note this is achieved by delegating the call to the `MetricManager` from the Metrics API Returns: - QuerySet: A queryset of the individual theme names. + QuerySet: A queryset of the individual metric names. Examples: - ``. + ``. """ return self.metric_manager.get_all_names_and_ids() @@ -253,29 +253,6 @@ def get_all_unique_sub_theme_names(self) -> QuerySet: """ return self.sub_theme_manager.get_all_unique_names() - def get_filtered_unique_sub_theme_names_for_parent_theme(self, parent_theme_id) -> QuerySet: - """Get all unique sub_theme names as a flat list queryset. - Note this is achieved by delegating the call to the `SubThemeManager` from the Metrics API - - Returns: - QuerySet: A queryset of the individual sub_theme names. - Examples: - ` - - """ - return self.sub_theme_manager.get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) - - def get_all_sub_theme_names_and_ids(self) -> QuerySet: - """Gets all available theme names as a flat list queryset. - Note this is achieved by delegating the call to the `ThemeManager` from the Metrics API - - Returns: - QuerySet: A queryset of the individual theme names. - Examples: - ``. - """ - return self.sub_theme_manager.get_all_names_and_ids() - def get_all_topic_names(self) -> QuerySet: """Gets all available topic names as a flat list queryset. Note this is achieved by delegating the call to the `TopicManager` from the Metrics API @@ -472,7 +449,7 @@ def get_all_geography_type_names_and_ids(self) -> QuerySet: Returns: QuerySet: A queryset of the individual geography_type names: Examples: - `` + `` """ return self.geography_type_manager.get_all_names_and_ids() diff --git a/metrics/api/serializers/geographies.py b/metrics/api/serializers/geographies.py index b0e45bf83..b4f7024c9 100644 --- a/metrics/api/serializers/geographies.py +++ b/metrics/api/serializers/geographies.py @@ -1,8 +1,9 @@ from collections import defaultdict +from django.db.models import QuerySet from rest_framework import serializers -from metrics.api.serializers.permission_sets import _queryset_to_id_name_tuples +from metrics.api.serializers import help_texts from metrics.data.in_memory_models.geography_relationships.handlers import ( get_upstream_relationships_for_geography, ) @@ -12,7 +13,6 @@ Geography, Topic, ) -from django.db.models import QuerySet GEOGRAPHY_TYPE_RESULT = dict[str, list[dict[str, str]]] @@ -63,8 +63,7 @@ def data(self) -> list[GEOGRAPHY_TYPE_RESULT]: """ topic: str = self.validated_data["topic"] queryset: CoreTimeSeriesQuerySet = ( - self.core_time_series_manager.get_available_geographies( - topic=topic) + self.core_time_series_manager.get_available_geographies(topic=topic) ) return _serialize_queryset(queryset=queryset) @@ -191,13 +190,12 @@ class GeographiesResponseSerializer(serializers.ListSerializer): class GeographyChoicesResponseSerializer(serializers.Serializer): """Formats the response for choice endpoints""" + choices = serializers.ListField( child=serializers.ListField( - child=serializers.CharField(), - min_length=2, - max_length=2 + child=serializers.CharField(), min_length=2, max_length=2 ), - help_text="List of [id, name] pairs for dropdown options" + help_text=help_texts.GEOGRAPHY_TUPLE_FORMATTING, ) @@ -232,48 +230,51 @@ class GeographyByGeographyTypeRequestSerializer(serializers.Serializer): def geography_manager(self): return self.context.get("geography_manager", Geography.objects) - def validate_geography_type_id(self, value): - """Validate theme_id is either wildcard or a valid integer""" + @staticmethod + def validate_geography_type_id(value): + """Validate geography_type_id is either wildcard or a valid integer""" if value == "-1": return value try: - int(value) - return value - except ValueError: - raise serializers.ValidationError( - "Geography Type must be a number or '-1'") + return int(value) + except ValueError as err: + message = "Geography Type must be a number or '-1'" + raise serializers.ValidationError(message) from err def data(self) -> dict: """ - Fetch sub-themes from DB and format as response. + Fetch geographies for specified geography type from DB and format as response. Returns: Dict with 'choices' key containing list of [id, name] pairs """ - geography_type_id = self.validated_data['geography_type_id'] + geography_type_id = self.validated_data["geography_type_id"] # Handle wildcard if geography_type_id == "-1": - return {'choices': [["-1", "* (All geographies)"]]} + return {"choices": [["-1", "* (All geographies)"]]} - # Fetch from interface parent_geography_type_id = int(geography_type_id) - geographies = self.geography_manager.get_geography_codes_and_names_by_geography_type_id( - parent_geography_type_id) - print(geographies) + geographies = ( + self.geography_manager.get_geography_codes_and_names_by_geography_type_id( + parent_geography_type_id + ) + ) geography_names_and_codes_tuples = _queryset_to_geography_code_name_tuples( - geographies) - - # Format response - print('geography data: ', geography_names_and_codes_tuples) - choices = [[str(geography_code), name] - for geography_code, name in geography_names_and_codes_tuples] + geographies + ) - return {'choices': choices} + choices = [ + [str(geography_code), name] + for geography_code, name in geography_names_and_codes_tuples + ] + return {"choices": choices} -def _queryset_to_geography_code_name_tuples(queryset: QuerySet) -> list[tuple[str, str]]: +def _queryset_to_geography_code_name_tuples( + queryset: QuerySet, +) -> list[tuple[str, str]]: """ Convert a QuerySet with 'id' and 'name' fields to a list of tuples. @@ -288,8 +289,4 @@ def _queryset_to_geography_code_name_tuples(queryset: QuerySet) -> list[tuple[st >>> queryset_to_id_name_tuples(qs) [(1, "item1"), (2, "item2")] """ - print('received queryset: ', queryset) - - for item in queryset: - print('item: ', item) - return [(item['geography_code'], item['name']) for item in queryset] + return [(item["geography_code"], item["name"]) for item in queryset] diff --git a/metrics/api/serializers/help_texts.py b/metrics/api/serializers/help_texts.py index f04c6dfc1..518d637d4 100644 --- a/metrics/api/serializers/help_texts.py +++ b/metrics/api/serializers/help_texts.py @@ -126,7 +126,9 @@ Boolean switch to decide whether to draw splines on individual data points. If set to false, linear point-to-point lines will be drawn between points. """ - CONFIDENCE_INTERVALS: str = """ Boolean switch to decide whether to draw confidence intervals if provided """ +GEOGRAPHY_TUPLE_FORMATTING: str = """ +"List of [id, name] pairs for dropdown options" +""" diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py index f57f352b9..8db852c79 100644 --- a/metrics/api/serializers/permission_sets.py +++ b/metrics/api/serializers/permission_sets.py @@ -1,11 +1,12 @@ +from django.db.models import QuerySet from rest_framework import serializers -from metrics.data.models.core_models.supporting import SubTheme, Topic, Metric -from django.db.models import QuerySet +from metrics.data.models.core_models.supporting import Metric, SubTheme, Topic class SubThemeRequestSerializer(serializers.Serializer): """Fetches and formats sub-theme choices based on theme_id""" + theme_id = serializers.CharField(required=True) @property @@ -16,17 +17,19 @@ def sub_theme_manager(self): """ return self.context.get("sub_theme_manager", SubTheme.objects) - def validate_theme_id(self, value): + @staticmethod + def validate_theme_id(value): """Validate theme_id is either wildcard or a valid integer""" if value == "-1": return value try: int(value) + except ValueError as err: + msg = "theme_id must be a number or '-1'" + raise serializers.ValidationError(msg) from err + else: return value - except ValueError: - raise serializers.ValidationError( - "theme_id must be a number or '-1'") def data(self) -> dict: """ @@ -35,26 +38,25 @@ def data(self) -> dict: Returns: Dict with 'choices' key containing list of [id, name] pairs """ - theme_id = self.validated_data['theme_id'] + theme_id = self.validated_data["theme_id"] - # Handle wildcard if theme_id == "-1": - return {'choices': [["-1", "* (All sub-themes)"]]} + return {"choices": [["-1", "* (All sub-themes)"]]} - # Fetch from interface parent_theme_id = int(theme_id) - sub_theme_tuples = _queryset_to_id_name_tuples(self.sub_theme_manager.get_filtered_unique_names_related_to_theme( - parent_theme_id)) - - # Format response - print('sub_themes: ', sub_theme_tuples) - choices = [[str(id), name] for id, name in sub_theme_tuples] + sub_theme_tuples = _queryset_to_id_name_tuples( + self.sub_theme_manager.get_filtered_unique_names_related_to_theme( + parent_theme_id + ) + ) + choices = [[str(item_id), name] for item_id, name in sub_theme_tuples] - return {'choices': choices} + return {"choices": choices} class TopicRequestSerializer(serializers.Serializer): """Fetches and formats topic related to sub-themes based on provided parent sub_theme_id""" + sub_theme_id = serializers.CharField(required=True) @property @@ -65,17 +67,19 @@ def topic_manager(self): """ return self.context.get("topic_manager", Topic.objects) - def validate_sub_theme_id(self, value): + @staticmethod + def validate_sub_theme_id(value): """Validate sub_theme_id is either wildcard or a valid integer""" if value == "-1": return value try: int(value) + except ValueError as err: + msg = "sub_theme_id must be a number or '-1'" + raise serializers.ValidationError(msg) from err + else: return value - except ValueError: - raise serializers.ValidationError( - "sub_theme_id must be a number or '-1'") def data(self) -> dict: """ @@ -84,26 +88,26 @@ def data(self) -> dict: Returns: Dict with 'choices' key containing list of [id, name] pairs """ - sub_theme_id = self.validated_data['sub_theme_id'] + sub_theme_id = self.validated_data["sub_theme_id"] - # Handle wildcard if sub_theme_id == "-1": - return {'choices': [["-1", "* (All sub-themes)"]]} + return {"choices": [["-1", "* (All topics)"]]} - # Fetch from interface parent_sub_theme_id = int(sub_theme_id) - topic_tuples = _queryset_to_id_name_tuples(self.topic_manager.get_filtered_unique_names_related_to_sub_theme( - parent_sub_theme_id)) + topic_tuples = _queryset_to_id_name_tuples( + self.topic_manager.get_filtered_unique_names_related_to_sub_theme( + parent_sub_theme_id + ) + ) - # Format response - print('sub_themes: ', topic_tuples) - choices = [[str(id), name] for id, name in topic_tuples] + choices = [[str(item_id), name] for item_id, name in topic_tuples] - return {'choices': choices} + return {"choices": choices} class MetricRequestSerializer(serializers.Serializer): """Fetches and formats metrics related to topics based on provided parent topic_id""" + topic_id = serializers.CharField(required=True) @property @@ -114,17 +118,19 @@ def metric_manager(self): """ return self.context.get("metric_manager", Metric.objects) - def validate_topic_id(self, value): + @staticmethod + def validate_topic_id(value): """Validate topic_id is either wildcard or a valid integer""" if value == "-1": return value try: int(value) + except ValueError as err: + msg = "topic_id must be a number or '-1'" + raise serializers.ValidationError(msg) from err + else: return value - except ValueError: - raise serializers.ValidationError( - "topic_id must be a number or '-1'") def data(self) -> dict: """ @@ -133,33 +139,31 @@ def data(self) -> dict: Returns: Dict with 'choices' key containing list of [id, name] pairs """ - topic_id = self.validated_data['topic_id'] + topic_id = self.validated_data["topic_id"] - # Handle wildcard if topic_id == "-1": - return {'choices': [["-1", "* (All sub-themes)"]]} + return {"choices": [["-1", "* (All metrics)"]]} - # Fetch from interface parent_topic_id = int(topic_id) - metric_tuples = _queryset_to_id_name_tuples(self.metric_manager.get_filtered_unique_names_related_to_parent_topic_id( - parent_topic_id)) + metric_tuples = _queryset_to_id_name_tuples( + self.metric_manager.get_filtered_unique_names_related_to_parent_topic_id( + parent_topic_id + ) + ) - # Format response - print('metrics: ', metric_tuples) - choices = [[str(id), name] for id, name in metric_tuples] + choices = [[str(item_id), name] for item_id, name in metric_tuples] - return {'choices': choices} + return {"choices": choices} class PermissionSetResponseSerializer(serializers.Serializer): """Formats the response for choice endpoints""" + choices = serializers.ListField( child=serializers.ListField( - child=serializers.CharField(), - min_length=2, - max_length=2 + child=serializers.CharField(), min_length=2, max_length=2 ), - help_text="List of [id, name] pairs for dropdown options" + help_text="List of [id, name] pairs for dropdown options", ) @@ -178,5 +182,4 @@ def _queryset_to_id_name_tuples(queryset: QuerySet) -> list[tuple[int, str]]: >>> queryset_to_id_name_tuples(qs) [(1, "item1"), (2, "item2")] """ - print('received queryset: ', queryset) - return [(item['id'], item['name']) for item in queryset] + return [(item["id"], item["name"]) for item in queryset] diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index 5519e23d2..0af934f35 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -36,10 +36,18 @@ ) from metrics.api.views.charts import DualCategoryChartsView from metrics.api.views.charts.subplot_charts import SubplotChartsView -from metrics.api.views.geographies import GeographiesByGeographyTypeView, GeographiesView, GeographiesViewDeprecated +from metrics.api.views.geographies import ( + GeographiesByGeographyTypeView, + GeographiesView, + GeographiesViewDeprecated, +) from metrics.api.views.health import InternalHealthView from metrics.api.views.maps import MapsView -from metrics.api.views.permission_sets import MetricsByTopicView, SubThemesByThemeView, TopicsBySubThemeView +from metrics.api.views.permission_sets import ( + MetricsByTopicView, + SubThemesByThemeView, + TopicsBySubThemeView, +) from public_api import construct_url_patterns_for_public_api router = routers.DefaultRouter() @@ -82,8 +90,7 @@ def construct_cms_admin_urlpatterns( prefix: str = "" if app_mode == enums.AppMode.CMS_ADMIN.value else "cms-admin/" return [ path(prefix, include(wagtailadmin_urls)), - path("choose-page/", LinkBrowseView.as_view(), - name="wagtailadmin_choose_page"), + path("choose-page/", LinkBrowseView.as_view(), name="wagtailadmin_choose_page"), ] @@ -131,24 +138,34 @@ def construct_public_api_urlpatterns( # Headless CMS API - pages + drafts endpoints path(API_PREFIX, cms_api_router.urls), path(f"{API_PREFIX}global-banners/v2", GlobalBannerView.as_view()), - path(f"{API_PREFIX}permission-set/subthemes/", - SubThemesByThemeView.as_view(), name='get_subthemes'), - path(f"{API_PREFIX}permission-set/topics/", - TopicsBySubThemeView.as_view(), name='get_topics'), - path(f"{API_PREFIX}permission-set/metrics/", - MetricsByTopicView.as_view(), name='get_metrics'), - path(f"{API_PREFIX}permission-set/geographies/", - GeographiesByGeographyTypeView.as_view(), name='get_geographies'), + path( + f"{API_PREFIX}permission-set/subthemes/", + SubThemesByThemeView.as_view(), + name="get_subthemes", + ), + path( + f"{API_PREFIX}permission-set/topics/", + TopicsBySubThemeView.as_view(), + name="get_topics", + ), + path( + f"{API_PREFIX}permission-set/metrics/", + MetricsByTopicView.as_view(), + name="get_metrics", + ), + path( + f"{API_PREFIX}permission-set/geographies/", + GeographiesByGeographyTypeView.as_view(), + name="get_geographies", + ), path(f"{API_PREFIX}menus/v1", MenuView.as_view()), - path(f"{API_PREFIX}alerts/v1/heat", - heat_alert_list, name="heat-alerts-list"), + path(f"{API_PREFIX}alerts/v1/heat", heat_alert_list, name="heat-alerts-list"), path( f"{API_PREFIX}alerts/v1/heat/", heat_alert_detail, name="heat-alerts-detail", ), - path(f"{API_PREFIX}alerts/v1/cold", - cold_alert_list, name="cold-alerts-list"), + path(f"{API_PREFIX}alerts/v1/cold", cold_alert_list, name="cold-alerts-list"), path( f"{API_PREFIX}alerts/v1/cold/", cold_alert_detail, @@ -159,28 +176,23 @@ def construct_public_api_urlpatterns( re_path(f"^{API_PREFIX}charts/subplot/v1", SubplotChartsView.as_view()), re_path(f"^{API_PREFIX}downloads/v2", DownloadsView.as_view()), re_path(f"^{API_PREFIX}bulkdownloads/v1", BulkDownloadsView.as_view()), - re_path(f"^{API_PREFIX}downloads/subplot/v1", - SubplotDownloadsView.as_view()), + re_path(f"^{API_PREFIX}downloads/subplot/v1", SubplotDownloadsView.as_view()), re_path( f"^{API_PREFIX}geographies/v2/(?P[^/]+)", GeographiesViewDeprecated.as_view(), ), re_path(f"^{API_PREFIX}geographies/v3", GeographiesView.as_view()), - re_path(f"^{API_PREFIX}headlines/v3", HeadlinesView.as_view()), re_path(f"^{API_PREFIX}maps/v1", MapsView.as_view()), re_path(f"^{API_PREFIX}tables/v4", TablesView.as_view()), re_path(f"^{API_PREFIX}tables/subplot/v1", TablesSubplotView.as_view()), re_path(f"^{API_PREFIX}trends/v3", TrendsView.as_view()), - ] # Audit API endpoints audit_api_timeseries_list = AuditAPITimeSeriesViewSet.as_view({"get": "list"}) -audit_core_timeseries_list = AuditCoreTimeseriesViewSet.as_view({ - "get": "list"}) -audit_api_core_headline_list = AuditCoreHeadlineViewSet.as_view({ - "get": "list"}) +audit_core_timeseries_list = AuditCoreTimeseriesViewSet.as_view({"get": "list"}) +audit_api_core_headline_list = AuditCoreHeadlineViewSet.as_view({"get": "list"}) audit_api_urlpatterns = [ path( @@ -200,8 +212,7 @@ def construct_public_api_urlpatterns( ), re_path(f"^{API_PREFIX}charts/v2", ChartsView.as_view()), re_path(f"^{API_PREFIX}charts/v3", EncodedChartsView.as_view()), - re_path(f"^{API_PREFIX}charts/dual-category/v1", - DualCategoryChartsView.as_view()), + re_path(f"^{API_PREFIX}charts/dual-category/v1", DualCategoryChartsView.as_view()), re_path(f"^{API_PREFIX}charts/subplot/v1", SubplotChartsView.as_view()), ] @@ -222,8 +233,7 @@ def construct_public_api_urlpatterns( ] static_urlpatterns = [ - re_path(r"^static/(?P.*)$", serve, - {"document_root": settings.STATIC_ROOT}), + re_path(r"^static/(?P.*)$", serve, {"document_root": settings.STATIC_ROOT}), ] common_urlpatterns = [ diff --git a/metrics/api/views/geographies.py b/metrics/api/views/geographies.py index eb90d8270..07f38d435 100644 --- a/metrics/api/views/geographies.py +++ b/metrics/api/views/geographies.py @@ -66,17 +66,12 @@ class GeographiesView(APIView): ) def get(self, request, *args, **kwargs) -> Response: """This endpoint returns a list of geography types along with an aggregated list of their geographies. - --- - # Main errors - A query parameter of either `topic` or `geography_type` must be provided. If neither are provided **or** both are provided, then a 400 `Bad Request` 400 will be returned. - """ - request_serializer = GeographiesRequestSerializer( - data=request.query_params) + request_serializer = GeographiesRequestSerializer(data=request.query_params) request_serializer.is_valid(raise_exception=True) payload = request_serializer.data @@ -109,12 +104,17 @@ def _handle_geographies_by_geography_type( return serializer.data() -@extend_schema(request=GeographyByGeographyTypeRequestSerializer, tags=[GEOGRAPHIES_API_TAG], responses={HTTPStatus.OK.value: GeographyChoicesResponseSerializer}) +@extend_schema( + request=GeographyByGeographyTypeRequestSerializer, + tags=[GEOGRAPHIES_API_TAG], + responses={HTTPStatus.OK.value: GeographyChoicesResponseSerializer}, +) class GeographiesByGeographyTypeView(APIView): permission_classes = [] - def get(self, request, geography_type_id, *args, **kwargs): + def get(self, request, geography_type_id, *args, **kwargs): # noqa: PLR6301 serializer = GeographyByGeographyTypeRequestSerializer( - data={'geography_type_id': geography_type_id}) + data={"geography_type_id": geography_type_id} + ) serializer.is_valid(raise_exception=True) return Response(serializer.data()) diff --git a/metrics/api/views/permission_sets.py b/metrics/api/views/permission_sets.py index 49f233064..7a76eaf8f 100644 --- a/metrics/api/views/permission_sets.py +++ b/metrics/api/views/permission_sets.py @@ -1,48 +1,65 @@ from http import HTTPStatus from drf_spectacular.utils import extend_schema -from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework import status +from rest_framework.views import APIView -from metrics.api.serializers.permission_sets import MetricRequestSerializer, PermissionSetResponseSerializer, SubThemeRequestSerializer, TopicRequestSerializer +from metrics.api.serializers.permission_sets import ( + MetricRequestSerializer, + PermissionSetResponseSerializer, + SubThemeRequestSerializer, + TopicRequestSerializer, +) PERMISSION_SETS_API_TAG = "data hierarchy" -@extend_schema(request=SubThemeRequestSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) +@extend_schema( + request=SubThemeRequestSerializer, + tags=[PERMISSION_SETS_API_TAG], + responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}, +) class SubThemesByThemeView(APIView): """Get sub-themes filtered by theme ID""" + permission_classes = [] - def get(self, request, theme_id, *args, **kwargs): + def get(self, request, theme_id, *args, **kwargs): # noqa: PLR6301 """API endpoint to fetch sub-themes based on selected theme.""" - serializer = SubThemeRequestSerializer(data={'theme_id': theme_id}) + serializer = SubThemeRequestSerializer(data={"theme_id": theme_id}) serializer.is_valid(raise_exception=True) return Response(serializer.data()) -@extend_schema(request=TopicRequestSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) +@extend_schema( + request=TopicRequestSerializer, + tags=[PERMISSION_SETS_API_TAG], + responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}, +) class TopicsBySubThemeView(APIView): """Get topics filtered by sub-theme ID""" + permission_classes = [] - def get(self, request, sub_theme_id, *args, **kwargs): + def get(self, request, sub_theme_id, *args, **kwargs): # noqa: PLR6301 """API endpoint to fetch sub-themes based on selected theme.""" - serializer = TopicRequestSerializer( - data={'sub_theme_id': sub_theme_id}) + serializer = TopicRequestSerializer(data={"sub_theme_id": sub_theme_id}) serializer.is_valid(raise_exception=True) return Response(serializer.data()) -@extend_schema(request=MetricRequestSerializer, tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}) +@extend_schema( + request=MetricRequestSerializer, + tags=[PERMISSION_SETS_API_TAG], + responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}, +) class MetricsByTopicView(APIView): """Get metrics filtered by topic ID""" + permission_classes = [] - def get(self, request, topic_id, *args, **kwargs): + def get(self, request, topic_id, *args, **kwargs): # noqa: PLR6301 """API endpoint to fetch sub-themes based on selected theme.""" - serializer = MetricRequestSerializer( - data={'topic_id': topic_id}) + serializer = MetricRequestSerializer(data={"topic_id": topic_id}) serializer.is_valid(raise_exception=True) return Response(serializer.data()) diff --git a/metrics/data/managers/core_models/geography.py b/metrics/data/managers/core_models/geography.py index ec1ea2a93..675b99712 100644 --- a/metrics/data/managers/core_models/geography.py +++ b/metrics/data/managers/core_models/geography.py @@ -84,14 +84,14 @@ def get_geography_codes_and_names_by_geography_type_id( self, geography_type_id: int, ): - """Gets all available geography codes and names for the given `geography_type_name` + """Gets all available geography codes and names for the given `geography_type_id` Args: - geography_type_name: string representation of `geography_type_name` + geography_type_id: string representation of `geography_type_id` Returns: - QuerySet: A queryset of the individual geography codes - which are related to the given geography_type: + QuerySet: A queryset of the individual geography codes and geography names + which are related to the given geography_type_id: Examples: `` @@ -197,11 +197,10 @@ def get_geography_codes_and_names_by_geography_type_id( """Gets all available geography codes and names for a give `geography_type` Args: - geography_type_name: string representation of `geography_type_name` + geography_type_id: string representation of `geography_type_id` Returns: - QuerySet: A queryset of the individual geography codes - which are related to the given geography_type: + QuerySet: A queryset of the individual geography codes and names which are related to the given geography_type: Examples: `` diff --git a/metrics/data/managers/core_models/metric.py b/metrics/data/managers/core_models/metric.py index abf08ad45..c2e289782 100644 --- a/metrics/data/managers/core_models/metric.py +++ b/metrics/data/managers/core_models/metric.py @@ -44,8 +44,7 @@ def get_all_unique_change_type_names(self) -> models.QuerySet: `` """ return self.get_all_unique_names().filter( - models.Q(name__icontains="change") & ~models.Q( - name__icontains="percent") + models.Q(name__icontains="change") & ~models.Q(name__icontains="percent") ) def get_all_unique_percent_change_type_names(self) -> models.QuerySet: @@ -83,23 +82,25 @@ def get_all_headline_names(self) -> models.QuerySet: """ return self.get_all_unique_names().filter(metric_group__name="headline") - def get_filtered_unique_names_related_to_parent_topic_id(self, parent_topic_id) -> models.QuerySet: - """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. + def get_filtered_unique_names_related_to_parent_topic_id( + self, parent_topic_id + ) -> models.QuerySet: + """Gets all available unique metrics with id and name fields that are related to the parent topic ID. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ - return self.filter(topic_id=parent_topic_id).values('id', 'name').distinct() + return self.filter(topic_id=parent_topic_id).values("id", "name").distinct() def get_all_names_and_ids(self) -> models.QuerySet: - """Gets all available themes with id and name fields. + """Gets all available metrics with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ return self.all().values("id", "name").distinct() @@ -177,22 +178,26 @@ def get_all_headline_names(self) -> MetricQuerySet: """ return self.get_queryset().get_all_headline_names() - def get_filtered_unique_names_related_to_parent_topic_id(self, parent_topic_id: str) -> MetricQuerySet: - """Gets all available themes with id and name fields. + def get_filtered_unique_names_related_to_parent_topic_id( + self, parent_topic_id: str + ) -> MetricQuerySet: + """Gets all available metrics with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ - return self.get_queryset().get_filtered_unique_names_related_to_parent_topic_id(parent_topic_id=parent_topic_id) + return self.get_queryset().get_filtered_unique_names_related_to_parent_topic_id( + parent_topic_id=parent_topic_id + ) def get_all_names_and_ids(self) -> MetricQuerySet: - """Gets all available themes with id and name fields. + """Gets all available metrics with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ - return self .get_queryset().get_all_names_and_ids() + return self.get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/sub_theme.py b/metrics/data/managers/core_models/sub_theme.py index 42956fbdf..7fb72fea4 100644 --- a/metrics/data/managers/core_models/sub_theme.py +++ b/metrics/data/managers/core_models/sub_theme.py @@ -34,7 +34,7 @@ def get_all_unique_names(self) -> models.QuerySet: return self.all().values_list("name", flat=True).distinct().order_by("name") def get_all_names_and_ids(self) -> models.QuerySet: - """Gets all available themes with id and name fields. + """Gets all available sub_themes with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: @@ -43,15 +43,17 @@ def get_all_names_and_ids(self) -> models.QuerySet: """ return self.all().values("id", "name").distinct() - def get_filtered_unique_names_related_to_theme(self, parent_theme_id) -> models.QuerySet: - """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. + def get_filtered_unique_names_related_to_theme( + self, parent_theme_id + ) -> models.QuerySet: + """Gets all available unique sub_themes with id and name fields that are related to the parent theme ID. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: `` """ - return self.filter(theme_id=parent_theme_id).values('id', 'name').distinct() + return self.filter(theme_id=parent_theme_id).values("id", "name").distinct() class SubThemeManager(models.Manager): @@ -82,22 +84,26 @@ def get_all_unique_names(self) -> SubThemeQuerySet: """ return self.get_queryset().get_all_unique_names() - def get_filtered_unique_names_related_to_theme(self, parent_theme_id: str) -> SubThemeQuerySet: - """Gets all available themes with id and name fields. + def get_filtered_unique_names_related_to_theme( + self, parent_theme_id: str + ) -> SubThemeQuerySet: + """Gets all available sub_themes with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: `` """ - return self.get_queryset().get_filtered_unique_names_related_to_theme(parent_theme_id=parent_theme_id) + return self.get_queryset().get_filtered_unique_names_related_to_theme( + parent_theme_id=parent_theme_id + ) def get_all_names_and_ids(self) -> SubThemeQuerySet: - """Gets all available themes with id and name fields. + """Gets all available sub_themes with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ - return self .get_queryset().get_all_names_and_ids() + return self.get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/theme.py b/metrics/data/managers/core_models/theme.py index ec9ac54d6..7980a5076 100644 --- a/metrics/data/managers/core_models/theme.py +++ b/metrics/data/managers/core_models/theme.py @@ -57,4 +57,4 @@ def get_all_names_and_ids(self) -> ThemeQuerySet: Examples: `` """ - return self .get_queryset().get_all_names_and_ids() + return self.get_queryset().get_all_names_and_ids() diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index 0988625af..26633dc5f 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -45,23 +45,29 @@ def get_by_name(self, name: str) -> "Topic": """ return self.get(name=name) - def get_filtered_unique_names_related_to_sub_theme(self, parent_sub_theme_id) -> models.QuerySet: - """Gets all available unique sub themes with id and name fields that are related to the parent theme ID. + def get_filtered_unique_names_related_to_sub_theme( + self, parent_sub_theme_id + ) -> models.QuerySet: + """Gets all available topics with id and name fields that are related to the parent sub_theme ID. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ - return self.filter(sub_theme_id=parent_sub_theme_id).values('id', 'name').distinct() + return ( + self.filter(sub_theme_id=parent_sub_theme_id) + .values("id", "name") + .distinct() + ) def get_all_names_and_ids(self) -> models.QuerySet: - """Gets all available themes with id and name fields. + """Gets all available topics with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ return self.all().values("id", "name").distinct() @@ -114,15 +120,19 @@ def get_by_name(self, name: str) -> "Topic": """ return self.get_queryset().get_by_name(name=name) - def get_filtered_unique_names_related_to_sub_theme(self, parent_sub_theme_id: str) -> TopicQuerySet: - """Gets all available themes with id and name fields. + def get_filtered_unique_names_related_to_sub_theme( + self, parent_sub_theme_id: str + ) -> TopicQuerySet: + """Gets all available topics with id and name fields. Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ - return self.get_queryset().get_filtered_unique_names_related_to_sub_theme(parent_sub_theme_id=parent_sub_theme_id) + return self.get_queryset().get_filtered_unique_names_related_to_sub_theme( + parent_sub_theme_id=parent_sub_theme_id + ) def get_all_names_and_ids(self) -> TopicQuerySet: """Gets all available themes with id and name fields. @@ -130,6 +140,6 @@ def get_all_names_and_ids(self) -> TopicQuerySet: Returns: QuerySet: A queryset containing dictionaries with id and name: Examples: - `` + `` """ - return self .get_queryset().get_all_names_and_ids() + return self.get_queryset().get_all_names_and_ids() diff --git a/tests/factories/metrics/metric.py b/tests/factories/metrics/metric.py index acce07bb2..c5ee56d15 100644 --- a/tests/factories/metrics/metric.py +++ b/tests/factories/metrics/metric.py @@ -1,6 +1,6 @@ import factory -from metrics.data.models.core_models import Metric +from metrics.data.models.core_models import Metric, Topic class MetricFactory(factory.django.DjangoModelFactory): @@ -10,3 +10,12 @@ class MetricFactory(factory.django.DjangoModelFactory): class Meta: model = Metric + + @classmethod + def create_with_topic(cls, name: str, topic: str): + topic, _ = Topic.objects.get_or_create(name=topic) + + return cls.create( + name=name, + topic=topic, + ) diff --git a/tests/factories/metrics/sub_theme.py b/tests/factories/metrics/sub_theme.py index e965e0eda..3a2268af0 100644 --- a/tests/factories/metrics/sub_theme.py +++ b/tests/factories/metrics/sub_theme.py @@ -1,6 +1,6 @@ import factory -from metrics.data.models.core_models import SubTheme +from metrics.data.models.core_models import SubTheme, Theme class SubThemeFactory(factory.django.DjangoModelFactory): @@ -10,3 +10,12 @@ class SubThemeFactory(factory.django.DjangoModelFactory): class Meta: model = SubTheme + + @classmethod + def create_with_theme(cls, name: str, theme: str): + theme, _ = Theme.objects.get_or_create(name=theme) + + return cls.create( + name=name, + theme=theme, + ) diff --git a/tests/factories/metrics/theme.py b/tests/factories/metrics/theme.py new file mode 100644 index 000000000..c9ad717d9 --- /dev/null +++ b/tests/factories/metrics/theme.py @@ -0,0 +1,12 @@ +import factory + +from metrics.data.models.core_models import Theme, SubTheme + + +class ThemeFactory(factory.django.DjangoModelFactory): + """ + Factory for creating `Theme` instances for tests + """ + + class Meta: + model = Theme diff --git a/tests/factories/metrics/topic.py b/tests/factories/metrics/topic.py index 0f43b49bb..f72fb941d 100644 --- a/tests/factories/metrics/topic.py +++ b/tests/factories/metrics/topic.py @@ -1,6 +1,6 @@ import factory -from metrics.data.models.core_models import Topic +from metrics.data.models.core_models import Topic, SubTheme class TopicFactory(factory.django.DjangoModelFactory): @@ -10,3 +10,12 @@ class TopicFactory(factory.django.DjangoModelFactory): class Meta: model = Topic + + @classmethod + def create_with_sub_theme(cls, name: str, sub_theme: str): + sub_theme, _ = SubTheme.objects.get_or_create(name=sub_theme) + + return cls.create( + name=name, + sub_theme=sub_theme, + ) diff --git a/tests/integration/metrics/api/views/test_geographies.py b/tests/integration/metrics/api/views/test_geographies.py index cfceaa65b..c04a19144 100644 --- a/tests/integration/metrics/api/views/test_geographies.py +++ b/tests/integration/metrics/api/views/test_geographies.py @@ -265,3 +265,106 @@ def test_get_returns_correct_results_for_geography_type(self): assert result["geographies"][2]["geography_code"] == hackney.geography_code assert len(result["geographies"]) == 3 + + +class TestGeographiesByGeographyTypeView: + @property + def path(self) -> str: + return "/api/permission-set/geographies" + + @pytest.mark.django_db + def test_get_geographies_by_geography_type_id_should_return_geographies(self): + + client = APIClient() + ltla = "Lower Tier Local Authority" + + bexley = GeographyFactory.create_with_geography_type( + name="Bexley", geography_code="E09000004", geography_type=ltla + ) + arun = GeographyFactory.create_with_geography_type( + name="Arun", geography_code="E07000224", geography_type=ltla + ) + hackney = GeographyFactory.create_with_geography_type( + name="Hackney", geography_code="E09000012", geography_type=ltla + ) + GeographyFactory.create_with_geography_type( + name="England", geography_code="E92000001", geography_type="Nation" + ) + + geographyTypeId = 1 + path = f"{self.path}/{geographyTypeId}" + response: Response = client.get(path=path) + result = response.data + assert len(response.data["choices"]) == 3 + assert result["choices"][0][0] == arun.geography_code + assert result["choices"][0][1] == arun.name + + assert result["choices"][1][0] == bexley.geography_code + assert result["choices"][1][1] == bexley.name + + assert result["choices"][2][0] == hackney.geography_code + assert result["choices"][2][1] == hackney.name + + @pytest.mark.django_db + def test_get_geographies_by_geography_type_id_should_return_wildcard(self): + + client = APIClient() + ltla = "Lower Tier Local Authority" + + bexley = GeographyFactory.create_with_geography_type( + name="Bexley", geography_code="E09000004", geography_type=ltla + ) + arun = GeographyFactory.create_with_geography_type( + name="Arun", geography_code="E07000224", geography_type=ltla + ) + hackney = GeographyFactory.create_with_geography_type( + name="Hackney", geography_code="E09000012", geography_type=ltla + ) + GeographyFactory.create_with_geography_type( + name="England", geography_code="E92000001", geography_type="Nation" + ) + + geographyTypeId = -1 + path = f"{self.path}/{geographyTypeId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == "-1" + assert result["choices"][0][1] == "* (All geographies)" + + @pytest.mark.django_db + def test_get_geographies_by_geography_type_id_should_return_an_error(self): + + client = APIClient() + ltla = "Lower Tier Local Authority" + + bexley = GeographyFactory.create_with_geography_type( + name="Bexley", geography_code="E09000004", geography_type=ltla + ) + arun = GeographyFactory.create_with_geography_type( + name="Arun", geography_code="E07000224", geography_type=ltla + ) + hackney = GeographyFactory.create_with_geography_type( + name="Hackney", geography_code="E09000012", geography_type=ltla + ) + GeographyFactory.create_with_geography_type( + name="England", geography_code="E92000001", geography_type="Nation" + ) + + geographyTypeId = "string" + path = f"{self.path}/{geographyTypeId}" + response: Response = client.get(path=path) + result = response.data + + assert response.status_code == HTTPStatus.BAD_REQUEST + + # data should contain error + + assert ( + str(result["geography_type_id"][0]) + == "Geography Type must be a number or '-1'" + ) diff --git a/tests/integration/metrics/api/views/test_permission_sets.py b/tests/integration/metrics/api/views/test_permission_sets.py new file mode 100644 index 000000000..4553b5c3f --- /dev/null +++ b/tests/integration/metrics/api/views/test_permission_sets.py @@ -0,0 +1,235 @@ +from http import HTTPStatus + +import pytest +from rest_framework.response import Response +from rest_framework.test import APIClient + +from tests.factories.metrics.metric import MetricFactory +from tests.factories.metrics.sub_theme import SubThemeFactory +from tests.factories.metrics.topic import TopicFactory + + +class TestSubThemeByThemeView: + @property + def path(self) -> str: + return "/api/permission-set/subthemes" + + @pytest.mark.django_db + def test_get_sub_themes_by_theme_id_should_return_tuple_of_id_and_name(self): + + client = APIClient() + + # create subthemes + respiratorySubTheme = SubThemeFactory.create_with_theme( + name="respiratory", theme="infectious_disease" + ) + childhoodVaccinesSubtheme = SubThemeFactory.create_with_theme( + name="immunisation", theme="childhood_vaccines" + ) + + # Retrieve the subthemes + themeId = 1 + path = f"{self.path}/{themeId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == str(respiratorySubTheme.id) + assert result["choices"][0][1] == respiratorySubTheme.name + + @pytest.mark.django_db + def test_get_sub_themes_by_theme_id_should_return_wildcard(self): + + client = APIClient() + + # create subthemes + respiratorySubtheme = SubThemeFactory.create_with_theme( + name="respiratory", theme="infectious_disease" + ) + childhoodVaccinesSubtheme = SubThemeFactory.create_with_theme( + name="immunisation", theme="childhood_vaccines" + ) + + # Retrieve the subthemes + themeId = -1 + path = f"{self.path}/{themeId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == "-1" + assert result["choices"][0][1] == "* (All sub-themes)" + + @pytest.mark.django_db + def test_get_sub_themes_by_theme_id_should_return_an_error(self): + + client = APIClient() + + # create subthemes + respiratorySubtheme = SubThemeFactory.create_with_theme( + name="respiratory", theme="infectious_disease" + ) + childhoodVaccinesSubtheme = SubThemeFactory.create_with_theme( + name="immunisation", theme="childhood_vaccines" + ) + + # Retrieve the subthemes + themeId = "string" + path = f"{self.path}/{themeId}" + response: Response = client.get(path=path) + result = response.data + + assert response.status_code == HTTPStatus.BAD_REQUEST + + # data should contain error + assert str(result["theme_id"][0]) == "theme_id must be a number or '-1'" + + +class TestTopicBySubThemeView: + @property + def path(self) -> str: + return "/api/permission-set/topics" + + @pytest.mark.django_db + def test_get_topics_by_sub_theme_id_should_return_tuple_of_id_and_name(self): + + client = APIClient() + + # create subthemes + covid19Topic = TopicFactory.create_with_sub_theme( + name="COVID-19", sub_theme="respiratory" + ) + + # Retrieve the topics + subThemeId = 1 + path = f"{self.path}/{subThemeId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == str(covid19Topic.id) + assert result["choices"][0][1] == covid19Topic.name + + @pytest.mark.django_db + def test_get_topics_by_sub_theme_id_should_return_wildcard(self): + + client = APIClient() + + covid19Topic = TopicFactory.create_with_sub_theme( + name="COVID-19", sub_theme="respiratory" + ) + + # Retrieve the subthemes + themeId = -1 + path = f"{self.path}/{themeId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == "-1" + assert result["choices"][0][1] == "* (All topics)" + + @pytest.mark.django_db + def test_get_topics_by_sub_theme_id_should_return_an_error(self): + + client = APIClient() + + # create subthemes + covid19Topic = TopicFactory.create_with_sub_theme( + name="COVID-19", sub_theme="respiratory" + ) + + # Retrieve the subthemes + themeId = "string" + path = f"{self.path}/{themeId}" + response: Response = client.get(path=path) + result = response.data + + assert response.status_code == HTTPStatus.BAD_REQUEST + + # data should contain error + assert str(result["sub_theme_id"][0]) == "sub_theme_id must be a number or '-1'" + + +class TestMetricByTopicView: + @property + def path(self) -> str: + return "/api/permission-set/metrics" + + @pytest.mark.django_db + def test_get_metric_by_topic_id_should_return_tuple_of_id_and_name(self): + + client = APIClient() + + # create subthemes + covid19metric = MetricFactory.create_with_topic( + name="COVID-19_cases_rateRollingMean", topic="COVID-19" + ) + + # Retrieve the topics + topicId = 1 + path = f"{self.path}/{topicId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == str(covid19metric.id) + assert result["choices"][0][1] == covid19metric.name + + @pytest.mark.django_db + def test_get_metric_by_topic_id_should_return_wildcard(self): + + client = APIClient() + + covid19metric = MetricFactory.create_with_topic( + name="COVID-19_cases_rateRollingMean", topic="COVID-19" + ) + + # Retrieve the subthemes + topicId = -1 + path = f"{self.path}/{topicId}" + response: Response = client.get(path=path) + result = response.data + + # Choices length should only contain wildcard option + assert len(response.data["choices"]) == 1 + + # Should return a wildcard choice + assert result["choices"][0][0] == "-1" + assert result["choices"][0][1] == "* (All metrics)" + + @pytest.mark.django_db + def test_get_metric_by_topic_id_should_return_an_error(self): + + client = APIClient() + + # create subthemes + covid19metric = MetricFactory.create_with_topic( + name="COVID-19_cases_rateRollingMean", topic="COVID-19" + ) + + # Retrieve the subthemes + topicId = "string" + path = f"{self.path}/{topicId}" + response: Response = client.get(path=path) + result = response.data + + assert response.status_code == HTTPStatus.BAD_REQUEST + + # data should contain error + assert str(result["topic_id"][0]) == "topic_id must be a number or '-1'" 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 26f94727b..10fcc82f6 100644 --- a/tests/integration/metrics/data/managers/core_models/test_geography.py +++ b/tests/integration/metrics/data/managers/core_models/test_geography.py @@ -1,18 +1,16 @@ import pytest -from metrics.data.models.core_models.supporting import Geography +from metrics.data.models.core_models.supporting import GeographyType from tests.factories.metrics.geography_type import GeographyTypeFactory class TestGeographyManager: @pytest.mark.django_db - def test_query_for_all_geography_names_by_geography_type( - self, - ): + def test_query_for_get_all_names_and_ids(self): """ - Given a number of existing `Geography` records - When `get_all_geography_names_by_geography_type` is called - Then the geographies have bee filtered correctly + Given a number of existing `GeographyType` records + When `get_all_names_and_ids` is called + Then the geography types with their IDs and names are returned correctly """ # Given fake_geography_type_name_one = "Nation" @@ -20,24 +18,33 @@ def test_query_for_all_geography_names_by_geography_type( fake_geography_name_one = "England" fake_geography_name_two = "North west" - GeographyTypeFactory( + geography_type_one = GeographyTypeFactory( name=fake_geography_type_name_one, with_geographies=[fake_geography_name_one], ) - GeographyTypeFactory( + geography_type_two = GeographyTypeFactory( name=fake_geography_type_name_two, with_geographies=[fake_geography_name_two], ) # When - all_geography_names = Geography.objects.all() - all_geography_names_by_type = ( - Geography.objects.get_all_geography_names_by_geography_type( - geography_type_name=fake_geography_type_name_one - ) - ) + all_geography_type_names_and_ids = GeographyType.objects.get_all_names_and_ids() # Then - assert all_geography_names.count() == 2 - assert all_geography_names_by_type.count() == 1 - assert all_geography_names_by_type.first() == fake_geography_name_one + assert all_geography_type_names_and_ids.count() == 2 + + # Access the dictionary returned by .first() + first_result = all_geography_type_names_and_ids.first() + assert first_result["id"] == geography_type_one.id + assert first_result["name"] == fake_geography_type_name_one + + # Verify both records are present with correct structure + result_list = list(all_geography_type_names_and_ids) + assert result_list[0] == { + "id": geography_type_one.id, + "name": fake_geography_type_name_one, + } + assert result_list[1] == { + "id": geography_type_two.id, + "name": fake_geography_type_name_two, + } 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 new file mode 100644 index 000000000..26f94727b --- /dev/null +++ b/tests/integration/metrics/data/managers/core_models/test_geography_types.py @@ -0,0 +1,43 @@ +import pytest + +from metrics.data.models.core_models.supporting import Geography +from tests.factories.metrics.geography_type import GeographyTypeFactory + + +class TestGeographyManager: + @pytest.mark.django_db + def test_query_for_all_geography_names_by_geography_type( + self, + ): + """ + Given a number of existing `Geography` records + When `get_all_geography_names_by_geography_type` is called + Then the geographies have bee filtered correctly + """ + # Given + fake_geography_type_name_one = "Nation" + fake_geography_type_name_two = "Region" + fake_geography_name_one = "England" + fake_geography_name_two = "North west" + + GeographyTypeFactory( + name=fake_geography_type_name_one, + with_geographies=[fake_geography_name_one], + ) + GeographyTypeFactory( + name=fake_geography_type_name_two, + with_geographies=[fake_geography_name_two], + ) + + # When + all_geography_names = Geography.objects.all() + all_geography_names_by_type = ( + Geography.objects.get_all_geography_names_by_geography_type( + geography_type_name=fake_geography_type_name_one + ) + ) + + # Then + assert all_geography_names.count() == 2 + assert all_geography_names_by_type.count() == 1 + assert all_geography_names_by_type.first() == fake_geography_name_one 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 4c1971a84..abd18601c 100644 --- a/tests/integration/metrics/data/managers/core_models/test_metric.py +++ b/tests/integration/metrics/data/managers/core_models/test_metric.py @@ -59,3 +59,33 @@ def test_get_all_timeseries_metric_names(self): assert timeseries_metric_name in all_timeseries_metric_names.values_list( "name", flat=True ) + + @pytest.mark.django_db + def test_get_all_names_and_ids(self): + """ + Given a number of existing `Metric` records + When `get_all_timeseries_metric_names()` is called + from the `MetricManager` + Then the metrics returned have been filtered correctly + """ + # Given + timeseries_metric_name = "COVID-19_deaths_ONSByWeek" + timeseries_metric_group = MetricGroup.objects.create(name="deaths") + Metric.objects.create( + name=timeseries_metric_name, + metric_group=timeseries_metric_group, + ) + headline_metric_group = MetricGroup.objects.create(name="headline") + Metric.objects.create( + name="COVID-19_headline_ONSdeaths_7DayChange", + metric_group=headline_metric_group, + ) + + # When + all_metric_names_and_ids = Metric.objects.get_all_names_and_ids() + + # Then + assert all_metric_names_and_ids.count() == 2 + assert timeseries_metric_name in all_metric_names_and_ids.values_list( + "name", flat=True + ) diff --git a/tests/integration/metrics/data/managers/core_models/test_sub_theme.py b/tests/integration/metrics/data/managers/core_models/test_sub_theme.py index 4a4276ef0..0699f79e7 100644 --- a/tests/integration/metrics/data/managers/core_models/test_sub_theme.py +++ b/tests/integration/metrics/data/managers/core_models/test_sub_theme.py @@ -28,3 +28,25 @@ def test_query_for_unique_names(self): # Then assert all_sub_theme_names.count() == 3 assert all_unique_sub_theme_names.count() == 2 + + @pytest.mark.django_db + def test_query_for_get_all_names_and_ids(self): + """ + Given a number of existing `SubTheme` records + When `get_all_unique_names`is called + Then a unique set of `SubTheme` records is returned + """ + # Given + fake_sub_theme_name_one = "respiratory" + fake_sub_theme_name_two = "weather_alert" + fake_sub_theme_name_three = "infectious_disease" + + SubThemeFactory(name=fake_sub_theme_name_one) + SubThemeFactory(name=fake_sub_theme_name_two) + SubThemeFactory(name=fake_sub_theme_name_three) + + # When + all_sub_theme_names_and_ids = SubTheme.objects.get_all_names_and_ids() + + # Then + assert all_sub_theme_names_and_ids.count() == 3 diff --git a/tests/integration/metrics/data/managers/core_models/test_theme.py b/tests/integration/metrics/data/managers/core_models/test_theme.py new file mode 100644 index 000000000..3f691bce1 --- /dev/null +++ b/tests/integration/metrics/data/managers/core_models/test_theme.py @@ -0,0 +1,28 @@ +import pytest + +from metrics.data.models.core_models.supporting import Theme +from tests.factories.metrics.theme import ThemeFactory + + +class TestThemeManager: + @pytest.mark.django_db + def test_query_get_all_names_and_ids(self): + """ + Given a number of existing `Topic` records + When `get_all_names_and_ids` is called + Then a unique set of `Topic` records is returned. + """ + # Given + fake_theme_name_one = "respiratory" + fake_theme_name_two = "infectious_disease" + fake_theme_name_three = "immunisation" + + ThemeFactory(name=fake_theme_name_one) + ThemeFactory(name=fake_theme_name_two) + ThemeFactory(name=fake_theme_name_three) + + # When + get_all_names_and_ids = Theme.objects.get_all_names_and_ids() + + # Then + assert get_all_names_and_ids.count() == 3 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 241200117..9cffde122 100644 --- a/tests/integration/metrics/data/managers/core_models/test_topic.py +++ b/tests/integration/metrics/data/managers/core_models/test_topic.py @@ -28,3 +28,25 @@ def test_query_for_unique_names(self): # Then assert all_topic_names.count() == 3 assert all_unique_topic_names.count() == 2 + + @pytest.mark.django_db + def test_query_get_all_names_and_ids(self): + """ + Given a number of existing `Topic` records + When `get_all_names_and_ids` is called + Then a unique set of `Topic` records is returned. + """ + # Given + fake_topic_name_one = "COVID-19" + fake_topic_name_two = "Cold-alert" + fake_topic_name_three = "Influenza" + + TopicFactory(name=fake_topic_name_one) + TopicFactory(name=fake_topic_name_two) + TopicFactory(name=fake_topic_name_three) + + # When + get_all_names_and_ids = Topic.objects.get_all_names_and_ids() + + # Then + assert get_all_names_and_ids.count() == 3 diff --git a/tests/integration/metrics/data/managers/test_geography.py b/tests/integration/metrics/data/managers/test_geography.py new file mode 100644 index 000000000..26f94727b --- /dev/null +++ b/tests/integration/metrics/data/managers/test_geography.py @@ -0,0 +1,43 @@ +import pytest + +from metrics.data.models.core_models.supporting import Geography +from tests.factories.metrics.geography_type import GeographyTypeFactory + + +class TestGeographyManager: + @pytest.mark.django_db + def test_query_for_all_geography_names_by_geography_type( + self, + ): + """ + Given a number of existing `Geography` records + When `get_all_geography_names_by_geography_type` is called + Then the geographies have bee filtered correctly + """ + # Given + fake_geography_type_name_one = "Nation" + fake_geography_type_name_two = "Region" + fake_geography_name_one = "England" + fake_geography_name_two = "North west" + + GeographyTypeFactory( + name=fake_geography_type_name_one, + with_geographies=[fake_geography_name_one], + ) + GeographyTypeFactory( + name=fake_geography_type_name_two, + with_geographies=[fake_geography_name_two], + ) + + # When + all_geography_names = Geography.objects.all() + all_geography_names_by_type = ( + Geography.objects.get_all_geography_names_by_geography_type( + geography_type_name=fake_geography_type_name_one + ) + ) + + # Then + assert all_geography_names.count() == 2 + assert all_geography_names_by_type.count() == 1 + assert all_geography_names_by_type.first() == fake_geography_name_one diff --git a/tests/unit/cms/metrics_interface/test_field_choices_callables.py b/tests/unit/cms/metrics_interface/test_field_choices_callables.py index 9bd55d59d..0377b43a1 100644 --- a/tests/unit/cms/metrics_interface/test_field_choices_callables.py +++ b/tests/unit/cms/metrics_interface/test_field_choices_callables.py @@ -495,6 +495,67 @@ def test_delegates_call_correctly(self, mocked_get_all_theme_names: mock.MagicMo assert all_theme_names == [(x, x) for x in retrieved_theme_names] +class TestGetAllThemeNamesAndIds: + @mock.patch.object(interface.MetricsAPIInterface, "get_all_theme_names_and_ids") + def test_delegates_call_correctly( + self, mocked_get_all_theme_names_and_ids: mock.MagicMock + ): + """ + Given an instance of the `MetricsAPIInterface` which returns theme names + When `get_all_theme_names()` is called + Then the theme names are returned as a list of 2-item tuples + """ + # Given + retrieved_theme_names = [ + {"id": 3, "name": "extreme_event"}, + {"id": 1, "name": "immunisation"}, + {"id": 2, "name": "infectious_disease"}, + {"id": 4, "name": "non-communicable"}, + ] + mocked_get_all_theme_names_and_ids.return_value = retrieved_theme_names + + # When + all_theme_names_and_ids = field_choices_callables.get_all_theme_names_and_ids() + + # Then + assert all_theme_names_and_ids == [ + (str(x["id"]), x["name"]) for x in retrieved_theme_names + ] + + +class TestGetAllGeographyTypeNamesAndIds: + @mock.patch.object( + interface.MetricsAPIInterface, "get_all_geography_type_names_and_ids" + ) + def test_delegates_call_correctly( + self, mocked_get_all_geography_type_names_and_ids: mock.MagicMock + ): + """ + Given an instance of the `MetricsAPIInterface` which returns theme names + When `get_all_theme_names()` is called + Then the theme names are returned as a list of 2-item tuples + """ + # Given + retrieved_geography_type_names_and_ids = [ + {"id": 1, "name": "Region"}, + {"id": 2, "name": "Nation"}, + {"id": 3, "name": "Upper Tier Local Authority"}, + ] + mocked_get_all_geography_type_names_and_ids.return_value = ( + retrieved_geography_type_names_and_ids + ) + + # When + all_geography_type_names_and_ids = ( + field_choices_callables.get_all_geography_type_names_and_ids() + ) + + # Then + assert all_geography_type_names_and_ids == [ + (str(x["id"]), x["name"]) for x in retrieved_geography_type_names_and_ids + ] + + class TestGetAllSubThemeNames: @mock.patch.object(interface.MetricsAPIInterface, "get_all_sub_theme_names") def test_delegates_call_correctly( @@ -519,6 +580,36 @@ def test_delegates_call_correctly( assert all_sub_theme_names == [(x, x) for x in retrieved_sub_theme_names] +class TestGetAllSubThemeNamesAndIds: + @mock.patch.object(interface.MetricsAPIInterface, "get_all_sub_theme_names_and_ids") + def test_delegates_call_correctly( + self, mocked_get_all_sub_theme_names_and_ids: mock.MagicMock + ): + """ + Given an instance of the `MetricsAPIInterface` which returns theme names + When `get_all_theme_names()` is called + Then the theme names are returned as a list of 2-item tuples + """ + # Given + retrieved_sub_theme_names = [ + {"id": 3, "name": "extreme_event"}, + {"id": 1, "name": "immunisation"}, + {"id": 2, "name": "infectious_disease"}, + {"id": 4, "name": "non-communicable"}, + ] + mocked_get_all_sub_theme_names_and_ids.return_value = retrieved_sub_theme_names + + # When + all_sub_theme_names_and_ids = ( + field_choices_callables.get_all_sub_theme_names_and_ids() + ) + + # Then + assert all_sub_theme_names_and_ids == [ + (str(x["id"]), x["name"]) for x in retrieved_sub_theme_names + ] + + class TestGetAllUniqueSubThemeNames: @mock.patch.object(interface.MetricsAPIInterface, "get_all_unique_sub_theme_names") def test_delegates_call_correctly( @@ -549,6 +640,67 @@ def test_delegates_call_correctly( ] +class TestGetAllTopicNamesAndIds: + @mock.patch.object(interface.MetricsAPIInterface, "get_all_topic_names_and_ids") + def test_delegates_call_correctly( + self, mocked_get_all_topic_names_and_ids: mock.MagicMock + ): + """f + Given an instance of the `MetricsAPIInterface` which returns unique sub theme names + When `get_all_topic_names_and_ids()` is called + Then the topic names and ids are returned as a list of 2-item tuples + """ + # Given + retrieved_topic_names_and_ids = [ + {"id": 1, "name": "6-in-1"}, + {"id": 2, "name": "MMR1"}, + {"id": 3, "name": "COVID-19"}, + ] + mocked_get_all_topic_names_and_ids.return_value = retrieved_topic_names_and_ids + + # When + all_topic_names_and_ids = field_choices_callables.get_all_topic_names_and_ids() + + # Then + assert all_topic_names_and_ids == [ + (str(x["id"]), x["name"]) for x in retrieved_topic_names_and_ids + ] + + +class TestGetAllMetricNamesAndIds: + @mock.patch.object(interface.MetricsAPIInterface, "get_all_metric_names_and_ids") + def test_delegates_call_correctly( + self, mocked_get_all_metric_names_and_ids: mock.MagicMock + ): + """f + Given an instance of the `MetricsAPIInterface` which metric names and ids + When `get_all_metric_names_and_ids()` is called + Then the metric names and ids are returned as a list of 2-item tuples + """ + # Given + retrieved_metric_names_and_ids = [ + {"id": 1, "name": "6-in-1_coverage_coverageByYear"}, + { + "id": 58, + "name": "COVID-19-like_syndromic_emergencyDepartment_countsByDay", + }, + {"id": 46, "name": "COVID-19_cases_casesByDay"}, + ] + mocked_get_all_metric_names_and_ids.return_value = ( + retrieved_metric_names_and_ids + ) + + # When + all_metric_names_and_ids = ( + field_choices_callables.get_all_metric_names_and_ids() + ) + + # Then + assert all_metric_names_and_ids == [ + (str(x["id"]), x["name"]) for x in retrieved_metric_names_and_ids + ] + + class TestSimplifiedChartTypes: @mock.patch.object(interface.MetricsAPIInterface, "get_simplified_chart_types") def test_delegates_call_correctly( diff --git a/tests/unit/cms/metrics_interface/test_interface.py b/tests/unit/cms/metrics_interface/test_interface.py index e7f2b95fb..c582fe4bf 100644 --- a/tests/unit/cms/metrics_interface/test_interface.py +++ b/tests/unit/cms/metrics_interface/test_interface.py @@ -519,3 +519,107 @@ def test_get_geography_code_for_geography_delegates_call_correctly( spy_geography_manager.get_geography_code_for_geography.assert_called_once_with( geography=mock_geography, geography_type=mock_geography_type ) + + def test_get_all_theme_names_and_ids_delegates_call_correctly(self): + """ + Given a `ThemeManager` from the Metrics API app + When `get_all_names_and_ids()` is called from that object + Then the call is delegated to the correct method on the `ThemeManager` + """ + # Given + spy_theme_manager = mock.Mock() + metrics_api_interface = interface.MetricsAPIInterface( + theme_manager=spy_theme_manager, + metric_manager=mock.MagicMock(), + ) + + # When + all_theme_names_and_ids = metrics_api_interface.get_all_theme_names_and_ids() + + # Then + assert all_theme_names_and_ids == spy_theme_manager.get_all_names_and_ids() + + def test_get_all_sub_theme_names_and_ids_delegates_call_correctly(self): + """ + Given a `SubThemeManager` from the Metrics API app + When `get_all_names_and_ids()` is called from that object + Then the call is delegated to the correct method on the `SubThemeManager` + """ + # Given + spy_sub_theme_manager = mock.Mock() + metrics_api_interface = interface.MetricsAPIInterface( + sub_theme_manager=spy_sub_theme_manager, + metric_manager=mock.MagicMock(), + ) + + # When + all_sub_theme_names_and_ids = ( + metrics_api_interface.get_all_sub_theme_names_and_ids() + ) + + # Then + assert ( + all_sub_theme_names_and_ids == spy_sub_theme_manager.get_all_names_and_ids() + ) + + def test_get_all_topic_names_and_ids_delegates_call_correctly(self): + """ + Given a `SubThemeManager` from the Metrics API app + When `get_all_names_and_ids()` is called from that object + Then the call is delegated to the correct method on the `SubThemeManager` + """ + # Given + spy_topic_manager = mock.Mock() + metrics_api_interface = interface.MetricsAPIInterface( + topic_manager=spy_topic_manager, + metric_manager=mock.MagicMock(), + ) + + # When + all_topic_names_and_ids = metrics_api_interface.get_all_topic_names_and_ids() + + # Then + assert all_topic_names_and_ids == spy_topic_manager.get_all_names_and_ids() + + def test_get_all_metric_names_and_ids_delegates_call_correctly(self): + """ + Given a `SubThemeManager` from the Metrics API app + When `get_all_names_and_ids()` is called from that object + Then the call is delegated to the correct method on the `SubThemeManager` + """ + # Given + spy_metric_manager = mock.Mock() + metrics_api_interface = interface.MetricsAPIInterface( + metric_manager=spy_metric_manager, + ) + + # When + all_metric_names_and_ids = metrics_api_interface.get_all_metric_names_and_ids() + + # Then + assert all_metric_names_and_ids == spy_metric_manager.get_all_names_and_ids() + + def test_get_all_geography_type_names_and_ids_delegates_call_correctly( + self, + ): + """ + Given a `GeographyTypeManager` from the Metrics API app + When `get_all_geography_type_names_and_ids()` is called from an instance of the `MetricsAPIInterface` + Then the call is delegated to the correct method on the `GeographyTypeManager` + """ + # Given + spy_geography_type_manager = mock.Mock() + metrics_api_interface = interface.MetricsAPIInterface( + geography_type_manager=spy_geography_type_manager, + ) + + # When + all_geography_type_names = ( + metrics_api_interface.get_all_geography_type_names_and_ids() + ) + + # Then + assert ( + all_geography_type_names + == spy_geography_type_manager.get_all_names_and_ids() + ) diff --git a/tests/unit/metrics/api/serializers/test_geographies.py b/tests/unit/metrics/api/serializers/test_geographies.py index 246723971..acc6a1648 100644 --- a/tests/unit/metrics/api/serializers/test_geographies.py +++ b/tests/unit/metrics/api/serializers/test_geographies.py @@ -4,11 +4,14 @@ from rest_framework.exceptions import ValidationError +from metrics.data.models.core_models.supporting import Geography from validation.geography_code import UNITED_KINGDOM_GEOGRAPHY_CODE from metrics.api.serializers.geographies import ( GeographiesForTopicSerializer, _serialize_queryset, GeographiesRequestSerializer, + GeographyByGeographyTypeRequestSerializer, + _queryset_to_geography_code_name_tuples, ) from tests.fakes.factories.metrics.core_time_series_factory import ( FakeCoreTimeSeriesFactory, @@ -278,3 +281,365 @@ def test_raises_error_when_multiple_fields_are_provided(self): error.value.detail["non_field_errors"][0] == "Only one of 'topic' or 'geography_type' should be provided, not both." ) + + +class TestGeographyByGeographyTypeRequestSerializer: + """Tests for GeographyByGeographyTypeRequestSerializer""" + + def test_validates_wildcard_geography_type_id(self): + """ + Given a wildcard geography_type_id value of "-1" + When the value is validated + Then the wildcard is accepted + """ + # Given + data = {"geography_type_id": "-1"} + + # When + serializer = GeographyByGeographyTypeRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["geography_type_id"] == "-1" + + def test_validates_numeric_geography_type_id(self): + """ + Given a valid numeric geography_type_id + When the value is validated + Then the numeric value is converted to an integer + """ + # Given + data = {"geography_type_id": "3"} + + # When + serializer = GeographyByGeographyTypeRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["geography_type_id"] == 3 + + def test_rejects_invalid_geography_type_id(self): + """ + Given an invalid geography_type_id (not a number or wildcard) + When the value is validated + Then a ValidationError is raised + """ + # Given + data = {"geography_type_id": "invalid_value"} + + # When + serializer = GeographyByGeographyTypeRequestSerializer(data=data) + + # Then + assert not serializer.is_valid() + assert "geography_type_id" in serializer.errors + assert "Geography Type must be a number or '-1'" in str( + serializer.errors["geography_type_id"] + ) + + def test_validation_error_has_chained_exception(self): + """ + Given an invalid geography_type_id + When validation fails + Then the ValidationError is chained from the original ValueError + """ + # Given + data = {"geography_type_id": "not_a_number"} + + # When + serializer = GeographyByGeographyTypeRequestSerializer(data=data) + + # Then + with pytest.raises(ValidationError) as error: + serializer.is_valid(raise_exception=True) + + assert ( + error.value.detail["geography_type_id"][0] + == "Geography Type must be a number or '-1'" + ) + + def test_data_returns_wildcard_response_for_wildcard_geography_type_id(self): + """ + Given a wildcard geography_type_id of "-1" + When data() is called + Then a wildcard response is returned + """ + # Given + data = {"geography_type_id": "-1"} + serializer = GeographyByGeographyTypeRequestSerializer(data=data) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + assert response == {"choices": [["-1", "* (All geographies)"]]} + + def test_data_fetches_geographies_for_valid_geography_type_id(self): + """ + Given a valid numeric geography_type_id + When data() is called + Then geographies are fetched from the manager and formatted correctly + """ + # Given + mock_manager = mock.MagicMock() + mock_queryset = [ + {"geography_code": "E92000001", "name": "England"}, + {"geography_code": "E12000001", "name": "North East"}, + ] + mock_manager.get_geography_codes_and_names_by_geography_type_id.return_value = ( + mock_queryset + ) + + data = {"geography_type_id": "2"} + serializer = GeographyByGeographyTypeRequestSerializer( + data=data, context={"geography_manager": mock_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + mock_manager.get_geography_codes_and_names_by_geography_type_id.assert_called_once_with( + 2 + ) + assert response == { + "choices": [ + ["E92000001", "England"], + ["E12000001", "North East"], + ] + } + + def test_data_handles_empty_geography_queryset(self): + """ + Given a valid geography_type_id that returns no geographies + When data() is called + Then an empty choices list is returned + """ + # Given + mock_manager = mock.MagicMock() + mock_manager.get_geography_codes_and_names_by_geography_type_id.return_value = ( + [] + ) + + data = {"geography_type_id": "999"} + serializer = GeographyByGeographyTypeRequestSerializer( + data=data, context={"geography_manager": mock_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + assert response == {"choices": []} + + def test_data_converts_geography_codes_to_strings(self): + """ + Given geographies with various geography_code formats + When data() is called + Then all geography codes are converted to strings + """ + # Given + mock_manager = mock.MagicMock() + mock_queryset = [ + {"geography_code": "E92000001", "name": "England"}, + {"geography_code": 12345, "name": "Numeric Code Area"}, + ] + mock_manager.get_geography_codes_and_names_by_geography_type_id.return_value = ( + mock_queryset + ) + + data = {"geography_type_id": "1"} + serializer = GeographyByGeographyTypeRequestSerializer( + data=data, context={"geography_manager": mock_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + assert response == { + "choices": [ + ["E92000001", "England"], + ["12345", "Numeric Code Area"], + ] + } + # Verify all codes are strings + for choice in response["choices"]: + assert isinstance(choice[0], str) + + def test_geography_manager_uses_context_when_available(self): + """ + Given a geography_manager in the context + When the property is accessed + Then the context manager is returned + """ + # Given + mock_manager = mock.MagicMock() + serializer = GeographyByGeographyTypeRequestSerializer( + data={"geography_type_id": "1"}, context={"geography_manager": mock_manager} + ) + + # When / Then + assert serializer.geography_manager == mock_manager + + def test_geography_manager_falls_back_to_default(self): + """ + Given no geography_manager in the context + When the property is accessed + Then the default Geography.objects manager is returned + """ + # Given + serializer = GeographyByGeographyTypeRequestSerializer( + data={"geography_type_id": "1"} + ) + + # When / Then + assert serializer.geography_manager == Geography.objects + + def test_requires_geography_type_id_field(self): + """ + Given data without geography_type_id + When the serializer is validated + Then a validation error is raised + """ + # Given + data = {} + + # When + serializer = GeographyByGeographyTypeRequestSerializer(data=data) + + # Then + assert not serializer.is_valid() + assert "geography_type_id" in serializer.errors + + def test_data_calls_helper_function_to_convert_queryset(self): + """ + Given a valid geography_type_id + When data() is called + Then the helper function is used to convert the queryset + """ + # Given + mock_manager = mock.MagicMock() + mock_queryset = [ + {"geography_code": "S92000003", "name": "Scotland"}, + ] + mock_manager.get_geography_codes_and_names_by_geography_type_id.return_value = ( + mock_queryset + ) + + data = {"geography_type_id": "1"} + serializer = GeographyByGeographyTypeRequestSerializer( + data=data, context={"geography_manager": mock_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + # The helper function should have been used (implicitly tested by correct output) + assert response == {"choices": [["S92000003", "Scotland"]]} + + +class TestQuerysetToGeographyCodeNameTuples: + """Tests for the _queryset_to_geography_code_name_tuples helper function""" + + def test_converts_queryset_to_tuples(self): + """ + Given a QuerySet with geography_code and name fields + When converted using _queryset_to_geography_code_name_tuples + Then a list of (geography_code, name) tuples is returned + """ + # Given + mock_queryset = [ + {"geography_code": "E92000001", "name": "England"}, + {"geography_code": "W92000004", "name": "Wales"}, + {"geography_code": "S92000003", "name": "Scotland"}, + ] + + # When + result = _queryset_to_geography_code_name_tuples(mock_queryset) + + # Then + assert result == [ + ("E92000001", "England"), + ("W92000004", "Wales"), + ("S92000003", "Scotland"), + ] + + def test_handles_empty_queryset(self): + """ + Given an empty QuerySet + When converted using _queryset_to_geography_code_name_tuples + Then an empty list is returned + """ + # Given + mock_queryset = [] + + # When + result = _queryset_to_geography_code_name_tuples(mock_queryset) + + # Then + assert result == [] + + def test_preserves_geography_code_types(self): + """ + Given a QuerySet with various geography_code types + When converted using _queryset_to_geography_code_name_tuples + Then the geography_code types are preserved + """ + # Given + mock_queryset = [ + {"geography_code": "E12000001", "name": "North East"}, + {"geography_code": 12345, "name": "Numeric Code"}, + ] + + # When + result = _queryset_to_geography_code_name_tuples(mock_queryset) + + # Then + assert result[0] == ("E12000001", "North East") + assert result[1] == (12345, "Numeric Code") + assert isinstance(result[0][0], str) + assert isinstance(result[1][0], int) + + def test_handles_single_item_queryset(self): + """ + Given a QuerySet with a single item + When converted using _queryset_to_geography_code_name_tuples + Then a list with one tuple is returned + """ + # Given + mock_queryset = [{"geography_code": "N92000002", "name": "Northern Ireland"}] + + # When + result = _queryset_to_geography_code_name_tuples(mock_queryset) + + # Then + assert result == [("N92000002", "Northern Ireland")] + assert len(result) == 1 + + def test_handles_special_characters_in_names(self): + """ + Given a QuerySet with special characters in geography names + When converted using _queryset_to_geography_code_name_tuples + Then the special characters are preserved + """ + # Given + mock_queryset = [ + {"geography_code": "E06000001", "name": "Hartlepool & District"}, + {"geography_code": "E06000002", "name": "St. Albans"}, + ] + + # When + result = _queryset_to_geography_code_name_tuples(mock_queryset) + + # Then + assert result == [ + ("E06000001", "Hartlepool & District"), + ("E06000002", "St. Albans"), + ] diff --git a/tests/unit/metrics/api/serializers/test_permission_sets.py b/tests/unit/metrics/api/serializers/test_permission_sets.py new file mode 100644 index 000000000..7eaf64939 --- /dev/null +++ b/tests/unit/metrics/api/serializers/test_permission_sets.py @@ -0,0 +1,516 @@ +from unittest import mock + +import pytest +from rest_framework import serializers as drf_serializers + +from metrics.api.serializers.permission_sets import ( + MetricRequestSerializer, + PermissionSetResponseSerializer, + SubThemeRequestSerializer, + TopicRequestSerializer, + _queryset_to_id_name_tuples, +) +from metrics.data.models.core_models.supporting import Metric, SubTheme, Topic + + +class TestSubThemeRequestSerializer: + """Tests for SubThemeRequestSerializer""" + + def test_validates_wildcard_theme_id(self): + """ + Given a wildcard theme_id value of "-1" + When the value is validated + Then the wildcard is accepted + """ + # Given + data = {"theme_id": "-1"} + + # When + serializer = SubThemeRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["theme_id"] == "-1" + + def test_validates_numeric_theme_id(self): + """ + Given a valid numeric theme_id + When the value is validated + Then the numeric value is accepted + """ + # Given + data = {"theme_id": "123"} + + # When + serializer = SubThemeRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["theme_id"] == "123" + + def test_rejects_invalid_theme_id(self): + """ + Given an invalid theme_id (not a number or wildcard) + When the value is validated + Then a ValidationError is raised + """ + # Given + data = {"theme_id": "invalid"} + + # When + serializer = SubThemeRequestSerializer(data=data) + + # Then + assert not serializer.is_valid() + assert "theme_id" in serializer.errors + assert "theme_id must be a number or '-1'" in str(serializer.errors["theme_id"]) + + def test_data_returns_wildcard_response_for_wildcard_theme_id(self): + """ + Given a wildcard theme_id of "-1" + When data() is called + Then a wildcard response is returned + """ + # Given + data = {"theme_id": "-1"} + serializer = SubThemeRequestSerializer(data=data) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + assert response == {"choices": [["-1", "* (All sub-themes)"]]} + + def test_data_fetches_sub_themes_for_valid_theme_id(self): + """ + Given a valid numeric theme_id + When data() is called + Then sub-themes are fetched from the manager and formatted correctly + """ + # Given + metrics_manager = mock.MagicMock() + mock_queryset = [ + {"id": 1, "name": "respiratory"}, + {"id": 2, "name": "gastrointestinal"}, + ] + metrics_manager.get_filtered_unique_names_related_to_theme.return_value = ( + mock_queryset + ) + + data = {"theme_id": "5"} + serializer = SubThemeRequestSerializer( + data=data, context={"sub_theme_manager": metrics_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + metrics_manager.get_filtered_unique_names_related_to_theme.assert_called_once_with( + 5 + ) + assert response == { + "choices": [["1", "respiratory"], ["2", "gastrointestinal"]] + } + + def test_sub_theme_manager_uses_context_when_available(self): + """ + Given a sub_theme_manager in the context + When the property is accessed + Then the context manager is returned + """ + # Given + metrics_manager = mock.MagicMock() + serializer = SubThemeRequestSerializer( + data={"theme_id": "1"}, context={"sub_theme_manager": metrics_manager} + ) + + # When / Then + assert serializer.sub_theme_manager == metrics_manager + + def test_sub_theme_manager_falls_back_to_default(self): + """ + Given no sub_theme_manager in the context + When the property is accessed + Then the default SubTheme.objects manager is returned + """ + # Given + serializer = SubThemeRequestSerializer(data={"theme_id": "1"}) + + # When / Then + assert serializer.sub_theme_manager == SubTheme.objects + + +class TestTopicRequestSerializer: + """Tests for TopicRequestSerializer""" + + def test_validates_wildcard_sub_theme_id(self): + """ + Given a wildcard sub_theme_id value of "-1" + When the value is validated + Then the wildcard is accepted + """ + # Given + data = {"sub_theme_id": "-1"} + + # When + serializer = TopicRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["sub_theme_id"] == "-1" + + def test_validates_numeric_sub_theme_id(self): + """ + Given a valid numeric sub_theme_id + When the value is validated + Then the numeric value is accepted + """ + # Given + data = {"sub_theme_id": "456"} + + # When + serializer = TopicRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["sub_theme_id"] == "456" + + def test_rejects_invalid_sub_theme_id(self): + """ + Given an invalid sub_theme_id (not a number or wildcard) + When the value is validated + Then a ValidationError is raised + """ + # Given + data = {"sub_theme_id": "not_a_number"} + + # When + serializer = TopicRequestSerializer(data=data) + + # Then + assert not serializer.is_valid() + assert "sub_theme_id" in serializer.errors + assert "sub_theme_id must be a number or '-1'" in str( + serializer.errors["sub_theme_id"] + ) + + def test_data_returns_wildcard_response_for_wildcard_sub_theme_id(self): + """ + Given a wildcard sub_theme_id of "-1" + When data() is called + Then a wildcard response is returned + """ + # Given + data = {"sub_theme_id": "-1"} + serializer = TopicRequestSerializer(data=data) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + assert response == {"choices": [["-1", "* (All topics)"]]} + + def test_data_fetches_topics_for_valid_sub_theme_id(self): + """ + Given a valid numeric sub_theme_id + When data() is called + Then topics are fetched from the manager and formatted correctly + """ + # Given + metrics_manager = mock.MagicMock() + mock_queryset = [ + {"id": 10, "name": "COVID-19"}, + {"id": 11, "name": "Influenza"}, + ] + metrics_manager.get_filtered_unique_names_related_to_sub_theme.return_value = ( + mock_queryset + ) + + data = {"sub_theme_id": "3"} + serializer = TopicRequestSerializer( + data=data, context={"topic_manager": metrics_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + metrics_manager.get_filtered_unique_names_related_to_sub_theme.assert_called_once_with( + 3 + ) + assert response == {"choices": [["10", "COVID-19"], ["11", "Influenza"]]} + + def test_topic_manager_uses_context_when_available(self): + """ + Given a topic_manager in the context + When the property is accessed + Then the context manager is returned + """ + # Given + metrics_manager = mock.MagicMock() + serializer = TopicRequestSerializer( + data={"sub_theme_id": "1"}, context={"topic_manager": metrics_manager} + ) + + # When / Then + assert serializer.topic_manager == metrics_manager + + def test_topic_manager_falls_back_to_default(self): + """ + Given no topic_manager in the context + When the property is accessed + Then the default Topic.objects manager is returned + """ + # Given + serializer = TopicRequestSerializer(data={"sub_theme_id": "1"}) + + # When / Then + assert serializer.topic_manager == Topic.objects + + +class TestMetricRequestSerializer: + """Tests for MetricRequestSerializer""" + + def test_validates_wildcard_topic_id(self): + """ + Given a wildcard topic_id value of "-1" + When the value is validated + Then the wildcard is accepted + """ + # Given + data = {"topic_id": "-1"} + + # When + serializer = MetricRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["topic_id"] == "-1" + + def test_validates_numeric_topic_id(self): + """ + Given a valid numeric topic_id + When the value is validated + Then the numeric value is accepted + """ + # Given + data = {"topic_id": "789"} + + # When + serializer = MetricRequestSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data["topic_id"] == "789" + + def test_rejects_invalid_topic_id(self): + """ + Given an invalid topic_id (not a number or wildcard) + When the value is validated + Then a ValidationError is raised + """ + # Given + data = {"topic_id": "abc123"} + + # When + serializer = MetricRequestSerializer(data=data) + + # Then + assert not serializer.is_valid() + assert "topic_id" in serializer.errors + assert "topic_id must be a number or '-1'" in str(serializer.errors["topic_id"]) + + def test_data_returns_wildcard_response_for_wildcard_topic_id(self): + """ + Given a wildcard topic_id of "-1" + When data() is called + Then a wildcard response is returned + """ + # Given + data = {"topic_id": "-1"} + serializer = MetricRequestSerializer(data=data) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + assert response == {"choices": [["-1", "* (All metrics)"]]} + + def test_data_fetches_metrics_for_valid_topic_id(self): + """ + Given a valid numeric topic_id + When data() is called + Then metrics are fetched from the manager and formatted correctly + """ + # Given + metrics_manager = mock.MagicMock() + mock_queryset = [ + {"id": 100, "name": "COVID-19_cases_rate"}, + {"id": 101, "name": "COVID-19_deaths_rate"}, + ] + metrics_manager.get_filtered_unique_names_related_to_parent_topic_id.return_value = ( + mock_queryset + ) + + data = {"topic_id": "15"} + serializer = MetricRequestSerializer( + data=data, context={"metric_manager": metrics_manager} + ) + serializer.is_valid(raise_exception=True) + + # When + response = serializer.data() + + # Then + metrics_manager.get_filtered_unique_names_related_to_parent_topic_id.assert_called_once_with( + 15 + ) + assert response == { + "choices": [ + ["100", "COVID-19_cases_rate"], + ["101", "COVID-19_deaths_rate"], + ] + } + + def test_metric_manager_uses_context_when_available(self): + """ + Given a metric_manager in the context + When the property is accessed + Then the context manager is returned + """ + # Given + metrics_manager = mock.MagicMock() + serializer = MetricRequestSerializer( + data={"topic_id": "1"}, context={"metric_manager": metrics_manager} + ) + + # When / Then + assert serializer.metric_manager == metrics_manager + + def test_metric_manager_falls_back_to_default(self): + """ + Given no metric_manager in the context + When the property is accessed + Then the default Metric.objects manager is returned + """ + # Given + serializer = MetricRequestSerializer(data={"topic_id": "1"}) + + # When / Then + assert serializer.metric_manager == Metric.objects + + +class TestPermissionSetResponseSerializer: + """Tests for PermissionSetResponseSerializer""" + + def test_serializes_valid_choices_structure(self): + """ + Given a valid choices structure + When the data is serialized + Then the serializer validates successfully + """ + # Given + data = {"choices": [["1", "Option 1"], ["2", "Option 2"]]} + + # When + serializer = PermissionSetResponseSerializer(data=data) + + # Then + assert serializer.is_valid() + assert serializer.validated_data == data + + def test_rejects_invalid_choice_structure(self): + """ + Given an invalid choices structure (not pairs) + When the data is serialized + Then validation fails + """ + # Given + data = {"choices": [["1", "Option 1", "Extra"], ["2"]]} + + # When + serializer = PermissionSetResponseSerializer(data=data) + + # Then + assert not serializer.is_valid() + + def test_choices_field_has_correct_help_text(self): + """ + Given the PermissionSetResponseSerializer + When the fields are inspected + Then the choices field has the expected help text + """ + # Given + serializer = PermissionSetResponseSerializer() + + # When + choices_field = serializer.fields["choices"] + + # Then + assert ( + choices_field.help_text == "List of [id, name] pairs for dropdown options" + ) + + +class TestQuerysetToIdNameTuples: + """Tests for the _queryset_to_id_name_tuples helper function""" + + def test_converts_queryset_to_tuples(self): + """ + Given a QuerySet with id and name fields + When converted using _queryset_to_id_name_tuples + Then a list of (id, name) tuples is returned + """ + # Given + mock_queryset = [ + {"id": 1, "name": "First"}, + {"id": 2, "name": "Second"}, + {"id": 3, "name": "Third"}, + ] + + # When + result = _queryset_to_id_name_tuples(mock_queryset) + + # Then + assert result == [(1, "First"), (2, "Second"), (3, "Third")] + + def test_handles_empty_queryset(self): + """ + Given an empty QuerySet + When converted using _queryset_to_id_name_tuples + Then an empty list is returned + """ + # Given + mock_queryset = [] + + # When + result = _queryset_to_id_name_tuples(mock_queryset) + + # Then + assert result == [] + + def test_preserves_id_types(self): + """ + Given a QuerySet with various id types + When converted using _queryset_to_id_name_tuples + Then the id types are preserved + """ + # Given + mock_queryset = [ + {"id": 999, "name": "Large ID"}, + {"id": 1, "name": "Small ID"}, + ] + + # When + result = _queryset_to_id_name_tuples(mock_queryset) + + # Then + assert result[0][0] == 999 + assert result[1][0] == 1 + assert isinstance(result[0][0], int) 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 42d7a9ac0..e6385c4da 100644 --- a/tests/unit/metrics/data/managers/core_models/test_geography.py +++ b/tests/unit/metrics/data/managers/core_models/test_geography.py @@ -30,3 +30,29 @@ def test_get_all_geography_names_by_type( spy_get_all_geography_names_by_type.assert_called_with( geography_type_name=fake_geography_type, ) + + @mock.patch.object( + GeographyQuerySet, "get_geography_codes_and_names_by_geography_type_id" + ) + def test_get_geography_codes_and_names_by_geography_type_id( + self, spy_get_geography_codes_and_names_by_geography_type_id: mock.MagicMock + ): + """ + Given a payload containing the required field + When `get_all_geography_names_by_type` is called, + Then it delegates call to `GeographyQuerySet`. + """ + # Given + fake_geography_type_id = 1 + geography_manager = GeographyManager() + + # When + GeographyManager.get_geography_codes_and_names_by_geography_type_id( + geography_manager, + geography_type_id=fake_geography_type_id, + ) + + # Then + spy_get_geography_codes_and_names_by_geography_type_id.assert_called_with( + geography_type_id=fake_geography_type_id, + ) diff --git a/tests/unit/metrics/data/managers/core_models/test_geography_type.py b/tests/unit/metrics/data/managers/core_models/test_geography_type.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/metrics/data/managers/core_models/test_metric.py b/tests/unit/metrics/data/managers/core_models/test_metric.py new file mode 100644 index 000000000..6071ce5fe --- /dev/null +++ b/tests/unit/metrics/data/managers/core_models/test_metric.py @@ -0,0 +1,27 @@ +import unittest +from unittest import mock + +from metrics.data.managers.core_models.metric import ( + MetricManager, + MetricQuerySet, +) + + +class TestThemeManager(unittest.TestCase): + @mock.patch.object(MetricQuerySet, "get_all_names_and_ids") + def test_get_all_theme_names_and_ids( + self, spy_get_all_names_and_ids: mock.MagicMock + ): + """ + Given an instance of a `metricManager` + When `get_all_names` is called + Then it delegates call to `MetricQuerySet`. + """ + # Given + metric_manager = MetricManager() + + # When + metric_manager.get_all_names_and_ids() + + # Then + spy_get_all_names_and_ids.assert_called_once() diff --git a/tests/unit/metrics/data/managers/core_models/test_sub_theme.py b/tests/unit/metrics/data/managers/core_models/test_sub_theme.py index 4b74fa4b9..437836d06 100644 --- a/tests/unit/metrics/data/managers/core_models/test_sub_theme.py +++ b/tests/unit/metrics/data/managers/core_models/test_sub_theme.py @@ -39,3 +39,19 @@ def test_get_all_unique_names(self, spy_get_all_unique_names: mock.MagicMock): # Then spy_get_all_unique_names.assert_called_once_with() + + @mock.patch.object(SubThemeQuerySet, "get_all_names_and_ids") + def test_get_all_sub_theme_names(self, spy_get_all_names_and_ids: mock.MagicMock): + """ + Given an instance of a `SubThemeManager` + When `get_all_names` is called + Then it delegates call to `SubThemeQuerySet`. + """ + # Given + sub_theme_manager = SubThemeManager() + + # When + sub_theme_manager.get_all_names_and_ids() + + # Then + spy_get_all_names_and_ids.assert_called_once_with() diff --git a/tests/unit/metrics/data/managers/core_models/test_theme.py b/tests/unit/metrics/data/managers/core_models/test_theme.py index 0bdeb7064..ab4d04410 100644 --- a/tests/unit/metrics/data/managers/core_models/test_theme.py +++ b/tests/unit/metrics/data/managers/core_models/test_theme.py @@ -23,3 +23,39 @@ def test_get_all_theme_names(self, spy_get_all_names: mock.MagicMock): # Then spy_get_all_names.assert_called_once() + + @mock.patch.object(ThemeQuerySet, "get_all_names_and_ids") + def test_get_all_theme_names_and_ids( + self, spy_get_all_names_and_ids: mock.MagicMock + ): + """ + Given an instance of a `ThemeManager` + When `get_all_names` is called + Then it delegates call to `ThemeQuerySet`. + """ + # Given + theme_manager = ThemeManager() + + # When + theme_manager.get_all_names_and_ids() + + # Then + spy_get_all_names_and_ids.assert_called_once() + + +class TestThemeQuerySet(unittest.TestCase): + @mock.patch.object(ThemeQuerySet, "get_all_names") + def test_get_all_theme_names(self, spy_get_all_names: mock.MagicMock): + """ + Given an instance of a `ThemeManager` + When `get_all_names` is called + Then it delegates call to `ThemeQuerySet`. + """ + # Given + theme_manager = ThemeManager() + + # When + theme_manager.get_all_names() + + # Then + spy_get_all_names.assert_called_once() diff --git a/tests/unit/metrics/data/managers/core_models/test_topic.py b/tests/unit/metrics/data/managers/core_models/test_topic.py new file mode 100644 index 000000000..83e19722a --- /dev/null +++ b/tests/unit/metrics/data/managers/core_models/test_topic.py @@ -0,0 +1,27 @@ +import unittest +from unittest import mock + +from metrics.data.managers.core_models.topic import ( + TopicManager, + TopicQuerySet, +) + + +class TestThemeManager(unittest.TestCase): + @mock.patch.object(TopicQuerySet, "get_all_names_and_ids") + def test_get_all_theme_names_and_ids( + self, spy_get_all_names_and_ids: mock.MagicMock + ): + """ + Given an instance of a `topicManager` + When `get_all_names` is called + Then it delegates call to `TopicQuerySet`. + """ + # Given + topic_manager = TopicManager() + + # When + topic_manager.get_all_names_and_ids() + + # Then + spy_get_all_names_and_ids.assert_called_once() diff --git a/validation/enums/helper_enum.py b/validation/enums/helper_enum.py index d46ddc25d..545ef2e9a 100644 --- a/validation/enums/helper_enum.py +++ b/validation/enums/helper_enum.py @@ -7,6 +7,6 @@ def return_list(self): def return_name_list(self): return [e.name for e in self.value] - + def return_tuple_list(self): return [(e.value, e.name.replace("_", " ").title()) for e in self.value] From c3dec5dc96bedc8452e03e16e779da5e1fd0ff4c Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 26 Mar 2026 16:15:07 +0000 Subject: [PATCH 070/186] WIP: Separate model files and create permission set block --- .../{models.py => models/permission_sets.py} | 26 ++------ auth_content/models/users.py | 25 +++++++ auth_content/wagtail_hooks.py | 5 +- cms/dynamic_content/blocks.py | 65 ++++++++++++------- cms/dynamic_content/sections.py | 4 -- 5 files changed, 73 insertions(+), 52 deletions(-) rename auth_content/{models.py => models/permission_sets.py} (94%) create mode 100644 auth_content/models/users.py diff --git a/auth_content/models.py b/auth_content/models/permission_sets.py similarity index 94% rename from auth_content/models.py rename to auth_content/models/permission_sets.py index 903ed7a98..e3b5e1a54 100644 --- a/auth_content/models.py +++ b/auth_content/models/permission_sets.py @@ -5,9 +5,7 @@ from django.db import models from wagtail.admin.forms import WagtailAdminModelForm from wagtail.admin.panels import FieldPanel -from django.core.validators import RegexValidator -from wagtail.fields import StreamField -from cms.dynamic_content import sections + from cms.metrics_interface.field_choices_callables import ( get_all_geography_type_names_and_ids, @@ -277,22 +275,6 @@ def _find_label_in_choices(choices: list[tuple], value: str) -> str: def __str__(self): return self.name or f"Permission Set {self.id}" - -class Users(models.Model): - user_entra_id = models.CharField( - validators=[ - RegexValidator( - regex="^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$" - ) - ], - ) - permission_set = StreamField( - [ - ("section", sections.DropdownSection()), - ], - use_json_field=True, -) - panels = [ - FieldPanel("user_entra_id"), - FieldPanel("permission_set"), - ] + + + diff --git a/auth_content/models/users.py b/auth_content/models/users.py new file mode 100644 index 000000000..332af2bc3 --- /dev/null +++ b/auth_content/models/users.py @@ -0,0 +1,25 @@ +from cms.dynamic_content.blocks import PermissionSetChoiceBlock +from django.db import models +from django.core.validators import RegexValidator +from wagtail.fields import StreamField +from wagtail.admin.panels import FieldPanel + + +class User(models.Model): + user_entra_id = models.CharField( + validators=[ + RegexValidator( + regex="^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$" + ) + ], + ) + permission_set = StreamField( + [ + ("section", PermissionSetChoiceBlock(required=True)), + ], + use_json_field=True, + ) + panels = [ + FieldPanel("user_entra_id"), + FieldPanel("permission_set"), + ] diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py index cc66ab3a8..04cd791a8 100644 --- a/auth_content/wagtail_hooks.py +++ b/auth_content/wagtail_hooks.py @@ -4,7 +4,8 @@ from wagtail.admin.viewsets.model import ModelViewSetGroup from wagtail.snippets.views.snippets import SnippetViewSet -from auth_content.models import PermissionSet, Users +from auth_content.models.permission_sets import PermissionSet +from auth_content.models.users import User class PermissionSetViewSet(SnippetViewSet): @@ -13,7 +14,7 @@ class PermissionSetViewSet(SnippetViewSet): icon = "key" class UserViewSet(SnippetViewSet): - model = Users + model = User menu_label = "Users" icon = "user" diff --git a/cms/dynamic_content/blocks.py b/cms/dynamic_content/blocks.py index 21f2df846..09ad58fa6 100644 --- a/cms/dynamic_content/blocks.py +++ b/cms/dynamic_content/blocks.py @@ -1,6 +1,6 @@ from django.core.exceptions import ValidationError from django.db import models -from wagtail import blocks +from wagtail.blocks import CharBlock, ChoiceBlock, PageChooserBlock, StreamBlock, StructBlock, StructValue, TextBlock, URLBlock from wagtail.snippets.blocks import SnippetChooserBlock from cms.dynamic_content import help_texts @@ -10,13 +10,14 @@ TrendNumberComponent, ) from validation.url import validate_https_scheme +from auth_content.models.permission_sets import PermissionSet MINIMUM_ROWS_NUMBER_BLOCK_COUNT: int = 1 MAXIMUM_ROWS_NUMBER_BLOCK_COUNT: int = 2 METRIC_NUMBER_BLOCK_DATE_PREFIX_DEFAULT_TEXT = "Up to" -class HeadlineNumberBlockTypes(blocks.StreamBlock): +class HeadlineNumberBlockTypes(StreamBlock): headline_number = HeadlineNumberComponent(help_text=help_texts.HEADLINE_BLOCK_FIELD) trend_number = TrendNumberComponent(help_text=help_texts.TREND_BLOCK_FIELD) percentage_number = PercentageNumberComponent( @@ -27,9 +28,9 @@ class Meta: icon = "bars" -class MetricNumberBlockTypes(blocks.StructBlock): - title = blocks.TextBlock(required=True, help_text=help_texts.TITLE_FIELD) - date_prefix = blocks.TextBlock( +class MetricNumberBlockTypes(StructBlock): + title = TextBlock(required=True, help_text=help_texts.TITLE_FIELD) + date_prefix = TextBlock( required=True, default=METRIC_NUMBER_BLOCK_DATE_PREFIX_DEFAULT_TEXT, help_text=help_texts.HEADLINE_DATE_PREFIX, @@ -45,7 +46,7 @@ class Meta: icon = "table" -class MetricNumberBlock(blocks.StreamBlock): +class MetricNumberBlock(StreamBlock): column = MetricNumberBlockTypes() @@ -60,18 +61,18 @@ def get_programming_languages(cls) -> tuple[tuple[str, str]]: return tuple((language.value, language.value) for language in cls) -class CodeSnippet(blocks.StructBlock): - language = blocks.ChoiceBlock( +class CodeSnippet(StructBlock): + language = ChoiceBlock( choices=ProgrammingLanguages.get_programming_languages, default=ProgrammingLanguages.JAVASCRIPT.value, ) - code = blocks.TextBlock( + code = TextBlock( form_classname="codeblock_monospace", help_text=help_texts.CODE_SNIPPET, ) -class CodeBlock(blocks.StreamBlock): +class CodeBlock(StreamBlock): code_snippet = CodeSnippet() @@ -126,7 +127,7 @@ def get_api_representation(cls, value, context=None) -> dict | None: return None -class PageLinkChooserBlock(blocks.PageChooserBlock): +class PageLinkChooserBlock(PageChooserBlock): @classmethod def get_api_representation(cls, value, context=None) -> str | None: if value: @@ -135,40 +136,40 @@ def get_api_representation(cls, value, context=None) -> str | None: return None -class PageLink(blocks.StructBlock): - title = blocks.CharBlock( +class PageLink(StructBlock): + title = CharBlock( required=True, help_text=help_texts.PAGE_LINK_TITLE, ) - sub_title = blocks.CharBlock( + sub_title = CharBlock( required=False, help_text=help_texts.PAGE_LINK_SUB_TITLE, ) page = PageLinkChooserBlock(target_model=["topic.TopicPage"]) -class InternalPageLinks(blocks.StreamBlock): +class InternalPageLinks(StreamBlock): page_link = PageLink() class Meta: icon = "link" -class RelatedLink(blocks.StructBlock): - link_display_text = blocks.CharBlock( +class RelatedLink(StructBlock): + link_display_text = CharBlock( required=True, help_text=help_texts.RELATED_LINK_TEXT ) - link = blocks.CharBlock(required=True, help_text=help_texts.RELATED_LINK_URL) + link = CharBlock(required=True, help_text=help_texts.RELATED_LINK_URL) -class RelatedLinkBlock(blocks.StreamBlock): +class RelatedLinkBlock(StreamBlock): related_link = RelatedLink() -class SourceLinkBlock(blocks.StructBlock): +class SourceLinkBlock(StructBlock): """Source link supporting internal (page) or external (URL) links.""" - link_display_text = blocks.CharBlock( + link_display_text = CharBlock( required=False, help_text=help_texts.SOURCE_LINK_TEXT, ) @@ -177,19 +178,19 @@ class SourceLinkBlock(blocks.StructBlock): required=False, help_text=help_texts.SOURCE_LINK_PAGE, ) - external_url = blocks.URLBlock( + external_url = URLBlock( required=False, help_text=help_texts.SOURCE_LINK_URL, validators=[validate_https_scheme], ) - def clean(self, value: blocks.StructValue): + def clean(self, value: StructValue): self._validate_only_one_of_page_or_external_url(value=value) return super().clean(value=value) @classmethod def _validate_only_one_of_page_or_external_url( - cls, *, value: blocks.StructValue + cls, *, value: StructValue ) -> None: """Validate that only one of the page or external_url fields is set if provided.""" page = value.get("page") @@ -198,3 +199,19 @@ def _validate_only_one_of_page_or_external_url( if page and external_url: error_message = "Use either page OR external_url, not both." raise ValidationError(error_message) + + + +class PermissionSetChoiceBlock(ChoiceBlock): + def __init__(self, **kwargs): + super().__init__(choices=[], **kwargs) + + def get_form_class(self): + form_class = super().get_form_class() + + # Fetch choices dynamically each time the form is rendered + permission_sets = PermissionSet.objects.all().values_list("id", "theme") + + form_class.base_fields[self.name].widget.choices = list(permission_sets) + + return form_class \ No newline at end of file diff --git a/cms/dynamic_content/sections.py b/cms/dynamic_content/sections.py index 7285946bb..6a6e048c0 100644 --- a/cms/dynamic_content/sections.py +++ b/cms/dynamic_content/sections.py @@ -69,10 +69,6 @@ class TextSection(StructBlock): body = RichTextBlock(help_text=help_texts.REQUIRED_BODY_FIELD, required=True) -class DropdownSection(StructBlock): - choice = ChoiceBlock(choices=[("1", "one"), ("2", "two")]) - - class CodeExample(StructBlock): heading = TextBlock(help_text=help_texts.HEADING_BLOCK, required=False) content = blocks.CodeBlock(help_text=help_texts.CODE_EXAMPLE, required=True) From d823c58cd4b9349beb472ae1ff2fd6fd91ce275c Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 26 Mar 2026 16:18:34 +0000 Subject: [PATCH 071/186] add name back in --- cms/dynamic_content/blocks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/dynamic_content/blocks.py b/cms/dynamic_content/blocks.py index 09ad58fa6..552bf190e 100644 --- a/cms/dynamic_content/blocks.py +++ b/cms/dynamic_content/blocks.py @@ -210,7 +210,7 @@ def get_form_class(self): form_class = super().get_form_class() # Fetch choices dynamically each time the form is rendered - permission_sets = PermissionSet.objects.all().values_list("id", "theme") + permission_sets = PermissionSet.objects.all().values_list("id", "name") form_class.base_fields[self.name].widget.choices = list(permission_sets) From e47447a077d4454918c0fdb0464f68d3f6f997f7 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Mon, 30 Mar 2026 10:58:30 +0100 Subject: [PATCH 072/186] working draft --- ...r_permissionset_geography_type_and_more.py | 62 ++++++++ auth_content/models/users.py | 6 +- auth_content/permissions/api.py | 12 ++ auth_content/static/js/child_theme.js | 2 +- auth_content/static/js/permission_sets.js | 148 ++++++++++++++++++ auth_content/wagtail_hooks.py | 5 + cms/dynamic_content/blocks.py | 14 +- metrics/api/urls_construction.py | 7 + metrics/api/views/permission_sets.py | 22 +++ 9 files changed, 265 insertions(+), 13 deletions(-) create mode 100644 auth_content/migrations/0002_user_alter_permissionset_geography_type_and_more.py create mode 100644 auth_content/permissions/api.py create mode 100644 auth_content/static/js/permission_sets.js diff --git a/auth_content/migrations/0002_user_alter_permissionset_geography_type_and_more.py b/auth_content/migrations/0002_user_alter_permissionset_geography_type_and_more.py new file mode 100644 index 000000000..ac6c445e6 --- /dev/null +++ b/auth_content/migrations/0002_user_alter_permissionset_geography_type_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2.12 on 2026-03-30 08:54 + +import django.core.validators +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "user_entra_id", + models.CharField( + validators=[ + django.core.validators.RegexValidator( + regex="^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$" + ) + ] + ), + ), + ( + "permission_set", + wagtail.fields.StreamField( + [("section", 0)], + block_lookup={ + 0: ( + "wagtail.blocks.ChoiceBlock", + [], + {"choices": [("", "...............")]}, + ) + }, + ), + ), + ], + ), + migrations.AlterField( + model_name="permissionset", + name="geography_type", + field=models.CharField(default="", max_length=255), + ), + migrations.AlterField( + model_name="permissionset", + name="theme", + field=models.CharField(default="", max_length=255), + ), + ] diff --git a/auth_content/models/users.py b/auth_content/models/users.py index 332af2bc3..647356c67 100644 --- a/auth_content/models/users.py +++ b/auth_content/models/users.py @@ -13,12 +13,16 @@ class User(models.Model): ) ], ) + permission_set = StreamField( [ - ("section", PermissionSetChoiceBlock(required=True)), + ("section", PermissionSetChoiceBlock()), + ], use_json_field=True, + ) + panels = [ FieldPanel("user_entra_id"), FieldPanel("permission_set"), diff --git a/auth_content/permissions/api.py b/auth_content/permissions/api.py new file mode 100644 index 000000000..b10b1a9b8 --- /dev/null +++ b/auth_content/permissions/api.py @@ -0,0 +1,12 @@ + +from django.http import JsonResponse +from auth_content.models.permission_sets import PermissionSet +from django.contrib.admin.views.decorators import staff_member_required + +@staff_member_required +def permission_sets_api(request): + data = [ + {"id": str(p.id), "name": p.name} + for p in PermissionSet.objects.all() + ] + return JsonResponse(data, safe=False) diff --git a/auth_content/static/js/child_theme.js b/auth_content/static/js/child_theme.js index 20a9391bb..e653038d0 100644 --- a/auth_content/static/js/child_theme.js +++ b/auth_content/static/js/child_theme.js @@ -319,7 +319,7 @@ !geographyType || !geography ) { - console.error("Permission set dropdowns not found on this page"); + console.warn("Permission set dropdowns not found on this page"); return; } diff --git a/auth_content/static/js/permission_sets.js b/auth_content/static/js/permission_sets.js new file mode 100644 index 000000000..85e9f9256 --- /dev/null +++ b/auth_content/static/js/permission_sets.js @@ -0,0 +1,148 @@ +;(function () { + "use strict" + + /** + * Fetch choices from the PermissionSet API endpoint. + */ + async function fetchPermissionSets() { + try { + const url = "/api/permission-set/all" + + const response = await fetch(url) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + console.error( + `Permission Set API error: ${errorData.error || "Unknown error"}`, + ) + return [] + } + + const data = await response.json() + return data.choices || [] + } catch (error) { + console.error("Error fetching permission sets:", error) + return [] + } + } + + /** + * Populate a dropdown with permission set choices. - */ - function populatePermissionSetDropdown(dropdown, choices) { - const currentValue = dropdown.value - const usedValues = getSelectedPermissionSetValues() - - dropdown.disabled = false - dropdown.innerHTML = "" - - // Default empty choice - const emptyOption = document.createElement("option") - emptyOption.value = "" - emptyOption.textContent = "---------" - dropdown.appendChild(emptyOption) - - // Populate dynamic permission sets - choices.forEach(([id, name]) => { - const opt = document.createElement("option") - opt.value = id - opt.textContent = name - - if (usedValues.includes(id) && id !== currentValue) { - opt.disabled = true - opt.textContent = `${name} (already selected)` - } - - dropdown.appendChild(opt) - }) - - // Restore previous value (if any) - if (currentValue) { - dropdown.value = currentValue - } - } - - /** - * Find all PermissionSet selects inside StreamField blocks. - */ - function findAllPermissionSetDropdowns() { - return document.querySelectorAll('select[name$="-value"]') - } - - /** - * Populate all dropdowns on the page - */ - async function populateAllDropdowns() { - const selects = findAllPermissionSetDropdowns() - if (selects.length === 0) { - return - } - - const choices = await fetchPermissionSets() - - selects.forEach((select) => { - populatePermissionSetDropdown(select, choices) - }) - } - - /** - * StreamField creates new blocks dynamically. - * We observe DOM changes to catch new block insertions - * and populate the dropdown inside new blocks. - */ - function observeStreamFieldChanges() { - const observer = new MutationObserver(async (mutations) => { - for (const mutation of mutations) { - if (mutation.addedNodes.length > 0) { - const selects = [] - - mutation.addedNodes.forEach((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - // Case 1: the node itself is a select - if (node.matches && node.matches("select[name$='-value']")) { - selects.push(node) - } - - // Case 2: the node contains the select - const innerSelects = node.querySelectorAll - ? node.querySelectorAll('select[name$="-value"]') - : [] - innerSelects.forEach((el) => selects.push(el)) - } - }) - - if (selects.length > 0) { - const choices = await fetchPermissionSets() - selects.forEach((select) => - populatePermissionSetDropdown(select, choices), - ) - } - } - } - }) - - observer.observe(document.body, { - subtree: true, - childList: true, - }) - } - - function getSelectedPermissionSetValues() { - return Array.from(document.querySelectorAll('select[name$="-value"]')) - .map((selected) => selected.value) - .filter((value) => value !== "") - } - - /** - * Initialize once DOM is ready - */ - function initialize() { - // Initial population - populateAllDropdowns() - - // Handle StreamField block insertions - observeStreamFieldChanges() - } - - document.addEventListener("DOMContentLoaded", initialize) -})() diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py index 59f5b3dce..8faefb25d 100644 --- a/auth_content/wagtail_hooks.py +++ b/auth_content/wagtail_hooks.py @@ -32,9 +32,4 @@ def register_auth_viewset(): @hooks.register("insert_editor_js") def permission_set_js(): - return format_html('', static("js/child_theme.js")) - - -@hooks.register("insert_editor_js") -def permission_set_dropdown_js(): - return format_html('', static("js/permission_sets.js")) \ No newline at end of file + return format_html('', static("js/child_theme.js")) \ No newline at end of file diff --git a/cms/dynamic_content/blocks.py b/cms/dynamic_content/blocks.py index e95e4067e..42fab4140 100644 --- a/cms/dynamic_content/blocks.py +++ b/cms/dynamic_content/blocks.py @@ -1,6 +1,6 @@ from django.core.exceptions import ValidationError from django.db import models -from wagtail.blocks import CharBlock, ChoiceBlock, PageChooserBlock, StreamBlock, StructBlock, StructValue, TextBlock, URLBlock +from wagtail.blocks import CharBlock, ChoiceBlock, ListBlock, PageChooserBlock, StreamBlock, StructBlock, StructValue, TextBlock, URLBlock from wagtail.snippets.blocks import SnippetChooserBlock from cms.dynamic_content import help_texts @@ -200,10 +200,3 @@ def _validate_only_one_of_page_or_external_url( error_message = "Use either page OR external_url, not both." raise ValidationError(error_message) - - -class PermissionSetChoiceBlock(ChoiceBlock): - def __init__(self, **kwargs): - kwargs["choices"] = [("", "...............")] - kwargs["help_text"]= "If no permission sets are showing, create one via the permission sets page" - super().__init__(**kwargs) From a8eb2bea1c1891bd696c115ef2a37b05cacf765a Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 31 Mar 2026 14:21:47 +0100 Subject: [PATCH 079/186] remove old code --- metrics/api/urls_construction.py | 5 -- .../metrics/api/views/test_permission_sets.py | 69 ------------------- 2 files changed, 74 deletions(-) diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index 4cbea4d01..6382dac8b 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -159,11 +159,6 @@ def construct_public_api_urlpatterns( GeographiesByGeographyTypeView.as_view(), name="get_geographies", ), - path( - f"{API_PREFIX}permission-set/all", - PermissionSetChoicesView.as_view(), - name="permission_sets_choices", - ), path(f"{API_PREFIX}menus/v1", MenuView.as_view()), path(f"{API_PREFIX}alerts/v1/heat", heat_alert_list, name="heat-alerts-list"), diff --git a/tests/integration/metrics/api/views/test_permission_sets.py b/tests/integration/metrics/api/views/test_permission_sets.py index ce3f1f35a..57fc4962e 100644 --- a/tests/integration/metrics/api/views/test_permission_sets.py +++ b/tests/integration/metrics/api/views/test_permission_sets.py @@ -235,72 +235,3 @@ def test_get_metric_by_topic_id_should_return_an_error(self): # data should contain error assert str(result["topic_id"][0]) == "topic_id must be a number or '-1'" - -class TestPermissionSetChoicesView: - @property - def path(self) -> str: - return "/api/permission-set/all" - - @pytest.mark.django_db - def test_get_permission_set_choices_returns_expected_shape(self): - """ - Should return: {"choices": [[id, name], ...]} - """ - - # Given - ps1 = PermissionSet.objects.create( - theme="theme1", sub_theme="subtheme1", topic="topic1", metric="metric1", geography_type="g", geography="gg" - ) - ps2 = PermissionSet.objects.create( - theme="theme2", sub_theme="subtheme2", topic="topic2", metric="metric2", geography_type="g", geography="gg" - ) - - client = APIClient() - - # When - response: Response = client.get(self.path) - - # Then - assert response.status_code == HTTPStatus.OK - assert "choices" in response.data - assert response.data["choices"] == [ - [str(ps1.id), ps1.name], - [str(ps2.id), ps2.name], - ] - - @pytest.mark.django_db - def test_empty_permission_sets_returns_empty_list(self): - """ - When the DB has no permission sets, return: {"choices": []} - """ - client = APIClient() - - response = client.get(self.path) - - assert response.status_code == HTTPStatus.OK - assert response.data == {"choices": []} - - @pytest.mark.django_db - def test_results_are_sorted_by_name(self): - """ - Should respect .order_by("name") in the view. - """ - ps1 = PermissionSet.objects.create( - theme="themeb1", sub_theme="subthemeb1", topic="topicb1", metric="metricb1", geography_type="g", geography="gg" - ) - ps2 = PermissionSet.objects.create( - theme="themea2", sub_theme="subthemea2", topic="topica2", metric="metrica2", geography_type="g", geography="gg" - ) - - client = APIClient() - - response = client.get(self.path) - assert response.status_code == HTTPStatus.OK - - # Should be alphabetically sorted: 2 then 1 - assert response.data["choices"] == [ - [str(ps2.id), ps2.name], - [str(ps1.id), ps1.name], - ] - - From 780a5a6f07233769d9424ad7038c9277a403e7b2 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 31 Mar 2026 14:28:21 +0100 Subject: [PATCH 080/186] Remove old code --- auth_content/models/users.py | 2 +- cms/dynamic_content/blocks.py | 3 +-- cms/dynamic_content/sections.py | 1 - metrics/api/urls_construction.py | 1 - metrics/api/views/permission_sets.py | 20 -------------------- 5 files changed, 2 insertions(+), 25 deletions(-) diff --git a/auth_content/models/users.py b/auth_content/models/users.py index 0324cbe50..fcc5c246e 100644 --- a/auth_content/models/users.py +++ b/auth_content/models/users.py @@ -32,7 +32,7 @@ def __init__(self, *args, **kwargs): class User(models.Model): user_id = models.UUIDField(unique=True) - permission_sets = models.ManyToManyField("PermissionSet", blank=True, help_text="If no permission sets are showing, create one on the Permission Sets page") + permission_sets = models.ManyToManyField("PermissionSet", blank=True) base_form_class = UserForm diff --git a/cms/dynamic_content/blocks.py b/cms/dynamic_content/blocks.py index 42fab4140..f35753675 100644 --- a/cms/dynamic_content/blocks.py +++ b/cms/dynamic_content/blocks.py @@ -1,6 +1,6 @@ from django.core.exceptions import ValidationError from django.db import models -from wagtail.blocks import CharBlock, ChoiceBlock, ListBlock, PageChooserBlock, StreamBlock, StructBlock, StructValue, TextBlock, URLBlock +from wagtail.blocks import CharBlock, ChoiceBlock, PageChooserBlock, StreamBlock, StructBlock, StructValue, TextBlock, URLBlock from wagtail.snippets.blocks import SnippetChooserBlock from cms.dynamic_content import help_texts @@ -10,7 +10,6 @@ TrendNumberComponent, ) from validation.url import validate_https_scheme -from auth_content.models.permission_sets import PermissionSet MINIMUM_ROWS_NUMBER_BLOCK_COUNT: int = 1 MAXIMUM_ROWS_NUMBER_BLOCK_COUNT: int = 2 diff --git a/cms/dynamic_content/sections.py b/cms/dynamic_content/sections.py index 6a6e048c0..2bdd5368a 100644 --- a/cms/dynamic_content/sections.py +++ b/cms/dynamic_content/sections.py @@ -1,5 +1,4 @@ from wagtail.blocks import ( - ChoiceBlock, RichTextBlock, StreamBlock, StructBlock, diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index 6382dac8b..c529540f8 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -45,7 +45,6 @@ from metrics.api.views.maps import MapsView from metrics.api.views.permission_sets import ( MetricsByTopicView, - PermissionSetChoicesView, SubThemesByThemeView, TopicsBySubThemeView, ) diff --git a/metrics/api/views/permission_sets.py b/metrics/api/views/permission_sets.py index 46861d974..cb267e47e 100644 --- a/metrics/api/views/permission_sets.py +++ b/metrics/api/views/permission_sets.py @@ -65,23 +65,3 @@ def get(self, request, topic_id, *args, **kwargs): # noqa: PLR6301 serializer.is_valid(raise_exception=True) return Response(serializer.data()) - -@extend_schema( - request=None, - tags=[PERMISSION_SETS_API_TAG], - responses={HTTPStatus.OK.value: PermissionSetResponseSerializer}, -) -class PermissionSetChoicesView(APIView): - """API endpoint to fetch PermissionSet dropdown options.""" - - permission_classes = [] - - def get(self, request, *args, **kwargs): - """API endpoint to fetch permission sets in [[id, name], ...] pairs for a dropdown.""" - choices = [ - [str(p.id), p.name] - for p in PermissionSet.objects.all().order_by("name") - ] - - response_data = {"choices": choices} - return Response(response_data, status=HTTPStatus.OK) From 8b15e7561cecaaba9fe002237c7bb73fb26b663a Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 31 Mar 2026 14:56:28 +0100 Subject: [PATCH 081/186] CDD-3175: update for PR comments --- auth_content/constants.py | 9 +- auth_content/models.py | 98 +++++++------------ auth_content/static/js/permission_set.js | 35 ++++--- .../field_choices_callables.py | 42 +++++++- cms/metrics_interface/interface.py | 12 +++ metrics/api/serializers/geographies.py | 16 +-- metrics/api/serializers/help_texts.py | 2 +- metrics/api/serializers/permission_sets.py | 60 +++++------- metrics/api/views/geographies.py | 3 +- .../data/managers/core_models/geography.py | 15 +++ .../metrics/api/views/test_geographies.py | 3 +- .../metrics/api/views/test_permission_sets.py | 7 +- .../api/serializers/test_geographies.py | 19 ++-- .../api/serializers/test_permission_sets.py | 37 +++---- 14 files changed, 203 insertions(+), 155 deletions(-) diff --git a/auth_content/constants.py b/auth_content/constants.py index 1ba9a2f31..6ea05d5d4 100644 --- a/auth_content/constants.py +++ b/auth_content/constants.py @@ -1,6 +1,9 @@ -from cms.metrics_interface.field_choices_callables import get_all_geography_type_names_and_ids, get_all_theme_names_and_ids - +from cms.metrics_interface.field_choices_callables import ( + get_all_geography_type_names_and_ids, + get_all_theme_names_and_ids, +) +WILDCARD_ID_VALUE = "-1" PERMISSION_SET_FIELDS = [ { "field_name": "theme", @@ -43,5 +46,5 @@ "field_choice_default": "Select geography first", "field_choice_wildcard": None, "field_choice_callable": None, - } + }, ] diff --git a/auth_content/models.py b/auth_content/models.py index 9ef2074f2..03e9c49a0 100644 --- a/auth_content/models.py +++ b/auth_content/models.py @@ -1,5 +1,5 @@ +from collections.abc import Callable from itertools import starmap -from typing import Callable from django import forms from django.core.exceptions import ValidationError @@ -7,8 +7,9 @@ from wagtail.admin.forms import WagtailAdminModelForm from wagtail.admin.panels import FieldPanel -from auth_content.constants import PERMISSION_SET_FIELDS +from auth_content.constants import PERMISSION_SET_FIELDS, WILDCARD_ID_VALUE from cms.metrics_interface.field_choices_callables import ( + get_all_geography_names_and_codes, get_all_geography_type_names_and_ids, get_all_metric_names_and_ids, get_all_sub_theme_names_and_ids, @@ -31,18 +32,18 @@ def get_theme_child_map(): def _create_form_field(field: dict[str, str | Callable | None]) -> forms.CharField: - choices = [("", field["field_choice_default"]),] + choices = [ + ("", field["field_choice_default"]), + ] if field["field_choice_wildcard"]: - choices += [("-1", field["field_choice_wildcard"])] + choices += [(WILDCARD_ID_VALUE, field["field_choice_wildcard"])] if field["field_choice_callable"]: choices += field["field_choice_callable"]() return forms.CharField( - required=True, - label=field["field_label"], - widget=forms.Select(choices=choices) + required=True, label=field["field_label"], widget=forms.Select(choices=choices) ) @@ -50,59 +51,33 @@ class PermissionSetForm(WagtailAdminModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Use CharField with Select widget to bypass choice validation for field in PERMISSION_SET_FIELDS: self.fields[field["field_name"]] = _create_form_field(field) if self.instance and self.instance.pk: - # Sub-theme - if self.instance.sub_theme and self.instance.sub_theme != "-1": - self.fields["sub_theme"].widget.choices = [ - ("", "Select theme first"), - ( - self.instance.sub_theme, - f"Loading... (ID: {self.instance.sub_theme})", - ), - ] - elif self.instance.sub_theme == "-1": - self.fields["sub_theme"].widget.choices = [ - ("-1", "* (All sub-themes)")] - - # Topic - if self.instance.topic and self.instance.topic != "-1": - self.fields["topic"].widget.choices = [ - ("", "Select sub-theme first"), - (self.instance.topic, - f"Loading... (ID: {self.instance.topic})"), - ] - elif self.instance.topic == "-1": - self.fields["topic"].widget.choices = [ - ("-1", "* (All topics)")] - - # Metric - if self.instance.metric and self.instance.metric != "-1": - self.fields["metric"].widget.choices = [ - ("", "Select topic first"), - (self.instance.metric, - f"Loading... (ID: {self.instance.metric})"), - ] - elif self.instance.metric == "-1": - self.fields["metric"].widget.choices = [ - ("-1", "* (All metrics)")] - - # Geography - if self.instance.geography and self.instance.geography != "-1": - self.fields["geography"].widget.choices = [ - ("", "Select geography type first"), - ( - self.instance.geography, - f"Loading... (ID: {self.instance.geography})", - ), - ] - elif self.instance.geography == "-1": - self.fields["geography"].widget.choices = [ - ("-1", "* (All geographies)") - ] + self._initialize_dependent_fields() + + def _initialize_dependent_fields(self): + """Initialize choices for cascading dependent fields""" + dependent_fields = { + "sub_theme": ("Select theme first", "* (All sub-themes)"), + "topic": ("Select sub-theme first", "* (All topics)"), + "metric": ("Select topic first", "* (All metrics)"), + "geography": ("Select geography type first", "* (All geographies)"), + } + + for field_name, (placeholder, wildcard_label) in dependent_fields.items(): + value = getattr(self.instance, field_name, None) + if value: + choices = self._get_field_choices(value, placeholder, wildcard_label) + self.fields[field_name].widget.choices = choices + + @staticmethod + def _get_field_choices(value, placeholder, wildcard_label): + """Generate choices list based on field value""" + if value == WILDCARD_ID_VALUE: + return [(WILDCARD_ID_VALUE, wildcard_label)] + return [("", placeholder), (value, f"Loading... (ID: {value})")] def clean(self): """Validate that this permission set doesn't already exist""" @@ -193,7 +168,7 @@ def format_field(field_name: str, field_value: str, label: str) -> str | None: Args: field_name: The field identifier (e.g., "theme", "sub-theme") - field_value: The stored value (ID or "-1") + field_value: The stored value (ID or WILDCARD_ID_VALUE) label: The display label (e.g., "Theme", "Sub-theme") Returns: @@ -202,14 +177,9 @@ def format_field(field_name: str, field_value: str, label: str) -> str | None: if not field_value: return None - if field_value == "-1": + if field_value == WILDCARD_ID_VALUE: return f"{label}: * (All)" - # Special case for geography_type - format the enum value - if field_name == "geography_type": - formatted_value = field_value.replace("_", " ").title() - return f"{label}: {formatted_value}" - # For other fields, use choice label lookup choice_label = self._get_choice_label(field_name, field_value) return f"{label}: {choice_label}" @@ -235,6 +205,8 @@ def _get_choice_label(self, field_name: str, value: str) -> str: "sub-theme": get_all_sub_theme_names_and_ids, "topic": get_all_topic_names_and_ids, "metric": get_all_metric_names_and_ids, + "geography_type": get_all_geography_type_names_and_ids, + "geography": get_all_geography_names_and_codes, } # Get the appropriate lookup function diff --git a/auth_content/static/js/permission_set.js b/auth_content/static/js/permission_set.js index 3992e422b..011d98687 100644 --- a/auth_content/static/js/permission_set.js +++ b/auth_content/static/js/permission_set.js @@ -1,6 +1,7 @@ (function () { "use strict"; let theme, subTheme, topic, metric, geographyType, geography; + const WILDCARD_ID_VALUE = "-1"; /** * Generic function to fetch choices from the API @@ -46,7 +47,7 @@ //dropdown wildcard choice const wildcardOption = document.createElement("option"); - wildcardOption.value = "-1"; + wildcardOption.value = WILDCARD_ID_VALUE; wildcardOption.textContent = wildcardValue; dropdown.appendChild(wildcardOption); @@ -87,11 +88,11 @@ dropdown.innerHTML = ""; const option = document.createElement("option"); - option.value = "-1"; + option.value = WILDCARD_ID_VALUE; option.textContent = message; dropdown.appendChild(option); - dropdown.value = "-1"; + dropdown.value = WILDCARD_ID_VALUE; } /** @@ -108,7 +109,7 @@ return; } - if (themeValue === "-1") { + if (themeValue === WILDCARD_ID_VALUE) { setToWildcard(subTheme, "* (All sub-themes)"); setToWildcard(topic, "* (All topics)"); setToWildcard(metric, "* (All metrics)"); @@ -142,7 +143,7 @@ return; } - if (subThemeValue === "-1") { + if (subThemeValue === WILDCARD_ID_VALUE) { // Wildcard sub-theme = cascade wildcard to children setToWildcard(topic, "* (All topics)"); setToWildcard(metric, "* (All metrics)"); @@ -175,7 +176,7 @@ return; } - if (topicValue === "-1") { + if (topicValue === WILDCARD_ID_VALUE) { // Wildcard topic = cascade wildcard to metrics setToWildcard(metric, "* (All metrics)"); return; @@ -201,7 +202,7 @@ return; } - if (geographyTypeValue === "-1") { + if (geographyTypeValue === WILDCARD_ID_VALUE) { // Wildcard topic = cascade wildcard to metrics setToWildcard(geography, "* (All geographies)"); return; @@ -232,7 +233,7 @@ const savedGeography = geography.value; // If theme has a value (not wildcard, not empty), load sub-themes - if (savedTheme && savedTheme !== "" && savedTheme !== "-1") { + if (savedTheme && savedTheme !== "" && savedTheme !== WILDCARD_ID_VALUE) { const subThemeChoices = await fetchChoices("subthemes", savedTheme); if (subThemeChoices.length > 0) { populateDropdown(subTheme, subThemeChoices, "* (All sub-themes)"); @@ -240,7 +241,11 @@ } // If sub-theme has a value, load topics - if (savedSubTheme && savedSubTheme !== "" && savedSubTheme !== "-1") { + if ( + savedSubTheme && + savedSubTheme !== "" && + savedSubTheme !== WILDCARD_ID_VALUE + ) { const topicChoices = await fetchChoices("topics", savedSubTheme); if (topicChoices.length > 0) { populateDropdown(topic, topicChoices, "* (All topics)"); @@ -248,7 +253,11 @@ } // If topic has a value, load metrics - if (savedTopic && savedTopic !== "" && savedTopic !== "-1") { + if ( + savedTopic && + savedTopic !== "" && + savedTopic !== WILDCARD_ID_VALUE + ) { const metricChoices = await fetchChoices("metrics", savedTopic); if (metricChoices.length > 0) { populateDropdown(metric, metricChoices, "* (All metrics)"); @@ -256,7 +265,7 @@ } } } - } else if (savedTheme === "-1") { + } else if (savedTheme === WILDCARD_ID_VALUE) { // Theme is wildcard, cascade to children setToWildcard(subTheme, "* (All sub-themes)"); setToWildcard(topic, "* (All topics)"); @@ -267,7 +276,7 @@ if ( savedGeographyType && savedGeographyType !== "" && - savedGeographyType !== "-1" + savedGeographyType !== WILDCARD_ID_VALUE ) { const geographyChoices = await fetchChoices( "geographies", @@ -277,7 +286,7 @@ populateDropdown(geography, geographyChoices, "* (All geographies)"); geography.value = savedGeography; // Restore selection } - } else if (savedGeographyType === "-1") { + } else if (savedGeographyType === WILDCARD_ID_VALUE) { setToWildcard(geography, "* (All geographies)"); } } diff --git a/cms/metrics_interface/field_choices_callables.py b/cms/metrics_interface/field_choices_callables.py index 0c606e095..8f0528021 100644 --- a/cms/metrics_interface/field_choices_callables.py +++ b/cms/metrics_interface/field_choices_callables.py @@ -26,7 +26,9 @@ def _build_two_item_tuple_choices( return [(choice, choice) for choice in choices] -def _build_id_name_tuple_choices(*, choices: QuerySet) -> list[tuple[str, str]]: +def _build_id_name_tuple_choices( + *, choices: QuerySet +) -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Build choices from a QuerySet containing id and name fields. Args: @@ -40,6 +42,22 @@ def _build_id_name_tuple_choices(*, choices: QuerySet) -> list[tuple[str, str]]: return [(str(choice["id"]), choice["name"]) for choice in choices] +def _build_geography_code_name_tuple_choices( + *, choices: QuerySet +) -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Build choices from a QuerySet containing id and name fields. + + Args: + choices: QuerySet with 'id' and 'name' fields + + Returns: + A list of 2-item tuples (id, name). + Examples: + [(1, "infectious_disease"), (2, "respiratory"), ...] + """ + return [(str(choice["geography_code"]), choice["name"]) for choice in choices] + + def get_possible_axis_choices() -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `chart axis` fields of the CMS blocks. @@ -612,6 +630,28 @@ def get_all_geography_type_names_and_ids() -> LIST_OF_TWO_STRING_ITEM_TUPLES: ) +def get_all_geography_names_and_codes() -> LIST_OF_TWO_STRING_ITEM_TUPLES: + """Callable for the `choices` on the `geography` fields of the CMS blocks on permission sets creation page + + Notes: + This callable wraps the `MetricsAPIInterface` + and is passed to a migration for the CMS blocks. + Instead, the 1-off migration is pointed at this callable. + So Wagtail will pull the choices by invoking this function. + + Returns: + A list of 2-item tuples of geography type names and codes. + Examples: + [("E06000001", "Hartlepool"), ...] + + """ + metrics_interface = MetricsAPIInterface() + + return _build_geography_code_name_tuple_choices( + choices=metrics_interface.get_all_geography_names_and_codes() + ) + + def get_all_sex_names() -> LIST_OF_TWO_STRING_ITEM_TUPLES: """Callable for the `choices` on the `sex` fields of the CMS blocks. diff --git a/cms/metrics_interface/interface.py b/cms/metrics_interface/interface.py index dc5bc606c..8db2665a1 100644 --- a/cms/metrics_interface/interface.py +++ b/cms/metrics_interface/interface.py @@ -453,3 +453,15 @@ def get_all_geography_type_names_and_ids(self) -> QuerySet: """ return self.geography_type_manager.get_all_names_and_ids() + + def get_all_geography_names_and_codes(self) -> QuerySet: + """Gets all available geography_type names as a flat list queryset. + Note this is achieved by delegating the call to the `GeographyTypeManager` from the Metrics API + + Returns: + QuerySet: A queryset of the individual geography names: + Examples: + `` + + """ + return self.geography_manager.get_all_names_and_codes() diff --git a/metrics/api/serializers/geographies.py b/metrics/api/serializers/geographies.py index b4f7024c9..76a639f86 100644 --- a/metrics/api/serializers/geographies.py +++ b/metrics/api/serializers/geographies.py @@ -3,6 +3,7 @@ from django.db.models import QuerySet from rest_framework import serializers +from auth_content.constants import WILDCARD_ID_VALUE from metrics.api.serializers import help_texts from metrics.data.in_memory_models.geography_relationships.handlers import ( get_upstream_relationships_for_geography, @@ -63,7 +64,8 @@ def data(self) -> list[GEOGRAPHY_TYPE_RESULT]: """ topic: str = self.validated_data["topic"] queryset: CoreTimeSeriesQuerySet = ( - self.core_time_series_manager.get_available_geographies(topic=topic) + self.core_time_series_manager.get_available_geographies( + topic=topic) ) return _serialize_queryset(queryset=queryset) @@ -195,7 +197,7 @@ class GeographyChoicesResponseSerializer(serializers.Serializer): child=serializers.ListField( child=serializers.CharField(), min_length=2, max_length=2 ), - help_text=help_texts.GEOGRAPHY_TUPLE_FORMATTING, + help_text=help_texts.GEOGRAPHY_LIST_FORMATTING, ) @@ -231,9 +233,9 @@ def geography_manager(self): return self.context.get("geography_manager", Geography.objects) @staticmethod - def validate_geography_type_id(value): + def validate_geography_type_id(value: str) -> str | int: """Validate geography_type_id is either wildcard or a valid integer""" - if value == "-1": + if value == WILDCARD_ID_VALUE: return value try: @@ -242,7 +244,7 @@ def validate_geography_type_id(value): message = "Geography Type must be a number or '-1'" raise serializers.ValidationError(message) from err - def data(self) -> dict: + def data(self) -> dict[str, list[tuple[str, str]]]: """ Fetch geographies for specified geography type from DB and format as response. @@ -252,8 +254,8 @@ def data(self) -> dict: geography_type_id = self.validated_data["geography_type_id"] # Handle wildcard - if geography_type_id == "-1": - return {"choices": [["-1", "* (All geographies)"]]} + if geography_type_id == WILDCARD_ID_VALUE: + return {"choices": [[WILDCARD_ID_VALUE, "* (All geographies)"]]} parent_geography_type_id = int(geography_type_id) geographies = ( diff --git a/metrics/api/serializers/help_texts.py b/metrics/api/serializers/help_texts.py index 518d637d4..e99a758cf 100644 --- a/metrics/api/serializers/help_texts.py +++ b/metrics/api/serializers/help_texts.py @@ -129,6 +129,6 @@ CONFIDENCE_INTERVALS: str = """ Boolean switch to decide whether to draw confidence intervals if provided """ -GEOGRAPHY_TUPLE_FORMATTING: str = """ +GEOGRAPHY_LIST_FORMATTING: str = """ "List of [id, name] pairs for dropdown options" """ diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py index 8db852c79..03c7e4413 100644 --- a/metrics/api/serializers/permission_sets.py +++ b/metrics/api/serializers/permission_sets.py @@ -1,9 +1,24 @@ from django.db.models import QuerySet from rest_framework import serializers +from auth_content.constants import WILDCARD_ID_VALUE from metrics.data.models.core_models.supporting import Metric, SubTheme, Topic +def _validate_input_id(value, field_name): + """Validate theme_id is either wildcard or a valid integer""" + if value == WILDCARD_ID_VALUE: + return value + + try: + int(value) + except ValueError as err: + msg = f"{field_name} must be a number or '-1'" + raise serializers.ValidationError(msg) from err + else: + return value + + class SubThemeRequestSerializer(serializers.Serializer): """Fetches and formats sub-theme choices based on theme_id""" @@ -20,16 +35,7 @@ def sub_theme_manager(self): @staticmethod def validate_theme_id(value): """Validate theme_id is either wildcard or a valid integer""" - if value == "-1": - return value - - try: - int(value) - except ValueError as err: - msg = "theme_id must be a number or '-1'" - raise serializers.ValidationError(msg) from err - else: - return value + return _validate_input_id(value, "theme_id") def data(self) -> dict: """ @@ -40,8 +46,8 @@ def data(self) -> dict: """ theme_id = self.validated_data["theme_id"] - if theme_id == "-1": - return {"choices": [["-1", "* (All sub-themes)"]]} + if theme_id == WILDCARD_ID_VALUE: + return {"choices": [[WILDCARD_ID_VALUE, "* (All sub-themes)"]]} parent_theme_id = int(theme_id) sub_theme_tuples = _queryset_to_id_name_tuples( @@ -70,16 +76,7 @@ def topic_manager(self): @staticmethod def validate_sub_theme_id(value): """Validate sub_theme_id is either wildcard or a valid integer""" - if value == "-1": - return value - - try: - int(value) - except ValueError as err: - msg = "sub_theme_id must be a number or '-1'" - raise serializers.ValidationError(msg) from err - else: - return value + return _validate_input_id(value, "sub_theme_id") def data(self) -> dict: """ @@ -90,8 +87,8 @@ def data(self) -> dict: """ sub_theme_id = self.validated_data["sub_theme_id"] - if sub_theme_id == "-1": - return {"choices": [["-1", "* (All topics)"]]} + if sub_theme_id == WILDCARD_ID_VALUE: + return {"choices": [[WILDCARD_ID_VALUE, "* (All topics)"]]} parent_sub_theme_id = int(sub_theme_id) topic_tuples = _queryset_to_id_name_tuples( @@ -121,16 +118,7 @@ def metric_manager(self): @staticmethod def validate_topic_id(value): """Validate topic_id is either wildcard or a valid integer""" - if value == "-1": - return value - - try: - int(value) - except ValueError as err: - msg = "topic_id must be a number or '-1'" - raise serializers.ValidationError(msg) from err - else: - return value + return _validate_input_id(value, "topic_id") def data(self) -> dict: """ @@ -141,8 +129,8 @@ def data(self) -> dict: """ topic_id = self.validated_data["topic_id"] - if topic_id == "-1": - return {"choices": [["-1", "* (All metrics)"]]} + if topic_id == WILDCARD_ID_VALUE: + return {"choices": [[WILDCARD_ID_VALUE, "* (All metrics)"]]} parent_topic_id = int(topic_id) metric_tuples = _queryset_to_id_name_tuples( diff --git a/metrics/api/views/geographies.py b/metrics/api/views/geographies.py index 07f38d435..ebea182a5 100644 --- a/metrics/api/views/geographies.py +++ b/metrics/api/views/geographies.py @@ -15,6 +15,7 @@ GeographyByGeographyTypeRequestSerializer, GeographyChoicesResponseSerializer, ) +from metrics.api.views.permission_sets import PERMISSION_SETS_API_TAG GEOGRAPHIES_API_TAG = "geographies" @@ -106,7 +107,7 @@ def _handle_geographies_by_geography_type( @extend_schema( request=GeographyByGeographyTypeRequestSerializer, - tags=[GEOGRAPHIES_API_TAG], + tags=[PERMISSION_SETS_API_TAG], responses={HTTPStatus.OK.value: GeographyChoicesResponseSerializer}, ) class GeographiesByGeographyTypeView(APIView): diff --git a/metrics/data/managers/core_models/geography.py b/metrics/data/managers/core_models/geography.py index 675b99712..6716e42fb 100644 --- a/metrics/data/managers/core_models/geography.py +++ b/metrics/data/managers/core_models/geography.py @@ -118,6 +118,9 @@ def get_geography_code_for_geography( model = self.get(name=geography, geography_type__name=geography_type) return model.geography_code + def get_all_names_and_codes(self): + return self.all().values("name", "geography_code").order_by("geography_code") + class GeographyManager(models.Manager): """Custom model manager class for the `Geography` model.""" @@ -253,3 +256,15 @@ def get_geography_code_for_geography( return self.get_queryset().get_geography_code_for_geography( geography=geography, geography_type=geography_type ) + + def get_all_names_and_codes(self) -> GeographyQuerySet: + """Gets all available deduplicated geography names as a flat list queryset. + + Returns: + QuerySet: A queryset of the individual geography names + ordered in descending ordering starting from A -> Z: + Examples: + `` + + """ + return self.get_queryset().get_all_names_and_codes() diff --git a/tests/integration/metrics/api/views/test_geographies.py b/tests/integration/metrics/api/views/test_geographies.py index 5b7b8a16c..31b15c627 100644 --- a/tests/integration/metrics/api/views/test_geographies.py +++ b/tests/integration/metrics/api/views/test_geographies.py @@ -4,6 +4,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient +from auth_content.constants import WILDCARD_ID_VALUE from tests.factories.metrics.geography import GeographyFactory from tests.factories.metrics.time_series import CoreTimeSeriesFactory from validation.geography_code import UNITED_KINGDOM_GEOGRAPHY_CODE @@ -333,7 +334,7 @@ def test_get_geographies_by_geography_type_id_should_return_wildcard(self): assert len(response.data["choices"]) == 1 # Should return a wildcard choice - assert result["choices"][0][0] == "-1" + assert result["choices"][0][0] == WILDCARD_ID_VALUE assert result["choices"][0][1] == "* (All geographies)" @pytest.mark.django_db diff --git a/tests/integration/metrics/api/views/test_permission_sets.py b/tests/integration/metrics/api/views/test_permission_sets.py index f7c9f3729..7acc8d51a 100644 --- a/tests/integration/metrics/api/views/test_permission_sets.py +++ b/tests/integration/metrics/api/views/test_permission_sets.py @@ -4,6 +4,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient +from auth_content.constants import WILDCARD_ID_VALUE from tests.factories.metrics.metric import MetricFactory from tests.factories.metrics.sub_theme import SubThemeFactory from tests.factories.metrics.topic import TopicFactory @@ -63,7 +64,7 @@ def test_get_sub_themes_by_theme_id_should_return_wildcard(self): assert len(response.data["choices"]) == 1 # Should return a wildcard choice - assert result["choices"][0][0] == "-1" + assert result["choices"][0][0] == WILDCARD_ID_VALUE assert result["choices"][0][1] == "* (All sub-themes)" @pytest.mark.django_db @@ -138,7 +139,7 @@ def test_get_topics_by_sub_theme_id_should_return_wildcard(self): assert len(response.data["choices"]) == 1 # Should return a wildcard choice - assert result["choices"][0][0] == "-1" + assert result["choices"][0][0] == WILDCARD_ID_VALUE assert result["choices"][0][1] == "* (All topics)" @pytest.mark.django_db @@ -210,7 +211,7 @@ def test_get_metric_by_topic_id_should_return_wildcard(self): assert len(response.data["choices"]) == 1 # Should return a wildcard choice - assert result["choices"][0][0] == "-1" + assert result["choices"][0][0] == WILDCARD_ID_VALUE assert result["choices"][0][1] == "* (All metrics)" @pytest.mark.django_db diff --git a/tests/unit/metrics/api/serializers/test_geographies.py b/tests/unit/metrics/api/serializers/test_geographies.py index acc6a1648..8f64d5223 100644 --- a/tests/unit/metrics/api/serializers/test_geographies.py +++ b/tests/unit/metrics/api/serializers/test_geographies.py @@ -4,6 +4,7 @@ from rest_framework.exceptions import ValidationError +from auth_content.constants import WILDCARD_ID_VALUE from metrics.data.models.core_models.supporting import Geography from validation.geography_code import UNITED_KINGDOM_GEOGRAPHY_CODE from metrics.api.serializers.geographies import ( @@ -288,19 +289,19 @@ class TestGeographyByGeographyTypeRequestSerializer: def test_validates_wildcard_geography_type_id(self): """ - Given a wildcard geography_type_id value of "-1" + Given a wildcard geography_type_id value of WILDCARD_ID_VALUE When the value is validated Then the wildcard is accepted """ # Given - data = {"geography_type_id": "-1"} + data = {"geography_type_id": WILDCARD_ID_VALUE} # When serializer = GeographyByGeographyTypeRequestSerializer(data=data) # Then assert serializer.is_valid() - assert serializer.validated_data["geography_type_id"] == "-1" + assert serializer.validated_data["geography_type_id"] == WILDCARD_ID_VALUE def test_validates_numeric_geography_type_id(self): """ @@ -360,12 +361,12 @@ def test_validation_error_has_chained_exception(self): def test_data_returns_wildcard_response_for_wildcard_geography_type_id(self): """ - Given a wildcard geography_type_id of "-1" + Given a wildcard geography_type_id of WILDCARD_ID_VALUE When data() is called Then a wildcard response is returned """ # Given - data = {"geography_type_id": "-1"} + data = {"geography_type_id": WILDCARD_ID_VALUE} serializer = GeographyByGeographyTypeRequestSerializer(data=data) serializer.is_valid(raise_exception=True) @@ -373,7 +374,8 @@ def test_data_returns_wildcard_response_for_wildcard_geography_type_id(self): response = serializer.data() # Then - assert response == {"choices": [["-1", "* (All geographies)"]]} + assert response == {"choices": [ + [WILDCARD_ID_VALUE, "* (All geographies)"]]} def test_data_fetches_geographies_for_valid_geography_type_id(self): """ @@ -542,7 +544,7 @@ def test_data_calls_helper_function_to_convert_queryset(self): # Then # The helper function should have been used (implicitly tested by correct output) - assert response == {"choices": [["S92000003", "Scotland"]]} + assert response == {"choices": [("S92000003", "Scotland")]} class TestQuerysetToGeographyCodeNameTuples: @@ -614,7 +616,8 @@ def test_handles_single_item_queryset(self): Then a list with one tuple is returned """ # Given - mock_queryset = [{"geography_code": "N92000002", "name": "Northern Ireland"}] + mock_queryset = [ + {"geography_code": "N92000002", "name": "Northern Ireland"}] # When result = _queryset_to_geography_code_name_tuples(mock_queryset) diff --git a/tests/unit/metrics/api/serializers/test_permission_sets.py b/tests/unit/metrics/api/serializers/test_permission_sets.py index 7eaf64939..62ebd2045 100644 --- a/tests/unit/metrics/api/serializers/test_permission_sets.py +++ b/tests/unit/metrics/api/serializers/test_permission_sets.py @@ -3,6 +3,7 @@ import pytest from rest_framework import serializers as drf_serializers +from auth_content.constants import WILDCARD_ID_VALUE from metrics.api.serializers.permission_sets import ( MetricRequestSerializer, PermissionSetResponseSerializer, @@ -18,19 +19,19 @@ class TestSubThemeRequestSerializer: def test_validates_wildcard_theme_id(self): """ - Given a wildcard theme_id value of "-1" + Given a wildcard theme_id value of WILDCARD_ID_VALUE When the value is validated Then the wildcard is accepted """ # Given - data = {"theme_id": "-1"} + data = {"theme_id": WILDCARD_ID_VALUE} # When serializer = SubThemeRequestSerializer(data=data) # Then assert serializer.is_valid() - assert serializer.validated_data["theme_id"] == "-1" + assert serializer.validated_data["theme_id"] == WILDCARD_ID_VALUE def test_validates_numeric_theme_id(self): """ @@ -67,12 +68,12 @@ def test_rejects_invalid_theme_id(self): def test_data_returns_wildcard_response_for_wildcard_theme_id(self): """ - Given a wildcard theme_id of "-1" + Given a wildcard theme_id of WILDCARD_ID_VALUE When data() is called Then a wildcard response is returned """ # Given - data = {"theme_id": "-1"} + data = {"theme_id": WILDCARD_ID_VALUE} serializer = SubThemeRequestSerializer(data=data) serializer.is_valid(raise_exception=True) @@ -80,7 +81,7 @@ def test_data_returns_wildcard_response_for_wildcard_theme_id(self): response = serializer.data() # Then - assert response == {"choices": [["-1", "* (All sub-themes)"]]} + assert response == {"choices": [[WILDCARD_ID_VALUE, "* (All sub-themes)"]]} def test_data_fetches_sub_themes_for_valid_theme_id(self): """ @@ -148,19 +149,19 @@ class TestTopicRequestSerializer: def test_validates_wildcard_sub_theme_id(self): """ - Given a wildcard sub_theme_id value of "-1" + Given a wildcard sub_theme_id value of WILDCARD_ID_VALUE When the value is validated Then the wildcard is accepted """ # Given - data = {"sub_theme_id": "-1"} + data = {"sub_theme_id": WILDCARD_ID_VALUE} # When serializer = TopicRequestSerializer(data=data) # Then assert serializer.is_valid() - assert serializer.validated_data["sub_theme_id"] == "-1" + assert serializer.validated_data["sub_theme_id"] == WILDCARD_ID_VALUE def test_validates_numeric_sub_theme_id(self): """ @@ -199,12 +200,12 @@ def test_rejects_invalid_sub_theme_id(self): def test_data_returns_wildcard_response_for_wildcard_sub_theme_id(self): """ - Given a wildcard sub_theme_id of "-1" + Given a wildcard sub_theme_id of WILDCARD_ID_VALUE When data() is called Then a wildcard response is returned """ # Given - data = {"sub_theme_id": "-1"} + data = {"sub_theme_id": WILDCARD_ID_VALUE} serializer = TopicRequestSerializer(data=data) serializer.is_valid(raise_exception=True) @@ -212,7 +213,7 @@ def test_data_returns_wildcard_response_for_wildcard_sub_theme_id(self): response = serializer.data() # Then - assert response == {"choices": [["-1", "* (All topics)"]]} + assert response == {"choices": [[WILDCARD_ID_VALUE, "* (All topics)"]]} def test_data_fetches_topics_for_valid_sub_theme_id(self): """ @@ -278,19 +279,19 @@ class TestMetricRequestSerializer: def test_validates_wildcard_topic_id(self): """ - Given a wildcard topic_id value of "-1" + Given a wildcard topic_id value of WILDCARD_ID_VALUE When the value is validated Then the wildcard is accepted """ # Given - data = {"topic_id": "-1"} + data = {"topic_id": WILDCARD_ID_VALUE} # When serializer = MetricRequestSerializer(data=data) # Then assert serializer.is_valid() - assert serializer.validated_data["topic_id"] == "-1" + assert serializer.validated_data["topic_id"] == WILDCARD_ID_VALUE def test_validates_numeric_topic_id(self): """ @@ -327,12 +328,12 @@ def test_rejects_invalid_topic_id(self): def test_data_returns_wildcard_response_for_wildcard_topic_id(self): """ - Given a wildcard topic_id of "-1" + Given a wildcard topic_id of WILDCARD_ID_VALUE When data() is called Then a wildcard response is returned """ # Given - data = {"topic_id": "-1"} + data = {"topic_id": WILDCARD_ID_VALUE} serializer = MetricRequestSerializer(data=data) serializer.is_valid(raise_exception=True) @@ -340,7 +341,7 @@ def test_data_returns_wildcard_response_for_wildcard_topic_id(self): response = serializer.data() # Then - assert response == {"choices": [["-1", "* (All metrics)"]]} + assert response == {"choices": [[WILDCARD_ID_VALUE, "* (All metrics)"]]} def test_data_fetches_metrics_for_valid_topic_id(self): """ From c05163fb898669596fa87067368b155fc81709e5 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Wed, 1 Apr 2026 09:55:41 +0100 Subject: [PATCH 082/186] CDD-3175: update for PR comments --- auth_content/constants.py | 2 +- .../managers/core_models/test_geography.py | 49 ++++++++++++++- .../test_field_choices_callables.py | 60 ++++++++++++++++--- .../cms/metrics_interface/test_interface.py | 25 ++++++++ .../api/serializers/test_geographies.py | 2 +- 5 files changed, 126 insertions(+), 12 deletions(-) diff --git a/auth_content/constants.py b/auth_content/constants.py index 6ea05d5d4..0920faa1f 100644 --- a/auth_content/constants.py +++ b/auth_content/constants.py @@ -43,7 +43,7 @@ { "field_name": "geography", "field_label": "Geography", - "field_choice_default": "Select geography first", + "field_choice_default": "Select geography type first", "field_choice_wildcard": None, "field_choice_callable": None, }, 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 10fcc82f6..f5836f662 100644 --- a/tests/integration/metrics/data/managers/core_models/test_geography.py +++ b/tests/integration/metrics/data/managers/core_models/test_geography.py @@ -1,6 +1,7 @@ import pytest -from metrics.data.models.core_models.supporting import GeographyType +from metrics.data.models.core_models.supporting import Geography, GeographyType +from tests.factories.metrics.geography import GeographyFactory from tests.factories.metrics.geography_type import GeographyTypeFactory @@ -48,3 +49,49 @@ def test_query_for_get_all_names_and_ids(self): "id": geography_type_two.id, "name": fake_geography_type_name_two, } + + @pytest.mark.django_db + def test_query_for_get_all_names_and_codes(self): + """ + Given a number of existing `geography` records + When `get_all_names_and_codes` is called + Then the geography types with their IDs and names are returned correctly + """ + geography_one = GeographyFactory.create_with_geography_type( + name="Leeds", + geography_code="E08000035", + geography_type="Lower Tier Local Authority" + ) + + geography_two = GeographyFactory.create_with_geography_type( + name="London", + geography_code="E12000007", + geography_type="Region" + ) + geography_three = GeographyFactory.create_with_geography_type( + name="England", + geography_code="E92000001", + geography_type="Nation", + ) + + # When + all_geography_names_and_codes = Geography.objects.get_all_names_and_codes() + + # Then + assert all_geography_names_and_codes.count() == 3 + + # Access the dictionary returned by .first() + first_result = all_geography_names_and_codes.first() + assert first_result["geography_code"] == geography_one.geography_code + assert first_result["name"] == geography_one.name + + # Verify both records are present with correct structure + result_list = list(all_geography_names_and_codes) + assert result_list[0] == { + "geography_code": geography_one.geography_code, + "name": geography_one.name, + } + assert result_list[1] == { + "geography_code": geography_two.geography_code, + "name": geography_two.name, + } diff --git a/tests/unit/cms/metrics_interface/test_field_choices_callables.py b/tests/unit/cms/metrics_interface/test_field_choices_callables.py index 0377b43a1..65efd019c 100644 --- a/tests/unit/cms/metrics_interface/test_field_choices_callables.py +++ b/tests/unit/cms/metrics_interface/test_field_choices_callables.py @@ -32,7 +32,8 @@ def test_delegates_call_correctly( unique_metric_names = field_choices_callables.get_all_unique_metric_names() # Then - assert unique_metric_names == [(x, x) for x in retrieved_unique_metric_names] + assert unique_metric_names == [(x, x) + for x in retrieved_unique_metric_names] class TestGetAlLTimeSeriesMetricNames: @@ -55,7 +56,8 @@ def test_delegates_calls_correctly( unique_metric_names = field_choices_callables.get_all_timeseries_metric_names() # Then - assert unique_metric_names == [(x, x) for x in retrieved_unique_metric_names] + assert unique_metric_names == [(x, x) + for x in retrieved_unique_metric_names] class TestGetAllHeadlineMetricNames: @@ -68,7 +70,8 @@ def test_delegates_calls_correctly( When `get_all_headline_metric_names()` is called Then the headline metric names are returned as a list of 2-item tuples """ - retrieved_headline_metric_names = ["COVID-19_headline_cases_7DayTotals"] + retrieved_headline_metric_names = [ + "COVID-19_headline_cases_7DayTotals"] mocked_get_all_headline_metric_names.return_value = ( retrieved_headline_metric_names ) @@ -95,7 +98,8 @@ def test_delegates_call_correctly( Then the unique metric names are returned as a list of 2-item tuples """ # Given - retrieved_unique_change_type_metric_names = ["COVID-19_deaths_ONSRollingMean"] + retrieved_unique_change_type_metric_names = [ + "COVID-19_deaths_ONSRollingMean"] mocked_get_all_unique_change_type_metric_names.return_value = ( retrieved_unique_change_type_metric_names ) @@ -433,7 +437,8 @@ def test_delegates_call_correctly( geography_type_names = field_choices_callables.get_all_geography_type_names() # Then - assert geography_type_names == [(x, x) for x in retrieved_geography_type_names] + assert geography_type_names == [(x, x) + for x in retrieved_geography_type_names] class TestGetAllSexNames: @@ -577,7 +582,8 @@ def test_delegates_call_correctly( all_sub_theme_names = field_choices_callables.get_all_sub_theme_names() # Then - assert all_sub_theme_names == [(x, x) for x in retrieved_sub_theme_names] + assert all_sub_theme_names == [(x, x) + for x in retrieved_sub_theme_names] class TestGetAllSubThemeNamesAndIds: @@ -741,7 +747,8 @@ def test_delegates_call_correctly( mocked_get_all_sex_names = ["all", "f", "m"] mocked_get_all_age_names.return_value = ["00-04", "05-11"] mocked_get_all_stratum_names.return_value = ["default"] - mocked_get_all_geography_names.return_value = ["London", "Yorkshire and Humber"] + mocked_get_all_geography_names.return_value = [ + "London", "Yorkshire and Humber"] # When retrieved_subcategory_choices = ( @@ -831,8 +838,10 @@ def test_receives_subcategory_choices_grouped_by_category( Then a dictionary is returned containing the subcategory choices grouped by category """ # Given - mocked_get_all_age_names.return_value = [("00-04", "00-04"), ("05-11", "05-11")] - mocked_get_all_sex_names.return_value = [("all", "all"), ("m", "m"), ("f", "f")] + mocked_get_all_age_names.return_value = [ + ("00-04", "00-04"), ("05-11", "05-11")] + mocked_get_all_sex_names.return_value = [ + ("all", "all"), ("m", "m"), ("f", "f")] mocked_get_all_stratum_names.return_value = [("default", "default")] mocked_all_geography_choices_grouped_by_type.return_value = [ ("London", "London"), @@ -852,3 +861,36 @@ def test_receives_subcategory_choices_grouped_by_category( # Then assert received_categories == expected_subcategory_choices + + +class TestGetAllGeographyNamesAndCodes: + @mock.patch.object( + interface.MetricsAPIInterface, "get_all_geography_names_and_codes" + ) + def test_delegates_call_correctly( + self, mocked_get_all_geography_names_and_codes: mock.MagicMock + ): + """ + Given an instance of the `MetricsAPIInterface` which returns theme names + When `get_all_theme_names()` is called + Then the theme names are returned as a list of 2-item tuples + """ + # Given + retrieved_geography_names_and_codes = [ + {"geography_code": "E09000004", "name": "Bexley"}, + {"geography_code": "E07000224", "name": "Arun"}, + {"geography_code": "E09000012", "name": "Hackney"}, + ] + mocked_get_all_geography_names_and_codes.return_value = ( + retrieved_geography_names_and_codes + ) + + # When + all_geography_names_and_codes = ( + field_choices_callables.get_all_geography_names_and_codes() + ) + + # Then + assert all_geography_names_and_codes == [ + (str(x["geography_code"]), x["name"]) for x in retrieved_geography_names_and_codes + ] diff --git a/tests/unit/cms/metrics_interface/test_interface.py b/tests/unit/cms/metrics_interface/test_interface.py index c582fe4bf..f7f8ec221 100644 --- a/tests/unit/cms/metrics_interface/test_interface.py +++ b/tests/unit/cms/metrics_interface/test_interface.py @@ -623,3 +623,28 @@ def test_get_all_geography_type_names_and_ids_delegates_call_correctly( all_geography_type_names == spy_geography_type_manager.get_all_names_and_ids() ) + + def test_get_all_geography_names_and_codes_delegates_call_correctly( + self, + ): + """ + Given a `GeographyTypeManager` from the Metrics API app + When `get_all_geography_names_and_codes()` is called from an instance of the `MetricsAPIInterface` + Then the call is delegated to the correct method on the `GeographyTypeManager` + """ + # Given + spy_geography_manager = mock.Mock() + metrics_api_interface = interface.MetricsAPIInterface( + geography_manager=spy_geography_manager, + ) + + # When + get_all_geography_names_and_codes = ( + metrics_api_interface.get_all_geography_names_and_codes() + ) + + # Then + assert ( + get_all_geography_names_and_codes + == spy_geography_manager.get_all_names_and_codes() + ) diff --git a/tests/unit/metrics/api/serializers/test_geographies.py b/tests/unit/metrics/api/serializers/test_geographies.py index 8f64d5223..86a7afb4a 100644 --- a/tests/unit/metrics/api/serializers/test_geographies.py +++ b/tests/unit/metrics/api/serializers/test_geographies.py @@ -544,7 +544,7 @@ def test_data_calls_helper_function_to_convert_queryset(self): # Then # The helper function should have been used (implicitly tested by correct output) - assert response == {"choices": [("S92000003", "Scotland")]} + assert response == {"choices": [["S92000003", "Scotland"]]} class TestQuerysetToGeographyCodeNameTuples: From beebd08d04486aa7ecd8ef8d3f6cf9d2b2638fa4 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 1 Apr 2026 17:04:04 +0100 Subject: [PATCH 083/186] remove old file --- tests/integration/metrics/api/views/test_permission_sets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/metrics/api/views/test_permission_sets.py b/tests/integration/metrics/api/views/test_permission_sets.py index 57fc4962e..8efed2817 100644 --- a/tests/integration/metrics/api/views/test_permission_sets.py +++ b/tests/integration/metrics/api/views/test_permission_sets.py @@ -6,7 +6,6 @@ from auth_content.models.permission_sets import PermissionSet from tests.factories.metrics.metric import MetricFactory -from tests.factories.metrics.permission_set import PermissionSetFactory from tests.factories.metrics.sub_theme import SubThemeFactory from tests.factories.metrics.topic import TopicFactory From 1c16ace9a47580f97f4308e18989283d107a7fb2 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Thu, 2 Apr 2026 09:08:19 +0100 Subject: [PATCH 084/186] CDD-3175: Update method annotation --- metrics/api/serializers/geographies.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/metrics/api/serializers/geographies.py b/metrics/api/serializers/geographies.py index 76a639f86..ed91389c5 100644 --- a/metrics/api/serializers/geographies.py +++ b/metrics/api/serializers/geographies.py @@ -244,7 +244,7 @@ def validate_geography_type_id(value: str) -> str | int: message = "Geography Type must be a number or '-1'" raise serializers.ValidationError(message) from err - def data(self) -> dict[str, list[tuple[str, str]]]: + def data(self) -> dict[str, list[list[str, str]]]: """ Fetch geographies for specified geography type from DB and format as response. @@ -278,17 +278,17 @@ def _queryset_to_geography_code_name_tuples( queryset: QuerySet, ) -> list[tuple[str, str]]: """ - Convert a QuerySet with 'id' and 'name' fields to a list of tuples. + Convert a QuerySet with 'geography_code' and 'name' fields to a list of tuples. Args: - queryset: QuerySet containing dicts with 'id' and 'name' keys + queryset: QuerySet containing dicts with 'geography_code' and 'name' keys Returns: - List of (id, name) tuples + List of (geography_code, name) tuples Examples: >>> qs = Model.objects.values('id', 'name') - >>> queryset_to_id_name_tuples(qs) - [(1, "item1"), (2, "item2")] + >>> queryset_to_geography_code_name_tuples(qs) + [("E92000001", "England"), ("E12000007", "London")] """ return [(item["geography_code"], item["name"]) for item in queryset] From 4a3c3b75d1106f7b283cf588adc763c55446a153 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Thu, 2 Apr 2026 09:08:19 +0100 Subject: [PATCH 085/186] CDD-3175: Update method annotation --- metrics/api/serializers/geographies.py | 3 +- .../managers/core_models/test_geography.py | 6 ++-- .../test_field_choices_callables.py | 30 +++++++------------ .../api/serializers/test_geographies.py | 6 ++-- 4 files changed, 16 insertions(+), 29 deletions(-) diff --git a/metrics/api/serializers/geographies.py b/metrics/api/serializers/geographies.py index ed91389c5..f19bdf6a3 100644 --- a/metrics/api/serializers/geographies.py +++ b/metrics/api/serializers/geographies.py @@ -64,8 +64,7 @@ def data(self) -> list[GEOGRAPHY_TYPE_RESULT]: """ topic: str = self.validated_data["topic"] queryset: CoreTimeSeriesQuerySet = ( - self.core_time_series_manager.get_available_geographies( - topic=topic) + self.core_time_series_manager.get_available_geographies(topic=topic) ) return _serialize_queryset(queryset=queryset) 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 f5836f662..03972de53 100644 --- a/tests/integration/metrics/data/managers/core_models/test_geography.py +++ b/tests/integration/metrics/data/managers/core_models/test_geography.py @@ -60,13 +60,11 @@ def test_query_for_get_all_names_and_codes(self): geography_one = GeographyFactory.create_with_geography_type( name="Leeds", geography_code="E08000035", - geography_type="Lower Tier Local Authority" + geography_type="Lower Tier Local Authority", ) geography_two = GeographyFactory.create_with_geography_type( - name="London", - geography_code="E12000007", - geography_type="Region" + name="London", geography_code="E12000007", geography_type="Region" ) geography_three = GeographyFactory.create_with_geography_type( name="England", diff --git a/tests/unit/cms/metrics_interface/test_field_choices_callables.py b/tests/unit/cms/metrics_interface/test_field_choices_callables.py index 65efd019c..467f1b27c 100644 --- a/tests/unit/cms/metrics_interface/test_field_choices_callables.py +++ b/tests/unit/cms/metrics_interface/test_field_choices_callables.py @@ -32,8 +32,7 @@ def test_delegates_call_correctly( unique_metric_names = field_choices_callables.get_all_unique_metric_names() # Then - assert unique_metric_names == [(x, x) - for x in retrieved_unique_metric_names] + assert unique_metric_names == [(x, x) for x in retrieved_unique_metric_names] class TestGetAlLTimeSeriesMetricNames: @@ -56,8 +55,7 @@ def test_delegates_calls_correctly( unique_metric_names = field_choices_callables.get_all_timeseries_metric_names() # Then - assert unique_metric_names == [(x, x) - for x in retrieved_unique_metric_names] + assert unique_metric_names == [(x, x) for x in retrieved_unique_metric_names] class TestGetAllHeadlineMetricNames: @@ -70,8 +68,7 @@ def test_delegates_calls_correctly( When `get_all_headline_metric_names()` is called Then the headline metric names are returned as a list of 2-item tuples """ - retrieved_headline_metric_names = [ - "COVID-19_headline_cases_7DayTotals"] + retrieved_headline_metric_names = ["COVID-19_headline_cases_7DayTotals"] mocked_get_all_headline_metric_names.return_value = ( retrieved_headline_metric_names ) @@ -98,8 +95,7 @@ def test_delegates_call_correctly( Then the unique metric names are returned as a list of 2-item tuples """ # Given - retrieved_unique_change_type_metric_names = [ - "COVID-19_deaths_ONSRollingMean"] + retrieved_unique_change_type_metric_names = ["COVID-19_deaths_ONSRollingMean"] mocked_get_all_unique_change_type_metric_names.return_value = ( retrieved_unique_change_type_metric_names ) @@ -437,8 +433,7 @@ def test_delegates_call_correctly( geography_type_names = field_choices_callables.get_all_geography_type_names() # Then - assert geography_type_names == [(x, x) - for x in retrieved_geography_type_names] + assert geography_type_names == [(x, x) for x in retrieved_geography_type_names] class TestGetAllSexNames: @@ -582,8 +577,7 @@ def test_delegates_call_correctly( all_sub_theme_names = field_choices_callables.get_all_sub_theme_names() # Then - assert all_sub_theme_names == [(x, x) - for x in retrieved_sub_theme_names] + assert all_sub_theme_names == [(x, x) for x in retrieved_sub_theme_names] class TestGetAllSubThemeNamesAndIds: @@ -747,8 +741,7 @@ def test_delegates_call_correctly( mocked_get_all_sex_names = ["all", "f", "m"] mocked_get_all_age_names.return_value = ["00-04", "05-11"] mocked_get_all_stratum_names.return_value = ["default"] - mocked_get_all_geography_names.return_value = [ - "London", "Yorkshire and Humber"] + mocked_get_all_geography_names.return_value = ["London", "Yorkshire and Humber"] # When retrieved_subcategory_choices = ( @@ -838,10 +831,8 @@ def test_receives_subcategory_choices_grouped_by_category( Then a dictionary is returned containing the subcategory choices grouped by category """ # Given - mocked_get_all_age_names.return_value = [ - ("00-04", "00-04"), ("05-11", "05-11")] - mocked_get_all_sex_names.return_value = [ - ("all", "all"), ("m", "m"), ("f", "f")] + mocked_get_all_age_names.return_value = [("00-04", "00-04"), ("05-11", "05-11")] + mocked_get_all_sex_names.return_value = [("all", "all"), ("m", "m"), ("f", "f")] mocked_get_all_stratum_names.return_value = [("default", "default")] mocked_all_geography_choices_grouped_by_type.return_value = [ ("London", "London"), @@ -892,5 +883,6 @@ def test_delegates_call_correctly( # Then assert all_geography_names_and_codes == [ - (str(x["geography_code"]), x["name"]) for x in retrieved_geography_names_and_codes + (str(x["geography_code"]), x["name"]) + for x in retrieved_geography_names_and_codes ] diff --git a/tests/unit/metrics/api/serializers/test_geographies.py b/tests/unit/metrics/api/serializers/test_geographies.py index 86a7afb4a..fa9d1583d 100644 --- a/tests/unit/metrics/api/serializers/test_geographies.py +++ b/tests/unit/metrics/api/serializers/test_geographies.py @@ -374,8 +374,7 @@ def test_data_returns_wildcard_response_for_wildcard_geography_type_id(self): response = serializer.data() # Then - assert response == {"choices": [ - [WILDCARD_ID_VALUE, "* (All geographies)"]]} + assert response == {"choices": [[WILDCARD_ID_VALUE, "* (All geographies)"]]} def test_data_fetches_geographies_for_valid_geography_type_id(self): """ @@ -616,8 +615,7 @@ def test_handles_single_item_queryset(self): Then a list with one tuple is returned """ # Given - mock_queryset = [ - {"geography_code": "N92000002", "name": "Northern Ireland"}] + mock_queryset = [{"geography_code": "N92000002", "name": "Northern Ireland"}] # When result = _queryset_to_geography_code_name_tuples(mock_queryset) From 4deebb33c367d99227dfd6413477180ced95127f Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 2 Apr 2026 11:10:41 +0100 Subject: [PATCH 086/186] Update checkboxes --- ...r_permissionset_geography_type_and_more.py | 8 ++--- auth_content/models/users.py | 32 ++----------------- auth_content/wagtail_hooks.py | 7 ++-- 3 files changed, 7 insertions(+), 40 deletions(-) diff --git a/auth_content/migrations/0002_alter_permissionset_geography_type_and_more.py b/auth_content/migrations/0002_alter_permissionset_geography_type_and_more.py index 3085f532e..84bab3279 100644 --- a/auth_content/migrations/0002_alter_permissionset_geography_type_and_more.py +++ b/auth_content/migrations/0002_alter_permissionset_geography_type_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.12 on 2026-03-31 13:15 +# Generated by Django 5.2.12 on 2026-04-02 10:00 from django.db import migrations, models @@ -35,11 +35,7 @@ class Migration(migrations.Migration): ("user_id", models.UUIDField(unique=True)), ( "permission_sets", - models.ManyToManyField( - blank=True, - help_text="If no permission sets are showing, create one on the Permission Sets page", - to="auth_content.permissionset", - ), + models.ManyToManyField(blank=True, to="auth_content.permissionset"), ), ], ), diff --git a/auth_content/models/users.py b/auth_content/models/users.py index fcc5c246e..d6bd6ccf0 100644 --- a/auth_content/models/users.py +++ b/auth_content/models/users.py @@ -1,47 +1,19 @@ from django import forms -from auth_content.models.permission_sets import PermissionSet from django.db import models -from wagtail.admin.panels import FieldPanel, WagtailAdminModelForm +from wagtail.admin.panels import FieldPanel -def get_permission_set_choices(): - return [(str(obj.id), obj.name) for obj in PermissionSet.objects.all()] - - - -class UserForm(WagtailAdminModelForm): - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - choices = get_permission_set_choices() - - self.fields["permission_sets"] = forms.MultipleChoiceField( - required=False, - choices=choices, - widget=forms.CheckboxSelectMultiple, - label="Permission Sets" - ) - - if self.instance and self.instance.pk: - self.fields["permission_sets"].initial = [ - str(v) for v in self.instance.permission_sets - ] - class User(models.Model): user_id = models.UUIDField(unique=True) permission_sets = models.ManyToManyField("PermissionSet", blank=True) - base_form_class = UserForm - panels = [ FieldPanel("user_id"), - FieldPanel("permission_sets"), + FieldPanel("permission_sets", widget=forms.CheckboxSelectMultiple), ] def __str__(self): return f"User {self.user_id}" - diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py index 8faefb25d..1e77b61ca 100644 --- a/auth_content/wagtail_hooks.py +++ b/auth_content/wagtail_hooks.py @@ -1,19 +1,18 @@ from django.templatetags.static import static from django.utils.html import format_html from wagtail import hooks -from wagtail.admin.viewsets.model import ModelViewSetGroup -from wagtail.snippets.views.snippets import SnippetViewSet +from wagtail.admin.viewsets.model import ModelViewSet, ModelViewSetGroup from auth_content.models.permission_sets import PermissionSet from auth_content.models.users import User -class PermissionSetViewSet(SnippetViewSet): +class PermissionSetViewSet(ModelViewSet): model = PermissionSet menu_label = "Permission Sets" icon = "key" -class UserViewSet(SnippetViewSet): +class UserViewSet(ModelViewSet): model = User menu_label = "Users" icon = "user" From bccf9d8c64c3bfb2006ee4e9993375bb583a9db9 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 2 Apr 2026 11:17:51 +0100 Subject: [PATCH 087/186] Linting fixes --- auth_content/models/__init__.py | 1 + auth_content/models/permission_sets.py | 4 --- auth_content/models/users.py | 5 +--- auth_content/wagtail_hooks.py | 4 ++- cms/dynamic_content/blocks.py | 19 ++++++++----- metrics/api/urls_construction.py | 1 - metrics/api/views/permission_sets.py | 2 -- tests/factories/metrics/metric.py | 1 + .../metrics/api/views/test_geographies.py | 6 ++--- .../test_field_choices_callables.py | 27 +++++++------------ 10 files changed, 29 insertions(+), 41 deletions(-) create mode 100644 auth_content/models/__init__.py diff --git a/auth_content/models/__init__.py b/auth_content/models/__init__.py new file mode 100644 index 000000000..4d59541cb --- /dev/null +++ b/auth_content/models/__init__.py @@ -0,0 +1 @@ +from auth_content.models import permission_sets, users diff --git a/auth_content/models/permission_sets.py b/auth_content/models/permission_sets.py index 885be7788..03e9c49a0 100644 --- a/auth_content/models/permission_sets.py +++ b/auth_content/models/permission_sets.py @@ -7,7 +7,6 @@ from wagtail.admin.forms import WagtailAdminModelForm from wagtail.admin.panels import FieldPanel - from auth_content.constants import PERMISSION_SET_FIELDS, WILDCARD_ID_VALUE from cms.metrics_interface.field_choices_callables import ( get_all_geography_names_and_codes, @@ -238,6 +237,3 @@ def _find_label_in_choices(choices: list[tuple], value: str) -> str: def __str__(self): return self.name or f"Permission Set {self.id}" - - - diff --git a/auth_content/models/users.py b/auth_content/models/users.py index d6bd6ccf0..bcb87ebbc 100644 --- a/auth_content/models/users.py +++ b/auth_content/models/users.py @@ -1,10 +1,8 @@ from django import forms - from django.db import models from wagtail.admin.panels import FieldPanel - class User(models.Model): user_id = models.UUIDField(unique=True) permission_sets = models.ManyToManyField("PermissionSet", blank=True) @@ -13,7 +11,6 @@ class User(models.Model): FieldPanel("user_id"), FieldPanel("permission_sets", widget=forms.CheckboxSelectMultiple), ] - - + def __str__(self): return f"User {self.user_id}" diff --git a/auth_content/wagtail_hooks.py b/auth_content/wagtail_hooks.py index 8127c4ab4..3e250d9c4 100644 --- a/auth_content/wagtail_hooks.py +++ b/auth_content/wagtail_hooks.py @@ -11,12 +11,14 @@ class PermissionSetViewSet(ModelViewSet): model = PermissionSet menu_label = "Permission Sets" icon = "key" - + + class UserViewSet(ModelViewSet): model = User menu_label = "Users" icon = "user" + class AuthGroup(ModelViewSetGroup): items = (PermissionSetViewSet, UserViewSet) menu_label = "Auth" diff --git a/cms/dynamic_content/blocks.py b/cms/dynamic_content/blocks.py index 8415d9be1..024165c64 100644 --- a/cms/dynamic_content/blocks.py +++ b/cms/dynamic_content/blocks.py @@ -1,6 +1,15 @@ from django.core.exceptions import ValidationError from django.db import models -from wagtail.blocks import CharBlock, ChoiceBlock, PageChooserBlock, StreamBlock, StructBlock, StructValue, TextBlock, URLBlock +from wagtail.blocks import ( + CharBlock, + ChoiceBlock, + PageChooserBlock, + StreamBlock, + StructBlock, + StructValue, + TextBlock, + URLBlock, +) from wagtail.snippets.blocks import SnippetChooserBlock from cms.dynamic_content import help_texts @@ -155,9 +164,7 @@ class Meta: class RelatedLink(StructBlock): - link_display_text = CharBlock( - required=True, help_text=help_texts.RELATED_LINK_TEXT - ) + link_display_text = CharBlock(required=True, help_text=help_texts.RELATED_LINK_TEXT) link = CharBlock(required=True, help_text=help_texts.RELATED_LINK_URL) @@ -188,9 +195,7 @@ def clean(self, value: StructValue): return super().clean(value=value) @classmethod - def _validate_only_one_of_page_or_external_url( - cls, *, value: StructValue - ) -> None: + def _validate_only_one_of_page_or_external_url(cls, *, value: StructValue) -> None: """Validate that only one of the page or external_url fields is set if provided.""" page = value.get("page") external_url = value.get("external_url") diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index 4b1d6ccb5..604fba2fe 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -158,7 +158,6 @@ def construct_public_api_urlpatterns( GeographiesByGeographyTypeView.as_view(), name="get_geographies", ), - path(f"{API_PREFIX}menus/v1", MenuView.as_view()), path(f"{API_PREFIX}alerts/v1/heat", heat_alert_list, name="heat-alerts-list"), path( diff --git a/metrics/api/views/permission_sets.py b/metrics/api/views/permission_sets.py index cb267e47e..7a76eaf8f 100644 --- a/metrics/api/views/permission_sets.py +++ b/metrics/api/views/permission_sets.py @@ -4,7 +4,6 @@ from rest_framework.response import Response from rest_framework.views import APIView -from auth_content.models.permission_sets import PermissionSet from metrics.api.serializers.permission_sets import ( MetricRequestSerializer, PermissionSetResponseSerializer, @@ -64,4 +63,3 @@ def get(self, request, topic_id, *args, **kwargs): # noqa: PLR6301 serializer = MetricRequestSerializer(data={"topic_id": topic_id}) serializer.is_valid(raise_exception=True) return Response(serializer.data()) - diff --git a/tests/factories/metrics/metric.py b/tests/factories/metrics/metric.py index 25b94e229..c5ee56d15 100644 --- a/tests/factories/metrics/metric.py +++ b/tests/factories/metrics/metric.py @@ -2,6 +2,7 @@ from metrics.data.models.core_models import Metric, Topic + class MetricFactory(factory.django.DjangoModelFactory): """ Factory for creating `Metric` instances for tests diff --git a/tests/integration/metrics/api/views/test_geographies.py b/tests/integration/metrics/api/views/test_geographies.py index d984fce6b..31b15c627 100644 --- a/tests/integration/metrics/api/views/test_geographies.py +++ b/tests/integration/metrics/api/views/test_geographies.py @@ -172,8 +172,7 @@ def test_get_returns_correct_results_for_topic(self): # When query_params = {"topic": topic} - response: Response = client.get( - path=self.path, query_params=query_params) + response: Response = client.get(path=self.path, query_params=query_params) # Then # Geographies are returned in descending alphabetical order @@ -246,8 +245,7 @@ def test_get_returns_correct_results_for_geography_type(self): # When query_params = {"geography_type": ltla} - response: Response = client.get( - path=self.path, query_params=query_params) + response: Response = client.get(path=self.path, query_params=query_params) # Then # Geographies are returned in descending alphabetical order diff --git a/tests/unit/cms/metrics_interface/test_field_choices_callables.py b/tests/unit/cms/metrics_interface/test_field_choices_callables.py index 203e9cd81..8f8ec3372 100644 --- a/tests/unit/cms/metrics_interface/test_field_choices_callables.py +++ b/tests/unit/cms/metrics_interface/test_field_choices_callables.py @@ -32,8 +32,7 @@ def test_delegates_call_correctly( unique_metric_names = field_choices_callables.get_all_unique_metric_names() # Then - assert unique_metric_names == [(x, x) - for x in retrieved_unique_metric_names] + assert unique_metric_names == [(x, x) for x in retrieved_unique_metric_names] class TestGetAlLTimeSeriesMetricNames: @@ -56,8 +55,7 @@ def test_delegates_calls_correctly( unique_metric_names = field_choices_callables.get_all_timeseries_metric_names() # Then - assert unique_metric_names == [(x, x) - for x in retrieved_unique_metric_names] + assert unique_metric_names == [(x, x) for x in retrieved_unique_metric_names] class TestGetAllHeadlineMetricNames: @@ -70,8 +68,7 @@ def test_delegates_calls_correctly( When `get_all_headline_metric_names()` is called Then the headline metric names are returned as a list of 2-item tuples """ - retrieved_headline_metric_names = [ - "COVID-19_headline_cases_7DayTotals"] + retrieved_headline_metric_names = ["COVID-19_headline_cases_7DayTotals"] mocked_get_all_headline_metric_names.return_value = ( retrieved_headline_metric_names ) @@ -98,8 +95,7 @@ def test_delegates_call_correctly( Then the unique metric names are returned as a list of 2-item tuples """ # Given - retrieved_unique_change_type_metric_names = [ - "COVID-19_deaths_ONSRollingMean"] + retrieved_unique_change_type_metric_names = ["COVID-19_deaths_ONSRollingMean"] mocked_get_all_unique_change_type_metric_names.return_value = ( retrieved_unique_change_type_metric_names ) @@ -437,8 +433,7 @@ def test_delegates_call_correctly( geography_type_names = field_choices_callables.get_all_geography_type_names() # Then - assert geography_type_names == [(x, x) - for x in retrieved_geography_type_names] + assert geography_type_names == [(x, x) for x in retrieved_geography_type_names] class TestGetAllSexNames: @@ -582,8 +577,7 @@ def test_delegates_call_correctly( all_sub_theme_names = field_choices_callables.get_all_sub_theme_names() # Then - assert all_sub_theme_names == [(x, x) - for x in retrieved_sub_theme_names] + assert all_sub_theme_names == [(x, x) for x in retrieved_sub_theme_names] class TestGetAllSubThemeNamesAndIds: @@ -807,8 +801,7 @@ def test_delegates_call_correctly( mocked_get_all_sex_names = ["all", "f", "m"] mocked_get_all_age_names.return_value = ["00-04", "05-11"] mocked_get_all_stratum_names.return_value = ["default"] - mocked_get_all_geography_names.return_value = [ - "London", "Yorkshire and Humber"] + mocked_get_all_geography_names.return_value = ["London", "Yorkshire and Humber"] # When retrieved_subcategory_choices = ( @@ -898,10 +891,8 @@ def test_receives_subcategory_choices_grouped_by_category( Then a dictionary is returned containing the subcategory choices grouped by category """ # Given - mocked_get_all_age_names.return_value = [ - ("00-04", "00-04"), ("05-11", "05-11")] - mocked_get_all_sex_names.return_value = [ - ("all", "all"), ("m", "m"), ("f", "f")] + mocked_get_all_age_names.return_value = [("00-04", "00-04"), ("05-11", "05-11")] + mocked_get_all_sex_names.return_value = [("all", "all"), ("m", "m"), ("f", "f")] mocked_get_all_stratum_names.return_value = [("default", "default")] mocked_all_geography_choices_grouped_by_type.return_value = [ ("London", "London"), From 8d5787c204e5405e6efb0544ba5c1b192cf2fa4c Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 2 Apr 2026 11:20:42 +0100 Subject: [PATCH 088/186] remove merge issue --- auth_content/static/js/permission_set.js | 355 +---------------------- tests/factories/metrics/topic.py | 2 - 2 files changed, 1 insertion(+), 356 deletions(-) diff --git a/auth_content/static/js/permission_set.js b/auth_content/static/js/permission_set.js index a2655ab66..531cfe2aa 100644 --- a/auth_content/static/js/permission_set.js +++ b/auth_content/static/js/permission_set.js @@ -342,357 +342,4 @@ // Initialize when DOM is ready document.addEventListener("DOMContentLoaded", initialize); -})(); - -(function () { - "use strict"; - let theme, subTheme, topic, metric, geographyType, geography; - - /** - * Generic function to fetch choices from the API - * @param {string} endpoint - The API endpoint (e.g., 'subthemes', 'topics', 'metrics') - * @param {string} dataItemId - The ID value to pass - * @returns {Promise} Array of choices [[id, name], ...] - */ - async function fetchChoices(endpoint, dataItemId) { - try { - const url = `/api/permission-set/${endpoint}/${dataItemId}`; - - const response = await fetch(url); - - if (!response.ok) { - const errorData = await response.json(); - console.error(`API error: ${errorData.error || "Unknown error"}`); - return []; - } - - const data = await response.json(); - return data.choices || []; - } catch (error) { - console.error(`Error fetching ${endpoint}:`, error); - return []; - } - } - async function fetchGeographies(endpoint, dataItemId) { - try { - const url = `/api/permission-set/${endpoint}/${dataItemId}`; - - const response = await fetch(url); - - if (!response.ok) { - const errorData = await response.json(); - console.error(`API error: ${errorData.error || "Unknown error"}`); - return []; - } - - const data = await response.json(); - return data.choices || []; - } catch (error) { - console.error(`Error fetching ${endpoint}:`, error); - return []; - } - } - - /** - * Generic function to populate a dropdown with choices - * @param {HTMLSelectElement} dropdown - The select element to populate - * @param {Array} choices - Array of [id, name] tuples - */ - function populateDropdown(dropdown, choices, wildcardValue = "* All Items") { - const currentValue = dropdown.value; - dropdown.disabled = false; - dropdown.innerHTML = ""; - - //dropdown empty - const nullOption = document.createElement("option"); - nullOption.value = ""; - nullOption.textContent = "--------"; - dropdown.appendChild(nullOption); - - //dropdown wildcard choice - const wildcardOption = document.createElement("option"); - wildcardOption.value = "-1"; - wildcardOption.textContent = wildcardValue; - dropdown.appendChild(wildcardOption); - - choices.forEach(([id, name]) => { - const option = document.createElement("option"); - option.value = id; - option.textContent = name; - dropdown.appendChild(option); - }); - - if (currentValue) { - dropdown.value = currentValue; - } - } - - function clearDropdown(dropdown, message = "Select parent first") { - dropdown.innerHTML = ""; - - const option = document.createElement("option"); - option.value = ""; - option.textContent = message; - dropdown.appendChild(option); - - dropdown.value = ""; - } - - /** - * Set dropdown to wildcard and disable it - * Used when parent is wildcard, cascading "all" to children - */ - function setToWildcard( - dropdown, - message = "* (All - inherited from parent)", - ) { - dropdown.innerHTML = ""; - - const option = document.createElement("option"); - option.value = "-1"; - option.textContent = message; - dropdown.appendChild(option); - - dropdown.value = "-1"; - } - - /** - * Handle theme selection change - */ - async function handleThemeChange() { - const themeValue = theme.value; - - // Clear all dependent dropdowns - if (!themeValue || themeValue === "") { - clearDropdown(subTheme, "Select theme first"); - clearDropdown(topic, "Select sub-theme first"); - clearDropdown(metric, "Select topic first"); - return; - } - - if (themeValue === "-1") { - setToWildcard(subTheme, "* (All sub-themes)"); - setToWildcard(topic, "* (All topics)"); - setToWildcard(metric, "* (All metrics)"); - return; - } - - clearDropdown(subTheme, "--------"); - clearDropdown(topic, "--------"); - clearDropdown(metric, "--------"); - - // Fetch and populate sub-themes - const choices = await fetchChoices("subthemes", themeValue); - - if (choices.length > 0) { - populateDropdown(subTheme, choices, "* All sub-themes"); - } else { - clearDropdown(subTheme, "No sub-themes available"); - } - } - - /** - * Handle sub-theme selection change - */ - async function handleSubThemeChange() { - const subThemeValue = subTheme.value; - - if (!subThemeValue || subThemeValue === "") { - // No sub-theme selected - clear children - clearDropdown(topic, "Select sub-theme first"); - clearDropdown(metric, "Select topic first"); - return; - } - - if (subThemeValue === "-1") { - // Wildcard sub-theme = cascade wildcard to children - setToWildcard(topic, "* (All topics)"); - setToWildcard(metric, "* (All metrics)"); - return; - } - - // Clear dependent dropdowns - clearDropdown(topic, "Select sub-theme"); - clearDropdown(metric, "Select metric"); - - // Fetch and populate topics - const choices = await fetchChoices("topics", subThemeValue); - - if (choices.length > 0) { - populateDropdown(topic, choices, "* All topics"); - } else { - clearDropdown(topic, "No topics available"); - } - } - - /** - * Handle topic selection change - */ - async function handleTopicChange() { - const topicValue = topic.value; - - if (!topicValue || topicValue === "") { - // No topic selected - clear metrics - clearDropdown(metric, "Select topic first"); - return; - } - - if (topicValue === "-1") { - // Wildcard topic = cascade wildcard to metrics - setToWildcard(metric, "* (All metrics)"); - return; - } - - clearDropdown(metric, "--------"); - - // Fetch and populate metrics - const choices = await fetchChoices("metrics", topicValue); - - if (choices.length > 0) { - populateDropdown(metric, choices, "* All metrics"); - } else { - clearDropdown(metric, "No metrics available"); - } - } - async function handleGeographyTypeChange() { - const geographyTypeValue = geographyType.value; - - if (!geographyTypeValue || geographyTypeValue === "") { - // No topic selected - clear metrics - clearDropdown(geography, "Select geography type first"); - return; - } - - if (geographyTypeValue === "-1") { - // Wildcard topic = cascade wildcard to metrics - setToWildcard(geography, "* (All geographies)"); - return; - } - clearDropdown(geography, "--------"); - - // Fetch and populate metrics - const choices = await fetchGeographies("geographies", geographyTypeValue); - - if (choices.length > 0) { - populateDropdown(geography, choices, "* All geographies"); - } else { - clearDropdown(geography, "No geographies available"); - } - } - - /** - * Initialize dropdowns for edit mode - * Loads the dropdown options based on saved values - */ - async function initializeEditMode() { - // Store original values before we start manipulating dropdowns - const savedTheme = theme.value; - const savedSubTheme = subTheme.value; - const savedTopic = topic.value; - const savedMetric = metric.value; - const savedGeographyType = geographyType.value; - const savedGeography = geography.value; - - // If theme has a value (not wildcard, not empty), load sub-themes - if (savedTheme && savedTheme !== "" && savedTheme !== "-1") { - const subThemeChoices = await fetchChoices("subthemes", savedTheme); - if (subThemeChoices.length > 0) { - populateDropdown(subTheme, subThemeChoices, "* (All sub-themes)"); - subTheme.value = savedSubTheme; // Restore selection - } - - // If sub-theme has a value, load topics - if (savedSubTheme && savedSubTheme !== "" && savedSubTheme !== "-1") { - const topicChoices = await fetchChoices("topics", savedSubTheme); - if (topicChoices.length > 0) { - populateDropdown(topic, topicChoices, "* (All topics)"); - topic.value = savedTopic; // Restore selection - } - - // If topic has a value, load metrics - if (savedTopic && savedTopic !== "" && savedTopic !== "-1") { - const metricChoices = await fetchChoices("metrics", savedTopic); - if (metricChoices.length > 0) { - populateDropdown(metric, metricChoices, "* (All metrics)"); - metric.value = savedMetric; // Restore selection - } - } - } - } else if (savedTheme === "-1") { - // Theme is wildcard, cascade to children - setToWildcard(subTheme, "* (All sub-themes)"); - setToWildcard(topic, "* (All topics)"); - setToWildcard(metric, "* (All metrics)"); - } - - // Handle geography independently - if ( - savedGeographyType && - savedGeographyType !== "" && - savedGeographyType !== "-1" - ) { - const geographyChoices = await fetchChoices( - "geographies", - savedGeographyType, - ); - if (geographyChoices.length > 0) { - populateDropdown(geography, geographyChoices, "* (All geographies)"); - geography.value = savedGeography; // Restore selection - } - } else if (savedGeographyType === "-1") { - setToWildcard(geography, "* (All geographies)"); - } - } - - /** - * Initialize the cascading dropdowns - */ - function initialize() { - // Get dropdown elements - theme = document.querySelector('select[name="theme"]'); - subTheme = document.querySelector('select[name="sub_theme"]'); - topic = document.querySelector('select[name="topic"]'); - metric = document.querySelector('select[name="metric"]'); - geographyType = document.querySelector('select[name="geography_type"]'); - geography = document.querySelector('select[name="geography"]'); - - // Exit if not on permission set page - if ( - !theme || - !subTheme || - !topic || - !metric || - !geographyType || - !geography - ) { - console.warn("Permission set dropdowns not found on this page"); - return; - } - - // Add event listeners - theme.addEventListener("change", handleThemeChange); - subTheme.addEventListener("change", handleSubThemeChange); - topic.addEventListener("change", handleTopicChange); - geographyType.addEventListener("change", handleGeographyTypeChange); - - const isEditMode = - theme.value || - subTheme.value || - topic.value || - metric.value || - geographyType.value || - geography.value; - - if (isEditMode) { - initializeEditMode(); - } else { - clearDropdown(subTheme, "Select theme first"); - clearDropdown(topic, "Select sub-theme first"); - clearDropdown(metric, "Select topic first"); - clearDropdown(geography, "Select geography type first"); - } - } - - // Initialize when DOM is ready - document.addEventListener("DOMContentLoaded", initialize); -})(); +})(); \ No newline at end of file diff --git a/tests/factories/metrics/topic.py b/tests/factories/metrics/topic.py index f62570271..9dd820b82 100644 --- a/tests/factories/metrics/topic.py +++ b/tests/factories/metrics/topic.py @@ -1,8 +1,6 @@ import factory from metrics.data.models.core_models import Topic, SubTheme -from metrics.data.models.core_models import Topic, SubTheme - class TopicFactory(factory.django.DjangoModelFactory): """ From cca3cf36fc9b3f0f2a7341d239e01d05f6567f32 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 2 Apr 2026 11:24:59 +0100 Subject: [PATCH 089/186] linting things --- cms/dynamic_content/blocks.py | 8 +++----- tests/factories/metrics/topic.py | 1 + 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cms/dynamic_content/blocks.py b/cms/dynamic_content/blocks.py index 024165c64..179e81d04 100644 --- a/cms/dynamic_content/blocks.py +++ b/cms/dynamic_content/blocks.py @@ -205,13 +205,11 @@ def _validate_only_one_of_page_or_external_url(cls, *, value: StructValue) -> No raise ValidationError(error_message) -class SectionFooterLink(blocks.StructBlock): - badge_label = blocks.CharBlock( +class SectionFooterLink(StructBlock): + badge_label = CharBlock( help_text=help_texts.SECTION_FOOTER_BADGE_LABEL, required=True ) - text = blocks.CharBlock( - help_text=help_texts.SECTION_FOOTER_LINK_TEXT, required=True - ) + text = CharBlock(help_text=help_texts.SECTION_FOOTER_LINK_TEXT, required=True) link = SourceLinkBlock(help_text=help_texts.SECTION_FOOTER_LINK, required=True) class Meta: diff --git a/tests/factories/metrics/topic.py b/tests/factories/metrics/topic.py index 9dd820b82..f72fb941d 100644 --- a/tests/factories/metrics/topic.py +++ b/tests/factories/metrics/topic.py @@ -2,6 +2,7 @@ from metrics.data.models.core_models import Topic, SubTheme + class TopicFactory(factory.django.DjangoModelFactory): """ Factory for creating `Topic` instances for tests From e1502f557fc7ba7ccdca38e5ebc7868597c7c52d Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Thu, 2 Apr 2026 13:31:55 +0100 Subject: [PATCH 090/186] CDD-3175: Update urls for permission set endpoints --- metrics/api/urls_construction.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index 604fba2fe..a2f831d33 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -134,10 +134,7 @@ def construct_public_api_urlpatterns( cold_alert_list = ColdAlertViewSet.as_view({"get": "list"}) cold_alert_detail = ColdAlertViewSet.as_view({"get": "retrieve"}) -private_api_urlpatterns = [ - # Headless CMS API - pages + drafts endpoints - path(API_PREFIX, cms_api_router.urls), - path(f"{API_PREFIX}global-banners/v2", GlobalBannerView.as_view()), +permission_set_urlpatterns = [ path( f"{API_PREFIX}data-hierarchy/subthemes/", SubThemesByThemeView.as_view(), @@ -158,6 +155,12 @@ def construct_public_api_urlpatterns( GeographiesByGeographyTypeView.as_view(), name="get_geographies", ), +] + +private_api_urlpatterns = [ + # Headless CMS API - pages + drafts endpoints + path(API_PREFIX, cms_api_router.urls), + path(f"{API_PREFIX}global-banners/v2", GlobalBannerView.as_view()), path(f"{API_PREFIX}menus/v1", MenuView.as_view()), path(f"{API_PREFIX}alerts/v1/heat", heat_alert_list, name="heat-alerts-list"), path( @@ -296,12 +299,14 @@ def construct_urlpatterns( app_mode=app_mode ) constructed_url_patterns += audit_api_urlpatterns + constructed_url_patterns += permission_set_urlpatterns case enums.AppMode.PUBLIC_API.value: constructed_url_patterns += construct_public_api_urlpatterns( app_mode=app_mode ) case enums.AppMode.PRIVATE_API.value: constructed_url_patterns += private_api_urlpatterns + constructed_url_patterns += permission_set_urlpatterns case enums.AppMode.FEEDBACK_API.value: constructed_url_patterns += feedback_urlpatterns case enums.AppMode.INGESTION.value: @@ -318,5 +323,6 @@ def construct_urlpatterns( constructed_url_patterns += private_api_urlpatterns constructed_url_patterns += feedback_urlpatterns constructed_url_patterns += audit_api_urlpatterns + constructed_url_patterns += permission_set_urlpatterns return constructed_url_patterns From 58c3fa749b3110ce1aff27db7ebf2cbb15e8a810 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 7 Apr 2026 09:54:40 +0100 Subject: [PATCH 091/186] CDD-3176: remove duplicated tests --- .../test_field_choices_callables.py | 60 ------------------- 1 file changed, 60 deletions(-) diff --git a/tests/unit/cms/metrics_interface/test_field_choices_callables.py b/tests/unit/cms/metrics_interface/test_field_choices_callables.py index 8f8ec3372..467f1b27c 100644 --- a/tests/unit/cms/metrics_interface/test_field_choices_callables.py +++ b/tests/unit/cms/metrics_interface/test_field_choices_callables.py @@ -610,66 +610,6 @@ def test_delegates_call_correctly( ] -class TestGetAllSubThemeNamesAndIds: - @mock.patch.object(interface.MetricsAPIInterface, "get_all_sub_theme_names_and_ids") - def test_delegates_call_correctly( - self, mocked_get_all_sub_theme_names_and_ids: mock.MagicMock - ): - """ - Given an instance of the `MetricsAPIInterface` which returns theme names - When `get_all_theme_names()` is called - Then the theme names are returned as a list of 2-item tuples - """ - # Given - retrieved_sub_theme_names = [ - {"id": 3, "name": "extreme_event"}, - {"id": 1, "name": "immunisation"}, - {"id": 2, "name": "infectious_disease"}, - {"id": 4, "name": "non-communicable"}, - ] - mocked_get_all_sub_theme_names_and_ids.return_value = retrieved_sub_theme_names - - # When - all_sub_theme_names_and_ids = ( - field_choices_callables.get_all_sub_theme_names_and_ids() - ) - - # Then - assert all_sub_theme_names_and_ids == [ - (str(x["id"]), x["name"]) for x in retrieved_sub_theme_names - ] - - -class TestGetAllSubThemeNamesAndIds: - @mock.patch.object(interface.MetricsAPIInterface, "get_all_sub_theme_names_and_ids") - def test_delegates_call_correctly( - self, mocked_get_all_sub_theme_names_and_ids: mock.MagicMock - ): - """ - Given an instance of the `MetricsAPIInterface` which returns theme names - When `get_all_theme_names()` is called - Then the theme names are returned as a list of 2-item tuples - """ - # Given - retrieved_sub_theme_names = [ - {"id": 3, "name": "extreme_event"}, - {"id": 1, "name": "immunisation"}, - {"id": 2, "name": "infectious_disease"}, - {"id": 4, "name": "non-communicable"}, - ] - mocked_get_all_sub_theme_names_and_ids.return_value = retrieved_sub_theme_names - - # When - all_sub_theme_names_and_ids = ( - field_choices_callables.get_all_sub_theme_names_and_ids() - ) - - # Then - assert all_sub_theme_names_and_ids == [ - (str(x["id"]), x["name"]) for x in retrieved_sub_theme_names - ] - - class TestGetAllUniqueSubThemeNames: @mock.patch.object(interface.MetricsAPIInterface, "get_all_unique_sub_theme_names") def test_delegates_call_correctly( From 7251853b64e74e776665f6c3dde8f1cdfd9a6c85 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 7 Apr 2026 14:59:51 +0100 Subject: [PATCH 092/186] CDD-3172: Update to add the functionality for retrieving user permission sets --- auth_content/models/users.py | 4 + metrics/api/serializers/user.py | 144 ++++++++++++++++++++++ metrics/api/urls_construction.py | 30 +++-- metrics/api/views/user.py | 38 ++++++ metrics/data/managers/rbac_models/user.py | 80 ++++++++++++ 5 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 metrics/api/serializers/user.py create mode 100644 metrics/api/views/user.py create mode 100644 metrics/data/managers/rbac_models/user.py diff --git a/auth_content/models/users.py b/auth_content/models/users.py index bcb87ebbc..fcfae3c89 100644 --- a/auth_content/models/users.py +++ b/auth_content/models/users.py @@ -2,6 +2,8 @@ from django.db import models from wagtail.admin.panels import FieldPanel +from metrics.data.managers.rbac_models.user import UserManager + class User(models.Model): user_id = models.UUIDField(unique=True) @@ -12,5 +14,7 @@ class User(models.Model): FieldPanel("permission_sets", widget=forms.CheckboxSelectMultiple), ] + objects = UserManager() + def __str__(self): return f"User {self.user_id}" diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py new file mode 100644 index 000000000..872be1f91 --- /dev/null +++ b/metrics/api/serializers/user.py @@ -0,0 +1,144 @@ +from django.db.models import QuerySet +from rest_framework import serializers +import uuid + +from auth_content.models.users import User + + +def _validate_user_id(value): + """Validate theme_id is either wildcard or a valid integer""" + try: + uuid_obj = uuid.UUID(value, version=4) + except ValueError as err: + msg = "User ID must be a valid UUID" + raise serializers.ValidationError(msg) from err + else: + return value + + +class UserRequestSerializer(serializers.Serializer): + """Fetches and formats sub-theme choices based on theme_id""" + + user_id = serializers.CharField(required=True) + + @property + def user_manager(self): + """ + Fetch the topic manager from the context if available. + If not get the Manager which has been declared on the `Topic` model. + """ + return self.context.get("user_manager", User.objects) + + @staticmethod + def validate_user_id(value): + """Validate user_id is a guid""" + return _validate_user_id(value) + + def data(self) -> dict: + """ + Fetch user permission sets from DB and format as response. + + Returns: + Dict with user_id, permission_sets list, and count + + Example: + { + "user_id": "123e4567-e89b-12d3-a456-426614174000", + "permission_sets": [ + { + "id": 1, + "name": "Theme: Infectious Disease | ...", + "theme": "1", + "sub_theme": "3", + ... + } + ], + "permission_set_count": 1 + } + """ + user_id_str = self.validated_data["user_id"] + user_uuid = uuid.UUID(user_id_str) + + # Get permission sets for this user + permission_sets = self.user_manager.get_permission_sets_for_user( + user_uuid) + + # Check if user exists or has permissions + if not permission_sets.exists(): + # Return empty structure rather than raising exception + # The view can check permission_set_count and return 404 if needed + return { + "user_id": user_id_str, + "permission_sets": [], + "permission_set_count": 0, + } + + # Convert QuerySet to list of dicts + permission_set_list = _queryset_to_permission_set_dicts( + permission_sets) + + return { + "user_id": user_id_str, + "permission_sets": permission_set_list, + "permission_set_count": len(permission_set_list), + } + + +class UserPermissionSetResponseSerializer(serializers.Serializer): + """Formats the response for choice endpoints""" + + user_id = serializers.CharField( + help_text="UUID of the user" + ) + + permission_sets = serializers.ListField( + child=serializers.DictField(), + help_text="List of permission set objects assigned to the user" + ) + + permission_set_count = serializers.IntegerField( + help_text="Total number of permission sets assigned to the user" + ) + + +def _queryset_to_permission_set_dicts( + queryset: QuerySet, +) -> list[dict]: + """ + Convert a PermissionSet QuerySet to a list of dictionaries. + + Args: + queryset: QuerySet of PermissionSet objects + + Returns: + List of dictionaries containing permission set data + + Examples: + >>> qs = PermissionSet.objects.filter(user__user_id=some_uuid) + >>> _queryset_to_permission_set_dicts(qs) + [ + { + 'id': 1, + 'name': 'Theme: Infectious Disease | ...', + 'theme': '1', + 'sub_theme': '3', + 'topic': '5', + 'metric': '10', + 'geography_type': 'Nation', + 'geography': 'E92000001' + }, + ... + ] + """ + return list( + queryset.values( + "id", + "name", + "theme", + "sub_theme", + "topic", + "metric", + "geography_type", + "geography", + ) + ) diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index a2f831d33..709d73e43 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -48,6 +48,7 @@ SubThemesByThemeView, TopicsBySubThemeView, ) +from metrics.api.views.user import UserPermissionSetsByUserIdView from public_api import construct_url_patterns_for_public_api router = routers.DefaultRouter() @@ -90,7 +91,8 @@ def construct_cms_admin_urlpatterns( prefix: str = "" if app_mode == enums.AppMode.CMS_ADMIN.value else "cms-admin/" return [ path(prefix, include(wagtailadmin_urls)), - path("choose-page/", LinkBrowseView.as_view(), name="wagtailadmin_choose_page"), + path("choose-page/", LinkBrowseView.as_view(), + name="wagtailadmin_choose_page"), ] @@ -155,6 +157,11 @@ def construct_public_api_urlpatterns( GeographiesByGeographyTypeView.as_view(), name="get_geographies", ), + path( + f"{API_PREFIX}user//permissions", + UserPermissionSetsByUserIdView.as_view(), + name="get_user_permissions", + ), ] private_api_urlpatterns = [ @@ -162,13 +169,15 @@ def construct_public_api_urlpatterns( path(API_PREFIX, cms_api_router.urls), path(f"{API_PREFIX}global-banners/v2", GlobalBannerView.as_view()), path(f"{API_PREFIX}menus/v1", MenuView.as_view()), - path(f"{API_PREFIX}alerts/v1/heat", heat_alert_list, name="heat-alerts-list"), + path(f"{API_PREFIX}alerts/v1/heat", + heat_alert_list, name="heat-alerts-list"), path( f"{API_PREFIX}alerts/v1/heat/", heat_alert_detail, name="heat-alerts-detail", ), - path(f"{API_PREFIX}alerts/v1/cold", cold_alert_list, name="cold-alerts-list"), + path(f"{API_PREFIX}alerts/v1/cold", + cold_alert_list, name="cold-alerts-list"), path( f"{API_PREFIX}alerts/v1/cold/", cold_alert_detail, @@ -179,7 +188,8 @@ def construct_public_api_urlpatterns( re_path(f"^{API_PREFIX}charts/subplot/v1", SubplotChartsView.as_view()), re_path(f"^{API_PREFIX}downloads/v2", DownloadsView.as_view()), re_path(f"^{API_PREFIX}bulkdownloads/v1", BulkDownloadsView.as_view()), - re_path(f"^{API_PREFIX}downloads/subplot/v1", SubplotDownloadsView.as_view()), + re_path(f"^{API_PREFIX}downloads/subplot/v1", + SubplotDownloadsView.as_view()), re_path( f"^{API_PREFIX}geographies/v2/(?P[^/]+)", GeographiesViewDeprecated.as_view(), @@ -194,8 +204,10 @@ def construct_public_api_urlpatterns( # Audit API endpoints audit_api_timeseries_list = AuditAPITimeSeriesViewSet.as_view({"get": "list"}) -audit_core_timeseries_list = AuditCoreTimeseriesViewSet.as_view({"get": "list"}) -audit_api_core_headline_list = AuditCoreHeadlineViewSet.as_view({"get": "list"}) +audit_core_timeseries_list = AuditCoreTimeseriesViewSet.as_view({ + "get": "list"}) +audit_api_core_headline_list = AuditCoreHeadlineViewSet.as_view({ + "get": "list"}) audit_api_urlpatterns = [ path( @@ -215,7 +227,8 @@ def construct_public_api_urlpatterns( ), re_path(f"^{API_PREFIX}charts/v2", ChartsView.as_view()), re_path(f"^{API_PREFIX}charts/v3", EncodedChartsView.as_view()), - re_path(f"^{API_PREFIX}charts/dual-category/v1", DualCategoryChartsView.as_view()), + re_path(f"^{API_PREFIX}charts/dual-category/v1", + DualCategoryChartsView.as_view()), re_path(f"^{API_PREFIX}charts/subplot/v1", SubplotChartsView.as_view()), ] @@ -236,7 +249,8 @@ def construct_public_api_urlpatterns( ] static_urlpatterns = [ - re_path(r"^static/(?P.*)$", serve, {"document_root": settings.STATIC_ROOT}), + re_path(r"^static/(?P.*)$", serve, + {"document_root": settings.STATIC_ROOT}), ] common_urlpatterns = [ diff --git a/metrics/api/views/user.py b/metrics/api/views/user.py new file mode 100644 index 000000000..d3c36ee2f --- /dev/null +++ b/metrics/api/views/user.py @@ -0,0 +1,38 @@ +from http import HTTPStatus + +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.response import Response +from rest_framework.views import APIView + +from metrics.api.serializers.user import UserPermissionSetResponseSerializer, UserRequestSerializer + +USER_API_TAG = "Authenticated User" + + +@extend_schema( + request=UserRequestSerializer, + tags=[USER_API_TAG], + parameters=[ + OpenApiParameter( + name='user_id', + type=str, + location=OpenApiParameter.PATH, + description='UUID of the user' + ) + ], + responses={ + HTTPStatus.OK.value: UserPermissionSetResponseSerializer, + 403: {"description": "Not authorized to view these permissions"}, + 404: {"description": "User not found or has no permissions"}, + }, +) +class UserPermissionSetsByUserIdView(APIView): + """Get user permission sets filtered by user ID""" + + permission_classes = [] + + def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 + """API endpoint to fetch a users assigned permission sets using user_id""" + serializer = UserRequestSerializer(data={"user_id": user_id}) + serializer.is_valid(raise_exception=True) + return Response(serializer.data()) diff --git a/metrics/data/managers/rbac_models/user.py b/metrics/data/managers/rbac_models/user.py new file mode 100644 index 000000000..8fdee52a0 --- /dev/null +++ b/metrics/data/managers/rbac_models/user.py @@ -0,0 +1,80 @@ +""" +Custom QuerySet and Manager classes for the User model. + +The application layer should only call into the Manager class. +The application should not interact directly with the QuerySet class. +""" + +from uuid import UUID + +from django.db import models + +from auth_content.models.permission_sets import PermissionSet + + +class UserQuerySet(models.QuerySet): + """Custom queryset for User model operations.""" + + def get_user_with_permission_sets(self, user_id: UUID) -> models.QuerySet: + """ + Get user with their permission sets prefetched. + + Args: + user_id: UUID of the user + + Returns: + QuerySet containing the user with permission_sets prefetched + + Examples: + >>> user = User.objects.get_user_with_permission_sets(uuid_value).first() + >>> user.permission_sets.all() + , ...]> + """ + return self.filter(user_id=user_id).prefetch_related("permission_sets") + + +class UserManager(models.Manager): + """Custom model manager class for the User model.""" + + def get_queryset(self) -> UserQuerySet: + return UserQuerySet(model=self.model, using=self.db) + + def get_user_with_permission_sets(self, user_id: UUID) -> UserQuerySet: + """ + Get user with their permission sets prefetched. + + This efficiently loads the user and all their related permission sets + in a minimal number of database queries. + + Args: + user_id: UUID of the user + + Returns: + QuerySet containing the user with permission_sets prefetched + + Examples: + >>> user = User.objects.get_user_with_permission_sets(some_uuid).first() + >>> for perm in user.permission_sets.all(): + ... print(perm.name) + """ + return self.get_queryset().get_user_with_permission_sets(user_id=user_id) + + def get_permission_sets_for_user(self, user_id: UUID) -> models.QuerySet: + """ + Get all permission sets for a user directly. + + This bypasses the User object and returns the permission sets directly, + which is useful for the API endpoints. + + Args: + user_id: UUID of the user + + Returns: + QuerySet of PermissionSet objects assigned to the user + + Examples: + >>> perms = User.objects.get_permission_sets_for_user(some_uuid) + >>> perms.count() + 5 + """ + return PermissionSet.objects.filter(user__user_id=user_id) From 34d6b85f49275716baa4a6ba2857be415cc0c52b Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 7 Apr 2026 15:36:46 +0100 Subject: [PATCH 093/186] CDD-3172: linting --- metrics/api/serializers/user.py | 17 +++++++--------- metrics/api/urls_construction.py | 24 ++++++++--------------- metrics/api/views/user.py | 9 ++++++--- metrics/data/managers/rbac_models/user.py | 3 ++- 4 files changed, 23 insertions(+), 30 deletions(-) diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index 872be1f91..202e680a1 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -1,6 +1,7 @@ +import uuid + from django.db.models import QuerySet from rest_framework import serializers -import uuid from auth_content.models.users import User @@ -8,7 +9,7 @@ def _validate_user_id(value): """Validate theme_id is either wildcard or a valid integer""" try: - uuid_obj = uuid.UUID(value, version=4) + uuid_obj = uuid.UUID(value, version=4) # noqa: F841 except ValueError as err: msg = "User ID must be a valid UUID" raise serializers.ValidationError(msg) from err @@ -60,8 +61,7 @@ def data(self) -> dict: user_uuid = uuid.UUID(user_id_str) # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user( - user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) # Check if user exists or has permissions if not permission_sets.exists(): @@ -74,8 +74,7 @@ def data(self) -> dict: } # Convert QuerySet to list of dicts - permission_set_list = _queryset_to_permission_set_dicts( - permission_sets) + permission_set_list = _queryset_to_permission_set_dicts(permission_sets) return { "user_id": user_id_str, @@ -87,13 +86,11 @@ def data(self) -> dict: class UserPermissionSetResponseSerializer(serializers.Serializer): """Formats the response for choice endpoints""" - user_id = serializers.CharField( - help_text="UUID of the user" - ) + user_id = serializers.CharField(help_text="UUID of the user") permission_sets = serializers.ListField( child=serializers.DictField(), - help_text="List of permission set objects assigned to the user" + help_text="List of permission set objects assigned to the user", ) permission_set_count = serializers.IntegerField( diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index 709d73e43..a78ac03e1 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -91,8 +91,7 @@ def construct_cms_admin_urlpatterns( prefix: str = "" if app_mode == enums.AppMode.CMS_ADMIN.value else "cms-admin/" return [ path(prefix, include(wagtailadmin_urls)), - path("choose-page/", LinkBrowseView.as_view(), - name="wagtailadmin_choose_page"), + path("choose-page/", LinkBrowseView.as_view(), name="wagtailadmin_choose_page"), ] @@ -169,15 +168,13 @@ def construct_public_api_urlpatterns( path(API_PREFIX, cms_api_router.urls), path(f"{API_PREFIX}global-banners/v2", GlobalBannerView.as_view()), path(f"{API_PREFIX}menus/v1", MenuView.as_view()), - path(f"{API_PREFIX}alerts/v1/heat", - heat_alert_list, name="heat-alerts-list"), + path(f"{API_PREFIX}alerts/v1/heat", heat_alert_list, name="heat-alerts-list"), path( f"{API_PREFIX}alerts/v1/heat/", heat_alert_detail, name="heat-alerts-detail", ), - path(f"{API_PREFIX}alerts/v1/cold", - cold_alert_list, name="cold-alerts-list"), + path(f"{API_PREFIX}alerts/v1/cold", cold_alert_list, name="cold-alerts-list"), path( f"{API_PREFIX}alerts/v1/cold/", cold_alert_detail, @@ -188,8 +185,7 @@ def construct_public_api_urlpatterns( re_path(f"^{API_PREFIX}charts/subplot/v1", SubplotChartsView.as_view()), re_path(f"^{API_PREFIX}downloads/v2", DownloadsView.as_view()), re_path(f"^{API_PREFIX}bulkdownloads/v1", BulkDownloadsView.as_view()), - re_path(f"^{API_PREFIX}downloads/subplot/v1", - SubplotDownloadsView.as_view()), + re_path(f"^{API_PREFIX}downloads/subplot/v1", SubplotDownloadsView.as_view()), re_path( f"^{API_PREFIX}geographies/v2/(?P[^/]+)", GeographiesViewDeprecated.as_view(), @@ -204,10 +200,8 @@ def construct_public_api_urlpatterns( # Audit API endpoints audit_api_timeseries_list = AuditAPITimeSeriesViewSet.as_view({"get": "list"}) -audit_core_timeseries_list = AuditCoreTimeseriesViewSet.as_view({ - "get": "list"}) -audit_api_core_headline_list = AuditCoreHeadlineViewSet.as_view({ - "get": "list"}) +audit_core_timeseries_list = AuditCoreTimeseriesViewSet.as_view({"get": "list"}) +audit_api_core_headline_list = AuditCoreHeadlineViewSet.as_view({"get": "list"}) audit_api_urlpatterns = [ path( @@ -227,8 +221,7 @@ def construct_public_api_urlpatterns( ), re_path(f"^{API_PREFIX}charts/v2", ChartsView.as_view()), re_path(f"^{API_PREFIX}charts/v3", EncodedChartsView.as_view()), - re_path(f"^{API_PREFIX}charts/dual-category/v1", - DualCategoryChartsView.as_view()), + re_path(f"^{API_PREFIX}charts/dual-category/v1", DualCategoryChartsView.as_view()), re_path(f"^{API_PREFIX}charts/subplot/v1", SubplotChartsView.as_view()), ] @@ -249,8 +242,7 @@ def construct_public_api_urlpatterns( ] static_urlpatterns = [ - re_path(r"^static/(?P.*)$", serve, - {"document_root": settings.STATIC_ROOT}), + re_path(r"^static/(?P.*)$", serve, {"document_root": settings.STATIC_ROOT}), ] common_urlpatterns = [ diff --git a/metrics/api/views/user.py b/metrics/api/views/user.py index d3c36ee2f..45567ca77 100644 --- a/metrics/api/views/user.py +++ b/metrics/api/views/user.py @@ -4,7 +4,10 @@ from rest_framework.response import Response from rest_framework.views import APIView -from metrics.api.serializers.user import UserPermissionSetResponseSerializer, UserRequestSerializer +from metrics.api.serializers.user import ( + UserPermissionSetResponseSerializer, + UserRequestSerializer, +) USER_API_TAG = "Authenticated User" @@ -14,10 +17,10 @@ tags=[USER_API_TAG], parameters=[ OpenApiParameter( - name='user_id', + name="user_id", type=str, location=OpenApiParameter.PATH, - description='UUID of the user' + description="UUID of the user", ) ], responses={ diff --git a/metrics/data/managers/rbac_models/user.py b/metrics/data/managers/rbac_models/user.py index 8fdee52a0..f4a3194d1 100644 --- a/metrics/data/managers/rbac_models/user.py +++ b/metrics/data/managers/rbac_models/user.py @@ -59,7 +59,8 @@ def get_user_with_permission_sets(self, user_id: UUID) -> UserQuerySet: """ return self.get_queryset().get_user_with_permission_sets(user_id=user_id) - def get_permission_sets_for_user(self, user_id: UUID) -> models.QuerySet: + @staticmethod + def get_permission_sets_for_user(user_id: UUID) -> models.QuerySet: """ Get all permission sets for a user directly. From 75774191c61cb644453f535e9ced82837bcd4aa2 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Thu, 9 Apr 2026 16:11:46 +0100 Subject: [PATCH 094/186] CDD-3175: Update to add ability to get by id and to create initial permission set hierarchy --- ingestion/operations/truncated_dataset.py | 4 +- metrics/api/serializers/user.py | 102 ++++ metrics/api/urls_construction.py | 10 +- metrics/api/views/user.py | 30 ++ .../data/managers/core_models/geography.py | 35 ++ .../managers/core_models/geography_type.py | 35 ++ metrics/data/managers/core_models/metric.py | 35 ++ .../data/managers/core_models/sub_theme.py | 35 ++ metrics/data/managers/core_models/theme.py | 36 ++ metrics/data/managers/core_models/topic.py | 35 ++ metrics/utils/permission_hierarchy.py | 443 ++++++++++++++++++ 11 files changed, 797 insertions(+), 3 deletions(-) create mode 100644 metrics/utils/permission_hierarchy.py diff --git a/ingestion/operations/truncated_dataset.py b/ingestion/operations/truncated_dataset.py index 9aec9d8aa..7515e9081 100644 --- a/ingestion/operations/truncated_dataset.py +++ b/ingestion/operations/truncated_dataset.py @@ -20,7 +20,7 @@ def _gather_test_data_source_file_paths() -> list[Path]: - path_to_test_source_data = f"{ROOT_LEVEL_BASE_DIR}/source_data" + path_to_test_source_data = f"{ROOT_LEVEL_BASE_DIR}/luke_source_data" source_file_names = next(os.walk(path_to_test_source_data))[2] return [ Path(f"{path_to_test_source_data}/{source_file_name}") @@ -100,7 +100,7 @@ def upload_truncated_test_data(*, multiprocessing_enabled: bool = True) -> None: None """ - clear_metrics_tables() + # clear_metrics_tables() test_source_data_file_paths: list[Path] = _gather_test_data_source_file_paths() diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index 202e680a1..1d13235f8 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -4,6 +4,7 @@ from rest_framework import serializers from auth_content.models.users import User +from metrics.utils.permission_hierarchy import build_permission_hierarchy def _validate_user_id(value): @@ -83,6 +84,66 @@ def data(self) -> dict: } +class UserHierarchyRequestSerializer(serializers.Serializer): + """Fetches and formats sub-theme choices based on theme_id""" + + user_id = serializers.CharField(required=True) + + @property + def user_manager(self): + """ + Fetch the topic manager from the context if available. + If not get the Manager which has been declared on the `Topic` model. + """ + return self.context.get("user_manager", User.objects) + + @staticmethod + def validate_user_id(value): + """Validate user_id is a guid""" + return _validate_user_id(value) + + def data(self) -> dict: + """ + Fetch user permission sets from DB and format as response. + + Returns: + Dict with user_id, permission_sets list, and count + + Example: + { + "user_id": "123e4567-e89b-12d3-a456-426614174000", + "permission_set_hierarchy": [ + { + "id": 1, + "name": "Theme: Infectious Disease | ...", + "theme": "1", + "sub_theme": "3", + ... + } + ] + } + """ + user_id_str = self.validated_data["user_id"] + user_uuid = uuid.UUID(user_id_str) + + # Get permission sets for this user + permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) + + # Check if user exists or has permissions + if not permission_sets.exists(): + # Return empty structure rather than raising exception + # The view can check permission_set_count and return 404 if needed + return { + "user_id": user_id_str, + "permission_set_hierarchy": [], + } + + # Convert QuerySet to list of dicts + permission_set_list = _queryset_to_permission_hierarchy(permission_sets) + + return {"user_id": user_id_str, "permission_sets": permission_set_list} + + class UserPermissionSetResponseSerializer(serializers.Serializer): """Formats the response for choice endpoints""" @@ -139,3 +200,44 @@ def _queryset_to_permission_set_dicts( "geography", ) ) + + +def _queryset_to_permission_hierarchy(queryset: QuerySet) -> dict: + """ + Convert a PermissionSet QuerySet to a deduplicated permission hierarchy. + + This uses subsumption logic to remove overlapping permissions without + needing to query for all child items in the database. + + Args: + queryset: QuerySet of PermissionSet objects + + Returns: + Dict containing permission_set_hierarchy list and summary + + Examples: + >>> qs = PermissionSet.objects.filter(user__user_id=some_uuid) + >>> _queryset_to_permission_hierarchy(qs) + { + 'permission_set_hierarchy': [ + { + 'theme': {'id': '2', 'name': 'infectious_disease'}, + 'sub_theme': {'id': '2', 'name': 'respiratory'}, + 'topic': {'id': '3', 'name': 'COVID-19'}, + 'metric': {'id': '-1', 'name': '* (All)'}, + 'geography_type': {'id': '-1', 'name': '* (All)'}, + 'geography': {'id': '-1', 'name': '* (All)'} + } + ], + 'summary': { + 'total_permission_sets': 2, + 'deduplicated_count': 1, + 'removed_count': 1, + 'has_global_access': False, + 'wildcard_themes': [], + 'wildcard_geography_types': ['* (All)'] + } + } + """ + + return build_permission_hierarchy(queryset) diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index a78ac03e1..a9ae248d3 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -48,7 +48,10 @@ SubThemesByThemeView, TopicsBySubThemeView, ) -from metrics.api.views.user import UserPermissionSetsByUserIdView +from metrics.api.views.user import ( + UserPermissionHierarchyByUserIdView, + UserPermissionSetsByUserIdView, +) from public_api import construct_url_patterns_for_public_api router = routers.DefaultRouter() @@ -161,6 +164,11 @@ def construct_public_api_urlpatterns( UserPermissionSetsByUserIdView.as_view(), name="get_user_permissions", ), + path( + f"{API_PREFIX}user//permissions/hierarchy", + UserPermissionHierarchyByUserIdView.as_view(), + name="get_user_permission_hierarchy", + ), ] private_api_urlpatterns = [ diff --git a/metrics/api/views/user.py b/metrics/api/views/user.py index 45567ca77..2c908da02 100644 --- a/metrics/api/views/user.py +++ b/metrics/api/views/user.py @@ -5,6 +5,7 @@ from rest_framework.views import APIView from metrics.api.serializers.user import ( + UserHierarchyRequestSerializer, UserPermissionSetResponseSerializer, UserRequestSerializer, ) @@ -39,3 +40,32 @@ def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 serializer = UserRequestSerializer(data={"user_id": user_id}) serializer.is_valid(raise_exception=True) return Response(serializer.data()) + + +@extend_schema( + request=UserRequestSerializer, + tags=[USER_API_TAG], + parameters=[ + OpenApiParameter( + name="user_id", + type=str, + location=OpenApiParameter.PATH, + description="UUID of the user", + ) + ], + responses={ + HTTPStatus.OK.value: UserPermissionSetResponseSerializer, + 403: {"description": "Not authorized to view these permissions"}, + 404: {"description": "User not found or has no permissions"}, + }, +) +class UserPermissionHierarchyByUserIdView(APIView): + """Get user permission sets filtered by user ID""" + + permission_classes = [] + + def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 + """API endpoint to fetch a users assigned permission set hierarchy using user_id""" + serializer = UserHierarchyRequestSerializer(data={"user_id": user_id}) + serializer.is_valid(raise_exception=True) + return Response(serializer.data()) diff --git a/metrics/data/managers/core_models/geography.py b/metrics/data/managers/core_models/geography.py index 6716e42fb..829595fd7 100644 --- a/metrics/data/managers/core_models/geography.py +++ b/metrics/data/managers/core_models/geography.py @@ -25,6 +25,24 @@ def get_all_names(self) -> Self: """ return self.all().values_list("name", flat=True).distinct().order_by("name") + def get_name_by_id(self, geography_code: int) -> str | None: + """ + Gets the geography_code name which matches the given theme id. + + Args: + geography_code: The geography code of the geography to look up + + Returns: + The geography name if found, None otherwise + + Examples: + >>> GeographyQuerySet.get_name_by_id(1) + 'England' + >>> GeographyQuerySet.get_name_by_id(999) + None + """ + return self.filter(geography_code=geography_code).values_list("name", flat=True).first() + def get_all_geography_codes_by_geography_type( self, geography_type_name: str ) -> Self: @@ -128,6 +146,23 @@ class GeographyManager(models.Manager): def get_queryset(self) -> GeographyQuerySet: return GeographyQuerySet(model=self.model, using=self.db) + def get_name_by_id(self, geography_code: int) -> str | None: + """Gets the geography name which matches the given geography_code. + + Args: + geography_code: The ID of the geography to look up + + Returns: + The geography name if found, None otherwise + + Examples: + >>> GeographyManager.get_name_by_id(1) + 'England' + >>> GeographyManager.get_name_by_id(999) + None + """ + return self.get_queryset().get_name_by_id(geography_code) + 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/geography_type.py b/metrics/data/managers/core_models/geography_type.py index 87d893bc5..ae45b36b7 100644 --- a/metrics/data/managers/core_models/geography_type.py +++ b/metrics/data/managers/core_models/geography_type.py @@ -23,6 +23,24 @@ def get_all_names(self) -> models.QuerySet: """ return self.all().values_list("name", flat=True).order_by("name") + def get_name_by_id(self, geography_type_id: int) -> str | None: + """ + Gets the geography_type name which matches the given theme id. + + Args: + geography_type_id: The ID of the geography_type to look up + + Returns: + The geography_type name if found, None otherwise + + Examples: + >>> GeographyTypeQuerySet.get_name_by_id(1) + 'Nation' + >>> GeographyTypeQuerySet.get_name_by_id(999) + None + """ + return self.filter(id=geography_type_id).values_list("name", flat=True).first() + def get_all_names_and_ids(self) -> models.QuerySet: """Gets all available geography_type names as a flat list queryset. @@ -42,6 +60,23 @@ class GeographyTypeManager(models.Manager): def get_queryset(self) -> GeographyTypeQuerySet: return GeographyTypeQuerySet(model=self.model, using=self.db) + def get_name_by_id(self, geography_type_id: int) -> str | None: + """Gets the geography name which matches the given geography_code. + + Args: + geography_type_id: The ID of the geography_type to look up + + Returns: + The geography type name if found, None otherwise + + Examples: + >>> GeographyManager.get_name_by_id(1) + 'Region' + >>> GeographyManager.get_name_by_id(999) + None + """ + return self.get_queryset().get_name_by_id(geography_type_id) + def get_all_names(self) -> GeographyTypeQuerySet: """Gets all available geography_type names as a flat list queryset. diff --git a/metrics/data/managers/core_models/metric.py b/metrics/data/managers/core_models/metric.py index c2e289782..344147a0a 100644 --- a/metrics/data/managers/core_models/metric.py +++ b/metrics/data/managers/core_models/metric.py @@ -11,6 +11,24 @@ class MetricQuerySet(models.QuerySet): """Custom queryset which can be used by the `MetricManager`""" + def get_name_by_id(self, metric_id: int) -> str | None: + """ + Gets the metric name which matches the given theme id. + + Args: + metric_id: The ID of the metric to look up + + Returns: + The metric name if found, None otherwise + + Examples: + >>> MetricQuerySet.get_name_by_id(1) + 'infectious_disease' + >>> MetricQuerySet.get_name_by_id(999) + None + """ + return self.filter(id=metric_id).values_list("name", flat=True).first() + def get_all_names(self) -> models.QuerySet: """Gets all available metric names as a flat list queryset. @@ -110,6 +128,23 @@ class MetricManager(models.Manager): def get_queryset(self) -> MetricQuerySet: return MetricQuerySet(model=self.model, using=self.db) + + def get_name_by_id(self, metric_id: int) -> str | None: + """Gets the metric name which matches the given metric id. + + Args: + metric_id: The ID of the theme to look up + + Returns: + The metric name if found, None otherwise + + Examples: + >>> MetricManager.get_name_by_id(1) + 'COVID-19' + >>> MetricManager.get_name_by_id(999) + None + """ + return self.get_queryset().get_name_by_id(metric_id) def get_all_names(self) -> MetricQuerySet: """Gets all available metric names as a flat list queryset. diff --git a/metrics/data/managers/core_models/sub_theme.py b/metrics/data/managers/core_models/sub_theme.py index 7fb72fea4..97f58eeb9 100644 --- a/metrics/data/managers/core_models/sub_theme.py +++ b/metrics/data/managers/core_models/sub_theme.py @@ -11,6 +11,24 @@ class SubThemeQuerySet(models.QuerySet): """Custom queryset which can be used by the `SubThemeManager`""" + def get_name_by_id(self, sub_theme_id: int) -> str | None: + """ + Gets the sub_theme name which matches the given sub_theme id. + + Args: + sub_theme_id: The ID of the theme to look up + + Returns: + The sub_theme name if found, None otherwise + + Examples: + >>> SubThemeQuerySet.get_name_by_id(1) + 'respiratory' + >>> SubThemeQuerySet.get_name_by_id(999) + None + """ + return self.filter(id=sub_theme_id).values_list("name", flat=True).first() + def get_all_names(self) -> models.QuerySet: """Gets all available sub_theme names as a flat list queryset. @@ -62,6 +80,23 @@ class SubThemeManager(models.Manager): def get_queryset(self) -> SubThemeQuerySet: return SubThemeQuerySet(model=self.model, using=self.db) + def get_name_by_id(self, sub_theme_id: int) -> str | None: + """Gets the sub_theme name which matches the given sub_theme id. + + Args: + sub_theme_id: The ID of the theme to look up + + Returns: + The sub_theme name if found, None otherwise + + Examples: + >>> SubThemeManager.get_name_by_id(1) + 'respiratory' + >>> SubThemeManager.get_name_by_id(999) + None + """ + return self.get_queryset().get_name_by_id(sub_theme_id) + def get_all_names(self) -> SubThemeQuerySet: """Gets all available sub_theme names as a flat list queryset. diff --git a/metrics/data/managers/core_models/theme.py b/metrics/data/managers/core_models/theme.py index 7980a5076..2b7d8e132 100644 --- a/metrics/data/managers/core_models/theme.py +++ b/metrics/data/managers/core_models/theme.py @@ -11,6 +11,25 @@ class ThemeQuerySet(models.QuerySet): """Custom queryset which can be used by the `ThemeManger`""" + def get_name_by_id(self, theme_id: int) -> str | None: + """ + Gets the theme name which matches the given theme id. + + Args: + theme_id: The ID of the theme to look up + + Returns: + The theme name if found, None otherwise + + Examples: + >>> ThemeQuerySet.get_name_by_id(1) + 'infectious_disease' + >>> ThemeQuerySet.get_name_by_id(999) + None + """ + return self.filter(id=theme_id).values_list("name", flat=True).first() + + def get_all_names(self) -> models.QuerySet: """Gets all available theme names as a flat list queryset. @@ -38,6 +57,23 @@ class ThemeManager(models.Manager): def get_queryset(self) -> ThemeQuerySet: return ThemeQuerySet(model=self.model, using=self.db) + def get_name_by_id(self, theme_id: int) -> str | None: + """Gets the theme name which matches the given theme id. + + Args: + theme_id: The ID of the theme to look up + + Returns: + The theme name if found, None otherwise + + Examples: + >>> ThemeManager.get_name_by_id(1) + 'infectious_disease' + >>> ThemeManager.get_name_by_id(999) + None + """ + return self.get_queryset().get_name_by_id(theme_id) + def get_all_names(self) -> ThemeQuerySet: """Gets all available topic 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 26633dc5f..dccd34fd9 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -22,6 +22,24 @@ def get_all_names(self) -> models.QuerySet: """ return self.all().values_list("name", flat=True) + def get_name_by_id(self, topic_id: int) -> str | None: + """ + Gets the topic name which matches the given theme id. + + Args: + topic_id: The ID of the topic to look up + + Returns: + The topic name if found, None otherwise + + Examples: + >>> TopicQuerySet.get_name_by_id(1) + 'infectious_disease' + >>> TopicQuerySet.get_name_by_id(999) + None + """ + return self.filter(id=topic_id).values_list("name", flat=True).first() + def get_all_unique_names(self) -> models.QuerySet: """Gets all available unique topic names as a flat list queryset. @@ -78,6 +96,23 @@ class TopicManager(models.Manager): def get_queryset(self) -> TopicQuerySet: return TopicQuerySet(model=self.model, using=self.db) + def get_name_by_id(self, topic_id: int) -> str | None: + """Gets the topic name which matches the given topic id. + + Args: + topic_id: The ID of the theme to look up + + Returns: + The topic name if found, None otherwise + + Examples: + >>> TopicManager.get_name_by_id(1) + 'COVID-19' + >>> TopicManager.get_name_by_id(999) + None + """ + return self.get_queryset().get_name_by_id(topic_id) + def get_all_names(self) -> TopicQuerySet: """Gets all available topic names as a flat list queryset. diff --git a/metrics/utils/permission_hierarchy.py b/metrics/utils/permission_hierarchy.py new file mode 100644 index 000000000..8cc235d9a --- /dev/null +++ b/metrics/utils/permission_hierarchy.py @@ -0,0 +1,443 @@ +""" +Permission hierarchy utilities for deduplicating user permission sets. + +This module provides functionality to build a minimal permission hierarchy +from a user's permission sets by removing subsumed (redundant) permissions. +""" + +from dataclasses import dataclass +from typing import Any + +from django.db.models import QuerySet + +from auth_content.models.permission_sets import PermissionSet +from cms.metrics_interface.field_choices_callables import ( + get_all_metric_names_and_ids, + get_all_sub_theme_names_and_ids, + get_all_topic_names_and_ids, +) +from metrics.data.models.core_models.supporting import Geography, GeographyType, Metric, SubTheme, Theme, Topic + + +@dataclass +class NormalizedPermission: + """ + Normalized representation of a permission set for comparison. + + All IDs stored as strings, wildcards as "-1". + Includes human-readable names for API responses. + + Attributes: + theme_id: Theme ID or "-1" for wildcard + sub_theme_id: Sub-theme ID or "-1" for wildcard + topic_id: Topic ID or "-1" for wildcard + metric_id: Metric ID or "-1" for wildcard + geography_type_id: Geography type ID or "-1" for wildcard + geography_id: Geography code or "-1" for wildcard + theme_name: Human-readable theme name + sub_theme_name: Human-readable sub-theme name + topic_name: Human-readable topic name + metric_name: Human-readable metric name + geography_type_name: Human-readable geography type name + geography_name: Human-readable geography name + """ + + theme_id: str + sub_theme_id: str + topic_id: str + metric_id: str + geography_type_id: str + geography_id: str + + # Display names + theme_name: str = "" + sub_theme_name: str = "" + topic_name: str = "" + metric_name: str = "" + geography_type_name: str = "" + geography_name: str = "" + + @classmethod + def from_permission_set(cls, perm: PermissionSet) -> "NormalizedPermission": + """ + Create a NormalizedPermission from a PermissionSet instance. + + Args: + perm: PermissionSet model instance + + Returns: + NormalizedPermission with populated names + """ + normalized = cls( + theme_id=perm.theme or "", + sub_theme_id=perm.sub_theme or "", + topic_id=perm.topic or "", + metric_id=perm.metric or "", + geography_type_id=perm.geography_type or "", + geography_id=perm.geography or "", + ) + normalized._populate_names() + return normalized + + def _populate_names(self) -> None: + """Populate human-readable names for all fields.""" + # Theme + if self.theme_id == "-1": + self.theme_name = "* (All)" + elif self.theme_id: + self.theme_name = _get_choice_label( + field_name="theme", value=self.theme_id) + + # Sub-theme + if self.sub_theme_id == "-1": + self.sub_theme_name = "* (All)" + elif self.sub_theme_id: + self.sub_theme_name = _get_choice_label( + field_name="sub-theme", value=self.sub_theme_id) + + # Topic + if self.topic_id == "-1": + self.topic_name = "* (All)" + elif self.topic_id: + self.topic_name = _get_choice_label( + field_name="topic", value=self.topic_id) + + # Metric + if self.metric_id == "-1": + self.metric_name = "* (All)" + elif self.metric_id: + self.metric_name = _get_choice_label( + field_name="metric", value=self.metric_id) + + # Geography Type + if self.geography_type_id == "-1": + self.geography_type_name = "* (All)" + elif self.geography_type_id: + self.geography_type_name = _get_choice_label( + field_name="geography_type", value=self.geography_type_id) + + # Geography + if self.geography_id == "-1": + self.geography_name = "* (All)" + elif self.geography_id: + self.geography_name = _get_choice_label( + field_name="geography", value=self.geography_id) + + def subsumes(self, other: "NormalizedPermission") -> bool: + """ + Check if this permission subsumes (is more general than) another. + + A permission subsumes another if it grants access to everything + the other permission grants. This requires BOTH the theme path + and geography path to subsume. + + Args: + other: Another permission to compare against + + Returns: + True if self subsumes other, False otherwise + + Examples: + >>> # Wildcard theme subsumes specific theme (same geography) + >>> p1 = NormalizedPermission(theme_id="-1", ..., geography_id="E12000008") + >>> p2 = NormalizedPermission(theme_id="2", ..., geography_id="E12000008") + >>> p1.subsumes(p2) + True + + >>> # Wildcard geography subsumes specific geography (same theme) + >>> p3 = NormalizedPermission(theme_id="2", ..., geography_id="-1") + >>> p4 = NormalizedPermission(theme_id="2", ..., geography_id="E12000008") + >>> p3.subsumes(p4) + True + + >>> # Different themes, no subsumption + >>> p5 = NormalizedPermission(theme_id="2", ..., geography_id="E12000008") + >>> p6 = NormalizedPermission(theme_id="3", ..., geography_id="E12000008") + >>> p5.subsumes(p6) + False + """ + return self._theme_path_subsumes(other) and self._geography_path_subsumes(other) + + def _theme_path_subsumes(self, other: "NormalizedPermission") -> bool: + """ + Check if this permission's theme path subsumes another's. + + Theme path is: theme → sub_theme → topic → metric + + A wildcard at any level subsumes all specific values at that level + and all levels below. Empty values are treated as "not specified" + (less general than wildcard). + + Args: + other: Another permission to compare against + + Returns: + True if self's theme path subsumes other's theme path + """ + # Theme level + if self.theme_id == "-1": + return True # Wildcard theme subsumes everything + if not self.theme_id and other.theme_id: + return False + if self.theme_id != other.theme_id: + return False + + # Sub-theme level + if self.sub_theme_id == "-1": + return ( + True # Wildcard sub-theme subsumes all topics/metrics under this theme + ) + if not self.sub_theme_id and other.sub_theme_id: + return False + if self.sub_theme_id != other.sub_theme_id: + return False + + # Topic level + if self.topic_id == "-1": + return True # Wildcard topic subsumes all metrics under this sub-theme + if not self.topic_id and other.topic_id: + return False + if self.topic_id != other.topic_id: + return False + + # Metric level + if self.metric_id == "-1": + return True # Wildcard metric subsumes all specific metrics + if not self.metric_id and other.metric_id: + return False + if self.metric_id != other.metric_id: + return False + + # Paths are identical + return True + + def _geography_path_subsumes(self, other: "NormalizedPermission") -> bool: + """ + Check if this permission's geography path subsumes another's. + + Geography path is: geography_type → geography + + Simpler than theme path as only 2 levels. + + Args: + other: Another permission to compare against + + Returns: + True if self's geography path subsumes other's geography path + """ + # Geography type level + if self.geography_type_id == "-1": + return True # Wildcard geography type subsumes all geographies + if not self.geography_type_id and other.geography_type_id: + return False + if self.geography_type_id != other.geography_type_id: + return False + + # Geography level + if self.geography_id == "-1": + return True # Wildcard geography subsumes all specific geographies + if not self.geography_id and other.geography_id: + return False + if self.geography_id != other.geography_id: + return False + + # Paths are identical + return True + + def to_dict(self) -> dict[str, Any]: + """ + Convert to dictionary for API serialization. + + Returns compact structure suitable for JWT encoding and API responses. + + Returns: + Dict with theme, sub_theme, topic, metric, geography_type, geography keys, + each containing id and name + """ + return { + "theme": { + "id": self.theme_id or None, + "name": self.theme_name or None, + }, + "sub_theme": { + "id": self.sub_theme_id or None, + "name": self.sub_theme_name or None, + }, + "topic": { + "id": self.topic_id or None, + "name": self.topic_name or None, + }, + "metric": { + "id": self.metric_id or None, + "name": self.metric_name or None, + }, + "geography_type": { + "id": self.geography_type_id or None, + "name": self.geography_type_name or None, + }, + "geography": { + "id": self.geography_id or None, + "name": self.geography_name or None, + }, + } + + +def build_permission_hierarchy(permission_sets: QuerySet) -> dict[str, Any]: + """ + Build deduplicated permission hierarchy from user's permission sets. + + Removes permissions that are subsumed by more general permissions. + Each permission represents a cross-product of (theme path × geography path). + + This function does NOT query the database for children - it only uses + the permission set data itself to determine subsumption. + + Args: + permission_sets: QuerySet of PermissionSet objects for a user + + Returns: + Dict with 'permission_set_hierarchy' list and 'summary' statistics + + Example: + >>> # User has: + >>> # 1. COVID-19 × All geographies (wildcard) + >>> # 2. COVID-19 × South East region (SUBSUMED by #1) + >>> perms = PermissionSet.objects.filter(user__user_id=some_uuid) + >>> hierarchy = build_permission_hierarchy(perms) + >>> len(hierarchy['permission_set_hierarchy']) + 1 # Only #1 remains + >>> hierarchy['summary']['removed_count'] + 1 + """ + # Convert all permission sets to normalized form + normalized_perms = [ + NormalizedPermission.from_permission_set(perm) for perm in permission_sets + ] + + # Deduplicate - remove subsumed permissions + deduplicated = _remove_subsumed_permissions(normalized_perms) + + # Build summary statistics + summary = _build_summary(normalized_perms, deduplicated) + + return { + "permission_set_hierarchy": [perm.to_dict() for perm in deduplicated], + "summary": summary, + } + + +def _remove_subsumed_permissions( + permissions: list[NormalizedPermission], +) -> list[NormalizedPermission]: + """ + Remove permissions that are subsumed by more general permissions. + + Algorithm: + 1. Iterate through each permission + 2. Check if it's subsumed by any permission already in the result + 3. If not subsumed, remove any existing permissions that this one subsumes + 4. Add this permission to the result + + Time complexity: O(n²) where n = number of permissions + This is acceptable for typical permission set sizes (< 100) + For very large sets (1000+), could optimize with indexing. + + Args: + permissions: List of normalized permissions + + Returns: + List with subsumed permissions removed + + Example: + >>> perms = [ + ... NormalizedPermission(theme_id="2", ..., geography_id="-1"), # General + ... NormalizedPermission(theme_id="2", ..., geography_id="E12000008"), # Specific + ... ] + >>> result = _remove_subsumed_permissions(perms) + >>> len(result) + 1 # Only the general permission remains + """ + result = [] + + for perm in permissions: + # Check if this permission is subsumed by any already in result + is_subsumed = any(existing.subsumes(perm) for existing in result) + + if is_subsumed: + # Skip this permission - it's redundant + continue + + # This permission is not subsumed, so check if it subsumes any existing ones + # Remove any existing permissions that this one subsumes + result = [ + existing for existing in result if not perm.subsumes(existing)] + + # Add this permission to the result + result.append(perm) + + return result + + +def _build_summary( + original: list[NormalizedPermission], + deduplicated: list[NormalizedPermission], +) -> dict[str, Any]: + """ + Build summary statistics about the permission hierarchy. + + Useful for debugging and understanding the deduplication results. + + Args: + original: Original list of permissions before deduplication + deduplicated: List after removing subsumed permissions + + Returns: + Dict with statistics about the deduplication process + """ + # Check for global wildcard (theme + geography both wildcarded) + has_global_access = any( + perm.theme_id == "-1" and perm.geography_type_id == "-1" + for perm in deduplicated + ) + + # Find wildcard themes + wildcard_themes = [ + perm.theme_name for perm in deduplicated if perm.theme_id == "-1" + ] + + + + return { + "total_permission_sets": len(original), + "deduplicated_count": len(deduplicated), + "removed_count": len(original) - len(deduplicated), + "has_global_access": has_global_access, + "wildcard_themes": wildcard_themes, + } + + +@staticmethod +def _get_choice_label(field_name: str, value: str) -> str: + """Get the display label for a choice field""" + + # Map field names to their model managers + field_manager_map = { + "theme": Theme.objects, + "sub-theme": SubTheme.objects, + "topic": Topic.objects, + "metric": Metric.objects, + "geography": Geography.objects, + "geography_type": GeographyType.objects + } + + manager = field_manager_map.get(field_name) + + if manager: + name = "" + if field_name == "geography": + name = manager.get_name_by_id(value) + else: + name = manager.get_name_by_id(int(value)) + return name if name else value + + return value From 7848563d504753bcef896574fdb027ffc55bbe20 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Fri, 10 Apr 2026 10:41:59 +0100 Subject: [PATCH 095/186] CDD-3175: add group by functionality --- metrics/api/serializers/user.py | 111 ++++++++++-- metrics/api/views/user.py | 23 ++- metrics/utils/permission_grouping.py | 242 ++++++++++++++++++++++++++ metrics/utils/permission_hierarchy.py | 28 ++- 4 files changed, 375 insertions(+), 29 deletions(-) create mode 100644 metrics/utils/permission_grouping.py diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index 1d13235f8..9419aefd4 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -4,7 +4,8 @@ from rest_framework import serializers from auth_content.models.users import User -from metrics.utils.permission_hierarchy import build_permission_hierarchy +from metrics.utils.permission_grouping import group_by_geography, group_by_geography_type, group_by_theme +from metrics.utils.permission_hierarchy import build_permission_hierarchy, get_deduplicated_permissions def _validate_user_id(value): @@ -18,6 +19,20 @@ def _validate_user_id(value): return value +@staticmethod +def _validate_group_by(value): + """Validate group_by parameter is a valid option""" + if not value: # Empty string or None + return None + + valid_options = ['geography_type', 'geography', 'theme'] + if value not in valid_options: + msg = f"Invalid group_by parameter: '{value}'. Valid options: {', '.join(valid_options)}" + raise serializers.ValidationError(msg) + + return value + + class UserRequestSerializer(serializers.Serializer): """Fetches and formats sub-theme choices based on theme_id""" @@ -62,7 +77,8 @@ def data(self) -> dict: user_uuid = uuid.UUID(user_id_str) # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user( + user_uuid) # Check if user exists or has permissions if not permission_sets.exists(): @@ -75,7 +91,8 @@ def data(self) -> dict: } # Convert QuerySet to list of dicts - permission_set_list = _queryset_to_permission_set_dicts(permission_sets) + permission_set_list = _queryset_to_permission_set_dicts( + permission_sets) return { "user_id": user_id_str, @@ -85,9 +102,18 @@ def data(self) -> dict: class UserHierarchyRequestSerializer(serializers.Serializer): - """Fetches and formats sub-theme choices based on theme_id""" + """ + Fetches and formats user permission hierarchy with optional grouping. + + Supports different grouping strategies via 'group_by' parameter: + - None (default): Flat deduplicated hierarchy + - 'geography_type': Group by geography type → geography + - 'geography': Group by specific geography + - 'theme': Group by theme → sub-theme → topic + """ user_id = serializers.CharField(required=True) + group_by = serializers.CharField(required=False, allow_blank=True) @property def user_manager(self): @@ -102,32 +128,53 @@ def validate_user_id(value): """Validate user_id is a guid""" return _validate_user_id(value) + @staticmethod + def validate_group_by(value): + """Validate user_id is a guid""" + return _validate_group_by(value) + def data(self) -> dict: """ Fetch user permission sets from DB and format as response. Returns: - Dict with user_id, permission_sets list, and count + Dict with user_id and either: + - permission_sets dict (with hierarchy and summary) if no grouping + - permissions_by_geography_type dict if group_by='geography_type' + - permissions_by_geography dict if group_by='geography' + - permissions_by_theme dict if group_by='theme' - Example: + Example (no grouping): { "user_id": "123e4567-e89b-12d3-a456-426614174000", - "permission_set_hierarchy": [ - { - "id": 1, - "name": "Theme: Infectious Disease | ...", - "theme": "1", - "sub_theme": "3", - ... + "permission_sets": { + "permission_set_hierarchy": [...], + "summary": {...} + } + } + + Example (geography_type grouping): + { + "user_id": "123e4567-e89b-12d3-a456-426614174000", + "permissions_by_geography_type": { + "Region": { + "E12000008": { + "geography_name": "South East", + "permissions": [...] + } } - ] + }, + "total_permissions": 4 } """ + user_id_str = self.validated_data["user_id"] user_uuid = uuid.UUID(user_id_str) + group_by = self.validated_data.get("group_by") # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user( + user_uuid) # Check if user exists or has permissions if not permission_sets.exists(): @@ -139,9 +186,39 @@ def data(self) -> dict: } # Convert QuerySet to list of dicts - permission_set_list = _queryset_to_permission_hierarchy(permission_sets) + # permission_set_list = _queryset_to_permission_hierarchy( + # permission_sets) + + deduplicated_perms = get_deduplicated_permissions(permission_sets) + + if group_by == 'geography_type': + return { + "user_id": user_id_str, + "permissions_by_geography_type": group_by_geography_type(deduplicated_perms), + "total_permissions": len(deduplicated_perms), + } + + elif group_by == 'geography': + return { + "user_id": user_id_str, + "permissions_by_geography": group_by_geography(deduplicated_perms), + "total_permissions": len(deduplicated_perms), + } + + elif group_by == 'theme': + return { + "user_id": user_id_str, + "permissions_by_theme": group_by_theme(deduplicated_perms), + "total_permissions": len(deduplicated_perms), + } - return {"user_id": user_id_str, "permission_sets": permission_set_list} + else: + # Default: Return flat deduplicated hierarchy with summary + hierarchy = _queryset_to_permission_hierarchy(permission_sets) + return { + "user_id": user_id_str, + "permission_sets": hierarchy, + } class UserPermissionSetResponseSerializer(serializers.Serializer): diff --git a/metrics/api/views/user.py b/metrics/api/views/user.py index 2c908da02..1b58af4cd 100644 --- a/metrics/api/views/user.py +++ b/metrics/api/views/user.py @@ -51,21 +51,36 @@ def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 type=str, location=OpenApiParameter.PATH, description="UUID of the user", - ) + ), + OpenApiParameter( + name="group_by", + type=str, + location=OpenApiParameter.QUERY, + description="Optional grouping strategy: 'geography_type', 'geography', or 'theme'", + required=False, + enum=['geography_type', 'geography', 'theme'], + ), ], responses={ HTTPStatus.OK.value: UserPermissionSetResponseSerializer, + 400: {"description": "Invalid group_by parameter"}, 403: {"description": "Not authorized to view these permissions"}, 404: {"description": "User not found or has no permissions"}, }, ) class UserPermissionHierarchyByUserIdView(APIView): - """Get user permission sets filtered by user ID""" + """Get user permission sets filtered by user ID with optional grouping""" permission_classes = [] def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 - """API endpoint to fetch a users assigned permission set hierarchy using user_id""" - serializer = UserHierarchyRequestSerializer(data={"user_id": user_id}) + """API endpoint to fetch a user's assigned permission set hierarchy using user_id""" + # Pass query parameter to serializer + serializer = UserHierarchyRequestSerializer( + data={ + "user_id": user_id, + "group_by": request.query_params.get('group_by', ''), + } + ) serializer.is_valid(raise_exception=True) return Response(serializer.data()) diff --git a/metrics/utils/permission_grouping.py b/metrics/utils/permission_grouping.py new file mode 100644 index 000000000..2b264e2f7 --- /dev/null +++ b/metrics/utils/permission_grouping.py @@ -0,0 +1,242 @@ +""" +Permission grouping utilities for organizing deduplicated permissions. + +This module provides functionality to group permissions by various dimensions +(geography_type, theme, etc.) for different UI/API use cases. +""" + +from collections import defaultdict +from typing import Any + +from metrics.utils.permission_hierarchy import NormalizedPermission + + +def group_by_geography_type( + permissions: list[NormalizedPermission], +) -> dict[str, Any]: + """ + Group permissions by geography type, then by specific geography. + + Structure: + { + "All Geography Types": { + "*": { + "geography_name": "All Geographies", + "geography_code": "*", + "permissions": [...] + } + }, + "Nation": { + "E92000001": { + "geography_name": "England", + "geography_code": "E92000001", + "permissions": [...] + } + }, + "Region": { + "E12000008": { + "geography_name": "South East", + "geography_code": "E12000008", + "permissions": [...] + } + } + } + + Args: + permissions: List of deduplicated NormalizedPermission objects + + Returns: + Nested dict grouped by geography type, then geography code + """ + grouped = defaultdict(lambda: defaultdict(lambda: { + "geography_name": None, + "geography_code": None, + "permissions": [] + })) + + for perm in permissions: + # Determine geography type display name + if perm.geography_type_id == "-1": + geo_type_display = "All Geography Types" + else: + geo_type_display = perm.geography_type_name + + # Determine geography code/display + if perm.geography_id == "-1": + geo_code = "*" + geo_name = f"All {geo_type_display}s" if perm.geography_type_id != "-1" else "All Geographies" + else: + geo_code = perm.geography_id + geo_name = perm.geography_name + + # Initialize geography entry if needed + if not grouped[geo_type_display][geo_code]["geography_name"]: + grouped[geo_type_display][geo_code]["geography_name"] = geo_name + grouped[geo_type_display][geo_code]["geography_code"] = geo_code + + # Add permission to this geography + grouped[geo_type_display][geo_code]["permissions"].append({ + "theme": { + "id": perm.theme_id or None, + "name": perm.theme_name or None, + }, + "sub_theme": { + "id": perm.sub_theme_id or None, + "name": perm.sub_theme_name or None, + }, + "topic": { + "id": perm.topic_id or None, + "name": perm.topic_name or None, + }, + "metric": { + "id": perm.metric_id or None, + "name": perm.metric_name or None, + }, + }) + + # Convert defaultdict to regular dict for JSON serialization + return {k: dict(v) for k, v in grouped.items()} + + +def group_by_theme( + permissions: list[NormalizedPermission], +) -> dict[str, Any]: + """ + Group permissions by theme hierarchy. + + Structure: + { + "infectious_disease": { + "theme_id": "2", + "sub_themes": { + "respiratory": { + "sub_theme_id": "2", + "topics": { + "COVID-19": { + "topic_id": "3", + "geographies": [...] + } + } + } + } + } + } + + Args: + permissions: List of deduplicated NormalizedPermission objects + + Returns: + Nested dict grouped by theme → sub-theme → topic + """ + grouped = defaultdict(lambda: { + "theme_id": None, + "sub_themes": defaultdict(lambda: { + "sub_theme_id": None, + "topics": defaultdict(lambda: { + "topic_id": None, + "geographies": [] + }) + }) + }) + + for perm in permissions: + theme_key = perm.theme_name or perm.theme_id + sub_theme_key = perm.sub_theme_name or perm.sub_theme_id + topic_key = perm.topic_name or perm.topic_id + + # Set IDs if not already set + if not grouped[theme_key]["theme_id"]: + grouped[theme_key]["theme_id"] = perm.theme_id + + if not grouped[theme_key]["sub_themes"][sub_theme_key]["sub_theme_id"]: + grouped[theme_key]["sub_themes"][sub_theme_key]["sub_theme_id"] = perm.sub_theme_id + + if not grouped[theme_key]["sub_themes"][sub_theme_key]["topics"][topic_key]["topic_id"]: + grouped[theme_key]["sub_themes"][sub_theme_key]["topics"][topic_key]["topic_id"] = perm.topic_id + + # Add geography to this topic + grouped[theme_key]["sub_themes"][sub_theme_key]["topics"][topic_key]["geographies"].append({ + "geography_type": { + "id": perm.geography_type_id or None, + "name": perm.geography_type_name or None, + }, + "geography": { + "id": perm.geography_id or None, + "name": perm.geography_name or None, + }, + "metric": { + "id": perm.metric_id or None, + "name": perm.metric_name or None, + }, + }) + + # Convert nested defaultdicts to regular dicts + result = {} + for theme_key, theme_data in grouped.items(): + result[theme_key] = { + "theme_id": theme_data["theme_id"], + "sub_themes": {} + } + for sub_theme_key, sub_theme_data in theme_data["sub_themes"].items(): + result[theme_key]["sub_themes"][sub_theme_key] = { + "sub_theme_id": sub_theme_data["sub_theme_id"], + "topics": {} + } + for topic_key, topic_data in sub_theme_data["topics"].items(): + result[theme_key]["sub_themes"][sub_theme_key]["topics"][topic_key] = dict( + topic_data) + + return result + + +def group_by_geography( + permissions: list[NormalizedPermission], +) -> dict[str, Any]: + """ + Group permissions by specific geography (without geography type level). + + Simpler flat structure showing what themes/topics are available per geography. + + Args: + permissions: List of deduplicated NormalizedPermission objects + + Returns: + Dict mapping geography codes to their available permissions + """ + grouped = defaultdict(lambda: { + "geography_name": None, + "geography_type": None, + "geography_code": None, + "permissions": [] + }) + + for perm in permissions: + geo_code = perm.geography_id or "*" + + # Initialize geography metadata + if not grouped[geo_code]["geography_name"]: + grouped[geo_code]["geography_name"] = perm.geography_name + grouped[geo_code]["geography_type"] = perm.geography_type_name + grouped[geo_code]["geography_code"] = geo_code + + # Add theme/topic permission + grouped[geo_code]["permissions"].append({ + "theme": { + "id": perm.theme_id or None, + "name": perm.theme_name or None, + }, + "sub_theme": { + "id": perm.sub_theme_id or None, + "name": perm.sub_theme_name or None, + }, + "topic": { + "id": perm.topic_id or None, + "name": perm.topic_name or None, + }, + "metric": { + "id": perm.metric_id or None, + "name": perm.metric_name or None, + }, + }) + + return dict(grouped) diff --git a/metrics/utils/permission_hierarchy.py b/metrics/utils/permission_hierarchy.py index 8cc235d9a..fe729d012 100644 --- a/metrics/utils/permission_hierarchy.py +++ b/metrics/utils/permission_hierarchy.py @@ -11,11 +11,7 @@ from django.db.models import QuerySet from auth_content.models.permission_sets import PermissionSet -from cms.metrics_interface.field_choices_callables import ( - get_all_metric_names_and_ids, - get_all_sub_theme_names_and_ids, - get_all_topic_names_and_ids, -) + from metrics.data.models.core_models.supporting import Geography, GeographyType, Metric, SubTheme, Theme, Topic @@ -378,6 +374,24 @@ def _remove_subsumed_permissions( return result +def get_deduplicated_permissions(permission_sets: QuerySet) -> list[NormalizedPermission]: + """ + Get deduplicated permissions without hierarchy structure. + + Useful for passing to grouping functions. + + Args: + permission_sets: QuerySet of PermissionSet objects + + Returns: + List of deduplicated NormalizedPermission objects + """ + normalized_perms = [ + NormalizedPermission.from_permission_set(perm) for perm in permission_sets + ] + return _remove_subsumed_permissions(normalized_perms) + + def _build_summary( original: list[NormalizedPermission], deduplicated: list[NormalizedPermission], @@ -405,12 +419,10 @@ def _build_summary( perm.theme_name for perm in deduplicated if perm.theme_id == "-1" ] - - return { "total_permission_sets": len(original), "deduplicated_count": len(deduplicated), - "removed_count": len(original) - len(deduplicated), + "removed_count": len(original) - len(deduplicated), "has_global_access": has_global_access, "wildcard_themes": wildcard_themes, } From f37ee3e35d6e45d022e20e8db97974a2f9ff5120 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Fri, 10 Apr 2026 12:05:01 +0100 Subject: [PATCH 096/186] CDD-3172: small refactor of permission_hierarchy and users and topics --- metrics/api/serializers/user.py | 11 ---- metrics/data/managers/core_models/topic.py | 2 +- metrics/utils/permission_hierarchy.py | 65 ++++++---------------- 3 files changed, 19 insertions(+), 59 deletions(-) diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index 9419aefd4..2737bb902 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -176,47 +176,36 @@ def data(self) -> dict: permission_sets = self.user_manager.get_permission_sets_for_user( user_uuid) - # Check if user exists or has permissions if not permission_sets.exists(): # Return empty structure rather than raising exception # The view can check permission_set_count and return 404 if needed return { - "user_id": user_id_str, "permission_set_hierarchy": [], } - # Convert QuerySet to list of dicts - # permission_set_list = _queryset_to_permission_hierarchy( - # permission_sets) - deduplicated_perms = get_deduplicated_permissions(permission_sets) if group_by == 'geography_type': return { - "user_id": user_id_str, "permissions_by_geography_type": group_by_geography_type(deduplicated_perms), "total_permissions": len(deduplicated_perms), } elif group_by == 'geography': return { - "user_id": user_id_str, "permissions_by_geography": group_by_geography(deduplicated_perms), "total_permissions": len(deduplicated_perms), } elif group_by == 'theme': return { - "user_id": user_id_str, "permissions_by_theme": group_by_theme(deduplicated_perms), "total_permissions": len(deduplicated_perms), } else: - # Default: Return flat deduplicated hierarchy with summary hierarchy = _queryset_to_permission_hierarchy(permission_sets) return { - "user_id": user_id_str, "permission_sets": hierarchy, } diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index dccd34fd9..00b6f0632 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -34,7 +34,7 @@ def get_name_by_id(self, topic_id: int) -> str | None: Examples: >>> TopicQuerySet.get_name_by_id(1) - 'infectious_disease' + 'COVID-19' >>> TopicQuerySet.get_name_by_id(999) None """ diff --git a/metrics/utils/permission_hierarchy.py b/metrics/utils/permission_hierarchy.py index fe729d012..01f8e898e 100644 --- a/metrics/utils/permission_hierarchy.py +++ b/metrics/utils/permission_hierarchy.py @@ -77,47 +77,24 @@ def from_permission_set(cls, perm: PermissionSet) -> "NormalizedPermission": def _populate_names(self) -> None: """Populate human-readable names for all fields.""" - # Theme - if self.theme_id == "-1": - self.theme_name = "* (All)" - elif self.theme_id: - self.theme_name = _get_choice_label( - field_name="theme", value=self.theme_id) - - # Sub-theme - if self.sub_theme_id == "-1": - self.sub_theme_name = "* (All)" - elif self.sub_theme_id: - self.sub_theme_name = _get_choice_label( - field_name="sub-theme", value=self.sub_theme_id) - - # Topic - if self.topic_id == "-1": - self.topic_name = "* (All)" - elif self.topic_id: - self.topic_name = _get_choice_label( - field_name="topic", value=self.topic_id) - - # Metric - if self.metric_id == "-1": - self.metric_name = "* (All)" - elif self.metric_id: - self.metric_name = _get_choice_label( - field_name="metric", value=self.metric_id) - - # Geography Type - if self.geography_type_id == "-1": - self.geography_type_name = "* (All)" - elif self.geography_type_id: - self.geography_type_name = _get_choice_label( - field_name="geography_type", value=self.geography_type_id) - - # Geography - if self.geography_id == "-1": - self.geography_name = "* (All)" - elif self.geography_id: - self.geography_name = _get_choice_label( - field_name="geography", value=self.geography_id) + # Map: (id_attribute, name_attribute, lookup_field_name) + field_mappings = [ + ("theme_id", "theme_name", "theme"), + ("sub_theme_id", "sub_theme_name", "sub-theme"), + ("topic_id", "topic_name", "topic"), + ("metric_id", "metric_name", "metric"), + ("geography_type_id", "geography_type_name", "geography_type"), + ("geography_id", "geography_name", "geography"), + ] + + for id_attr, name_attr, field_name in field_mappings: + id_value = getattr(self, id_attr) + + if id_value == "-1": + setattr(self, name_attr, "* (All)") + elif id_value: + setattr(self, name_attr, _get_choice_label( + field_name, id_value)) def subsumes(self, other: "NormalizedPermission") -> bool: """ @@ -334,10 +311,6 @@ def _remove_subsumed_permissions( 3. If not subsumed, remove any existing permissions that this one subsumes 4. Add this permission to the result - Time complexity: O(n²) where n = number of permissions - This is acceptable for typical permission set sizes (< 100) - For very large sets (1000+), could optimize with indexing. - Args: permissions: List of normalized permissions @@ -399,8 +372,6 @@ def _build_summary( """ Build summary statistics about the permission hierarchy. - Useful for debugging and understanding the deduplication results. - Args: original: Original list of permissions before deduplication deduplicated: List after removing subsumed permissions From b77e195f94787c005743f56c325ae79be73e4c2a Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Mon, 13 Apr 2026 12:34:11 +0100 Subject: [PATCH 097/186] Remove testing changes to truncated_dataset --- ingestion/operations/truncated_dataset.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ingestion/operations/truncated_dataset.py b/ingestion/operations/truncated_dataset.py index 7515e9081..e8ae15c48 100644 --- a/ingestion/operations/truncated_dataset.py +++ b/ingestion/operations/truncated_dataset.py @@ -20,7 +20,7 @@ def _gather_test_data_source_file_paths() -> list[Path]: - path_to_test_source_data = f"{ROOT_LEVEL_BASE_DIR}/luke_source_data" + path_to_test_source_data = f"{ROOT_LEVEL_BASE_DIR}/source_data" source_file_names = next(os.walk(path_to_test_source_data))[2] return [ Path(f"{path_to_test_source_data}/{source_file_name}") @@ -100,9 +100,10 @@ def upload_truncated_test_data(*, multiprocessing_enabled: bool = True) -> None: None """ - # clear_metrics_tables() + clear_metrics_tables() - test_source_data_file_paths: list[Path] = _gather_test_data_source_file_paths() + test_source_data_file_paths: list[Path] = _gather_test_data_source_file_paths( + ) if multiprocessing_enabled: run_with_multiple_processes( From 5930ec6da962a70e8dd20c46bb1b2250b77c1afb Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 14 Apr 2026 09:22:42 +0100 Subject: [PATCH 098/186] Remove group by geography --- metrics/api/serializers/user.py | 10 +----- metrics/api/views/user.py | 4 +-- metrics/utils/permission_grouping.py | 53 ---------------------------- 3 files changed, 3 insertions(+), 64 deletions(-) diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index 2737bb902..3c6aa2145 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -25,7 +25,7 @@ def _validate_group_by(value): if not value: # Empty string or None return None - valid_options = ['geography_type', 'geography', 'theme'] + valid_options = ['geography_type', 'theme'] if value not in valid_options: msg = f"Invalid group_by parameter: '{value}'. Valid options: {', '.join(valid_options)}" raise serializers.ValidationError(msg) @@ -108,7 +108,6 @@ class UserHierarchyRequestSerializer(serializers.Serializer): Supports different grouping strategies via 'group_by' parameter: - None (default): Flat deduplicated hierarchy - 'geography_type': Group by geography type → geography - - 'geography': Group by specific geography - 'theme': Group by theme → sub-theme → topic """ @@ -141,7 +140,6 @@ def data(self) -> dict: Dict with user_id and either: - permission_sets dict (with hierarchy and summary) if no grouping - permissions_by_geography_type dict if group_by='geography_type' - - permissions_by_geography dict if group_by='geography' - permissions_by_theme dict if group_by='theme' Example (no grouping): @@ -191,12 +189,6 @@ def data(self) -> dict: "total_permissions": len(deduplicated_perms), } - elif group_by == 'geography': - return { - "permissions_by_geography": group_by_geography(deduplicated_perms), - "total_permissions": len(deduplicated_perms), - } - elif group_by == 'theme': return { "permissions_by_theme": group_by_theme(deduplicated_perms), diff --git a/metrics/api/views/user.py b/metrics/api/views/user.py index 1b58af4cd..12dd99ea4 100644 --- a/metrics/api/views/user.py +++ b/metrics/api/views/user.py @@ -56,9 +56,9 @@ def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 name="group_by", type=str, location=OpenApiParameter.QUERY, - description="Optional grouping strategy: 'geography_type', 'geography', or 'theme'", + description="Optional grouping strategy: 'geography_type', or 'theme'", required=False, - enum=['geography_type', 'geography', 'theme'], + enum=['geography_type', 'theme'], ), ], responses={ diff --git a/metrics/utils/permission_grouping.py b/metrics/utils/permission_grouping.py index 2b264e2f7..5ad2d201f 100644 --- a/metrics/utils/permission_grouping.py +++ b/metrics/utils/permission_grouping.py @@ -187,56 +187,3 @@ def group_by_theme( topic_data) return result - - -def group_by_geography( - permissions: list[NormalizedPermission], -) -> dict[str, Any]: - """ - Group permissions by specific geography (without geography type level). - - Simpler flat structure showing what themes/topics are available per geography. - - Args: - permissions: List of deduplicated NormalizedPermission objects - - Returns: - Dict mapping geography codes to their available permissions - """ - grouped = defaultdict(lambda: { - "geography_name": None, - "geography_type": None, - "geography_code": None, - "permissions": [] - }) - - for perm in permissions: - geo_code = perm.geography_id or "*" - - # Initialize geography metadata - if not grouped[geo_code]["geography_name"]: - grouped[geo_code]["geography_name"] = perm.geography_name - grouped[geo_code]["geography_type"] = perm.geography_type_name - grouped[geo_code]["geography_code"] = geo_code - - # Add theme/topic permission - grouped[geo_code]["permissions"].append({ - "theme": { - "id": perm.theme_id or None, - "name": perm.theme_name or None, - }, - "sub_theme": { - "id": perm.sub_theme_id or None, - "name": perm.sub_theme_name or None, - }, - "topic": { - "id": perm.topic_id or None, - "name": perm.topic_name or None, - }, - "metric": { - "id": perm.metric_id or None, - "name": perm.metric_name or None, - }, - }) - - return dict(grouped) From 0ac42ee88c1119119cafebb9ef0339ce95f96483 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Tue, 14 Apr 2026 09:43:51 +0100 Subject: [PATCH 099/186] refactor permission grouping to group by id rather than name --- metrics/utils/permission_grouping.py | 163 ++++++++++++++------------- 1 file changed, 87 insertions(+), 76 deletions(-) diff --git a/metrics/utils/permission_grouping.py b/metrics/utils/permission_grouping.py index 5ad2d201f..b860f2c6c 100644 --- a/metrics/utils/permission_grouping.py +++ b/metrics/utils/permission_grouping.py @@ -19,25 +19,22 @@ def group_by_geography_type( Structure: { - "All Geography Types": { - "*": { - "geography_name": "All Geographies", - "geography_code": "*", - "permissions": [...] - } - }, - "Nation": { - "E92000001": { - "geography_name": "England", - "geography_code": "E92000001", - "permissions": [...] + "1": { # geography_type_id + "geography_type_name": "Region", + "geographies": { + "E12000008": { # geography_id (code) + "geography_name": "South East", + "permissions": [...] + } } }, - "Region": { - "E12000008": { - "geography_name": "South East", - "geography_code": "E12000008", - "permissions": [...] + "-1": { # wildcard geography_type_id + "geography_type_name": "All Geography Types", + "geographies": { + "*": { + "geography_name": "All Geographies", + "permissions": [...] + } } } } @@ -46,36 +43,41 @@ def group_by_geography_type( permissions: List of deduplicated NormalizedPermission objects Returns: - Nested dict grouped by geography type, then geography code + Nested dict grouped by geography type ID, then geography code """ - grouped = defaultdict(lambda: defaultdict(lambda: { - "geography_name": None, - "geography_code": None, - "permissions": [] - })) + grouped = defaultdict(lambda: { + "geography_type_name": None, + "geographies": defaultdict(lambda: { + "geography_name": None, + "permissions": [] + }) + }) for perm in permissions: - # Determine geography type display name - if perm.geography_type_id == "-1": - geo_type_display = "All Geography Types" - else: - geo_type_display = perm.geography_type_name - - # Determine geography code/display - if perm.geography_id == "-1": - geo_code = "*" - geo_name = f"All {geo_type_display}s" if perm.geography_type_id != "-1" else "All Geographies" - else: - geo_code = perm.geography_id - geo_name = perm.geography_name - - # Initialize geography entry if needed - if not grouped[geo_type_display][geo_code]["geography_name"]: - grouped[geo_type_display][geo_code]["geography_name"] = geo_name - grouped[geo_type_display][geo_code]["geography_code"] = geo_code + # Use ID as the key (handles wildcards and specific types) + geo_type_id = perm.geography_type_id or "*" + geo_code = perm.geography_id if perm.geography_id != "-1" else "*" + + # Set geography type name if not already set + if not grouped[geo_type_id]["geography_type_name"]: + if geo_type_id == "-1" or geo_type_id == "*": + grouped[geo_type_id]["geography_type_name"] = "All Geography Types" + else: + grouped[geo_type_id]["geography_type_name"] = perm.geography_type_name + + # Set geography name if not already set + if not grouped[geo_type_id]["geographies"][geo_code]["geography_name"]: + if geo_code == "*": + if geo_type_id == "-1" or geo_type_id == "*": + geo_name = "All Geographies" + else: + geo_name = f"All {perm.geography_type_name}s" + else: + geo_name = perm.geography_name + grouped[geo_type_id]["geographies"][geo_code]["geography_name"] = geo_name # Add permission to this geography - grouped[geo_type_display][geo_code]["permissions"].append({ + grouped[geo_type_id]["geographies"][geo_code]["permissions"].append({ "theme": { "id": perm.theme_id or None, "name": perm.theme_name or None, @@ -95,25 +97,32 @@ def group_by_geography_type( }) # Convert defaultdict to regular dict for JSON serialization - return {k: dict(v) for k, v in grouped.items()} + result = {} + for geo_type_id, geo_type_data in grouped.items(): + result[geo_type_id] = { + "geography_type_name": geo_type_data["geography_type_name"], + "geographies": dict(geo_type_data["geographies"]) + } + + return result def group_by_theme( permissions: list[NormalizedPermission], ) -> dict[str, Any]: """ - Group permissions by theme hierarchy. + Group permissions by theme hierarchy using IDs as keys. Structure: { - "infectious_disease": { - "theme_id": "2", + "2": { # theme_id + "theme_name": "infectious_disease", "sub_themes": { - "respiratory": { - "sub_theme_id": "2", + "2": { # sub_theme_id + "sub_theme_name": "respiratory", "topics": { - "COVID-19": { - "topic_id": "3", + "3": { # topic_id + "topic_name": "COVID-19", "geographies": [...] } } @@ -126,36 +135,37 @@ def group_by_theme( permissions: List of deduplicated NormalizedPermission objects Returns: - Nested dict grouped by theme → sub-theme → topic + Nested dict grouped by theme_id → sub_theme_id → topic_id """ grouped = defaultdict(lambda: { - "theme_id": None, + "theme_name": None, "sub_themes": defaultdict(lambda: { - "sub_theme_id": None, + "sub_theme_name": None, "topics": defaultdict(lambda: { - "topic_id": None, + "topic_name": None, "geographies": [] }) }) }) for perm in permissions: - theme_key = perm.theme_name or perm.theme_id - sub_theme_key = perm.sub_theme_name or perm.sub_theme_id - topic_key = perm.topic_name or perm.topic_id + # Use IDs as keys (handles wildcards and specific IDs) + theme_id = perm.theme_id or "*" + sub_theme_id = perm.sub_theme_id or "*" + topic_id = perm.topic_id or "*" - # Set IDs if not already set - if not grouped[theme_key]["theme_id"]: - grouped[theme_key]["theme_id"] = perm.theme_id + # Set names if not already set + if not grouped[theme_id]["theme_name"]: + grouped[theme_id]["theme_name"] = perm.theme_name - if not grouped[theme_key]["sub_themes"][sub_theme_key]["sub_theme_id"]: - grouped[theme_key]["sub_themes"][sub_theme_key]["sub_theme_id"] = perm.sub_theme_id + if not grouped[theme_id]["sub_themes"][sub_theme_id]["sub_theme_name"]: + grouped[theme_id]["sub_themes"][sub_theme_id]["sub_theme_name"] = perm.sub_theme_name - if not grouped[theme_key]["sub_themes"][sub_theme_key]["topics"][topic_key]["topic_id"]: - grouped[theme_key]["sub_themes"][sub_theme_key]["topics"][topic_key]["topic_id"] = perm.topic_id + if not grouped[theme_id]["sub_themes"][sub_theme_id]["topics"][topic_id]["topic_name"]: + grouped[theme_id]["sub_themes"][sub_theme_id]["topics"][topic_id]["topic_name"] = perm.topic_name # Add geography to this topic - grouped[theme_key]["sub_themes"][sub_theme_key]["topics"][topic_key]["geographies"].append({ + grouped[theme_id]["sub_themes"][sub_theme_id]["topics"][topic_id]["geographies"].append({ "geography_type": { "id": perm.geography_type_id or None, "name": perm.geography_type_name or None, @@ -170,20 +180,21 @@ def group_by_theme( }, }) - # Convert nested defaultdicts to regular dicts + # Convert nested default dicts to regular dicts result = {} - for theme_key, theme_data in grouped.items(): - result[theme_key] = { - "theme_id": theme_data["theme_id"], + for theme_id, theme_data in grouped.items(): + result[theme_id] = { + "theme_name": theme_data["theme_name"], "sub_themes": {} } - for sub_theme_key, sub_theme_data in theme_data["sub_themes"].items(): - result[theme_key]["sub_themes"][sub_theme_key] = { - "sub_theme_id": sub_theme_data["sub_theme_id"], + for sub_theme_id, sub_theme_data in theme_data["sub_themes"].items(): + result[theme_id]["sub_themes"][sub_theme_id] = { + "sub_theme_name": sub_theme_data["sub_theme_name"], "topics": {} } - for topic_key, topic_data in sub_theme_data["topics"].items(): - result[theme_key]["sub_themes"][sub_theme_key]["topics"][topic_key] = dict( - topic_data) + for topic_id, topic_data in sub_theme_data["topics"].items(): + result[theme_id]["sub_themes"][sub_theme_id]["topics"][topic_id] = dict( + topic_data + ) - return result + return result \ No newline at end of file From 67f8ab611f63ba110979c727a9ae381a7e727b68 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Wed, 15 Apr 2026 10:34:41 +0100 Subject: [PATCH 100/186] CDD-3172: linting --- ingestion/operations/truncated_dataset.py | 3 +- metrics/api/serializers/user.py | 35 +++-- metrics/api/views/user.py | 4 +- .../data/managers/core_models/geography.py | 6 +- metrics/data/managers/core_models/metric.py | 2 +- metrics/data/managers/core_models/theme.py | 1 - metrics/utils/permission_grouping.py | 147 ++++++++++-------- metrics/utils/permission_hierarchy.py | 126 ++++++--------- 8 files changed, 159 insertions(+), 165 deletions(-) diff --git a/ingestion/operations/truncated_dataset.py b/ingestion/operations/truncated_dataset.py index e8ae15c48..9aec9d8aa 100644 --- a/ingestion/operations/truncated_dataset.py +++ b/ingestion/operations/truncated_dataset.py @@ -102,8 +102,7 @@ def upload_truncated_test_data(*, multiprocessing_enabled: bool = True) -> None: """ clear_metrics_tables() - test_source_data_file_paths: list[Path] = _gather_test_data_source_file_paths( - ) + test_source_data_file_paths: list[Path] = _gather_test_data_source_file_paths() if multiprocessing_enabled: run_with_multiple_processes( diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index 3c6aa2145..563e91b8a 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -4,8 +4,11 @@ from rest_framework import serializers from auth_content.models.users import User -from metrics.utils.permission_grouping import group_by_geography, group_by_geography_type, group_by_theme -from metrics.utils.permission_hierarchy import build_permission_hierarchy, get_deduplicated_permissions +from metrics.utils.permission_grouping import group_by_geography_type, group_by_theme +from metrics.utils.permission_hierarchy import ( + build_permission_hierarchy, + get_deduplicated_permissions, +) def _validate_user_id(value): @@ -25,7 +28,7 @@ def _validate_group_by(value): if not value: # Empty string or None return None - valid_options = ['geography_type', 'theme'] + valid_options = ["geography_type", "theme"] if value not in valid_options: msg = f"Invalid group_by parameter: '{value}'. Valid options: {', '.join(valid_options)}" raise serializers.ValidationError(msg) @@ -77,8 +80,7 @@ def data(self) -> dict: user_uuid = uuid.UUID(user_id_str) # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user( - user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) # Check if user exists or has permissions if not permission_sets.exists(): @@ -91,8 +93,7 @@ def data(self) -> dict: } # Convert QuerySet to list of dicts - permission_set_list = _queryset_to_permission_set_dicts( - permission_sets) + permission_set_list = _queryset_to_permission_set_dicts(permission_sets) return { "user_id": user_id_str, @@ -171,8 +172,7 @@ def data(self) -> dict: group_by = self.validated_data.get("group_by") # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user( - user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) if not permission_sets.exists(): # Return empty structure rather than raising exception @@ -183,23 +183,24 @@ def data(self) -> dict: deduplicated_perms = get_deduplicated_permissions(permission_sets) - if group_by == 'geography_type': + if group_by == "geography_type": return { - "permissions_by_geography_type": group_by_geography_type(deduplicated_perms), + "permissions_by_geography_type": group_by_geography_type( + deduplicated_perms + ), "total_permissions": len(deduplicated_perms), } - elif group_by == 'theme': + if group_by == "theme": return { "permissions_by_theme": group_by_theme(deduplicated_perms), "total_permissions": len(deduplicated_perms), } - else: - hierarchy = _queryset_to_permission_hierarchy(permission_sets) - return { - "permission_sets": hierarchy, - } + hierarchy = _queryset_to_permission_hierarchy(permission_sets) + return { + "permission_sets": hierarchy, + } class UserPermissionSetResponseSerializer(serializers.Serializer): diff --git a/metrics/api/views/user.py b/metrics/api/views/user.py index 12dd99ea4..9f4609ce8 100644 --- a/metrics/api/views/user.py +++ b/metrics/api/views/user.py @@ -58,7 +58,7 @@ def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 location=OpenApiParameter.QUERY, description="Optional grouping strategy: 'geography_type', or 'theme'", required=False, - enum=['geography_type', 'theme'], + enum=["geography_type", "theme"], ), ], responses={ @@ -79,7 +79,7 @@ def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 serializer = UserHierarchyRequestSerializer( data={ "user_id": user_id, - "group_by": request.query_params.get('group_by', ''), + "group_by": request.query_params.get("group_by", ""), } ) serializer.is_valid(raise_exception=True) diff --git a/metrics/data/managers/core_models/geography.py b/metrics/data/managers/core_models/geography.py index 829595fd7..71e221731 100644 --- a/metrics/data/managers/core_models/geography.py +++ b/metrics/data/managers/core_models/geography.py @@ -41,7 +41,11 @@ def get_name_by_id(self, geography_code: int) -> str | None: >>> GeographyQuerySet.get_name_by_id(999) None """ - return self.filter(geography_code=geography_code).values_list("name", flat=True).first() + return ( + self.filter(geography_code=geography_code) + .values_list("name", flat=True) + .first() + ) def get_all_geography_codes_by_geography_type( self, geography_type_name: str diff --git a/metrics/data/managers/core_models/metric.py b/metrics/data/managers/core_models/metric.py index 344147a0a..e9fcb961d 100644 --- a/metrics/data/managers/core_models/metric.py +++ b/metrics/data/managers/core_models/metric.py @@ -128,7 +128,7 @@ class MetricManager(models.Manager): def get_queryset(self) -> MetricQuerySet: return MetricQuerySet(model=self.model, using=self.db) - + def get_name_by_id(self, metric_id: int) -> str | None: """Gets the metric name which matches the given metric id. diff --git a/metrics/data/managers/core_models/theme.py b/metrics/data/managers/core_models/theme.py index 2b7d8e132..72cfbb956 100644 --- a/metrics/data/managers/core_models/theme.py +++ b/metrics/data/managers/core_models/theme.py @@ -28,7 +28,6 @@ def get_name_by_id(self, theme_id: int) -> str | None: None """ return self.filter(id=theme_id).values_list("name", flat=True).first() - def get_all_names(self) -> models.QuerySet: """Gets all available theme names as a flat list queryset. diff --git a/metrics/utils/permission_grouping.py b/metrics/utils/permission_grouping.py index b860f2c6c..d864c51b4 100644 --- a/metrics/utils/permission_grouping.py +++ b/metrics/utils/permission_grouping.py @@ -45,13 +45,14 @@ def group_by_geography_type( Returns: Nested dict grouped by geography type ID, then geography code """ - grouped = defaultdict(lambda: { - "geography_type_name": None, - "geographies": defaultdict(lambda: { - "geography_name": None, - "permissions": [] - }) - }) + grouped = defaultdict( + lambda: { + "geography_type_name": None, + "geographies": defaultdict( + lambda: {"geography_name": None, "permissions": []} + ), + } + ) for perm in permissions: # Use ID as the key (handles wildcards and specific types) @@ -60,7 +61,7 @@ def group_by_geography_type( # Set geography type name if not already set if not grouped[geo_type_id]["geography_type_name"]: - if geo_type_id == "-1" or geo_type_id == "*": + if geo_type_id in {"-1", "*"}: grouped[geo_type_id]["geography_type_name"] = "All Geography Types" else: grouped[geo_type_id]["geography_type_name"] = perm.geography_type_name @@ -68,40 +69,44 @@ def group_by_geography_type( # Set geography name if not already set if not grouped[geo_type_id]["geographies"][geo_code]["geography_name"]: if geo_code == "*": - if geo_type_id == "-1" or geo_type_id == "*": - geo_name = "All Geographies" - else: - geo_name = f"All {perm.geography_type_name}s" + # Fixed: Use ternary operator + geo_name = ( + "All Geographies" + if geo_type_id in {"-1", "*"} + else f"All {perm.geography_type_name}s" + ) else: geo_name = perm.geography_name grouped[geo_type_id]["geographies"][geo_code]["geography_name"] = geo_name # Add permission to this geography - grouped[geo_type_id]["geographies"][geo_code]["permissions"].append({ - "theme": { - "id": perm.theme_id or None, - "name": perm.theme_name or None, - }, - "sub_theme": { - "id": perm.sub_theme_id or None, - "name": perm.sub_theme_name or None, - }, - "topic": { - "id": perm.topic_id or None, - "name": perm.topic_name or None, - }, - "metric": { - "id": perm.metric_id or None, - "name": perm.metric_name or None, - }, - }) + grouped[geo_type_id]["geographies"][geo_code]["permissions"].append( + { + "theme": { + "id": perm.theme_id or None, + "name": perm.theme_name or None, + }, + "sub_theme": { + "id": perm.sub_theme_id or None, + "name": perm.sub_theme_name or None, + }, + "topic": { + "id": perm.topic_id or None, + "name": perm.topic_name or None, + }, + "metric": { + "id": perm.metric_id or None, + "name": perm.metric_name or None, + }, + } + ) # Convert defaultdict to regular dict for JSON serialization result = {} for geo_type_id, geo_type_data in grouped.items(): result[geo_type_id] = { "geography_type_name": geo_type_data["geography_type_name"], - "geographies": dict(geo_type_data["geographies"]) + "geographies": dict(geo_type_data["geographies"]), } return result @@ -137,16 +142,19 @@ def group_by_theme( Returns: Nested dict grouped by theme_id → sub_theme_id → topic_id """ - grouped = defaultdict(lambda: { - "theme_name": None, - "sub_themes": defaultdict(lambda: { - "sub_theme_name": None, - "topics": defaultdict(lambda: { - "topic_name": None, - "geographies": [] - }) - }) - }) + grouped = defaultdict( + lambda: { + "theme_name": None, + "sub_themes": defaultdict( + lambda: { + "sub_theme_name": None, + "topics": defaultdict( + lambda: {"topic_name": None, "geographies": []} + ), + } + ), + } + ) for perm in permissions: # Use IDs as keys (handles wildcards and specific IDs) @@ -159,42 +167,49 @@ def group_by_theme( grouped[theme_id]["theme_name"] = perm.theme_name if not grouped[theme_id]["sub_themes"][sub_theme_id]["sub_theme_name"]: - grouped[theme_id]["sub_themes"][sub_theme_id]["sub_theme_name"] = perm.sub_theme_name + grouped[theme_id]["sub_themes"][sub_theme_id][ + "sub_theme_name" + ] = perm.sub_theme_name - if not grouped[theme_id]["sub_themes"][sub_theme_id]["topics"][topic_id]["topic_name"]: - grouped[theme_id]["sub_themes"][sub_theme_id]["topics"][topic_id]["topic_name"] = perm.topic_name + if not grouped[theme_id]["sub_themes"][sub_theme_id]["topics"][topic_id][ + "topic_name" + ]: + grouped[theme_id]["sub_themes"][sub_theme_id]["topics"][topic_id][ + "topic_name" + ] = perm.topic_name # Add geography to this topic - grouped[theme_id]["sub_themes"][sub_theme_id]["topics"][topic_id]["geographies"].append({ - "geography_type": { - "id": perm.geography_type_id or None, - "name": perm.geography_type_name or None, - }, - "geography": { - "id": perm.geography_id or None, - "name": perm.geography_name or None, - }, - "metric": { - "id": perm.metric_id or None, - "name": perm.metric_name or None, - }, - }) - - # Convert nested default dicts to regular dicts + grouped[theme_id]["sub_themes"][sub_theme_id]["topics"][topic_id][ + "geographies" + ].append( + { + "geography_type": { + "id": perm.geography_type_id or None, + "name": perm.geography_type_name or None, + }, + "geography": { + "id": perm.geography_id or None, + "name": perm.geography_name or None, + }, + "metric": { + "id": perm.metric_id or None, + "name": perm.metric_name or None, + }, + } + ) + + # Convert nested defaultdicts to regular dicts result = {} for theme_id, theme_data in grouped.items(): - result[theme_id] = { - "theme_name": theme_data["theme_name"], - "sub_themes": {} - } + result[theme_id] = {"theme_name": theme_data["theme_name"], "sub_themes": {}} for sub_theme_id, sub_theme_data in theme_data["sub_themes"].items(): result[theme_id]["sub_themes"][sub_theme_id] = { "sub_theme_name": sub_theme_data["sub_theme_name"], - "topics": {} + "topics": {}, } for topic_id, topic_data in sub_theme_data["topics"].items(): result[theme_id]["sub_themes"][sub_theme_id]["topics"][topic_id] = dict( topic_data ) - return result \ No newline at end of file + return result diff --git a/metrics/utils/permission_hierarchy.py b/metrics/utils/permission_hierarchy.py index 01f8e898e..4777d7352 100644 --- a/metrics/utils/permission_hierarchy.py +++ b/metrics/utils/permission_hierarchy.py @@ -11,8 +11,14 @@ from django.db.models import QuerySet from auth_content.models.permission_sets import PermissionSet - -from metrics.data.models.core_models.supporting import Geography, GeographyType, Metric, SubTheme, Theme, Topic +from metrics.data.models.core_models.supporting import ( + Geography, + GeographyType, + Metric, + SubTheme, + Theme, + Topic, +) @dataclass @@ -77,7 +83,6 @@ def from_permission_set(cls, perm: PermissionSet) -> "NormalizedPermission": def _populate_names(self) -> None: """Populate human-readable names for all fields.""" - # Map: (id_attribute, name_attribute, lookup_field_name) field_mappings = [ ("theme_id", "theme_name", "theme"), ("sub_theme_id", "sub_theme_name", "sub-theme"), @@ -93,8 +98,7 @@ def _populate_names(self) -> None: if id_value == "-1": setattr(self, name_attr, "* (All)") elif id_value: - setattr(self, name_attr, _get_choice_label( - field_name, id_value)) + setattr(self, name_attr, _get_choice_label(field_name, id_value)) def subsumes(self, other: "NormalizedPermission") -> bool: """ @@ -131,6 +135,27 @@ def subsumes(self, other: "NormalizedPermission") -> bool: """ return self._theme_path_subsumes(other) and self._geography_path_subsumes(other) + @staticmethod + def _field_subsumes(self_value: str, other_value: str) -> bool: + """ + Check if a single field value subsumes another. + + Args: + self_value: This permission's field value + other_value: Other permission's field value + + Returns: + True if self_value subsumes other_value + """ + # Wildcard subsumes everything + if self_value == "-1": + return True + + if not self_value and other_value: + return False + + return self_value == other_value + def _theme_path_subsumes(self, other: "NormalizedPermission") -> bool: """ Check if this permission's theme path subsumes another's. @@ -147,42 +172,13 @@ def _theme_path_subsumes(self, other: "NormalizedPermission") -> bool: Returns: True if self's theme path subsumes other's theme path """ - # Theme level - if self.theme_id == "-1": - return True # Wildcard theme subsumes everything - if not self.theme_id and other.theme_id: - return False - if self.theme_id != other.theme_id: - return False - - # Sub-theme level - if self.sub_theme_id == "-1": - return ( - True # Wildcard sub-theme subsumes all topics/metrics under this theme - ) - if not self.sub_theme_id and other.sub_theme_id: - return False - if self.sub_theme_id != other.sub_theme_id: - return False - - # Topic level - if self.topic_id == "-1": - return True # Wildcard topic subsumes all metrics under this sub-theme - if not self.topic_id and other.topic_id: - return False - if self.topic_id != other.topic_id: - return False - - # Metric level - if self.metric_id == "-1": - return True # Wildcard metric subsumes all specific metrics - if not self.metric_id and other.metric_id: - return False - if self.metric_id != other.metric_id: - return False - - # Paths are identical - return True + # Fixed: Reduced complexity by extracting common logic + return ( + self._field_subsumes(self.theme_id, other.theme_id) + and self._field_subsumes(self.sub_theme_id, other.sub_theme_id) + and self._field_subsumes(self.topic_id, other.topic_id) + and self._field_subsumes(self.metric_id, other.metric_id) + ) def _geography_path_subsumes(self, other: "NormalizedPermission") -> bool: """ @@ -190,32 +186,15 @@ def _geography_path_subsumes(self, other: "NormalizedPermission") -> bool: Geography path is: geography_type → geography - Simpler than theme path as only 2 levels. - Args: other: Another permission to compare against Returns: True if self's geography path subsumes other's geography path """ - # Geography type level - if self.geography_type_id == "-1": - return True # Wildcard geography type subsumes all geographies - if not self.geography_type_id and other.geography_type_id: - return False - if self.geography_type_id != other.geography_type_id: - return False - - # Geography level - if self.geography_id == "-1": - return True # Wildcard geography subsumes all specific geographies - if not self.geography_id and other.geography_id: - return False - if self.geography_id != other.geography_id: - return False - - # Paths are identical - return True + return self._field_subsumes( + self.geography_type_id, other.geography_type_id + ) and self._field_subsumes(self.geography_id, other.geography_id) def to_dict(self) -> dict[str, Any]: """ @@ -287,10 +266,8 @@ def build_permission_hierarchy(permission_sets: QuerySet) -> dict[str, Any]: NormalizedPermission.from_permission_set(perm) for perm in permission_sets ] - # Deduplicate - remove subsumed permissions deduplicated = _remove_subsumed_permissions(normalized_perms) - # Build summary statistics summary = _build_summary(normalized_perms, deduplicated) return { @@ -338,16 +315,16 @@ def _remove_subsumed_permissions( # This permission is not subsumed, so check if it subsumes any existing ones # Remove any existing permissions that this one subsumes - result = [ - existing for existing in result if not perm.subsumes(existing)] + result = [existing for existing in result if not perm.subsumes(existing)] - # Add this permission to the result result.append(perm) return result -def get_deduplicated_permissions(permission_sets: QuerySet) -> list[NormalizedPermission]: +def get_deduplicated_permissions( + permission_sets: QuerySet, +) -> list[NormalizedPermission]: """ Get deduplicated permissions without hierarchy structure. @@ -385,7 +362,6 @@ def _build_summary( for perm in deduplicated ) - # Find wildcard themes wildcard_themes = [ perm.theme_name for perm in deduplicated if perm.theme_id == "-1" ] @@ -410,17 +386,17 @@ def _get_choice_label(field_name: str, value: str) -> str: "topic": Topic.objects, "metric": Metric.objects, "geography": Geography.objects, - "geography_type": GeographyType.objects + "geography_type": GeographyType.objects, } manager = field_manager_map.get(field_name) if manager: - name = "" - if field_name == "geography": - name = manager.get_name_by_id(value) - else: - name = manager.get_name_by_id(int(value)) - return name if name else value + name = ( + manager.get_name_by_id(value) + if field_name == "geography" + else manager.get_name_by_id(int(value)) + ) + return name or value return value From d9c499804f22083becf51ddec3d4a8d25f40f3d5 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Thu, 16 Apr 2026 14:30:22 +0100 Subject: [PATCH 101/186] CDD-3172: tests --- tests/factories/auth_content/_init_.py | 0 tests/factories/auth_content/models/_init_.py | 0 .../auth_content/models/permission_sets.py | 39 +++++++++ tests/factories/auth_content/models/users.py | 53 ++++++++++++ .../metrics/api/views/test_user.py | 83 +++++++++++++++++++ .../managers/core_models/test_geography.py | 25 ++++++ .../core_models/test_geography_types.py | 26 +++++- .../data/managers/core_models/test_metric.py | 27 ++++++ .../managers/core_models/test_sub_theme.py | 22 +++++ .../data/managers/core_models/test_theme.py | 22 +++++ .../data/managers/core_models/test_topic.py | 22 +++++ .../metrics/utils/permission_grouping.spec.py | 0 .../utils/permission_hierarchy.spec.py | 0 .../managers/core_models/test_geography.py | 52 ++++++++++++ 14 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 tests/factories/auth_content/_init_.py create mode 100644 tests/factories/auth_content/models/_init_.py create mode 100644 tests/factories/auth_content/models/permission_sets.py create mode 100644 tests/factories/auth_content/models/users.py create mode 100644 tests/integration/metrics/api/views/test_user.py create mode 100644 tests/integration/metrics/utils/permission_grouping.spec.py create mode 100644 tests/integration/metrics/utils/permission_hierarchy.spec.py diff --git a/tests/factories/auth_content/_init_.py b/tests/factories/auth_content/_init_.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/factories/auth_content/models/_init_.py b/tests/factories/auth_content/models/_init_.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/factories/auth_content/models/permission_sets.py b/tests/factories/auth_content/models/permission_sets.py new file mode 100644 index 000000000..a4fc08757 --- /dev/null +++ b/tests/factories/auth_content/models/permission_sets.py @@ -0,0 +1,39 @@ +import factory + +from auth_content.models.permission_sets import PermissionSet + + +class PermissionSetFactory(factory.django.DjangoModelFactory): + """ + Factory for creating `PermissionSet` instances for tests + """ + + class Meta: + model = PermissionSet + + @classmethod + def create_wildcard_permission_set(cls): + WILDCARD_VALUE = "-1" + + return cls.create( + name="Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", + theme=WILDCARD_VALUE, + sub_theme=WILDCARD_VALUE, + topic=WILDCARD_VALUE, + metric=WILDCARD_VALUE, + geography_type=WILDCARD_VALUE, + geography=WILDCARD_VALUE + ) + + @classmethod + def create_permission_set(cls, name, theme, sub_theme, topic, metric, geography_type, geography): + + return cls.create( + name=name, + theme=theme, + sub_theme=sub_theme, + topic=topic, + metric=metric, + geography_type=geography_type, + geography=geography + ) diff --git a/tests/factories/auth_content/models/users.py b/tests/factories/auth_content/models/users.py new file mode 100644 index 000000000..592cfa4fe --- /dev/null +++ b/tests/factories/auth_content/models/users.py @@ -0,0 +1,53 @@ +import factory + +from auth_content.models.permission_sets import PermissionSet +from auth_content.models.users import User + + +class UserFactory(factory.django.DjangoModelFactory): + """ + Factory for creating `User` instances for tests + """ + + class Meta: + model = User + + @classmethod + def create_with_permission_set(cls, user_id: str, permission_set_name: str): + """ + Create a user with a single permission set. + + Args: + user_id: UUID for the user + permission_set_name: Name of the permission set to assign + + Returns: + User instance with permission set assigned + """ + permission_set, _ = PermissionSet.objects.get_or_create( + name=permission_set_name + ) + + # Create user first + user = cls.create(user_id=user_id) + + # Then add the permission set using .add() + user.permission_sets.add(permission_set) + + return user + + @classmethod + def create_with_permission_sets(cls, user_id: str, permission_sets: list): + """ + Create a user with multiple permission sets. + + Args: + user_id: UUID for the user + permission_sets: List of PermissionSet instances + + Returns: + User instance with permission sets assigned + """ + user = cls.create(user_id=user_id) + user.permission_sets.set(permission_sets) + return user \ No newline at end of file diff --git a/tests/integration/metrics/api/views/test_user.py b/tests/integration/metrics/api/views/test_user.py new file mode 100644 index 000000000..a866fc4f6 --- /dev/null +++ b/tests/integration/metrics/api/views/test_user.py @@ -0,0 +1,83 @@ +from http import HTTPStatus + +import pytest +from rest_framework.response import Response +from rest_framework.test import APIClient + +from auth_content.constants import WILDCARD_ID_VALUE +from tests.factories.auth_content.models.permission_sets import PermissionSetFactory +from tests.factories.auth_content.models.users import UserFactory +from tests.factories.metrics.metric import MetricFactory +from tests.factories.metrics.sub_theme import SubThemeFactory +from tests.factories.metrics.topic import TopicFactory + + +class TestPermissionSetByUser: + @property + def path(self) -> str: + return "/api/user" + + @pytest.mark.django_db + def test_get_user_wildcard_permission_set(self): + client = APIClient() + + userId = "f907e591-4c49-4847-89b3-665e3c0133a4" + + # create subthemes + wildcard_permission = PermissionSetFactory.create_wildcard_permission_set() + user_with_wildcard = UserFactory.create_with_permission_set( + user_id=userId, permission_set_name="Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)") + + # Retrieve the subthemes + path = f"{self.path}/{userId}/permissions" + response: Response = client.get(path=path) + result = response.data + + # Should return a wildcard choice + print(result) + assert result["user_id"] == userId + assert result["permission_sets"][0] == { + "id": 1, + "name": "Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", + "theme": "-1", + "sub_theme": "-1", + "topic": "-1", + "metric": "-1", + "geography_type": "-1", + "geography": "-1" + } + + @pytest.mark.django_db + def test_get_user_wildcard_permission_set(self): + + client = APIClient() + + userId = "f907e591-4c49-4847-89b3-665e3c0133a4" + + # create subthemes + wildcard_permission = PermissionSetFactory.create_wildcard_permission_set() + permission_one = PermissionSetFactory.create_permission_set( + name="Permission Set 1", theme=1, sub_theme=1, topic=2, metric=2, geography_type=2, geography=1) + permission_two = PermissionSetFactory.create_permission_set( + name=" Permission Set 2", theme=1, sub_theme=2, topic=1, metric=2, geography_type=1, geography=1) + user_with_wildcard = UserFactory.create_with_permission_sets( + user_id=userId, permission_sets=[wildcard_permission, permission_one, permission_two]) + + # Retrieve the subthemes + path = f"{self.path}/{userId}/permissions" + response: Response = client.get(path=path) + result = response.data + + # Should return a user with 3 permissions sets + assert result["user_id"] == userId + assert len(result["permission_sets"]) == 3 + assert result["permission_sets"][0] == { + "id": 1, + "name": "Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", + "theme": "-1", + "sub_theme": "-1", + "topic": "-1", + "metric": "-1", + "geography_type": "-1", + "geography": "-1" + } 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 03972de53..14f5ba842 100644 --- a/tests/integration/metrics/data/managers/core_models/test_geography.py +++ b/tests/integration/metrics/data/managers/core_models/test_geography.py @@ -93,3 +93,28 @@ def test_query_for_get_all_names_and_codes(self): "geography_code": geography_two.geography_code, "name": geography_two.name, } + + @pytest.mark.django_db + def test_get_name_by_id(self): + """ + Given a number of existing `geography` records + When `get_name_by_id` is called + Then the geography types with their codes and names are returned correctly + """ + geography_one = GeographyFactory.create_with_geography_type( + name="Leeds", + geography_code="E08000035", + geography_type="Lower Tier Local Authority", + ) + + geography_two = GeographyFactory.create_with_geography_type( + name="London", geography_code="E12000007", geography_type="Region" + ) + + # When + get_name_by_id = Geography.objects.get_name_by_id( + geography_code="E12000007") + + # Access the dictionary returned by .first() + result = get_name_by_id + assert result == geography_two.name 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 26f94727b..bdad9c31b 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 @@ -1,6 +1,6 @@ import pytest -from metrics.data.models.core_models.supporting import Geography +from metrics.data.models.core_models.supporting import Geography, GeographyType from tests.factories.metrics.geography_type import GeographyTypeFactory @@ -41,3 +41,27 @@ def test_query_for_all_geography_names_by_geography_type( assert all_geography_names.count() == 2 assert all_geography_names_by_type.count() == 1 assert all_geography_names_by_type.first() == fake_geography_name_one + + @pytest.mark.django_db + def test_get_name_by_id(self): + """ + Given a number of existing `geography -type` records + When `get_name_by_id` is called + Then the geography types with their codes and names are returned correctly + """ + geography_one = GeographyTypeFactory( + name="Lower Tier Local Authority", + with_geographies=["Hull"] + ) + + geography_two = GeographyTypeFactory( + name="Region", + with_geographies=["South East"] + ) + + # When + get_name_by_id = GeographyType.objects.get_name_by_id(2) + + # Access the dictionary returned by .first() + result = get_name_by_id + assert result == 'Region' 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 abd18601c..b3337e0e1 100644 --- a/tests/integration/metrics/data/managers/core_models/test_metric.py +++ b/tests/integration/metrics/data/managers/core_models/test_metric.py @@ -89,3 +89,30 @@ def test_get_all_names_and_ids(self): assert timeseries_metric_name in all_metric_names_and_ids.values_list( "name", flat=True ) + + @pytest.mark.django_db + def test_get_name_by_id(self): + """ + Given a number of existing `Metric` records + When `get_name_by_id()` is called + from the `MetricManager` + Then the metrics returned have been filtered correctly + """ + # Given + timeseries_metric_name = "COVID-19_deaths_ONSByWeek" + timeseries_metric_group = MetricGroup.objects.create(name="deaths") + Metric.objects.create( + name=timeseries_metric_name, + metric_group=timeseries_metric_group, + ) + headline_metric_group = MetricGroup.objects.create(name="headline") + Metric.objects.create( + name="COVID-19_headline_ONSdeaths_7DayChange", + metric_group=headline_metric_group, + ) + + # When + get_name_by_id = Metric.objects.get_name_by_id(2) + + # Then + assert get_name_by_id == "COVID-19_headline_ONSdeaths_7DayChange" diff --git a/tests/integration/metrics/data/managers/core_models/test_sub_theme.py b/tests/integration/metrics/data/managers/core_models/test_sub_theme.py index 0699f79e7..3f099b33d 100644 --- a/tests/integration/metrics/data/managers/core_models/test_sub_theme.py +++ b/tests/integration/metrics/data/managers/core_models/test_sub_theme.py @@ -50,3 +50,25 @@ def test_query_for_get_all_names_and_ids(self): # Then assert all_sub_theme_names_and_ids.count() == 3 + + @pytest.mark.django_db + def test_query_for_get_name_by_id(self): + """ + Given a number of existing `SubTheme` records + When `get_name_by_id`is called + Then a unique set of `SubTheme` records is returned + """ + # Given + fake_sub_theme_name_one = "respiratory" + fake_sub_theme_name_two = "weather_alert" + fake_sub_theme_name_three = "infectious_disease" + + SubThemeFactory(name=fake_sub_theme_name_one) + SubThemeFactory(name=fake_sub_theme_name_two) + SubThemeFactory(name=fake_sub_theme_name_three) + + # When + get_name_by_id = SubTheme.objects.get_name_by_id(3) + + # Then + assert get_name_by_id == fake_sub_theme_name_three diff --git a/tests/integration/metrics/data/managers/core_models/test_theme.py b/tests/integration/metrics/data/managers/core_models/test_theme.py index 3f691bce1..cd00f620e 100644 --- a/tests/integration/metrics/data/managers/core_models/test_theme.py +++ b/tests/integration/metrics/data/managers/core_models/test_theme.py @@ -26,3 +26,25 @@ def test_query_get_all_names_and_ids(self): # Then assert get_all_names_and_ids.count() == 3 + + @pytest.mark.django_db + def test_query_get_name_by_id(self): + """ + Given a number of existing `Topic` records + When `get_all_names_and_ids` is called + Then a unique set of `Topic` records is returned. + """ + # Given + fake_theme_name_one = "respiratory" + fake_theme_name_two = "infectious_disease" + fake_theme_name_three = "immunisation" + + ThemeFactory(name=fake_theme_name_one) + ThemeFactory(name=fake_theme_name_two) + ThemeFactory(name=fake_theme_name_three) + + # When + get_name_by_id = Theme.objects.get_name_by_id(2) + + # Then + assert get_name_by_id == fake_theme_name_two 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 9cffde122..893617c01 100644 --- a/tests/integration/metrics/data/managers/core_models/test_topic.py +++ b/tests/integration/metrics/data/managers/core_models/test_topic.py @@ -50,3 +50,25 @@ def test_query_get_all_names_and_ids(self): # Then assert get_all_names_and_ids.count() == 3 + + @pytest.mark.django_db + def test_query_get_name_by_id(self): + """ + Given a number of existing `Topic` records + When `get_name_by_id` is called + Then a unique set of `Topic` records is returned. + """ + # Given + fake_topic_name_one = "COVID-19" + fake_topic_name_two = "Cold-alert" + fake_topic_name_three = "Influenza" + + TopicFactory(name=fake_topic_name_one) + TopicFactory(name=fake_topic_name_two) + TopicFactory(name=fake_topic_name_three) + + # When + get_name_by_id = Topic.objects.get_name_by_id(3) + + # Then + assert get_name_by_id == fake_topic_name_three diff --git a/tests/integration/metrics/utils/permission_grouping.spec.py b/tests/integration/metrics/utils/permission_grouping.spec.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/metrics/utils/permission_hierarchy.spec.py b/tests/integration/metrics/utils/permission_hierarchy.spec.py new file mode 100644 index 000000000..e69de29bb 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 e6385c4da..d6c6a9ee2 100644 --- a/tests/unit/metrics/data/managers/core_models/test_geography.py +++ b/tests/unit/metrics/data/managers/core_models/test_geography.py @@ -56,3 +56,55 @@ def test_get_geography_codes_and_names_by_geography_type_id( spy_get_geography_codes_and_names_by_geography_type_id.assert_called_with( geography_type_id=fake_geography_type_id, ) + + @mock.patch.object( + GeographyQuerySet, "get_geography_codes_and_names_by_geography_type_id" + ) + def test_get_geography_codes_and_names_by_geography_type_id( + self, spy_get_geography_codes_and_names_by_geography_type_id: mock.MagicMock + ): + """ + Given a payload containing the required field + When `get_all_geography_names_by_type` is called, + Then it delegates call to `GeographyQuerySet`. + """ + # Given + fake_geography_type_id = 1 + geography_manager = GeographyManager() + + # When + GeographyManager.get_geography_codes_and_names_by_geography_type_id( + geography_manager, + geography_type_id=fake_geography_type_id, + ) + + # Then + spy_get_geography_codes_and_names_by_geography_type_id.assert_called_with( + geography_type_id=fake_geography_type_id, + ) + + @mock.patch.object( + GeographyQuerySet, "get_name_by_id" + ) + def test_get_name_by_id( + self, spy_get_name_by_id: mock.MagicMock + ): + """ + Given a payload containing the required field + When `get_name_by_id` is called, + Then it delegates call to `GeographyQuerySet`. + """ + # Given + fake_geography_code = "E92000001" + geography_manager = GeographyManager() + + # When + GeographyManager.get_name_by_id( + geography_manager, + geography_code=fake_geography_code, + ) + + # Then + spy_get_name_by_id.assert_called_with( + fake_geography_code + ) From 15627ab5238dece0a77bcaa34a87cc085dba246a Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Thu, 16 Apr 2026 17:13:50 +0100 Subject: [PATCH 102/186] CDD-3172: tests --- .../auth_content/models/permission_sets.py | 9 +- tests/factories/auth_content/models/users.py | 16 +- .../metrics/api/views/test_user.py | 243 ++++++++++++++++-- .../managers/core_models/test_geography.py | 3 +- .../core_models/test_geography_types.py | 8 +- .../unit/metrics/api/serializers/test_user.py | 0 .../managers/core_models/test_geography.py | 12 +- 7 files changed, 248 insertions(+), 43 deletions(-) create mode 100644 tests/unit/metrics/api/serializers/test_user.py diff --git a/tests/factories/auth_content/models/permission_sets.py b/tests/factories/auth_content/models/permission_sets.py index a4fc08757..7f73e093f 100644 --- a/tests/factories/auth_content/models/permission_sets.py +++ b/tests/factories/auth_content/models/permission_sets.py @@ -22,18 +22,19 @@ def create_wildcard_permission_set(cls): topic=WILDCARD_VALUE, metric=WILDCARD_VALUE, geography_type=WILDCARD_VALUE, - geography=WILDCARD_VALUE + geography=WILDCARD_VALUE, ) @classmethod - def create_permission_set(cls, name, theme, sub_theme, topic, metric, geography_type, geography): + def create_permission_set( + cls, theme, sub_theme, topic, metric, geography_type, geography + ): return cls.create( - name=name, theme=theme, sub_theme=sub_theme, topic=topic, metric=metric, geography_type=geography_type, - geography=geography + geography=geography, ) diff --git a/tests/factories/auth_content/models/users.py b/tests/factories/auth_content/models/users.py index 592cfa4fe..df74effbd 100644 --- a/tests/factories/auth_content/models/users.py +++ b/tests/factories/auth_content/models/users.py @@ -16,11 +16,11 @@ class Meta: def create_with_permission_set(cls, user_id: str, permission_set_name: str): """ Create a user with a single permission set. - + Args: user_id: UUID for the user permission_set_name: Name of the permission set to assign - + Returns: User instance with permission set assigned """ @@ -30,24 +30,24 @@ def create_with_permission_set(cls, user_id: str, permission_set_name: str): # Create user first user = cls.create(user_id=user_id) - + # Then add the permission set using .add() user.permission_sets.add(permission_set) - + return user - + @classmethod def create_with_permission_sets(cls, user_id: str, permission_sets: list): """ Create a user with multiple permission sets. - + Args: user_id: UUID for the user permission_sets: List of PermissionSet instances - + Returns: User instance with permission sets assigned """ user = cls.create(user_id=user_id) user.permission_sets.set(permission_sets) - return user \ No newline at end of file + return user diff --git a/tests/integration/metrics/api/views/test_user.py b/tests/integration/metrics/api/views/test_user.py index a866fc4f6..a48274f7c 100644 --- a/tests/integration/metrics/api/views/test_user.py +++ b/tests/integration/metrics/api/views/test_user.py @@ -1,4 +1,5 @@ from http import HTTPStatus +from uuid import uuid4 import pytest from rest_framework.response import Response @@ -19,23 +20,44 @@ def path(self) -> str: @pytest.mark.django_db def test_get_user_wildcard_permission_set(self): + client = APIClient() userId = "f907e591-4c49-4847-89b3-665e3c0133a4" # create subthemes wildcard_permission = PermissionSetFactory.create_wildcard_permission_set() - user_with_wildcard = UserFactory.create_with_permission_set( - user_id=userId, permission_set_name="Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)") + permission_one = PermissionSetFactory.create_permission_set( + name="Permission Set 1", + theme=1, + sub_theme=1, + topic=2, + metric=2, + geography_type=2, + geography=1, + ) + permission_two = PermissionSetFactory.create_permission_set( + name=" Permission Set 2", + theme=1, + sub_theme=2, + topic=1, + metric=2, + geography_type=1, + geography=1, + ) + user_with_wildcard = UserFactory.create_with_permission_sets( + user_id=userId, + permission_sets=[wildcard_permission, permission_one, permission_two], + ) # Retrieve the subthemes path = f"{self.path}/{userId}/permissions" response: Response = client.get(path=path) result = response.data - # Should return a wildcard choice - print(result) + # Should return a user with 3 permissions sets assert result["user_id"] == userId + assert len(result["permission_sets"]) == 3 assert result["permission_sets"][0] == { "id": 1, "name": "Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", @@ -44,33 +66,134 @@ def test_get_user_wildcard_permission_set(self): "topic": "-1", "metric": "-1", "geography_type": "-1", - "geography": "-1" + "geography": "-1", } @pytest.mark.django_db - def test_get_user_wildcard_permission_set(self): + def test_returns_400_for_invalid_uuid(self): + """ + Given an invalid UUID format + When requesting hierarchy + Then a 400 is returned with validation error + """ + # Given + client = APIClient() + invalid_uuid = "not-a-valid-uuid" + + # When + path = f"{self.path}/{invalid_uuid}/permissions/hierarchy" + response = client.get(path=path) + + # Then + assert response.status_code == HTTPStatus.BAD_REQUEST + assert "user_id" in response.data + + @pytest.mark.django_db + def test_returns_empty_permission_set_hierarchy_for_invalid_uuid(self): + """ + Given an invalid UUID format + When requesting hierarchy + Then a 400 is returned with validation error + """ + # Given + client = APIClient() + userId = str(uuid4()) + + # When + path = f"{self.path}/{userId}/permissions/hierarchy" + response = client.get(path=path) + + # Then + assert response.data["permission_set_hierarchy"] == [] + + @pytest.mark.django_db + def test_returns_empty_permission_sets_when_no_permissions(self): + """ + Given an invalid user id if no associated permission sets then + returns an empty list of permission sets + """ + # Given + client = APIClient() + userId = str(uuid4()) + + # When + path = f"{self.path}/{userId}/permissions" + response = client.get(path=path) + data = response.data + print(data) + + # Then + assert data["user_id"] == userId + assert data["permission_sets"] == [] + assert data["permission_set_count"] == 0 + + @pytest.mark.django_db + def test_global_wildcard_subsumes_everything(self): + """ + Given a user with a global wildcard and specific permissions + When requesting their hierarchy + Then only the global wildcard remains + """ + # Given + client = APIClient() + userId = str(uuid4()) + + # Global wildcard: All themes × All geographies + global_perm = PermissionSetFactory.create_wildcard_permission_set() + + # Specific permission (should be subsumed) + specific_perm = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + user = UserFactory.create_with_permission_sets( + user_id=userId, permission_sets=[global_perm, specific_perm] + ) + + # When + path = f"{self.path}/{userId}/permissions/hierarchy" + response = client.get(path=path) + + # Then + result = response.data + + summary = result["permission_sets"]["summary"] + print(summary) + + assert summary["total_permission_sets"] == 2 + assert summary["deduplicated_count"] == 1 + assert summary["has_global_access"] is True + hierarchy = result["permission_sets"]["permission_set_hierarchy"] + assert len(hierarchy) == 1 + assert hierarchy[0]["theme"]["id"] == "-1" + assert hierarchy[0]["geography_type"]["id"] == "-1" + + @pytest.mark.django_db + def test_get_user_wildcard_permission_set(self): client = APIClient() userId = "f907e591-4c49-4847-89b3-665e3c0133a4" # create subthemes wildcard_permission = PermissionSetFactory.create_wildcard_permission_set() - permission_one = PermissionSetFactory.create_permission_set( - name="Permission Set 1", theme=1, sub_theme=1, topic=2, metric=2, geography_type=2, geography=1) - permission_two = PermissionSetFactory.create_permission_set( - name=" Permission Set 2", theme=1, sub_theme=2, topic=1, metric=2, geography_type=1, geography=1) - user_with_wildcard = UserFactory.create_with_permission_sets( - user_id=userId, permission_sets=[wildcard_permission, permission_one, permission_two]) + user_with_wildcard = UserFactory.create_with_permission_set( + user_id=userId, + permission_set_name="Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", + ) # Retrieve the subthemes path = f"{self.path}/{userId}/permissions" response: Response = client.get(path=path) result = response.data - # Should return a user with 3 permissions sets + # Should return a wildcard choice assert result["user_id"] == userId - assert len(result["permission_sets"]) == 3 assert result["permission_sets"][0] == { "id": 1, "name": "Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", @@ -79,5 +202,95 @@ def test_get_user_wildcard_permission_set(self): "topic": "-1", "metric": "-1", "geography_type": "-1", - "geography": "-1" + "geography": "-1", } + + @pytest.mark.django_db + def test_accepts_empty_group_by_parameter(self): + """ + Given a valid user + When requesting hierarchy without group_by parameter + Then the request succeeds with default behavior + """ + # Given + client = APIClient() + user_id = str(uuid4()) + + user = UserFactory.create(user_id=user_id) + perm = PermissionSetFactory.create_wildcard_permission_set() + user.permission_sets.add(perm) + + # When - No query parameters + path = f"{self.path}/{user_id}/permissions/hierarchy" + response = client.get(path) + + # Then + assert response.status_code == HTTPStatus.OK + + @pytest.mark.django_db + def test_accepts_group_by_theme_parameter(self): + """ + Given a valid user + When requesting hierarchy with group_by=theme + Then the request succeeds + """ + # Given + client = APIClient() + user_id = str(uuid4()) + + user = UserFactory.create(user_id=user_id) + perm = PermissionSetFactory.create_wildcard_permission_set() + user.permission_sets.add(perm) + + # When + path = f"{self.path}/{user_id}/permissions/hierarchy?group_by=theme" + response = client.get(path) + + # Then + assert response.status_code == HTTPStatus.OK + + @pytest.mark.django_db + def test_accepts_group_by_geography_parameter(self): + """ + Given a valid user + When requesting hierarchy with group_by=geography + Then the request succeeds + """ + # Given + client = APIClient() + user_id = str(uuid4()) + + user = UserFactory.create(user_id=user_id) + perm = PermissionSetFactory.create_wildcard_permission_set() + user.permission_sets.add(perm) + + # When + path = f"{self.path}/{user_id}/permissions/hierarchy?group_by=geography_type" + response = client.get(path) + + # Then + assert response.status_code == HTTPStatus.OK + + @pytest.mark.django_db + def test_handles_invalid_group_by_parameter(self): + """ + Given a valid user + When requesting hierarchy with invalid group_by value + Then appropriate error is returned + """ + # Given + client = APIClient() + user_id = str(uuid4()) + + user = UserFactory.create(user_id=user_id) + perm = PermissionSetFactory.create_wildcard_permission_set() + user.permission_sets.add(perm) + + # When + path = f"{self.path}/{user_id}/permissions/hierarchy?group_by=invalid_value" + response = client.get(path) + + # Then + # Depending on your implementation, this might be 400 or just ignored + # Adjust based on your actual validation logic + assert response.status_code in [HTTPStatus.OK, HTTPStatus.BAD_REQUEST] 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 14f5ba842..183de0bc9 100644 --- a/tests/integration/metrics/data/managers/core_models/test_geography.py +++ b/tests/integration/metrics/data/managers/core_models/test_geography.py @@ -112,8 +112,7 @@ def test_get_name_by_id(self): ) # When - get_name_by_id = Geography.objects.get_name_by_id( - geography_code="E12000007") + get_name_by_id = Geography.objects.get_name_by_id(geography_code="E12000007") # Access the dictionary returned by .first() result = get_name_by_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 bdad9c31b..d2d0dab2b 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 @@ -50,13 +50,11 @@ def test_get_name_by_id(self): Then the geography types with their codes and names are returned correctly """ geography_one = GeographyTypeFactory( - name="Lower Tier Local Authority", - with_geographies=["Hull"] + name="Lower Tier Local Authority", with_geographies=["Hull"] ) geography_two = GeographyTypeFactory( - name="Region", - with_geographies=["South East"] + name="Region", with_geographies=["South East"] ) # When @@ -64,4 +62,4 @@ def test_get_name_by_id(self): # Access the dictionary returned by .first() result = get_name_by_id - assert result == 'Region' + assert result == "Region" diff --git a/tests/unit/metrics/api/serializers/test_user.py b/tests/unit/metrics/api/serializers/test_user.py new file mode 100644 index 000000000..e69de29bb 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 d6c6a9ee2..c18a81559 100644 --- a/tests/unit/metrics/data/managers/core_models/test_geography.py +++ b/tests/unit/metrics/data/managers/core_models/test_geography.py @@ -83,12 +83,8 @@ def test_get_geography_codes_and_names_by_geography_type_id( geography_type_id=fake_geography_type_id, ) - @mock.patch.object( - GeographyQuerySet, "get_name_by_id" - ) - def test_get_name_by_id( - self, spy_get_name_by_id: mock.MagicMock - ): + @mock.patch.object(GeographyQuerySet, "get_name_by_id") + def test_get_name_by_id(self, spy_get_name_by_id: mock.MagicMock): """ Given a payload containing the required field When `get_name_by_id` is called, @@ -105,6 +101,4 @@ def test_get_name_by_id( ) # Then - spy_get_name_by_id.assert_called_with( - fake_geography_code - ) + spy_get_name_by_id.assert_called_with(fake_geography_code) From 2ad0df8b03134691b762d59837f0f08b5766d260 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Mon, 20 Apr 2026 09:36:21 +0100 Subject: [PATCH 103/186] CDD-3172: tests and refactoring --- metrics/utils/permission_hierarchy.py | 3 - .../data/managers/rbac_models/test_user.py | 48 + .../metrics/utils/permission_grouping.spec.py | 0 .../utils/permission_hierarchy.spec.py | 0 .../metrics/utils/test_permission_grouping.py | 718 ++++++++++++ .../utils/test_permission_hierarchy.py | 1025 +++++++++++++++++ .../unit/metrics/api/serializers/test_user.py | 26 + .../data/managers/core_models/test_metric.py | 2 +- 8 files changed, 1818 insertions(+), 4 deletions(-) create mode 100644 tests/integration/metrics/data/managers/rbac_models/test_user.py delete mode 100644 tests/integration/metrics/utils/permission_grouping.spec.py delete mode 100644 tests/integration/metrics/utils/permission_hierarchy.spec.py create mode 100644 tests/integration/metrics/utils/test_permission_grouping.py create mode 100644 tests/integration/metrics/utils/test_permission_hierarchy.py diff --git a/metrics/utils/permission_hierarchy.py b/metrics/utils/permission_hierarchy.py index 4777d7352..6642d3bfa 100644 --- a/metrics/utils/permission_hierarchy.py +++ b/metrics/utils/permission_hierarchy.py @@ -151,9 +151,6 @@ def _field_subsumes(self_value: str, other_value: str) -> bool: if self_value == "-1": return True - if not self_value and other_value: - return False - return self_value == other_value def _theme_path_subsumes(self, other: "NormalizedPermission") -> bool: diff --git a/tests/integration/metrics/data/managers/rbac_models/test_user.py b/tests/integration/metrics/data/managers/rbac_models/test_user.py new file mode 100644 index 000000000..c49fdd8d7 --- /dev/null +++ b/tests/integration/metrics/data/managers/rbac_models/test_user.py @@ -0,0 +1,48 @@ +import pytest + +from auth_content.models.users import User +from tests.factories.auth_content.models.permission_sets import PermissionSetFactory +from tests.factories.auth_content.models.users import UserFactory + + +class TestUserManager: + @pytest.mark.django_db + def test_get_user_with_permission_sets(self): + """ + Given a number of existing `User` records + When `get_user_with_permission_sets()` is called + from the `UserManager` + Then the user is returned filtered correctly + """ + userId = "f907e591-4c49-4847-89b3-665e3c0133a4" + + wildcard_permission = PermissionSetFactory.create_wildcard_permission_set() + permission_one = PermissionSetFactory.create_permission_set( + theme=1, + sub_theme=1, + topic=2, + metric=2, + geography_type=2, + geography=1, + ) + permission_two = PermissionSetFactory.create_permission_set( + theme=1, + sub_theme=2, + topic=1, + metric=2, + geography_type=1, + geography=1, + ) + + user_with_wildcard = UserFactory.create_with_permission_sets( + user_id=userId, + permission_sets=[wildcard_permission, permission_one, permission_two], + ) + + # When + get_user_with_permission_sets = User.objects.get_user_with_permission_sets( + userId + ) + + # Then + assert get_user_with_permission_sets.count() == 1 diff --git a/tests/integration/metrics/utils/permission_grouping.spec.py b/tests/integration/metrics/utils/permission_grouping.spec.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/integration/metrics/utils/permission_hierarchy.spec.py b/tests/integration/metrics/utils/permission_hierarchy.spec.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/integration/metrics/utils/test_permission_grouping.py b/tests/integration/metrics/utils/test_permission_grouping.py new file mode 100644 index 000000000..c6f6c051d --- /dev/null +++ b/tests/integration/metrics/utils/test_permission_grouping.py @@ -0,0 +1,718 @@ +"""Tests for permission grouping utilities.""" + +import pytest + +from metrics.utils.permission_grouping import group_by_geography_type, group_by_theme +from metrics.utils.permission_hierarchy import NormalizedPermission +from tests.factories.auth_content.models.permission_sets import PermissionSetFactory + + +class TestGroupByGeographyType: + """Test suite for group_by_geography_type function.""" + + @pytest.mark.django_db + def test_groups_single_permission_by_geography_type(self): + """ + Given a single permission with specific geography type + When grouping by geography type + Then permission is correctly grouped under its geography type ID + """ + # Given + perm_set = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E12000008", + ) + + normalized = NormalizedPermission.from_permission_set(perm_set) + + # When + result = group_by_geography_type([normalized]) + + # Then + assert "1" in result + assert result["1"]["geography_type_name"] is not None + assert "E12000008" in result["1"]["geographies"] + assert len(result["1"]["geographies"]["E12000008"]["permissions"]) == 1 + + @pytest.mark.django_db + def test_groups_wildcard_geography_type(self): + """ + Given a permission with wildcard geography type + When grouping by geography type + Then permission is grouped under wildcard key with appropriate labels + """ + # Given + perm_set = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="-1", + geography="-1", + ) + + normalized = NormalizedPermission.from_permission_set(perm_set) + + # When + result = group_by_geography_type([normalized]) + + # Then + assert "-1" in result + assert result["-1"]["geography_type_name"] == "All Geography Types" + assert "*" in result["-1"]["geographies"] + assert result["-1"]["geographies"]["*"]["geography_name"] == "All Geographies" + + @pytest.mark.django_db + def test_groups_multiple_permissions_same_geography_type(self): + """ + Given multiple permissions with same geography type but different geographies + When grouping by geography type + Then all permissions are grouped under the same geography type ID + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="5", + metric="11", + geography_type="1", + geography="W92000004", + ) + + normalized_perms = [ + NormalizedPermission.from_permission_set(perm1), + NormalizedPermission.from_permission_set(perm2), + ] + + # When + result = group_by_geography_type(normalized_perms) + + # Then + assert "1" in result + assert len(result["1"]["geographies"]) == 2 + assert "E92000001" in result["1"]["geographies"] + assert "W92000004" in result["1"]["geographies"] + + @pytest.mark.django_db + def test_groups_multiple_permissions_different_geography_types(self): + """ + Given multiple permissions with different geography type IDs + When grouping by geography type + Then permissions are grouped separately by geography type ID + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="2", + geography="E12000008", + ) + + normalized_perms = [ + NormalizedPermission.from_permission_set(perm1), + NormalizedPermission.from_permission_set(perm2), + ] + + # When + result = group_by_geography_type(normalized_perms) + + # Then + assert len(result) == 2 + assert "1" in result + assert "2" in result + + @pytest.mark.django_db + def test_includes_theme_hierarchy_in_geography_groups(self): + """ + Given permissions grouped by geography + When examining the result + Then each permission includes complete theme hierarchy + """ + # Given + perm = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized = NormalizedPermission.from_permission_set(perm) + + # When + result = group_by_geography_type([normalized]) + + # Then + permission = result["1"]["geographies"]["E92000001"]["permissions"][0] + assert "theme" in permission + assert "sub_theme" in permission + assert "topic" in permission + assert "metric" in permission + assert "id" in permission["theme"] + assert "name" in permission["theme"] + + @pytest.mark.django_db + def test_handles_wildcard_geography_within_specific_type(self): + """ + Given a permission with specific geography type but wildcard geography + When grouping by geography type + Then geography name shows "All s" + """ + # Given + perm = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="2", + geography="-1", + ) + + normalized = NormalizedPermission.from_permission_set(perm) + + # When + result = group_by_geography_type([normalized]) + + # Then + assert "2" in result + assert "*" in result["2"]["geographies"] + geo_name = result["2"]["geographies"]["*"]["geography_name"] + assert "All" in geo_name + + @pytest.mark.django_db + def test_groups_empty_permission_list(self): + """ + Given an empty list of permissions + When grouping by geography type + Then an empty dict is returned + """ + # When + result = group_by_geography_type([]) + + # Then + assert result == {} + + @pytest.mark.django_db + def test_multiple_permissions_same_geography_accumulate(self): + """ + Given multiple permissions for the same specific geography + When grouping by geography type + Then all permissions accumulate under that geography + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="5", + metric="11", + geography_type="1", + geography="E92000001", + ) + + normalized_perms = [ + NormalizedPermission.from_permission_set(perm1), + NormalizedPermission.from_permission_set(perm2), + ] + + # When + result = group_by_geography_type(normalized_perms) + + # Then + permissions = result["1"]["geographies"]["E92000001"]["permissions"] + assert len(permissions) == 2 + + topic_ids = [p["topic"]["id"] for p in permissions] + assert "3" in topic_ids + assert "5" in topic_ids + + @pytest.mark.django_db + def test_geography_type_name_formatted_correctly(self): + """ + Given a permission with geography type ID + When grouping by geography type + Then geography type name is formatted with title case and underscores replaced + """ + perm = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized = NormalizedPermission.from_permission_set(perm) + + # When + result = group_by_geography_type([normalized]) + + # Then + assert "1" in result + geo_type_name = result["1"]["geography_type_name"] + assert geo_type_name is not None + assert geo_type_name != "" + + +class TestGroupByTheme: + """Test suite for group_by_theme function.""" + + @pytest.mark.django_db + def test_groups_single_permission_by_theme(self): + """ + Given a single permission + When grouping by theme + Then permission is correctly nested under theme → sub_theme → topic + """ + # Given + perm_set = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized = NormalizedPermission.from_permission_set(perm_set) + + # When + result = group_by_theme([normalized]) + + # Then + assert "2" in result + assert "2" in result["2"]["sub_themes"] + assert "3" in result["2"]["sub_themes"]["2"]["topics"] + + topic_data = result["2"]["sub_themes"]["2"]["topics"]["3"] + assert len(topic_data["geographies"]) == 1 + + @pytest.mark.django_db + def test_groups_wildcard_theme(self): + """ + Given a permission with wildcard theme + When grouping by theme + Then permission is grouped under wildcard key + """ + # Given + perm_set = PermissionSetFactory.create_wildcard_permission_set() + normalized = NormalizedPermission.from_permission_set(perm_set) + + # When + result = group_by_theme([normalized]) + + # Then + assert "-1" in result + assert result["-1"]["theme_name"] == "* (All)" + assert "-1" in result["-1"]["sub_themes"] + assert "-1" in result["-1"]["sub_themes"]["-1"]["topics"] + + @pytest.mark.django_db + def test_groups_multiple_topics_under_same_sub_theme(self): + """ + Given multiple permissions with same theme/sub_theme but different topics + When grouping by theme + Then topics are grouped under the same sub_theme + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="5", + metric="11", + geography_type="1", + geography="E92000001", + ) + + normalized_perms = [ + NormalizedPermission.from_permission_set(perm1), + NormalizedPermission.from_permission_set(perm2), + ] + + # When + result = group_by_theme(normalized_perms) + + # Then + sub_theme = result["2"]["sub_themes"]["2"] + assert len(sub_theme["topics"]) == 2 + assert "3" in sub_theme["topics"] + assert "5" in sub_theme["topics"] + + @pytest.mark.django_db + def test_groups_multiple_sub_themes_under_same_theme(self): + """ + Given multiple permissions with same theme but different sub_themes + When grouping by theme + Then sub_themes are grouped under the same theme + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="4", + topic="6", + metric="12", + geography_type="1", + geography="E92000001", + ) + + normalized_perms = [ + NormalizedPermission.from_permission_set(perm1), + NormalizedPermission.from_permission_set(perm2), + ] + + # When + result = group_by_theme(normalized_perms) + + # Then + theme = result["2"] + assert len(theme["sub_themes"]) == 2 + assert "2" in theme["sub_themes"] + assert "4" in theme["sub_themes"] + + @pytest.mark.django_db + def test_groups_multiple_themes(self): + """ + Given multiple permissions with different themes + When grouping by theme + Then themes are grouped separately at top level + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="5", + sub_theme="8", + topic="12", + metric="15", + geography_type="1", + geography="E92000001", + ) + + normalized_perms = [ + NormalizedPermission.from_permission_set(perm1), + NormalizedPermission.from_permission_set(perm2), + ] + + # When + result = group_by_theme(normalized_perms) + + # Then + assert len(result) == 2 + assert "2" in result + assert "5" in result + + @pytest.mark.django_db + def test_includes_geography_in_topic_groups(self): + """ + Given permissions grouped by theme + When examining the result + Then each topic includes geography information + """ + # Given + perm = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized = NormalizedPermission.from_permission_set(perm) + + # When + result = group_by_theme([normalized]) + + # Then + geographies = result["2"]["sub_themes"]["2"]["topics"]["3"]["geographies"] + assert len(geographies) == 1 + + geography = geographies[0] + assert "geography_type" in geography + assert "geography" in geography + assert "metric" in geography + + # Each should have id and name + assert "id" in geography["geography_type"] + assert "name" in geography["geography_type"] + + @pytest.mark.django_db + def test_multiple_geographies_under_same_topic(self): + """ + Given multiple permissions with same theme/sub_theme/topic but different geographies + When grouping by theme + Then geographies accumulate under the topic + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="W92000004", + ) + + normalized_perms = [ + NormalizedPermission.from_permission_set(perm1), + NormalizedPermission.from_permission_set(perm2), + ] + + # When + result = group_by_theme(normalized_perms) + + # Then + geographies = result["2"]["sub_themes"]["2"]["topics"]["3"]["geographies"] + assert len(geographies) == 2 + + geography_codes = [g["geography"]["id"] for g in geographies] + assert "E92000001" in geography_codes + assert "W92000004" in geography_codes + + @pytest.mark.django_db + def test_groups_empty_permission_list(self): + """ + Given an empty list of permissions + When grouping by theme + Then an empty dict is returned + """ + # When + result = group_by_theme([]) + + # Then + assert result == {} + + @pytest.mark.django_db + def test_preserves_names_correctly(self): + """ + Given permissions with populated names + When grouping by theme + Then names are preserved at each level + """ + # Given + perm = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized = NormalizedPermission.from_permission_set(perm) + + # When + result = group_by_theme([normalized]) + + # Then + assert result["2"]["theme_name"] is not None + assert result["2"]["sub_themes"]["2"]["sub_theme_name"] is not None + assert result["2"]["sub_themes"]["2"]["topics"]["3"]["topic_name"] is not None + + @pytest.mark.django_db + def test_handles_wildcard_at_sub_theme_level(self): + """ + Given a permission with specific theme but wildcard sub_theme + When grouping by theme + Then wildcard is correctly placed in hierarchy + """ + # Given + perm = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="-1", + topic="-1", + metric="-1", + geography_type="1", + geography="E92000001", + ) + + normalized = NormalizedPermission.from_permission_set(perm) + + # When + result = group_by_theme([normalized]) + + # Then + assert "2" in result + assert "-1" in result["2"]["sub_themes"] + assert result["2"]["sub_themes"]["-1"]["sub_theme_name"] == "* (All)" + + +class TestGroupingIntegration: + """Integration tests for both grouping functions together.""" + + @pytest.mark.django_db + def test_complex_scenario_multiple_dimensions(self): + """ + Given a complex set of permissions across multiple dimensions + When grouping by both geography and theme + Then both groupings produce correct structures + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="5", + metric="11", + geography_type="2", # Different geography type ID + geography="E12000008", + ) + perm3 = PermissionSetFactory.create_permission_set( + theme="5", + sub_theme="8", + topic="12", + metric="15", + geography_type="1", + geography="W92000004", + ) + + normalized_perms = [ + NormalizedPermission.from_permission_set(perm1), + NormalizedPermission.from_permission_set(perm2), + NormalizedPermission.from_permission_set(perm3), + ] + + # When + geo_grouped = group_by_geography_type(normalized_perms) + theme_grouped = group_by_theme(normalized_perms) + + # Then + assert len(geo_grouped) == 2 + assert "1" in geo_grouped + assert "2" in geo_grouped + assert len(geo_grouped["1"]["geographies"]) == 2 + + assert len(theme_grouped) == 2 + assert "2" in theme_grouped + assert "5" in theme_grouped + assert len(theme_grouped["2"]["sub_themes"]["2"]["topics"]) == 2 + + @pytest.mark.django_db + def test_wildcard_appears_in_both_groupings(self): + """ + Given a wildcard permission + When grouping by both geography and theme + Then wildcard appears correctly in both structures + """ + # Given + wildcard = PermissionSetFactory.create_wildcard_permission_set() + normalized = NormalizedPermission.from_permission_set(wildcard) + + # When + geo_grouped = group_by_geography_type([normalized]) + theme_grouped = group_by_theme([normalized]) + + # Then + assert "-1" in geo_grouped + assert geo_grouped["-1"]["geography_type_name"] == "All Geography Types" + + # Then + assert "-1" in theme_grouped + assert theme_grouped["-1"]["theme_name"] == "* (All)" + + @pytest.mark.django_db + def test_mixed_geography_types_with_same_theme(self): + """ + Given permissions with same theme but different geography type IDs + When grouping by geography type + Then they are grouped separately by geography type ID + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="2", + geography="E12000008", + ) + + normalized_perms = [ + NormalizedPermission.from_permission_set(perm1), + NormalizedPermission.from_permission_set(perm2), + ] + + # When + result = group_by_geography_type(normalized_perms) + + # Then + assert len(result) == 2 + assert "1" in result + assert "2" in result + assert len(result["1"]["geographies"]) == 1 + assert len(result["2"]["geographies"]) == 1 diff --git a/tests/integration/metrics/utils/test_permission_hierarchy.py b/tests/integration/metrics/utils/test_permission_hierarchy.py new file mode 100644 index 000000000..aa6e707a8 --- /dev/null +++ b/tests/integration/metrics/utils/test_permission_hierarchy.py @@ -0,0 +1,1025 @@ +"""Tests for permission hierarchy utilities.""" + +from uuid import uuid4 + +import pytest + +from metrics.utils.permission_hierarchy import ( + NormalizedPermission, + _get_choice_label, + build_permission_hierarchy, + get_deduplicated_permissions, +) +from tests.factories.auth_content.models.permission_sets import PermissionSetFactory +from tests.factories.metrics.theme import ThemeFactory + + +class TestNormalizedPermission: + """Test suite for NormalizedPermission dataclass.""" + + @pytest.mark.django_db + def test_from_permission_set_creates_normalized_permission(self): + """ + Given a PermissionSet instance + When creating a NormalizedPermission from it + Then all IDs are correctly extracted + """ + # Given + perm_set = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + # When + normalized = NormalizedPermission.from_permission_set(perm_set) + + # Then + assert normalized.theme_id == "2" + assert normalized.sub_theme_id == "2" + assert normalized.topic_id == "3" + assert normalized.metric_id == "10" + assert normalized.geography_type_id == "1" + assert normalized.geography_id == "E92000001" + + @pytest.mark.django_db + def test_from_permission_set_populates_names(self): + """ + Given a PermissionSet instance + When creating a NormalizedPermission from it + Then all names are populated via database lookups + """ + # Given + perm_set = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + # When + normalized = NormalizedPermission.from_permission_set(perm_set) + + # Then + assert normalized.theme_name != "" + assert normalized.sub_theme_name != "" + assert normalized.topic_name != "" + assert normalized.metric_name != "" + assert normalized.geography_type_name != "" + assert normalized.geography_name != "" + + @pytest.mark.django_db + def test_from_permission_set_handles_wildcards(self): + """ + Given a PermissionSet with wildcard values + When creating a NormalizedPermission from it + Then wildcard IDs are "-1" and names are "* (All)" + """ + # Given + wildcard_perm = PermissionSetFactory.create_wildcard_permission_set() + + # When + normalized = NormalizedPermission.from_permission_set(wildcard_perm) + + # Then + assert normalized.theme_id == "-1" + assert normalized.theme_name == "* (All)" + assert normalized.sub_theme_id == "-1" + assert normalized.sub_theme_name == "* (All)" + assert normalized.topic_id == "-1" + assert normalized.topic_name == "* (All)" + assert normalized.metric_id == "-1" + assert normalized.metric_name == "* (All)" + assert normalized.geography_type_id == "-1" + assert normalized.geography_type_name == "* (All)" + assert normalized.geography_id == "-1" + assert normalized.geography_name == "* (All)" + + @pytest.mark.django_db + def test_from_permission_set_handles_empty_values(self): + """ + Given a PermissionSet with some empty values, which can't really happen due to the model and the validation. + When creating a NormalizedPermission from it + Then empty values become empty strings + """ + # Given + perm_set = PermissionSetFactory.create( + theme="2", + sub_theme="", + topic="", + metric="10", + geography_type="1", + geography="E92000001", + ) + + # When + normalized = NormalizedPermission.from_permission_set(perm_set) + + # Then + assert normalized.sub_theme_id == "" + assert normalized.sub_theme_name == "" + assert normalized.topic_id == "" + assert normalized.topic_name == "" + + @pytest.mark.django_db + def test_to_dict_returns_correct_structure(self): + """ + Given a NormalizedPermission + When converting to dict + Then structure contains id and name for each field + """ + # Given + perm_set = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + normalized = NormalizedPermission.from_permission_set(perm_set) + + # When + result = normalized.to_dict() + + # Then + assert "theme" in result + assert "id" in result["theme"] + assert "name" in result["theme"] + + assert "sub_theme" in result + assert "topic" in result + assert "metric" in result + assert "geography_type" in result + assert "geography" in result + + # Verify values + assert result["theme"]["id"] == "2" + assert result["theme"]["name"] != "" + + +class TestSubsumption: + """Test suite for permission subsumption logic.""" + + @pytest.mark.django_db + def test_wildcard_theme_subsumes_specific_theme_same_geography(self): + """ + Given two permissions with same geography but different themes + When one has wildcard theme + Then it subsumes the specific theme + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="-1", + sub_theme="-1", + topic="-1", + metric="-1", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized1 = NormalizedPermission.from_permission_set(perm1) + normalized2 = NormalizedPermission.from_permission_set(perm2) + + # Then + assert normalized1.subsumes(normalized2) + assert not normalized2.subsumes(normalized1) + + @pytest.mark.django_db + def test_wildcard_geography_subsumes_specific_geography_same_theme(self): + """ + Given two permissions with same theme but different geographies + When one has wildcard geography + Then it subsumes the specific geography + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="-1", + geography="-1", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized1 = NormalizedPermission.from_permission_set(perm1) + normalized2 = NormalizedPermission.from_permission_set(perm2) + + # Then + assert normalized1.subsumes(normalized2) + assert not normalized2.subsumes(normalized1) + + @pytest.mark.django_db + def test_different_themes_different_geographies_no_subsumption(self): + """ + Given two permissions with different themes AND different geographies + When comparing them + Then neither subsumes the other + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="5", + sub_theme="8", + topic="12", + metric="15", + geography_type="2", + geography="E12000008", + ) + + normalized1 = NormalizedPermission.from_permission_set(perm1) + normalized2 = NormalizedPermission.from_permission_set(perm2) + + # Then + assert not normalized1.subsumes(normalized2) + assert not normalized2.subsumes(normalized1) + + @pytest.mark.django_db + def test_sub_theme_wildcard_subsumes_specific_topics(self): + """ + Given same theme and geography + When one has wildcard at sub-theme level + Then it subsumes specific topics/metrics + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="-1", + topic="-1", + metric="-1", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized1 = NormalizedPermission.from_permission_set(perm1) + normalized2 = NormalizedPermission.from_permission_set(perm2) + + # Then + assert normalized1.subsumes(normalized2) + assert not normalized2.subsumes(normalized1) + + @pytest.mark.django_db + def test_topic_wildcard_subsumes_specific_metrics(self): + """ + Given same theme/sub_theme and geography + When one has wildcard at topic level + Then it subsumes specific metrics + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="-1", + metric="-1", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized1 = NormalizedPermission.from_permission_set(perm1) + normalized2 = NormalizedPermission.from_permission_set(perm2) + + # Then + assert normalized1.subsumes(normalized2) + assert not normalized2.subsumes(normalized1) + + @pytest.mark.django_db + def test_metric_wildcard_subsumes_specific_metric(self): + """ + Given same theme/sub_theme/topic and geography + When one has wildcard metric + Then it subsumes specific metric + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="-1", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized1 = NormalizedPermission.from_permission_set(perm1) + normalized2 = NormalizedPermission.from_permission_set(perm2) + + # Then + assert normalized1.subsumes(normalized2) + assert not normalized2.subsumes(normalized1) + + @pytest.mark.django_db + def test_geography_type_wildcard_subsumes_specific_type(self): + """ + Given same theme path + When one has wildcard geography_type + Then it subsumes specific geography_type + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="-1", + geography="-1", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized1 = NormalizedPermission.from_permission_set(perm1) + normalized2 = NormalizedPermission.from_permission_set(perm2) + + # Then + assert normalized1.subsumes(normalized2) + assert not normalized2.subsumes(normalized1) + + @pytest.mark.django_db + def test_geography_wildcard_subsumes_specific_geography(self): + """ + Given same theme path and geography_type + When one has wildcard geography + Then it subsumes specific geography + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="-1", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized1 = NormalizedPermission.from_permission_set(perm1) + normalized2 = NormalizedPermission.from_permission_set(perm2) + + # Then + assert normalized1.subsumes(normalized2) + assert not normalized2.subsumes(normalized1) + + @pytest.mark.django_db + def test_global_wildcard_subsumes_everything(self): + """ + Given wildcard theme AND wildcard geography + Then it subsumes any specific permission + """ + # Given + global_wildcard = PermissionSetFactory.create_wildcard_permission_set() + specific = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + normalized_wildcard = NormalizedPermission.from_permission_set(global_wildcard) + normalized_specific = NormalizedPermission.from_permission_set(specific) + + # Then + assert normalized_wildcard.subsumes(normalized_specific) + assert not normalized_specific.subsumes(normalized_wildcard) + + @pytest.mark.django_db + def test_partial_overlap_no_subsumption(self): + """ + Given permissions with partial overlap + When one has wildcard theme but specific geography + And other has specific theme but wildcard geography + Then neither subsumes the other + """ + # Given + perm1 = PermissionSetFactory.create_permission_set( + theme="-1", + sub_theme="-1", + topic="-1", + metric="-1", + geography_type="1", + geography="E92000001", # Specific + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="-1", # Wildcard + geography="-1", + ) + + normalized1 = NormalizedPermission.from_permission_set(perm1) + normalized2 = NormalizedPermission.from_permission_set(perm2) + + # Then + assert not normalized1.subsumes(normalized2) + assert not normalized2.subsumes(normalized1) + + +class TestBuildPermissionHierarchy: + """Test suite for build_permission_hierarchy function.""" + + @pytest.mark.django_db + def test_single_permission_no_deduplication(self): + """ + Given a single permission set + When building hierarchy + Then no deduplication occurs + """ + # Given + from auth_content.models.users import User + + user = User.objects.create(user_id=uuid4()) + perm = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + user.permission_sets.add(perm) + + # When + result = build_permission_hierarchy(user.permission_sets.all()) + + # Then + assert result["summary"]["total_permission_sets"] == 1 + assert result["summary"]["deduplicated_count"] == 1 + assert result["summary"]["removed_count"] == 0 + assert len(result["permission_set_hierarchy"]) == 1 + + @pytest.mark.django_db + def test_removes_fully_subsumed_permission(self): + """ + Given permission A that fully subsumes permission B + When building hierarchy + Then only permission A is returned + """ + # Given + from auth_content.models.users import User + + user = User.objects.create(user_id=uuid4()) + + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="-1", + geography_type="-1", + geography="-1", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="-1", + geography_type="2", + geography="E12000008", + ) + + user.permission_sets.set([perm1, perm2]) + + # When + result = build_permission_hierarchy(user.permission_sets.all()) + + # Then + assert result["summary"]["total_permission_sets"] == 2 + assert result["summary"]["deduplicated_count"] == 1 + assert result["summary"]["removed_count"] == 1 + + hierarchy = result["permission_set_hierarchy"] + assert len(hierarchy) == 1 + assert hierarchy[0]["geography_type"]["id"] == "-1" + + @pytest.mark.django_db + def test_keeps_independent_permissions(self): + """ + Given two permissions that don't subsume each other + When building hierarchy + Then both are kept + """ + # Given + from auth_content.models.users import User + + user = User.objects.create(user_id=uuid4()) + + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="5", + metric="11", + geography_type="1", + geography="W92000004", + ) + + user.permission_sets.set([perm1, perm2]) + + # When + result = build_permission_hierarchy(user.permission_sets.all()) + + # Then + assert result["summary"]["deduplicated_count"] == 2 + assert result["summary"]["removed_count"] == 0 + assert len(result["permission_set_hierarchy"]) == 2 + + @pytest.mark.django_db + def test_complex_multi_level_deduplication(self): + """ + Given multiple overlapping permissions at different levels + When building hierarchy + Then correct deduplication occurs + """ + # Given + from auth_content.models.users import User + + user = User.objects.create(user_id=uuid4()) + + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="-1", + metric="-1", + geography_type="-1", + geography="-1", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="-1", + geography_type="1", + geography="E92000001", + ) + perm3 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + perm4 = PermissionSetFactory.create_permission_set( + theme="5", + sub_theme="8", + topic="12", + metric="-1", + geography_type="1", + geography="W92000004", + ) + + user.permission_sets.set([perm1, perm2, perm3, perm4]) + + # When + result = build_permission_hierarchy(user.permission_sets.all()) + + # Then + assert result["summary"]["total_permission_sets"] == 4 + assert result["summary"]["deduplicated_count"] == 2 + assert result["summary"]["removed_count"] == 2 + + @pytest.mark.django_db + def test_summary_contains_correct_statistics(self): + """ + Given various permission sets + When building hierarchy + Then summary contains accurate statistics + """ + # Given + from auth_content.models.users import User + + user = User.objects.create(user_id=uuid4()) + + global_perm = PermissionSetFactory.create_wildcard_permission_set() + specific_perm = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + user.permission_sets.set([global_perm, specific_perm]) + + # When + result = build_permission_hierarchy(user.permission_sets.all()) + + # Then + summary = result["summary"] + assert "total_permission_sets" in summary + assert "deduplicated_count" in summary + assert "removed_count" in summary + assert "has_global_access" in summary + assert "wildcard_themes" in summary + + assert summary["has_global_access"] is True + assert "* (All)" in summary["wildcard_themes"] + + @pytest.mark.django_db + def test_hierarchy_structure_is_correct(self): + """ + Given permission sets + When building hierarchy + Then each permission has correct structure + """ + # Given + from auth_content.models.users import User + + user = User.objects.create(user_id=uuid4()) + perm = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + user.permission_sets.add(perm) + + # When + result = build_permission_hierarchy(user.permission_sets.all()) + + # Then + hierarchy = result["permission_set_hierarchy"] + assert len(hierarchy) == 1 + + permission = hierarchy[0] + required_fields = [ + "theme", + "sub_theme", + "topic", + "metric", + "geography_type", + "geography", + ] + + for field in required_fields: + assert field in permission + assert "id" in permission[field] + assert "name" in permission[field] + + +class TestGetDeduplicatedPermissions: + """Test suite for get_deduplicated_permissions helper function.""" + + @pytest.mark.django_db + def test_returns_normalized_permission_list(self): + """ + Given permission sets + When getting deduplicated permissions + Then returns list of NormalizedPermission objects + """ + # Given + from auth_content.models.users import User + + user = User.objects.create(user_id=uuid4()) + perm = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + user.permission_sets.add(perm) + + # When + result = get_deduplicated_permissions(user.permission_sets.all()) + + # Then + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], NormalizedPermission) + + @pytest.mark.django_db + def test_deduplicates_permissions(self): + """ + Given overlapping permission sets + When getting deduplicated permissions + Then subsumed permissions are removed + """ + # Given + from auth_content.models.users import User + + user = User.objects.create(user_id=uuid4()) + + perm1 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="-1", + topic="-1", + metric="-1", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="10", + geography_type="1", + geography="E92000001", + ) + + user.permission_sets.set([perm1, perm2]) + + # When + result = get_deduplicated_permissions(user.permission_sets.all()) + + # Then + assert len(result) == 1 + assert result[0].sub_theme_id == "-1" + + @pytest.mark.django_db + def test_empty_queryset_returns_empty_hierarchy(self): + """ + Given an empty queryset + When building hierarchy + Then returns empty hierarchy with zero counts + """ + # Given + from auth_content.models.users import User + from auth_content.models.permission_sets import PermissionSet + + user = User.objects.create(user_id=uuid4()) + + # When + result = build_permission_hierarchy(PermissionSet.objects.none()) + + # Then + assert result["summary"]["total_permission_sets"] == 0 + assert result["summary"]["deduplicated_count"] == 0 + assert result["summary"]["removed_count"] == 0 + assert len(result["permission_set_hierarchy"]) == 0 + + @pytest.mark.django_db + def test_handles_null_fields_gracefully(self): + """ + Given permission sets with null/empty fields + When building hierarchy + Then handles gracefully without errors + """ + # Given + from auth_content.models.users import User + + user = User.objects.create(user_id=uuid4()) + perm = PermissionSetFactory.create( + theme="2", + sub_theme="", + topic="", + metric="10", + geography_type="1", + geography="E92000001", + ) + user.permission_sets.add(perm) + + # When + result = build_permission_hierarchy(user.permission_sets.all()) + + # Then + assert result["summary"]["deduplicated_count"] == 1 + hierarchy = result["permission_set_hierarchy"] + assert hierarchy[0]["sub_theme"]["id"] is None + assert hierarchy[0]["topic"]["id"] is None + + @pytest.mark.django_db + def test_all_wildcards_at_different_levels(self): + """ + Given permissions with wildcards at various levels + When building hierarchy + Then correctly handles all wildcard combinations + """ + # Given + from auth_content.models.users import User + + user = User.objects.create(user_id=uuid4()) + perm1 = PermissionSetFactory.create_permission_set( + theme="-1", + sub_theme="-1", + topic="-1", + metric="-1", + geography_type="1", + geography="E92000001", + ) + perm2 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="-1", + topic="-1", + metric="-1", + geography_type="1", + geography="E92000001", + ) + perm3 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="-1", + metric="-1", + geography_type="1", + geography="E92000001", + ) + perm4 = PermissionSetFactory.create_permission_set( + theme="2", + sub_theme="2", + topic="3", + metric="-1", + geography_type="1", + geography="E92000001", + ) + + user.permission_sets.set([perm1, perm2, perm3, perm4]) + + result = build_permission_hierarchy(user.permission_sets.all()) + + assert result["summary"]["deduplicated_count"] == 1 + hierarchy = result["permission_set_hierarchy"] + assert hierarchy[0]["theme"]["id"] == "-1" + + +class TestGetChoiceLabel: + """Test suite for _get_choice_label static method.""" + + def test_returns_value_for_unknown_field_name(self): + """ + Given an unknown field name not in the mapping + When getting choice label + Then returns the value unchanged (defensive fallback) + """ + + unknown_field = "unknown_field_type" + test_value = "12345" + + # When + result = _get_choice_label(unknown_field, test_value) + + # Then + assert result == test_value + + def test_returns_value_for_empty_field_name(self): + """ + Given an empty field name + When getting choice label + Then returns the value unchanged + """ + + # When + result = _get_choice_label("", "12345") + + # Then + assert result == "12345" + + def test_returns_value_for_none_field_name(self): + """ + Given None as field name + When getting choice label + Then returns the value unchanged + """ + + # When + result = _get_choice_label(None, "12345") + + # Then + assert result == "12345" + + @pytest.mark.django_db + def test_returns_name_for_valid_theme(self): + """ + Given a valid field name and value + When getting choice label + Then returns the name from database + """ + fake_theme_name_one = "respiratory" + fake_theme_name_two = "infectious_disease" + fake_theme_name_three = "immunisation" + + ThemeFactory(name=fake_theme_name_one) + ThemeFactory(name=fake_theme_name_two) + ThemeFactory(name=fake_theme_name_three) + + # When + result = _get_choice_label("theme", "2") + + # Then + assert result != "2" + assert result == "infectious_disease" + assert isinstance(result, str) + assert len(result) > 0 + + @pytest.mark.django_db + def test_returns_value_when_name_lookup_fails(self): + """ + Given a valid field name but non-existent ID + When getting choice label and lookup returns None + Then returns the original value as fallback + """ + + result = _get_choice_label("theme", "99999") + + assert result == "99999" + + @pytest.mark.django_db + def test_geography_uses_string_id(self): + """ + Given geography field name + When getting choice label + Then uses string value directly (not converted to int) + """ + + # When + result = _get_choice_label("geography", "E92000001") + + assert isinstance(result, str) + + @pytest.mark.django_db + def test_other_fields_use_int_id(self): + """ + Given non-geography field name + When getting choice label + Then converts value to int before lookup + """ + + fake_theme_name_one = "respiratory" + fake_theme_name_two = "infectious_disease" + fake_theme_name_three = "immunisation" + + ThemeFactory(name=fake_theme_name_one) + ThemeFactory(name=fake_theme_name_two) + ThemeFactory(name=fake_theme_name_three) + + # When + result = _get_choice_label("theme", "3") + + # Then + assert isinstance(result, str) + assert result != "3" diff --git a/tests/unit/metrics/api/serializers/test_user.py b/tests/unit/metrics/api/serializers/test_user.py index e69de29bb..6848b3983 100644 --- a/tests/unit/metrics/api/serializers/test_user.py +++ b/tests/unit/metrics/api/serializers/test_user.py @@ -0,0 +1,26 @@ +import unittest +from unittest import mock + +from metrics.data.managers.rbac_models.user import UserManager, UserQuerySet + + +class TestUserManager(unittest.TestCase): + @mock.patch.object(UserQuerySet, "get_user_with_permission_sets") + def test_get_all_theme_names_and_ids( + self, spy_get_user_with_permission_sets: mock.MagicMock + ): + """ + Given an instance of a `UserManager` + When `get_user_with_permission_sets` is called + Then it delegates call to `UserQuerySet`. + """ + # Given + user_manager = UserManager() + + mock_user_id = 1 + + # When + user_manager.get_user_with_permission_sets(mock_user_id) + + # Then + spy_get_user_with_permission_sets.assert_called_once() diff --git a/tests/unit/metrics/data/managers/core_models/test_metric.py b/tests/unit/metrics/data/managers/core_models/test_metric.py index 6071ce5fe..b392fe6a1 100644 --- a/tests/unit/metrics/data/managers/core_models/test_metric.py +++ b/tests/unit/metrics/data/managers/core_models/test_metric.py @@ -7,7 +7,7 @@ ) -class TestThemeManager(unittest.TestCase): +class TestMetricManager(unittest.TestCase): @mock.patch.object(MetricQuerySet, "get_all_names_and_ids") def test_get_all_theme_names_and_ids( self, spy_get_all_names_and_ids: mock.MagicMock From 862e7777edff3e921afe16a21ec6984b0bc302be Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Mon, 20 Apr 2026 14:57:48 +0100 Subject: [PATCH 104/186] CDD-3172: Update response format --- metrics/api/serializers/user.py | 21 +++++++++++-------- metrics/utils/permission_hierarchy.py | 8 ++++--- .../metrics/api/views/test_user.py | 4 ++-- .../utils/test_permission_hierarchy.py | 14 ++++++------- 4 files changed, 26 insertions(+), 21 deletions(-) diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index 563e91b8a..653ae6092 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -80,7 +80,8 @@ def data(self) -> dict: user_uuid = uuid.UUID(user_id_str) # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user( + user_uuid) # Check if user exists or has permissions if not permission_sets.exists(): @@ -93,7 +94,8 @@ def data(self) -> dict: } # Convert QuerySet to list of dicts - permission_set_list = _queryset_to_permission_set_dicts(permission_sets) + permission_set_list = _queryset_to_permission_set_dicts( + permission_sets) return { "user_id": user_id_str, @@ -140,8 +142,8 @@ def data(self) -> dict: Returns: Dict with user_id and either: - permission_sets dict (with hierarchy and summary) if no grouping - - permissions_by_geography_type dict if group_by='geography_type' - - permissions_by_theme dict if group_by='theme' + - permissions_sets dict if group_by='geography_type' + - permissions_ets dict if group_by='theme' Example (no grouping): { @@ -155,7 +157,7 @@ def data(self) -> dict: Example (geography_type grouping): { "user_id": "123e4567-e89b-12d3-a456-426614174000", - "permissions_by_geography_type": { + "permission_sets": { "Region": { "E12000008": { "geography_name": "South East", @@ -172,20 +174,21 @@ def data(self) -> dict: group_by = self.validated_data.get("group_by") # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user( + user_uuid) if not permission_sets.exists(): # Return empty structure rather than raising exception # The view can check permission_set_count and return 404 if needed return { - "permission_set_hierarchy": [], + "permission_sets": [], } deduplicated_perms = get_deduplicated_permissions(permission_sets) if group_by == "geography_type": return { - "permissions_by_geography_type": group_by_geography_type( + "permission_sets": group_by_geography_type( deduplicated_perms ), "total_permissions": len(deduplicated_perms), @@ -193,7 +196,7 @@ def data(self) -> dict: if group_by == "theme": return { - "permissions_by_theme": group_by_theme(deduplicated_perms), + "permission_sets": group_by_theme(deduplicated_perms), "total_permissions": len(deduplicated_perms), } diff --git a/metrics/utils/permission_hierarchy.py b/metrics/utils/permission_hierarchy.py index 6642d3bfa..973564c52 100644 --- a/metrics/utils/permission_hierarchy.py +++ b/metrics/utils/permission_hierarchy.py @@ -98,7 +98,8 @@ def _populate_names(self) -> None: if id_value == "-1": setattr(self, name_attr, "* (All)") elif id_value: - setattr(self, name_attr, _get_choice_label(field_name, id_value)) + setattr(self, name_attr, _get_choice_label( + field_name, id_value)) def subsumes(self, other: "NormalizedPermission") -> bool: """ @@ -268,7 +269,7 @@ def build_permission_hierarchy(permission_sets: QuerySet) -> dict[str, Any]: summary = _build_summary(normalized_perms, deduplicated) return { - "permission_set_hierarchy": [perm.to_dict() for perm in deduplicated], + "permission_sets": [perm.to_dict() for perm in deduplicated], "summary": summary, } @@ -312,7 +313,8 @@ def _remove_subsumed_permissions( # This permission is not subsumed, so check if it subsumes any existing ones # Remove any existing permissions that this one subsumes - result = [existing for existing in result if not perm.subsumes(existing)] + result = [ + existing for existing in result if not perm.subsumes(existing)] result.append(perm) diff --git a/tests/integration/metrics/api/views/test_user.py b/tests/integration/metrics/api/views/test_user.py index a48274f7c..a07cbb337 100644 --- a/tests/integration/metrics/api/views/test_user.py +++ b/tests/integration/metrics/api/views/test_user.py @@ -104,7 +104,7 @@ def test_returns_empty_permission_set_hierarchy_for_invalid_uuid(self): response = client.get(path=path) # Then - assert response.data["permission_set_hierarchy"] == [] + assert response.data["permission_sets"] == [] @pytest.mark.django_db def test_returns_empty_permission_sets_when_no_permissions(self): @@ -169,7 +169,7 @@ def test_global_wildcard_subsumes_everything(self): assert summary["deduplicated_count"] == 1 assert summary["has_global_access"] is True - hierarchy = result["permission_sets"]["permission_set_hierarchy"] + hierarchy = result["permission_sets"]["permission_sets"] assert len(hierarchy) == 1 assert hierarchy[0]["theme"]["id"] == "-1" assert hierarchy[0]["geography_type"]["id"] == "-1" diff --git a/tests/integration/metrics/utils/test_permission_hierarchy.py b/tests/integration/metrics/utils/test_permission_hierarchy.py index aa6e707a8..88eff4ad2 100644 --- a/tests/integration/metrics/utils/test_permission_hierarchy.py +++ b/tests/integration/metrics/utils/test_permission_hierarchy.py @@ -511,7 +511,7 @@ def test_single_permission_no_deduplication(self): assert result["summary"]["total_permission_sets"] == 1 assert result["summary"]["deduplicated_count"] == 1 assert result["summary"]["removed_count"] == 0 - assert len(result["permission_set_hierarchy"]) == 1 + assert len(result["permission_sets"]) == 1 @pytest.mark.django_db def test_removes_fully_subsumed_permission(self): @@ -552,7 +552,7 @@ def test_removes_fully_subsumed_permission(self): assert result["summary"]["deduplicated_count"] == 1 assert result["summary"]["removed_count"] == 1 - hierarchy = result["permission_set_hierarchy"] + hierarchy = result["permission_sets"] assert len(hierarchy) == 1 assert hierarchy[0]["geography_type"]["id"] == "-1" @@ -593,7 +593,7 @@ def test_keeps_independent_permissions(self): # Then assert result["summary"]["deduplicated_count"] == 2 assert result["summary"]["removed_count"] == 0 - assert len(result["permission_set_hierarchy"]) == 2 + assert len(result["permission_sets"]) == 2 @pytest.mark.django_db def test_complex_multi_level_deduplication(self): @@ -713,7 +713,7 @@ def test_hierarchy_structure_is_correct(self): result = build_permission_hierarchy(user.permission_sets.all()) # Then - hierarchy = result["permission_set_hierarchy"] + hierarchy = result["permission_sets"] assert len(hierarchy) == 1 permission = hierarchy[0] @@ -822,7 +822,7 @@ def test_empty_queryset_returns_empty_hierarchy(self): assert result["summary"]["total_permission_sets"] == 0 assert result["summary"]["deduplicated_count"] == 0 assert result["summary"]["removed_count"] == 0 - assert len(result["permission_set_hierarchy"]) == 0 + assert len(result["permission_sets"]) == 0 @pytest.mark.django_db def test_handles_null_fields_gracefully(self): @@ -850,7 +850,7 @@ def test_handles_null_fields_gracefully(self): # Then assert result["summary"]["deduplicated_count"] == 1 - hierarchy = result["permission_set_hierarchy"] + hierarchy = result["permission_sets"] assert hierarchy[0]["sub_theme"]["id"] is None assert hierarchy[0]["topic"]["id"] is None @@ -903,7 +903,7 @@ def test_all_wildcards_at_different_levels(self): result = build_permission_hierarchy(user.permission_sets.all()) assert result["summary"]["deduplicated_count"] == 1 - hierarchy = result["permission_set_hierarchy"] + hierarchy = result["permission_sets"] assert hierarchy[0]["theme"]["id"] == "-1" From bcf067d60e2b1508c2ed22fc86186d887ab89dbb Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Wed, 22 Apr 2026 11:18:24 +0100 Subject: [PATCH 105/186] sonar feedback: update based on sonarqube output --- .../metrics/api/views/test_user.py | 32 +++++++++---------- .../utils/test_permission_hierarchy.py | 9 +++--- .../managers/core_models/test_geography.py | 28 +--------------- 3 files changed, 21 insertions(+), 48 deletions(-) diff --git a/tests/integration/metrics/api/views/test_user.py b/tests/integration/metrics/api/views/test_user.py index a07cbb337..4d049f3bd 100644 --- a/tests/integration/metrics/api/views/test_user.py +++ b/tests/integration/metrics/api/views/test_user.py @@ -23,7 +23,7 @@ def test_get_user_wildcard_permission_set(self): client = APIClient() - userId = "f907e591-4c49-4847-89b3-665e3c0133a4" + user_id = "f907e591-4c49-4847-89b3-665e3c0133a4" # create subthemes wildcard_permission = PermissionSetFactory.create_wildcard_permission_set() @@ -46,17 +46,17 @@ def test_get_user_wildcard_permission_set(self): geography=1, ) user_with_wildcard = UserFactory.create_with_permission_sets( - user_id=userId, + user_id=user_id, permission_sets=[wildcard_permission, permission_one, permission_two], ) # Retrieve the subthemes - path = f"{self.path}/{userId}/permissions" + path = f"{self.path}/{user_id}/permissions" response: Response = client.get(path=path) result = response.data # Should return a user with 3 permissions sets - assert result["user_id"] == userId + assert result["user_id"] == user_id assert len(result["permission_sets"]) == 3 assert result["permission_sets"][0] == { "id": 1, @@ -97,10 +97,10 @@ def test_returns_empty_permission_set_hierarchy_for_invalid_uuid(self): """ # Given client = APIClient() - userId = str(uuid4()) + user_id = str(uuid4()) # When - path = f"{self.path}/{userId}/permissions/hierarchy" + path = f"{self.path}/{user_id}/permissions/hierarchy" response = client.get(path=path) # Then @@ -114,16 +114,16 @@ def test_returns_empty_permission_sets_when_no_permissions(self): """ # Given client = APIClient() - userId = str(uuid4()) + user_id = str(uuid4()) # When - path = f"{self.path}/{userId}/permissions" + path = f"{self.path}/{user_id}/permissions" response = client.get(path=path) data = response.data print(data) # Then - assert data["user_id"] == userId + assert data["user_id"] == user_id assert data["permission_sets"] == [] assert data["permission_set_count"] == 0 @@ -136,7 +136,7 @@ def test_global_wildcard_subsumes_everything(self): """ # Given client = APIClient() - userId = str(uuid4()) + user_id = str(uuid4()) # Global wildcard: All themes × All geographies global_perm = PermissionSetFactory.create_wildcard_permission_set() @@ -152,11 +152,11 @@ def test_global_wildcard_subsumes_everything(self): ) user = UserFactory.create_with_permission_sets( - user_id=userId, permission_sets=[global_perm, specific_perm] + user_id=user_id, permission_sets=[global_perm, specific_perm] ) # When - path = f"{self.path}/{userId}/permissions/hierarchy" + path = f"{self.path}/{user_id}/permissions/hierarchy" response = client.get(path=path) # Then @@ -178,22 +178,22 @@ def test_global_wildcard_subsumes_everything(self): def test_get_user_wildcard_permission_set(self): client = APIClient() - userId = "f907e591-4c49-4847-89b3-665e3c0133a4" + user_id = "f907e591-4c49-4847-89b3-665e3c0133a4" # create subthemes wildcard_permission = PermissionSetFactory.create_wildcard_permission_set() user_with_wildcard = UserFactory.create_with_permission_set( - user_id=userId, + user_id=user_id, permission_set_name="Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", ) # Retrieve the subthemes - path = f"{self.path}/{userId}/permissions" + path = f"{self.path}/{user_id}/permissions" response: Response = client.get(path=path) result = response.data # Should return a wildcard choice - assert result["user_id"] == userId + assert result["user_id"] == user_id assert result["permission_sets"][0] == { "id": 1, "name": "Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", diff --git a/tests/integration/metrics/utils/test_permission_hierarchy.py b/tests/integration/metrics/utils/test_permission_hierarchy.py index 88eff4ad2..9c4534632 100644 --- a/tests/integration/metrics/utils/test_permission_hierarchy.py +++ b/tests/integration/metrics/utils/test_permission_hierarchy.py @@ -439,8 +439,10 @@ def test_global_wildcard_subsumes_everything(self): geography="E92000001", ) - normalized_wildcard = NormalizedPermission.from_permission_set(global_wildcard) - normalized_specific = NormalizedPermission.from_permission_set(specific) + normalized_wildcard = NormalizedPermission.from_permission_set( + global_wildcard) + normalized_specific = NormalizedPermission.from_permission_set( + specific) # Then assert normalized_wildcard.subsumes(normalized_specific) @@ -810,11 +812,8 @@ def test_empty_queryset_returns_empty_hierarchy(self): Then returns empty hierarchy with zero counts """ # Given - from auth_content.models.users import User from auth_content.models.permission_sets import PermissionSet - user = User.objects.create(user_id=uuid4()) - # When result = build_permission_hierarchy(PermissionSet.objects.none()) 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 c18a81559..f0817e953 100644 --- a/tests/unit/metrics/data/managers/core_models/test_geography.py +++ b/tests/unit/metrics/data/managers/core_models/test_geography.py @@ -43,33 +43,7 @@ def test_get_geography_codes_and_names_by_geography_type_id( Then it delegates call to `GeographyQuerySet`. """ # Given - fake_geography_type_id = 1 - geography_manager = GeographyManager() - - # When - GeographyManager.get_geography_codes_and_names_by_geography_type_id( - geography_manager, - geography_type_id=fake_geography_type_id, - ) - - # Then - spy_get_geography_codes_and_names_by_geography_type_id.assert_called_with( - geography_type_id=fake_geography_type_id, - ) - - @mock.patch.object( - GeographyQuerySet, "get_geography_codes_and_names_by_geography_type_id" - ) - def test_get_geography_codes_and_names_by_geography_type_id( - self, spy_get_geography_codes_and_names_by_geography_type_id: mock.MagicMock - ): - """ - Given a payload containing the required field - When `get_all_geography_names_by_type` is called, - Then it delegates call to `GeographyQuerySet`. - """ - # Given - fake_geography_type_id = 1 + fake_geography_type_id = "1" geography_manager = GeographyManager() # When From 8838794faf042adc6e5ec1fab25cba3aad9520b5 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Wed, 22 Apr 2026 12:02:24 +0100 Subject: [PATCH 106/186] sonar feedback: update based on sonarqube output --- metrics/api/serializers/user.py | 2 +- tests/integration/metrics/api/views/test_user.py | 11 ++++++----- .../metrics/data/managers/rbac_models/test_user.py | 11 ++++++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index 653ae6092..df7b36517 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -14,7 +14,7 @@ def _validate_user_id(value): """Validate theme_id is either wildcard or a valid integer""" try: - uuid_obj = uuid.UUID(value, version=4) # noqa: F841 + uuid.UUID(value, version=4) # noqa: F841 except ValueError as err: msg = "User ID must be a valid UUID" raise serializers.ValidationError(msg) from err diff --git a/tests/integration/metrics/api/views/test_user.py b/tests/integration/metrics/api/views/test_user.py index 4d049f3bd..f89690bdb 100644 --- a/tests/integration/metrics/api/views/test_user.py +++ b/tests/integration/metrics/api/views/test_user.py @@ -45,9 +45,10 @@ def test_get_user_wildcard_permission_set(self): geography_type=1, geography=1, ) - user_with_wildcard = UserFactory.create_with_permission_sets( + UserFactory.create_with_permission_sets( user_id=user_id, - permission_sets=[wildcard_permission, permission_one, permission_two], + permission_sets=[wildcard_permission, + permission_one, permission_two], ) # Retrieve the subthemes @@ -151,7 +152,7 @@ def test_global_wildcard_subsumes_everything(self): geography="E92000001", ) - user = UserFactory.create_with_permission_sets( + UserFactory.create_with_permission_sets( user_id=user_id, permission_sets=[global_perm, specific_perm] ) @@ -181,8 +182,8 @@ def test_get_user_wildcard_permission_set(self): user_id = "f907e591-4c49-4847-89b3-665e3c0133a4" # create subthemes - wildcard_permission = PermissionSetFactory.create_wildcard_permission_set() - user_with_wildcard = UserFactory.create_with_permission_set( + PermissionSetFactory.create_wildcard_permission_set() + UserFactory.create_with_permission_set( user_id=user_id, permission_set_name="Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", ) diff --git a/tests/integration/metrics/data/managers/rbac_models/test_user.py b/tests/integration/metrics/data/managers/rbac_models/test_user.py index c49fdd8d7..f73c57c50 100644 --- a/tests/integration/metrics/data/managers/rbac_models/test_user.py +++ b/tests/integration/metrics/data/managers/rbac_models/test_user.py @@ -14,7 +14,7 @@ def test_get_user_with_permission_sets(self): from the `UserManager` Then the user is returned filtered correctly """ - userId = "f907e591-4c49-4847-89b3-665e3c0133a4" + user_id = "f907e591-4c49-4847-89b3-665e3c0133a4" wildcard_permission = PermissionSetFactory.create_wildcard_permission_set() permission_one = PermissionSetFactory.create_permission_set( @@ -34,14 +34,15 @@ def test_get_user_with_permission_sets(self): geography=1, ) - user_with_wildcard = UserFactory.create_with_permission_sets( - user_id=userId, - permission_sets=[wildcard_permission, permission_one, permission_two], + UserFactory.create_with_permission_sets( + user_id=user_id, + permission_sets=[wildcard_permission, + permission_one, permission_two], ) # When get_user_with_permission_sets = User.objects.get_user_with_permission_sets( - userId + user_id ) # Then From c023a71ba1de8d020db9a799da4c71d9cad04e32 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Wed, 22 Apr 2026 12:09:34 +0100 Subject: [PATCH 107/186] Linting --- metrics/api/serializers/user.py | 13 ++++--------- metrics/utils/permission_hierarchy.py | 6 ++---- tests/integration/metrics/api/views/test_user.py | 3 +-- .../data/managers/core_models/test_geography.py | 4 ++-- .../managers/core_models/test_geography_types.py | 6 ++---- .../metrics/data/managers/rbac_models/test_user.py | 3 +-- .../metrics/utils/test_permission_hierarchy.py | 6 ++---- 7 files changed, 14 insertions(+), 27 deletions(-) diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index df7b36517..6302b0312 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -80,8 +80,7 @@ def data(self) -> dict: user_uuid = uuid.UUID(user_id_str) # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user( - user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) # Check if user exists or has permissions if not permission_sets.exists(): @@ -94,8 +93,7 @@ def data(self) -> dict: } # Convert QuerySet to list of dicts - permission_set_list = _queryset_to_permission_set_dicts( - permission_sets) + permission_set_list = _queryset_to_permission_set_dicts(permission_sets) return { "user_id": user_id_str, @@ -174,8 +172,7 @@ def data(self) -> dict: group_by = self.validated_data.get("group_by") # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user( - user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) if not permission_sets.exists(): # Return empty structure rather than raising exception @@ -188,9 +185,7 @@ def data(self) -> dict: if group_by == "geography_type": return { - "permission_sets": group_by_geography_type( - deduplicated_perms - ), + "permission_sets": group_by_geography_type(deduplicated_perms), "total_permissions": len(deduplicated_perms), } diff --git a/metrics/utils/permission_hierarchy.py b/metrics/utils/permission_hierarchy.py index 973564c52..8f68b6ffd 100644 --- a/metrics/utils/permission_hierarchy.py +++ b/metrics/utils/permission_hierarchy.py @@ -98,8 +98,7 @@ def _populate_names(self) -> None: if id_value == "-1": setattr(self, name_attr, "* (All)") elif id_value: - setattr(self, name_attr, _get_choice_label( - field_name, id_value)) + setattr(self, name_attr, _get_choice_label(field_name, id_value)) def subsumes(self, other: "NormalizedPermission") -> bool: """ @@ -313,8 +312,7 @@ def _remove_subsumed_permissions( # This permission is not subsumed, so check if it subsumes any existing ones # Remove any existing permissions that this one subsumes - result = [ - existing for existing in result if not perm.subsumes(existing)] + result = [existing for existing in result if not perm.subsumes(existing)] result.append(perm) diff --git a/tests/integration/metrics/api/views/test_user.py b/tests/integration/metrics/api/views/test_user.py index f89690bdb..e394a9f54 100644 --- a/tests/integration/metrics/api/views/test_user.py +++ b/tests/integration/metrics/api/views/test_user.py @@ -47,8 +47,7 @@ def test_get_user_wildcard_permission_set(self): ) UserFactory.create_with_permission_sets( user_id=user_id, - permission_sets=[wildcard_permission, - permission_one, permission_two], + permission_sets=[wildcard_permission, permission_one, permission_two], ) # 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 183de0bc9..d81d2f878 100644 --- a/tests/integration/metrics/data/managers/core_models/test_geography.py +++ b/tests/integration/metrics/data/managers/core_models/test_geography.py @@ -66,7 +66,7 @@ def test_query_for_get_all_names_and_codes(self): geography_two = GeographyFactory.create_with_geography_type( name="London", geography_code="E12000007", geography_type="Region" ) - geography_three = GeographyFactory.create_with_geography_type( + GeographyFactory.create_with_geography_type( name="England", geography_code="E92000001", geography_type="Nation", @@ -101,7 +101,7 @@ def test_get_name_by_id(self): When `get_name_by_id` is called Then the geography types with their codes and names are returned correctly """ - geography_one = GeographyFactory.create_with_geography_type( + GeographyFactory.create_with_geography_type( name="Leeds", geography_code="E08000035", geography_type="Lower Tier Local Authority", 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 d2d0dab2b..39afb1380 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 @@ -49,13 +49,11 @@ def test_get_name_by_id(self): When `get_name_by_id` is called Then the geography types with their codes and names are returned correctly """ - geography_one = GeographyTypeFactory( + GeographyTypeFactory( name="Lower Tier Local Authority", with_geographies=["Hull"] ) - geography_two = GeographyTypeFactory( - name="Region", with_geographies=["South East"] - ) + GeographyTypeFactory(name="Region", with_geographies=["South East"]) # When get_name_by_id = GeographyType.objects.get_name_by_id(2) diff --git a/tests/integration/metrics/data/managers/rbac_models/test_user.py b/tests/integration/metrics/data/managers/rbac_models/test_user.py index f73c57c50..8e085950f 100644 --- a/tests/integration/metrics/data/managers/rbac_models/test_user.py +++ b/tests/integration/metrics/data/managers/rbac_models/test_user.py @@ -36,8 +36,7 @@ def test_get_user_with_permission_sets(self): UserFactory.create_with_permission_sets( user_id=user_id, - permission_sets=[wildcard_permission, - permission_one, permission_two], + permission_sets=[wildcard_permission, permission_one, permission_two], ) # When diff --git a/tests/integration/metrics/utils/test_permission_hierarchy.py b/tests/integration/metrics/utils/test_permission_hierarchy.py index 9c4534632..62ee32dba 100644 --- a/tests/integration/metrics/utils/test_permission_hierarchy.py +++ b/tests/integration/metrics/utils/test_permission_hierarchy.py @@ -439,10 +439,8 @@ def test_global_wildcard_subsumes_everything(self): geography="E92000001", ) - normalized_wildcard = NormalizedPermission.from_permission_set( - global_wildcard) - normalized_specific = NormalizedPermission.from_permission_set( - specific) + normalized_wildcard = NormalizedPermission.from_permission_set(global_wildcard) + normalized_specific = NormalizedPermission.from_permission_set(specific) # Then assert normalized_wildcard.subsumes(normalized_specific) From 26363e2146075f7900e8bc7ec5483ff56d15de62 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 28 Apr 2026 11:17:16 +0100 Subject: [PATCH 108/186] CDD-3171: Update permission set form now it's a page not a snippet --- auth_content/models/permission_sets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth_content/models/permission_sets.py b/auth_content/models/permission_sets.py index b3f70937e..6b7270905 100644 --- a/auth_content/models/permission_sets.py +++ b/auth_content/models/permission_sets.py @@ -4,7 +4,7 @@ from django import forms from django.core.exceptions import ValidationError from django.db import models -from wagtail.admin.forms import WagtailAdminModelForm +from wagtail.admin.forms import WagtailAdminPageForm from wagtail.admin.panels import FieldPanel, mark_safe from auth_content.constants import PERMISSION_SET_FIELDS, WILDCARD_ID_VALUE @@ -47,7 +47,7 @@ def _create_form_field(field: dict[str, str | Callable | None]) -> forms.CharFie ) -class PermissionSetForm(WagtailAdminModelForm): +class PermissionSetForm(WagtailAdminPageForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) From d0540cb2913187ff67b90a4448b1b368c7ea5d70 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 28 Apr 2026 14:42:11 +0100 Subject: [PATCH 109/186] Update topic page to include theme/subtheme/topic fields --- auth_content/static/js/populate_dropdowns.js | 181 +++++++++++++++++++ cms/topic/constants.py | 25 +++ cms/topic/models.py | 55 ++++++ 3 files changed, 261 insertions(+) create mode 100644 auth_content/static/js/populate_dropdowns.js create mode 100644 cms/topic/constants.py diff --git a/auth_content/static/js/populate_dropdowns.js b/auth_content/static/js/populate_dropdowns.js new file mode 100644 index 000000000..72deb59f8 --- /dev/null +++ b/auth_content/static/js/populate_dropdowns.js @@ -0,0 +1,181 @@ +;(function () { + "use strict" + let theme, subTheme, topic + + /** + * Generic function to fetch choices from the API + * @param {string} endpoint - The API endpoint (e.g., 'subthemes', 'topics') + * @param {string} dataItemId - The ID value to pass + * @returns {Promise} Array of choices [[id, name], ...] + */ + async function fetchChoices(endpoint, dataItemId) { + try { + const url = `/api/data-hierarchy/${endpoint}/${dataItemId}` + console.log("🦄 fetching choices") + const response = await fetch(url) + + if (!response.ok) { + const errorData = await response.json() + console.error(`API error: ${errorData.error || "Unknown error"}`) + return [] + } + + const data = await response.json() + return data.choices || [] + } catch (error) { + console.error(`Error fetching ${endpoint}:`, error) + return [] + } + } + + /** + * Generic function to populate a dropdown with choices + * @param {HTMLSelectElement} dropdown - The select element to populate + * @param {Array} choices - Array of [id, name] tuples + */ + function populateDropdown(dropdown, choices) { + const currentValue = dropdown.value + dropdown.disabled = false + dropdown.innerHTML = "" + + //dropdown empty + const nullOption = document.createElement("option") + nullOption.value = "" + nullOption.textContent = "--------" + dropdown.appendChild(nullOption) + + choices.forEach(([id, name]) => { + const option = document.createElement("option") + option.value = id + option.textContent = name + dropdown.appendChild(option) + }) + + if (currentValue) { + dropdown.value = currentValue + } + } + + function clearDropdown(dropdown, message = "Select parent first") { + dropdown.innerHTML = "" + + const option = document.createElement("option") + option.value = "" + option.textContent = message + dropdown.appendChild(option) + + dropdown.value = "" + } + + /** + * Handle theme selection change + */ + async function handleThemeChange() { + const themeValue = theme.value + + // Clear all dependent dropdowns + if (!themeValue || themeValue === "") { + clearDropdown(subTheme, "Select theme first") + clearDropdown(topic, "Select sub-theme first") + return + } + + clearDropdown(subTheme, "--------") + clearDropdown(topic, "--------") + + // Fetch and populate sub-themes + const choices = await fetchChoices("subthemes", themeValue) + + if (choices.length > 0) { + populateDropdown(subTheme, choices) + } else { + clearDropdown(subTheme, "No sub-themes available") + } + } + + /** + * Handle sub-theme selection change + */ + async function handleSubThemeChange() { + const subThemeValue = subTheme.value + + if (!subThemeValue || subThemeValue === "") { + // No sub-theme selected - clear children + clearDropdown(topic, "Select sub-theme first") + return + } + + // Clear dependent dropdowns + clearDropdown(topic, "Select sub-theme") + + // Fetch and populate topics + const choices = await fetchChoices("topics", subThemeValue) + + if (choices.length > 0) { + populateDropdown(topic, choices) + } else { + clearDropdown(topic, "No topics available") + } + } + + /** + * Initialize dropdowns for edit mode + * Loads the dropdown options based on saved values + */ + async function initializeEditMode() { + // Store original values before we start manipulating dropdowns + const savedTheme = theme.value + const savedSubTheme = subTheme.value + const savedTopic = topic.value + + // If theme has a value (not empty), load sub-themes + if (savedTheme && savedTheme !== "") { + const subThemeChoices = await fetchChoices("subthemes", savedTheme) + if (subThemeChoices.length > 0) { + populateDropdown(subTheme, subThemeChoices) + subTheme.value = savedSubTheme // Restore selection + } + + // If sub-theme has a value, load topics + if (savedSubTheme && savedSubTheme !== "") { + const topicChoices = await fetchChoices("topics", savedSubTheme) + if (topicChoices.length > 0) { + populateDropdown(topic, topicChoices) + topic.value = savedTopic // Restore selection + } + } + } + } + + /** + * Initialize the cascading dropdowns + */ + function initialize() { + // Get dropdown elements + theme = document.querySelector('select[name="theme"]') + subTheme = document.querySelector('select[name="sub_theme"]') + topic = document.querySelector('select[name="topic"]') + + // Exit if not on permission set page + if (!theme || !subTheme || !topic) { + console.error("No theme dropdowns found on this page") + return + } + + // Add event listeners + theme.addEventListener("change", handleThemeChange) + subTheme.addEventListener("change", handleSubThemeChange) + + const isEditMode = theme.value || subTheme.value || topic.value + + if (isEditMode) { + initializeEditMode() + } else { + clearDropdown(subTheme, "Select theme first") + clearDropdown(topic, "Select sub-theme first") + } + } + + // Initialize when DOM is ready + document.addEventListener("DOMContentLoaded", initialize) +})() diff --git a/cms/topic/constants.py b/cms/topic/constants.py new file mode 100644 index 000000000..dc55f4a3d --- /dev/null +++ b/cms/topic/constants.py @@ -0,0 +1,25 @@ +from cms.metrics_interface.field_choices_callables import ( + get_all_theme_names_and_ids, +) + +THEME_FIELDS = [ + { + "field_name": "theme", + "field_label": "Theme", + "field_choice_default": "----------", + "field_choice_callable": get_all_theme_names_and_ids, + }, + { + "field_name": "sub_theme", + "field_label": "Sub Theme", + "field_choice_default": "Select theme first", + "field_choice_callable": None, + }, + { + "field_name": "topic", + "field_label": "Topic", + "field_choice_default": "Select sub-theme first", + "field_choice_callable": None, + }, +] + diff --git a/cms/topic/models.py b/cms/topic/models.py index 9e687cc38..7549608f9 100644 --- a/cms/topic/models.py +++ b/cms/topic/models.py @@ -1,7 +1,11 @@ +from collections.abc import Callable + import datetime from django.core.exceptions import ValidationError from django.db import models +from django import forms + from modelcluster.fields import ParentalKey from wagtail.admin.panels import ( FieldPanel, @@ -29,15 +33,59 @@ from cms.dynamic_content.announcements import Announcement from cms.dynamic_content.blocks_deconstruction import CMSBlockParser from cms.metrics_interface import MetricsAPIInterface +from cms.topic.constants import THEME_FIELDS from cms.topic.managers import TopicPageManager DEFAULT_CORE_TIME_SERIES_MANGER = MetricsAPIInterface().core_time_series_manager DEFAULT_CORE_HEADLINE_MANGER = MetricsAPIInterface().core_headline_manager +def _create_form_field(field: dict[str, str | Callable | None]) -> forms.CharField: + choices = [ + ("", field["field_choice_default"]), + ] + + if field["field_choice_callable"]: + choices += field["field_choice_callable"]() + + return forms.CharField( + required=True, label=field["field_label"], widget=forms.Select(choices=choices) + ) + + class TopicPageAdminForm(WagtailAdminPageForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + for field in THEME_FIELDS: + self.fields[field["field_name"]] = _create_form_field(field) + + if self.instance and self.instance.pk: + self._initialize_dependent_fields() + + def _initialize_dependent_fields(self): + """Initialize choices for cascading dependent fields""" + dependent_fields = { + "sub_theme": ("Select theme first"), + "topic": ("Select sub-theme first"), + "metric": ("Select topic first"), + "geography": ("Select geography type first"), + } + + for field_name, (placeholder, wildcard_label) in dependent_fields.items(): + value = getattr(self.instance, field_name, None) + if value: + choices = self._get_field_choices(value, placeholder, wildcard_label) + self.fields[field_name].widget.choices = choices + + @staticmethod + def _get_field_choices(value, placeholder, wildcard_label): + """Generate choices list based on field value""" + return [("", placeholder), (value, f"Loading... (ID: {value})")] + class Media: js = ["js/classification_toggle.js"] + js = ["js/populate_dropdowns.js"] class TopicPage(UKHSAPage): @@ -66,6 +114,10 @@ class TopicPage(UKHSAPage): null=True, ) + theme = models.CharField(max_length=255, blank=True, default="") + sub_theme = models.CharField(max_length=255, blank=True, default="") + topic = models.CharField(max_length=255, blank=True, default="") + related_links_layout = models.CharField( verbose_name="Layout", help_text=help_texts.RELATED_LINKS_LAYOUT_FIELD, @@ -87,6 +139,9 @@ class TopicPage(UKHSAPage): FieldPanel("enable_area_selector"), FieldPanel("is_public"), FieldPanel("page_classification"), + FieldPanel("theme"), + FieldPanel("sub_theme"), + FieldPanel("topic"), FieldPanel("page_description"), FieldPanel("body"), ] From e35df3579d065b384f3e7afb000952e80ec3360a Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 28 Apr 2026 14:42:32 +0100 Subject: [PATCH 110/186] WIP: filter getPages based on is_public field --- cms/dashboard/viewsets.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index 949c50d18..7239f69c9 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -7,15 +7,23 @@ from caching.private_api.decorators import cache_response from cms.dashboard.serializers import CMSDraftPagesSerializer, ListablePageSerializer +from cms.metrics_documentation.models.child import MetricsDocumentationChildEntry +from cms.topic.models import TopicPage + +from django.db.models import Q @extend_schema(tags=["cms"]) class CMSPagesAPIViewSet(PagesAPIViewSet): + # This is the /pages (or proxy/pages env dependent endpoint) permission_classes = [] base_serializer_class = ListablePageSerializer listing_default_fields = PagesAPIViewSet.listing_default_fields + ["show_in_menus"] detail_only_fields = [] + # ** + # TODO: Is this endpoint used for nonpublic data? + # I would assume so, which means we need to change the caching - use the decorator? def get_queryset(self): """Returns the queryset as per the individual models @@ -38,7 +46,24 @@ def get_queryset(self): `, , ...]>` """ + queryset = super().get_queryset() + + req = self.request + if req.auth is None: + # Filter pages to find those with the is public field (and where is_public is true) + topic_page_id_with_is_public = TopicPage.objects.filter(is_public=True, page_ptr__in=queryset).values_list("page_ptr_id", flat=True) + metric_doc_child_page_id_with_is_public = MetricsDocumentationChildEntry.objects.filter(is_public=True, page_ptr__in=queryset).values_list("page_ptr_id", flat=True) + + # Combine all public pages into one queryset + topic_public_pages = queryset.filter(id__in=topic_page_id_with_is_public) + metric_child_public_pages = queryset.filter(id__in=metric_doc_child_page_id_with_is_public) + is_public_pages = topic_public_pages | metric_child_public_pages + pages_without_is_public = queryset.not_type(TopicPage, MetricsDocumentationChildEntry) + public_pages = is_public_pages | pages_without_is_public + + queryset = public_pages + return queryset.specific() @cache_response() @@ -46,11 +71,16 @@ def listing_view(self, request: Request) -> Response: """This endpoint returns a list of published pages from the CMS (Wagtail). The payload includes page `title`, `id` and `meta` data about each page. """ + print(f"I AM LISTING VIEW 🦄: {super().listing_view(request=request)}") return super().listing_view(request=request) @cache_response() def detail_view(self, request: Request, pk: int) -> Response: """This end point returns a page from the CMS based on a Page `ID`.""" + print(f"I AM DETAIL VIEW 🎯: {super().detail_view(request=request, pk=pk)}") + if request.auth is None: + print() + # check the is public flag & only return public pages return super().detail_view(request=request, pk=pk) From c8578bcf571741cb5f2c98716e69819da8fa3310 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 28 Apr 2026 15:59:57 +0100 Subject: [PATCH 111/186] Move auth content underneath CMS --- auth_content/models/__init__.py | 1 - .../auth_content}/__init__.py | 0 .../auth_content}/constants.py | 0 .../auth_content}/migrations/0001_initial.py | 0 ...r_permissionset_geography_type_and_more.py | 0 .../auth_content}/migrations/__init__.py | 0 cms/auth_content/models/__init__.py | 2 + .../auth_content}/models/permission_sets.py | 2 +- .../auth_content}/models/users.py | 0 .../auth_content}/static/js/permission_set.js | 0 .../static/js/populate_dropdowns.js | 0 .../auth_content}/wagtail_hooks.py | 4 +- .../static/js/classification_toggle.js | 28 ------------- .../toggle_available_fields_on_is_public.js | 42 +++++++++++++++++++ cms/metrics_documentation/models/child.py | 2 +- metrics/api/serializers/geographies.py | 2 +- metrics/api/serializers/permission_sets.py | 2 +- metrics/api/settings/default.py | 2 +- .../metrics/api/views/test_geographies.py | 2 +- .../metrics/api/views/test_permission_sets.py | 2 +- tests/unit/auth_content/test_wagtail_hooks.py | 4 +- .../api/serializers/test_geographies.py | 2 +- .../api/serializers/test_permission_sets.py | 2 +- 23 files changed, 57 insertions(+), 42 deletions(-) delete mode 100644 auth_content/models/__init__.py rename {auth_content => cms/auth_content}/__init__.py (100%) rename {auth_content => cms/auth_content}/constants.py (100%) rename {auth_content => cms/auth_content}/migrations/0001_initial.py (100%) rename {auth_content => cms/auth_content}/migrations/0002_alter_permissionset_geography_type_and_more.py (100%) rename {auth_content => cms/auth_content}/migrations/__init__.py (100%) create mode 100644 cms/auth_content/models/__init__.py rename {auth_content => cms/auth_content}/models/permission_sets.py (99%) rename {auth_content => cms/auth_content}/models/users.py (100%) rename {auth_content => cms/auth_content}/static/js/permission_set.js (100%) rename {auth_content => cms/auth_content}/static/js/populate_dropdowns.js (100%) rename {auth_content => cms/auth_content}/wagtail_hooks.py (92%) delete mode 100644 cms/dashboard/static/js/classification_toggle.js create mode 100644 cms/dashboard/static/js/toggle_available_fields_on_is_public.js diff --git a/auth_content/models/__init__.py b/auth_content/models/__init__.py deleted file mode 100644 index 4d59541cb..000000000 --- a/auth_content/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from auth_content.models import permission_sets, users diff --git a/auth_content/__init__.py b/cms/auth_content/__init__.py similarity index 100% rename from auth_content/__init__.py rename to cms/auth_content/__init__.py diff --git a/auth_content/constants.py b/cms/auth_content/constants.py similarity index 100% rename from auth_content/constants.py rename to cms/auth_content/constants.py diff --git a/auth_content/migrations/0001_initial.py b/cms/auth_content/migrations/0001_initial.py similarity index 100% rename from auth_content/migrations/0001_initial.py rename to cms/auth_content/migrations/0001_initial.py diff --git a/auth_content/migrations/0002_alter_permissionset_geography_type_and_more.py b/cms/auth_content/migrations/0002_alter_permissionset_geography_type_and_more.py similarity index 100% rename from auth_content/migrations/0002_alter_permissionset_geography_type_and_more.py rename to cms/auth_content/migrations/0002_alter_permissionset_geography_type_and_more.py diff --git a/auth_content/migrations/__init__.py b/cms/auth_content/migrations/__init__.py similarity index 100% rename from auth_content/migrations/__init__.py rename to cms/auth_content/migrations/__init__.py diff --git a/cms/auth_content/models/__init__.py b/cms/auth_content/models/__init__.py new file mode 100644 index 000000000..93449c3ce --- /dev/null +++ b/cms/auth_content/models/__init__.py @@ -0,0 +1,2 @@ +from cms.auth_content.models import users +from cms.auth_content.models import permission_sets diff --git a/auth_content/models/permission_sets.py b/cms/auth_content/models/permission_sets.py similarity index 99% rename from auth_content/models/permission_sets.py rename to cms/auth_content/models/permission_sets.py index 6b7270905..df03dc716 100644 --- a/auth_content/models/permission_sets.py +++ b/cms/auth_content/models/permission_sets.py @@ -7,7 +7,7 @@ from wagtail.admin.forms import WagtailAdminPageForm from wagtail.admin.panels import FieldPanel, mark_safe -from auth_content.constants import PERMISSION_SET_FIELDS, WILDCARD_ID_VALUE +from cms.auth_content.constants import PERMISSION_SET_FIELDS, WILDCARD_ID_VALUE from cms.metrics_interface.field_choices_callables import ( get_all_geography_names_and_codes, get_all_geography_type_names_and_ids, diff --git a/auth_content/models/users.py b/cms/auth_content/models/users.py similarity index 100% rename from auth_content/models/users.py rename to cms/auth_content/models/users.py diff --git a/auth_content/static/js/permission_set.js b/cms/auth_content/static/js/permission_set.js similarity index 100% rename from auth_content/static/js/permission_set.js rename to cms/auth_content/static/js/permission_set.js diff --git a/auth_content/static/js/populate_dropdowns.js b/cms/auth_content/static/js/populate_dropdowns.js similarity index 100% rename from auth_content/static/js/populate_dropdowns.js rename to cms/auth_content/static/js/populate_dropdowns.js diff --git a/auth_content/wagtail_hooks.py b/cms/auth_content/wagtail_hooks.py similarity index 92% rename from auth_content/wagtail_hooks.py rename to cms/auth_content/wagtail_hooks.py index 9a60e2fd2..7df8bfa8d 100644 --- a/auth_content/wagtail_hooks.py +++ b/cms/auth_content/wagtail_hooks.py @@ -7,8 +7,8 @@ ModelViewSetGroup, ) -from auth_content.models.permission_sets import PermissionSet -from auth_content.models.users import User +from cms.auth_content.models.permission_sets import PermissionSet +from cms.auth_content.models.users import User class NoEditPermissionPolicy(ModelPermissionPolicy): diff --git a/cms/dashboard/static/js/classification_toggle.js b/cms/dashboard/static/js/classification_toggle.js deleted file mode 100644 index 909e4a3af..000000000 --- a/cms/dashboard/static/js/classification_toggle.js +++ /dev/null @@ -1,28 +0,0 @@ -;(function () { - function toggleClassification() { - /* - When the is_public box is checked, this will clear any selected page_classification, - and disable the field. If the is_public box is then unchecked, it will re-enable the field - */ - const isPublicCheckbox = document.querySelector('input[name="is_public"]') - const classificationField = document.querySelector( - 'select[name="page_classification"]', - ) - - if (!isPublicCheckbox || !classificationField) return - - if (isPublicCheckbox.checked) { - classificationField.value = "" - classificationField.disabled = true - } else { - classificationField.disabled = false - } - } - - document.addEventListener("DOMContentLoaded", toggleClassification) - document.addEventListener("change", function (e) { - if (e.target.name === "is_public") { - toggleClassification() - } - }) -})() diff --git a/cms/dashboard/static/js/toggle_available_fields_on_is_public.js b/cms/dashboard/static/js/toggle_available_fields_on_is_public.js new file mode 100644 index 000000000..36aa34112 --- /dev/null +++ b/cms/dashboard/static/js/toggle_available_fields_on_is_public.js @@ -0,0 +1,42 @@ +;(function () { + function toggleAvailableFields() { + /* + When the is_public box is checked, this will clear any selected page_classification, + and disable the field. If the is_public box is then unchecked, it will re-enable the field + */ + const isPublicCheckbox = document.querySelector('input[name="is_public"]') + + const fields = { + classification: document.querySelector( + 'select[name="page_classification"]', + ), + theme: document.querySelector('select[name="theme"]'), + subTheme: document.querySelector('select[name="sub_theme"]'), + topic: document.querySelector('select[name="topic"]'), + } + + if (!isPublicCheckbox || !Object.values(fields).every(Boolean)) return + + if (isPublicCheckbox.checked) { + Object.values(fields).forEach(disableField) + } else { + Object.values(fields).forEach(enableField) + } + } + + function disableField(field) { + field.value = "" + field.disabled = true + } + + function enableField(field) { + field.disabled = false + } + + document.addEventListener("DOMContentLoaded", toggleAvailableFields) + document.addEventListener("change", function (e) { + if (e.target.name === "is_public") { + toggleAvailableFields() + } + }) +})() diff --git a/cms/metrics_documentation/models/child.py b/cms/metrics_documentation/models/child.py index 9dada0e7d..52d75e183 100644 --- a/cms/metrics_documentation/models/child.py +++ b/cms/metrics_documentation/models/child.py @@ -32,7 +32,7 @@ def __init__(self, topic: str, metric: str): class MetricsDocumentationChildEntryAdminForm(WagtailAdminPageForm): class Media: - js = ["js/classification_toggle.js"] + js = ["js/toggle_available_fields_on_is_public.js"] class MetricsDocumentationChildEntry(UKHSAPage): diff --git a/metrics/api/serializers/geographies.py b/metrics/api/serializers/geographies.py index f19bdf6a3..d702761d9 100644 --- a/metrics/api/serializers/geographies.py +++ b/metrics/api/serializers/geographies.py @@ -3,7 +3,7 @@ from django.db.models import QuerySet from rest_framework import serializers -from auth_content.constants import WILDCARD_ID_VALUE +from cms.auth_content.constants import WILDCARD_ID_VALUE from metrics.api.serializers import help_texts from metrics.data.in_memory_models.geography_relationships.handlers import ( get_upstream_relationships_for_geography, diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py index 03c7e4413..ae7605d1b 100644 --- a/metrics/api/serializers/permission_sets.py +++ b/metrics/api/serializers/permission_sets.py @@ -1,7 +1,7 @@ from django.db.models import QuerySet from rest_framework import serializers -from auth_content.constants import WILDCARD_ID_VALUE +from cms.auth_content.constants import WILDCARD_ID_VALUE from metrics.data.models.core_models.supporting import Metric, SubTheme, Topic diff --git a/metrics/api/settings/default.py b/metrics/api/settings/default.py index ef8c84d97..217eaefc3 100644 --- a/metrics/api/settings/default.py +++ b/metrics/api/settings/default.py @@ -52,6 +52,7 @@ "metrics.api", "cms.acknowledgement", "cms.home", + "cms.auth_content", "cms.topic", "cms.topics_list", "cms.dashboard", @@ -78,7 +79,6 @@ "wagtail_trash", "modelcluster", "taggit", - "auth_content", ] MIDDLEWARE = [ diff --git a/tests/integration/metrics/api/views/test_geographies.py b/tests/integration/metrics/api/views/test_geographies.py index 31b15c627..685595f63 100644 --- a/tests/integration/metrics/api/views/test_geographies.py +++ b/tests/integration/metrics/api/views/test_geographies.py @@ -4,7 +4,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient -from auth_content.constants import WILDCARD_ID_VALUE +from cms.auth_content.constants import WILDCARD_ID_VALUE from tests.factories.metrics.geography import GeographyFactory from tests.factories.metrics.time_series import CoreTimeSeriesFactory from validation.geography_code import UNITED_KINGDOM_GEOGRAPHY_CODE diff --git a/tests/integration/metrics/api/views/test_permission_sets.py b/tests/integration/metrics/api/views/test_permission_sets.py index 7acc8d51a..1bb4a9096 100644 --- a/tests/integration/metrics/api/views/test_permission_sets.py +++ b/tests/integration/metrics/api/views/test_permission_sets.py @@ -4,7 +4,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient -from auth_content.constants import WILDCARD_ID_VALUE +from cms.auth_content.constants import WILDCARD_ID_VALUE from tests.factories.metrics.metric import MetricFactory from tests.factories.metrics.sub_theme import SubThemeFactory from tests.factories.metrics.topic import TopicFactory diff --git a/tests/unit/auth_content/test_wagtail_hooks.py b/tests/unit/auth_content/test_wagtail_hooks.py index bf1c76cdb..3ca362565 100644 --- a/tests/unit/auth_content/test_wagtail_hooks.py +++ b/tests/unit/auth_content/test_wagtail_hooks.py @@ -2,8 +2,8 @@ from django.test import TestCase from django.utils.safestring import SafeData -from auth_content.models.permission_sets import PermissionSet -from auth_content.wagtail_hooks import NoEditPermissionPolicy, PermissionSetViewSet +from cms.auth_content.models.permission_sets import PermissionSet +from cms.auth_content.wagtail_hooks import NoEditPermissionPolicy, PermissionSetViewSet class TestPermissionSetDetailsProperty(TestCase): diff --git a/tests/unit/metrics/api/serializers/test_geographies.py b/tests/unit/metrics/api/serializers/test_geographies.py index fa9d1583d..108a7567e 100644 --- a/tests/unit/metrics/api/serializers/test_geographies.py +++ b/tests/unit/metrics/api/serializers/test_geographies.py @@ -4,7 +4,7 @@ from rest_framework.exceptions import ValidationError -from auth_content.constants import WILDCARD_ID_VALUE +from cms.auth_content.constants import WILDCARD_ID_VALUE from metrics.data.models.core_models.supporting import Geography from validation.geography_code import UNITED_KINGDOM_GEOGRAPHY_CODE from metrics.api.serializers.geographies import ( diff --git a/tests/unit/metrics/api/serializers/test_permission_sets.py b/tests/unit/metrics/api/serializers/test_permission_sets.py index 62ebd2045..7d6e95627 100644 --- a/tests/unit/metrics/api/serializers/test_permission_sets.py +++ b/tests/unit/metrics/api/serializers/test_permission_sets.py @@ -3,7 +3,7 @@ import pytest from rest_framework import serializers as drf_serializers -from auth_content.constants import WILDCARD_ID_VALUE +from cms.auth_content.constants import WILDCARD_ID_VALUE from metrics.api.serializers.permission_sets import ( MetricRequestSerializer, PermissionSetResponseSerializer, From 654b33757114d4424f5df17ac63a215ef910c1de Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 28 Apr 2026 17:11:12 +0100 Subject: [PATCH 112/186] Expose themes/subthemes/topics on topic and metric doc child pages --- cms/auth_content/auth_utils.py | 34 ++++ cms/auth_content/models/permission_sets.py | 34 +--- .../static/js/populate_dropdowns.js | 181 ----------------- cms/{topic => dashboard}/constants.py | 3 + .../toggle_available_fields_on_is_public.js | 188 +++++++++++++++++- cms/metrics_documentation/models/child.py | 37 ++++ cms/topic/models.py | 21 +- 7 files changed, 262 insertions(+), 236 deletions(-) create mode 100644 cms/auth_content/auth_utils.py delete mode 100644 cms/auth_content/static/js/populate_dropdowns.js rename cms/{topic => dashboard}/constants.py (84%) diff --git a/cms/auth_content/auth_utils.py b/cms/auth_content/auth_utils.py new file mode 100644 index 000000000..cfae47c21 --- /dev/null +++ b/cms/auth_content/auth_utils.py @@ -0,0 +1,34 @@ +from collections.abc import Callable + +from django import forms + + +def _create_form_field(field: dict[str, str | Callable | None], wildcard_id_value=None) -> forms.CharField: + choices = [ + ("", field["field_choice_default"]), + ] + + if field["field_choice_wildcard"]: + choices += [(wildcard_id_value, field["field_choice_wildcard"])] + + if field["field_choice_callable"]: + choices += field["field_choice_callable"]() + + return forms.CharField( + required=True, label=field["field_label"], widget=forms.Select(choices=choices) + ) + +def _initialize_dependent_fields(self): + """Initialize choices for cascading dependent fields""" + dependent_fields = { + "sub_theme": ("Select theme first", "* (All sub-themes)"), + "topic": ("Select sub-theme first", "* (All topics)"), + "metric": ("Select topic first", "* (All metrics)"), + "geography": ("Select geography type first", "* (All geographies)"), + } + + for field_name, (placeholder, wildcard_label) in dependent_fields.items(): + value = getattr(self.instance, field_name, None) + if value: + choices = self._get_field_choices(value, placeholder, wildcard_label) + self.fields[field_name].widget.choices = choices \ No newline at end of file diff --git a/cms/auth_content/models/permission_sets.py b/cms/auth_content/models/permission_sets.py index df03dc716..1260c539e 100644 --- a/cms/auth_content/models/permission_sets.py +++ b/cms/auth_content/models/permission_sets.py @@ -1,12 +1,12 @@ from collections.abc import Callable from itertools import starmap -from django import forms from django.core.exceptions import ValidationError from django.db import models from wagtail.admin.forms import WagtailAdminPageForm from wagtail.admin.panels import FieldPanel, mark_safe +from cms.auth_content.auth_utils import _create_form_field from cms.auth_content.constants import PERMISSION_SET_FIELDS, WILDCARD_ID_VALUE from cms.metrics_interface.field_choices_callables import ( get_all_geography_names_and_codes, @@ -17,42 +17,12 @@ get_all_topic_names_and_ids, ) - -def get_theme_child_map(): - """Returns an object of all parent to child mappings - e.g. - { - infectious_disease: [vaccine_preventable, respiratory ....], - extreme_event: [weather_alert, mortality_report...] - ... - } - - """ - return {} - - -def _create_form_field(field: dict[str, str | Callable | None]) -> forms.CharField: - choices = [ - ("", field["field_choice_default"]), - ] - - if field["field_choice_wildcard"]: - choices += [(WILDCARD_ID_VALUE, field["field_choice_wildcard"])] - - if field["field_choice_callable"]: - choices += field["field_choice_callable"]() - - return forms.CharField( - required=True, label=field["field_label"], widget=forms.Select(choices=choices) - ) - - class PermissionSetForm(WagtailAdminPageForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for field in PERMISSION_SET_FIELDS: - self.fields[field["field_name"]] = _create_form_field(field) + self.fields[field["field_name"]] = _create_form_field(field, WILDCARD_ID_VALUE) if self.instance and self.instance.pk: self._initialize_dependent_fields() diff --git a/cms/auth_content/static/js/populate_dropdowns.js b/cms/auth_content/static/js/populate_dropdowns.js deleted file mode 100644 index 72deb59f8..000000000 --- a/cms/auth_content/static/js/populate_dropdowns.js +++ /dev/null @@ -1,181 +0,0 @@ -;(function () { - "use strict" - let theme, subTheme, topic - - /** - * Generic function to fetch choices from the API - * @param {string} endpoint - The API endpoint (e.g., 'subthemes', 'topics') - * @param {string} dataItemId - The ID value to pass - * @returns {Promise} Array of choices [[id, name], ...] - */ - async function fetchChoices(endpoint, dataItemId) { - try { - const url = `/api/data-hierarchy/${endpoint}/${dataItemId}` - console.log("🦄 fetching choices") - const response = await fetch(url) - - if (!response.ok) { - const errorData = await response.json() - console.error(`API error: ${errorData.error || "Unknown error"}`) - return [] - } - - const data = await response.json() - return data.choices || [] - } catch (error) { - console.error(`Error fetching ${endpoint}:`, error) - return [] - } - } - - /** - * Generic function to populate a dropdown with choices - * @param {HTMLSelectElement} dropdown - The select element to populate - * @param {Array} choices - Array of [id, name] tuples - */ - function populateDropdown(dropdown, choices) { - const currentValue = dropdown.value - dropdown.disabled = false - dropdown.innerHTML = "" - - //dropdown empty - const nullOption = document.createElement("option") - nullOption.value = "" - nullOption.textContent = "--------" - dropdown.appendChild(nullOption) - - choices.forEach(([id, name]) => { - const option = document.createElement("option") - option.value = id - option.textContent = name - dropdown.appendChild(option) - }) - - if (currentValue) { - dropdown.value = currentValue - } - } - - function clearDropdown(dropdown, message = "Select parent first") { - dropdown.innerHTML = "" - - const option = document.createElement("option") - option.value = "" - option.textContent = message - dropdown.appendChild(option) - - dropdown.value = "" - } - - /** - * Handle theme selection change - */ - async function handleThemeChange() { - const themeValue = theme.value - - // Clear all dependent dropdowns - if (!themeValue || themeValue === "") { - clearDropdown(subTheme, "Select theme first") - clearDropdown(topic, "Select sub-theme first") - return - } - - clearDropdown(subTheme, "--------") - clearDropdown(topic, "--------") - - // Fetch and populate sub-themes - const choices = await fetchChoices("subthemes", themeValue) - - if (choices.length > 0) { - populateDropdown(subTheme, choices) - } else { - clearDropdown(subTheme, "No sub-themes available") - } - } - - /** - * Handle sub-theme selection change - */ - async function handleSubThemeChange() { - const subThemeValue = subTheme.value - - if (!subThemeValue || subThemeValue === "") { - // No sub-theme selected - clear children - clearDropdown(topic, "Select sub-theme first") - return - } - - // Clear dependent dropdowns - clearDropdown(topic, "Select sub-theme") - - // Fetch and populate topics - const choices = await fetchChoices("topics", subThemeValue) - - if (choices.length > 0) { - populateDropdown(topic, choices) - } else { - clearDropdown(topic, "No topics available") - } - } - - /** - * Initialize dropdowns for edit mode - * Loads the dropdown options based on saved values - */ - async function initializeEditMode() { - // Store original values before we start manipulating dropdowns - const savedTheme = theme.value - const savedSubTheme = subTheme.value - const savedTopic = topic.value - - // If theme has a value (not empty), load sub-themes - if (savedTheme && savedTheme !== "") { - const subThemeChoices = await fetchChoices("subthemes", savedTheme) - if (subThemeChoices.length > 0) { - populateDropdown(subTheme, subThemeChoices) - subTheme.value = savedSubTheme // Restore selection - } - - // If sub-theme has a value, load topics - if (savedSubTheme && savedSubTheme !== "") { - const topicChoices = await fetchChoices("topics", savedSubTheme) - if (topicChoices.length > 0) { - populateDropdown(topic, topicChoices) - topic.value = savedTopic // Restore selection - } - } - } - } - - /** - * Initialize the cascading dropdowns - */ - function initialize() { - // Get dropdown elements - theme = document.querySelector('select[name="theme"]') - subTheme = document.querySelector('select[name="sub_theme"]') - topic = document.querySelector('select[name="topic"]') - - // Exit if not on permission set page - if (!theme || !subTheme || !topic) { - console.error("No theme dropdowns found on this page") - return - } - - // Add event listeners - theme.addEventListener("change", handleThemeChange) - subTheme.addEventListener("change", handleSubThemeChange) - - const isEditMode = theme.value || subTheme.value || topic.value - - if (isEditMode) { - initializeEditMode() - } else { - clearDropdown(subTheme, "Select theme first") - clearDropdown(topic, "Select sub-theme first") - } - } - - // Initialize when DOM is ready - document.addEventListener("DOMContentLoaded", initialize) -})() diff --git a/cms/topic/constants.py b/cms/dashboard/constants.py similarity index 84% rename from cms/topic/constants.py rename to cms/dashboard/constants.py index dc55f4a3d..56e8c063f 100644 --- a/cms/topic/constants.py +++ b/cms/dashboard/constants.py @@ -7,18 +7,21 @@ "field_name": "theme", "field_label": "Theme", "field_choice_default": "----------", + "field_choice_wildcard": None, "field_choice_callable": get_all_theme_names_and_ids, }, { "field_name": "sub_theme", "field_label": "Sub Theme", "field_choice_default": "Select theme first", + "field_choice_wildcard": None, "field_choice_callable": None, }, { "field_name": "topic", "field_label": "Topic", "field_choice_default": "Select sub-theme first", + "field_choice_wildcard": None, "field_choice_callable": None, }, ] diff --git a/cms/dashboard/static/js/toggle_available_fields_on_is_public.js b/cms/dashboard/static/js/toggle_available_fields_on_is_public.js index 36aa34112..a69931388 100644 --- a/cms/dashboard/static/js/toggle_available_fields_on_is_public.js +++ b/cms/dashboard/static/js/toggle_available_fields_on_is_public.js @@ -1,31 +1,35 @@ ;(function () { + "use strict" + let theme, subTheme, topic, isPublicCheckbox + function toggleAvailableFields() { /* When the is_public box is checked, this will clear any selected page_classification, and disable the field. If the is_public box is then unchecked, it will re-enable the field */ - const isPublicCheckbox = document.querySelector('input[name="is_public"]') const fields = { classification: document.querySelector( 'select[name="page_classification"]', ), - theme: document.querySelector('select[name="theme"]'), - subTheme: document.querySelector('select[name="sub_theme"]'), - topic: document.querySelector('select[name="topic"]'), + theme: theme, + subTheme: subTheme, + topic: topic, } if (!isPublicCheckbox || !Object.values(fields).every(Boolean)) return if (isPublicCheckbox.checked) { Object.values(fields).forEach(disableField) + clearDropdown(subTheme, "Select theme first") + clearDropdown(topic, "Select sub-theme first") + theme.value = "" } else { Object.values(fields).forEach(enableField) } } function disableField(field) { - field.value = "" field.disabled = true } @@ -33,7 +37,179 @@ field.disabled = false } - document.addEventListener("DOMContentLoaded", toggleAvailableFields) + /** + * Generic function to fetch choices from the API + * @param {string} endpoint - The API endpoint (e.g., 'subthemes', 'topics') + * @param {string} dataItemId - The ID value to pass + * @returns {Promise} Array of choices [[id, name], ...] + */ + async function fetchChoices(endpoint, dataItemId) { + try { + const url = `/api/data-hierarchy/${endpoint}/${dataItemId}` + const response = await fetch(url) + + if (!response.ok) { + const errorData = await response.json() + console.error(`API error: ${errorData.error || "Unknown error"}`) + return [] + } + + const data = await response.json() + return data.choices || [] + } catch (error) { + console.error(`Error fetching ${endpoint}:`, error) + return [] + } + } + + /** + * Generic function to populate a dropdown with choices + * @param {HTMLSelectElement} dropdown - The select element to populate + * @param {Array} choices - Array of [id, name] tuples + */ + function populateDropdown(dropdown, choices) { + const currentValue = dropdown.value + dropdown.disabled = false + dropdown.innerHTML = "" + + //dropdown empty + const nullOption = document.createElement("option") + nullOption.value = "" + nullOption.textContent = "--------" + dropdown.appendChild(nullOption) + + choices.forEach(([id, name]) => { + const option = document.createElement("option") + option.value = id + option.textContent = name + dropdown.appendChild(option) + }) + + if (currentValue) { + dropdown.value = currentValue + } + } + + function clearDropdown(dropdown, message = "Select parent first") { + dropdown.innerHTML = "" + + const option = document.createElement("option") + option.value = "" + option.textContent = message + dropdown.appendChild(option) + + dropdown.value = "" + } + + /** + * Handle theme selection change + */ + async function handleThemeChange() { + const themeValue = theme.value + + // Clear all dependent dropdowns + if (!themeValue || themeValue === "") { + clearDropdown(subTheme, "Select theme first") + clearDropdown(topic, "Select sub-theme first") + return + } + + clearDropdown(subTheme, "--------") + clearDropdown(topic, "--------") + + // Fetch and populate sub-themes + const choices = await fetchChoices("subthemes", themeValue) + + if (choices.length > 0) { + populateDropdown(subTheme, choices) + } else { + clearDropdown(subTheme, "No sub-themes available") + } + } + + /** + * Handle sub-theme selection change + */ + async function handleSubThemeChange() { + const subThemeValue = subTheme.value + + if (!subThemeValue || subThemeValue === "") { + // No sub-theme selected - clear children + clearDropdown(topic, "Select sub-theme first") + return + } + + // Clear dependent dropdowns + clearDropdown(topic, "Select sub-theme") + + // Fetch and populate topics + const choices = await fetchChoices("topics", subThemeValue) + + if (choices.length > 0) { + populateDropdown(topic, choices) + } else { + clearDropdown(topic, "No topics available") + } + } + + /** + * Initialize dropdowns for edit mode + * Loads the dropdown options based on saved values + */ + async function initializeEditMode() { + // Store original values before we start manipulating dropdowns + const savedTheme = theme.value + const savedSubTheme = subTheme.value + const savedTopic = topic.value + + // If theme has a value (not empty), load sub-themes + if (savedTheme && savedTheme !== "") { + const subThemeChoices = await fetchChoices("subthemes", savedTheme) + if (subThemeChoices.length > 0) { + populateDropdown(subTheme, subThemeChoices) + subTheme.value = savedSubTheme // Restore selection + } + + // If sub-theme has a value, load topics + if (savedSubTheme && savedSubTheme !== "") { + const topicChoices = await fetchChoices("topics", savedSubTheme) + if (topicChoices.length > 0) { + populateDropdown(topic, topicChoices) + topic.value = savedTopic // Restore selection + } + } + } + } + + function initialize() { + // Get dropdown elements + + isPublicCheckbox = document.querySelector('input[name="is_public"]') + theme = document.querySelector('select[name="theme"]') + subTheme = document.querySelector('select[name="sub_theme"]') + topic = document.querySelector('select[name="topic"]') + + // Exit if not on page with themes and is_public toggle + if (!theme || !subTheme || !topic || !isPublicCheckbox) { + console.error("No theme dropdowns found on this page") + return + } + + // Add event listeners + theme.addEventListener("change", handleThemeChange) + subTheme.addEventListener("change", handleSubThemeChange) + + const isEditMode = theme.value || subTheme.value || topic.value + + if (isEditMode) { + initializeEditMode() + } else { + clearDropdown(subTheme, "Select theme first") + clearDropdown(topic, "Select sub-theme first") + } + } + + document.addEventListener("DOMContentLoaded", initialize) document.addEventListener("change", function (e) { if (e.target.name === "is_public") { toggleAvailableFields() diff --git a/cms/metrics_documentation/models/child.py b/cms/metrics_documentation/models/child.py index 52d75e183..2bfdc8b4b 100644 --- a/cms/metrics_documentation/models/child.py +++ b/cms/metrics_documentation/models/child.py @@ -12,6 +12,8 @@ from wagtail.api import APIField from wagtail.search import index +from cms.auth_content.auth_utils import _create_form_field +from cms.dashboard.constants import THEME_FIELDS from cms.dashboard.models import DataClassificationLevels, UKHSAPage from cms.dynamic_content import help_texts from cms.dynamic_content.access import ALLOWABLE_BODY_CONTENT_TEXT_SECTION @@ -31,6 +33,35 @@ def __init__(self, topic: str, metric: str): class MetricsDocumentationChildEntryAdminForm(WagtailAdminPageForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + for field in THEME_FIELDS: + self.fields[field["field_name"]] = _create_form_field(field) + + if self.instance and self.instance.pk: + self._initialize_dependent_fields() + + def _initialize_dependent_fields(self): + """Initialize choices for cascading dependent fields""" + dependent_fields = { + "sub_theme": ("Select theme first"), + "topic": ("Select sub-theme first"), + "metric": ("Select topic first"), + "geography": ("Select geography type first"), + } + + for field_name, (placeholder, wildcard_label) in dependent_fields.items(): + value = getattr(self.instance, field_name, None) + if value: + choices = self._get_field_choices(value, placeholder, wildcard_label) + self.fields[field_name].widget.choices = choices + + @staticmethod + def _get_field_choices(value, placeholder): + """Generate choices list based on field value""" + return [("", placeholder), (value, f"Loading... (ID: {value})")] + class Media: js = ["js/toggle_available_fields_on_is_public.js"] @@ -51,6 +82,9 @@ class MetricsDocumentationChildEntry(UKHSAPage): null=True, blank=True, ) + + theme = models.CharField(max_length=255, blank=True, default="") + sub_theme = models.CharField(max_length=255, blank=True, default="") topic = models.CharField( max_length=255, default="", @@ -69,6 +103,9 @@ class MetricsDocumentationChildEntry(UKHSAPage): FieldPanel("metric"), FieldPanel("is_public"), FieldPanel("page_classification"), + FieldPanel("theme"), + FieldPanel("sub_theme"), + FieldPanel("topic"), FieldPanel("body"), ] diff --git a/cms/topic/models.py b/cms/topic/models.py index 7549608f9..62c8c251b 100644 --- a/cms/topic/models.py +++ b/cms/topic/models.py @@ -18,6 +18,7 @@ from wagtail.fields import RichTextField from wagtail.search import index +from cms.auth_content.auth_utils import _create_form_field from cms.dashboard.enums import ( DEFAULT_RELATED_LINKS_LAYOUT_FIELD_LENGTH, RelatedLinksLayoutEnum, @@ -33,26 +34,13 @@ from cms.dynamic_content.announcements import Announcement from cms.dynamic_content.blocks_deconstruction import CMSBlockParser from cms.metrics_interface import MetricsAPIInterface -from cms.topic.constants import THEME_FIELDS +from cms.dashboard.constants import THEME_FIELDS from cms.topic.managers import TopicPageManager DEFAULT_CORE_TIME_SERIES_MANGER = MetricsAPIInterface().core_time_series_manager DEFAULT_CORE_HEADLINE_MANGER = MetricsAPIInterface().core_headline_manager -def _create_form_field(field: dict[str, str | Callable | None]) -> forms.CharField: - choices = [ - ("", field["field_choice_default"]), - ] - - if field["field_choice_callable"]: - choices += field["field_choice_callable"]() - - return forms.CharField( - required=True, label=field["field_label"], widget=forms.Select(choices=choices) - ) - - class TopicPageAdminForm(WagtailAdminPageForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -79,13 +67,12 @@ def _initialize_dependent_fields(self): self.fields[field_name].widget.choices = choices @staticmethod - def _get_field_choices(value, placeholder, wildcard_label): + def _get_field_choices(value, placeholder): """Generate choices list based on field value""" return [("", placeholder), (value, f"Loading... (ID: {value})")] class Media: - js = ["js/classification_toggle.js"] - js = ["js/populate_dropdowns.js"] + js = ["js/toggle_available_fields_on_is_public.js"] class TopicPage(UKHSAPage): From c1dd4645fba85a568a42fef6db96ba3f233cf2bb Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Wed, 29 Apr 2026 11:24:46 +0100 Subject: [PATCH 113/186] CDD-3172: move class for blocks --- cms/dynamic_content/blocks.py | 36 ++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/cms/dynamic_content/blocks.py b/cms/dynamic_content/blocks.py index 4ab2ddf4f..72ee91d9b 100644 --- a/cms/dynamic_content/blocks.py +++ b/cms/dynamic_content/blocks.py @@ -30,7 +30,8 @@ class HeadlineNumberBlockTypes(StreamBlock): - headline_number = HeadlineNumberComponent(help_text=help_texts.HEADLINE_BLOCK_FIELD) + headline_number = HeadlineNumberComponent( + help_text=help_texts.HEADLINE_BLOCK_FIELD) trend_number = TrendNumberComponent(help_text=help_texts.TREND_BLOCK_FIELD) percentage_number = PercentageNumberComponent( help_text=help_texts.PERCENTAGE_BLOCK_FIELD @@ -51,7 +52,8 @@ class MetricNumberBlockTypes(StructBlock): required=True, min_num=MINIMUM_ROWS_NUMBER_BLOCK_COUNT, max_num=MAXIMUM_ROWS_NUMBER_BLOCK_COUNT, - help_text=help_texts.NUMBERS_ROW_FIELD.format(MAXIMUM_ROWS_NUMBER_BLOCK_COUNT), + help_text=help_texts.NUMBERS_ROW_FIELD.format( + MAXIMUM_ROWS_NUMBER_BLOCK_COUNT), ) class Meta: @@ -59,13 +61,23 @@ class Meta: class PopularTopicsHeadlineNumberBlockTypes(StreamBlock): - headline_number = HeadlineNumberComponent(help_text=help_texts.HEADLINE_BLOCK_FIELD) + headline_number = HeadlineNumberComponent( + help_text=help_texts.HEADLINE_BLOCK_FIELD) trend_number = TrendNumberComponent(help_text=help_texts.TREND_BLOCK_FIELD) class Meta: icon = "bars" +class PageLinkChooserBlock(PageChooserBlock): + @classmethod + def get_api_representation(cls, value, context=None) -> str | None: + if value: + return value.full_url + + return None + + class PopularTopicsMetricNumberBlockTypes(StructBlock): title = TextBlock(required=True, help_text=help_texts.TITLE_FIELD) date_prefix = TextBlock( @@ -184,15 +196,6 @@ def get_api_representation(cls, value, context=None) -> dict | None: return None -class PageLinkChooserBlock(PageChooserBlock): - @classmethod - def get_api_representation(cls, value, context=None) -> str | None: - if value: - return value.full_url - - return None - - class PageLink(StructBlock): title = CharBlock( required=True, @@ -213,7 +216,8 @@ class Meta: class RelatedLink(StructBlock): - link_display_text = CharBlock(required=True, help_text=help_texts.RELATED_LINK_TEXT) + link_display_text = CharBlock( + required=True, help_text=help_texts.RELATED_LINK_TEXT) link = CharBlock(required=True, help_text=help_texts.RELATED_LINK_URL) @@ -258,8 +262,10 @@ class SectionFooterLink(StructBlock): badge_label = CharBlock( help_text=help_texts.SECTION_FOOTER_BADGE_LABEL, required=True ) - text = CharBlock(help_text=help_texts.SECTION_FOOTER_LINK_TEXT, required=True) - link = SourceLinkBlock(help_text=help_texts.SECTION_FOOTER_LINK, required=True) + text = CharBlock( + help_text=help_texts.SECTION_FOOTER_LINK_TEXT, required=True) + link = SourceLinkBlock( + help_text=help_texts.SECTION_FOOTER_LINK, required=True) class Meta: icon = "link" From 97160a78572dc3feed183d6e97906b9857fc6a15 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 29 Apr 2026 11:58:56 +0100 Subject: [PATCH 114/186] WIP: Add theme/subtheme/topic to pages --- cms/auth_content/auth_utils.py | 21 ++-------- .../toggle_available_fields_on_is_public.js | 11 ++--- cms/dynamic_content/help_texts.py | 4 ++ cms/metrics_documentation/models/child.py | 34 +++++++++++---- cms/topic/models.py | 42 +++++++++++++------ 5 files changed, 71 insertions(+), 41 deletions(-) diff --git a/cms/auth_content/auth_utils.py b/cms/auth_content/auth_utils.py index cfae47c21..dcbb0afb7 100644 --- a/cms/auth_content/auth_utils.py +++ b/cms/auth_content/auth_utils.py @@ -2,6 +2,8 @@ from django import forms +from cms.dynamic_content import help_texts + def _create_form_field(field: dict[str, str | Callable | None], wildcard_id_value=None) -> forms.CharField: choices = [ @@ -15,20 +17,5 @@ def _create_form_field(field: dict[str, str | Callable | None], wildcard_id_valu choices += field["field_choice_callable"]() return forms.CharField( - required=True, label=field["field_label"], widget=forms.Select(choices=choices) - ) - -def _initialize_dependent_fields(self): - """Initialize choices for cascading dependent fields""" - dependent_fields = { - "sub_theme": ("Select theme first", "* (All sub-themes)"), - "topic": ("Select sub-theme first", "* (All topics)"), - "metric": ("Select topic first", "* (All metrics)"), - "geography": ("Select geography type first", "* (All geographies)"), - } - - for field_name, (placeholder, wildcard_label) in dependent_fields.items(): - value = getattr(self.instance, field_name, None) - if value: - choices = self._get_field_choices(value, placeholder, wildcard_label) - self.fields[field_name].widget.choices = choices \ No newline at end of file + required=False, label=field["field_label"], widget=forms.Select(choices=choices), help_text=help_texts.NON_PUBLIC_PAGE_REQUIRED + ) \ No newline at end of file diff --git a/cms/dashboard/static/js/toggle_available_fields_on_is_public.js b/cms/dashboard/static/js/toggle_available_fields_on_is_public.js index a69931388..01d1d92b1 100644 --- a/cms/dashboard/static/js/toggle_available_fields_on_is_public.js +++ b/cms/dashboard/static/js/toggle_available_fields_on_is_public.js @@ -17,15 +17,14 @@ topic: topic, } - if (!isPublicCheckbox || !Object.values(fields).every(Boolean)) return - if (isPublicCheckbox.checked) { Object.values(fields).forEach(disableField) - clearDropdown(subTheme, "Select theme first") - clearDropdown(topic, "Select sub-theme first") - theme.value = "" + clearDropdown(fields.subTheme, "Select theme first") + clearDropdown(fields.topic, "Select sub-theme first") + fields.theme.value = "" } else { Object.values(fields).forEach(enableField) + fields.classification.value="official_sensitive" } } @@ -195,6 +194,8 @@ return } + toggleAvailableFields() + // Add event listeners theme.addEventListener("change", handleThemeChange) subTheme.addEventListener("change", handleSubThemeChange) diff --git a/cms/dynamic_content/help_texts.py b/cms/dynamic_content/help_texts.py index 389f8ac2c..638f99eb8 100644 --- a/cms/dynamic_content/help_texts.py +++ b/cms/dynamic_content/help_texts.py @@ -626,6 +626,10 @@ The classification level of all data on this page (only applies to non-public pages). Defaults to `Official-Sensitive`. """ +NON_PUBLIC_PAGE_REQUIRED: str = """ +This field is required for a non-public page. +""" + SECTION_FOOTER_BLOCKS: str = """ This is an optional footer for content sections to allow additional supporting information to be linked too. (E.g. a link to furhter information about how we define an outbreak) """ diff --git a/cms/metrics_documentation/models/child.py b/cms/metrics_documentation/models/child.py index 2bfdc8b4b..0cb3f8862 100644 --- a/cms/metrics_documentation/models/child.py +++ b/cms/metrics_documentation/models/child.py @@ -47,14 +47,12 @@ def _initialize_dependent_fields(self): dependent_fields = { "sub_theme": ("Select theme first"), "topic": ("Select sub-theme first"), - "metric": ("Select topic first"), - "geography": ("Select geography type first"), } - for field_name, (placeholder, wildcard_label) in dependent_fields.items(): + for field_name, (placeholder) in dependent_fields.items(): value = getattr(self.instance, field_name, None) if value: - choices = self._get_field_choices(value, placeholder, wildcard_label) + choices = self._get_field_choices(value, placeholder) self.fields[field_name].widget.choices = choices @staticmethod @@ -83,8 +81,8 @@ class MetricsDocumentationChildEntry(UKHSAPage): blank=True, ) - theme = models.CharField(max_length=255, blank=True, default="") - sub_theme = models.CharField(max_length=255, blank=True, default="") + theme = models.CharField(max_length=255, blank=True, default="", null=True,) + sub_theme = models.CharField(max_length=255, blank=True, default="", null=True,) topic = models.CharField( max_length=255, default="", @@ -206,13 +204,35 @@ def clean(self): # If is_public is true, automatically clear classification if self.is_public: self.page_classification = None - # If not public page, classification must be chosen + self.theme = None + self.sub_theme = None + self.topic = None + + # If not public page, non-public fields must be set elif not self.page_classification: raise ValidationError( { "page_classification": "Please select a classification level for this non-public page" } ) + elif not self.theme: + raise ValidationError( + { + "theme": "Please select a theme for this non-public page" + } + ) + elif not self.sub_theme: + raise ValidationError( + { + "sub_theme": "Please select a subtheme for this non-public page" + } + ) + elif not self.topic: + raise ValidationError( + { + "topic": "Please select a theme for this non-public page" + } + ) class MetricsDocumentationChildPageAnnouncement(Announcement): diff --git a/cms/topic/models.py b/cms/topic/models.py index 62c8c251b..8a80d00cc 100644 --- a/cms/topic/models.py +++ b/cms/topic/models.py @@ -1,10 +1,7 @@ -from collections.abc import Callable - import datetime from django.core.exceptions import ValidationError from django.db import models -from django import forms from modelcluster.fields import ParentalKey from wagtail.admin.panels import ( @@ -56,14 +53,12 @@ def _initialize_dependent_fields(self): dependent_fields = { "sub_theme": ("Select theme first"), "topic": ("Select sub-theme first"), - "metric": ("Select topic first"), - "geography": ("Select geography type first"), } - for field_name, (placeholder, wildcard_label) in dependent_fields.items(): + for field_name, (placeholder) in dependent_fields.items(): value = getattr(self.instance, field_name, None) if value: - choices = self._get_field_choices(value, placeholder, wildcard_label) + choices = self._get_field_choices(value, placeholder) self.fields[field_name].widget.choices = choices @staticmethod @@ -101,9 +96,9 @@ class TopicPage(UKHSAPage): null=True, ) - theme = models.CharField(max_length=255, blank=True, default="") - sub_theme = models.CharField(max_length=255, blank=True, default="") - topic = models.CharField(max_length=255, blank=True, default="") + theme = models.CharField(max_length=255, blank=True, default="", null=True) + sub_theme = models.CharField(max_length=255, blank=True, default="", null=True) + topic = models.CharField(max_length=255, blank=True, default="", null=True) related_links_layout = models.CharField( verbose_name="Layout", @@ -272,16 +267,39 @@ def last_updated_at(self) -> datetime.datetime: def clean(self): super().clean() - # If is_public is true, automatically clear classification + # If is_public is true, automatically clear non-public fields if self.is_public: self.page_classification = None - # If not public page, classification must be chosen + self.theme = None + self.sub_theme = None + self.topic = None + + # If not public page, non-public fields must be set elif not self.page_classification: raise ValidationError( { "page_classification": "Please select a classification level for this non-public page" } ) + elif not self.theme: + raise ValidationError( + { + "theme": "Please select a theme for this non-public page" + } + ) + elif not self.sub_theme: + raise ValidationError( + { + "sub_theme": "Please select a sub theme for this non-public page" + } + ) + elif not self.topic: + raise ValidationError( + { + "topic": "Please select a topic for this non-public page" + } + ) + class TopicPageRelatedLink(UKHSAPageRelatedLink): From 372eadd839a0d127477ac23f88e6c83daa139bc3 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Wed, 29 Apr 2026 15:06:22 +0100 Subject: [PATCH 115/186] linting and permission set url changes --- cms/dynamic_content/blocks.py | 18 ++++++------------ metrics/api/urls_construction.py | 1 - 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/cms/dynamic_content/blocks.py b/cms/dynamic_content/blocks.py index 72ee91d9b..ce74cbcaa 100644 --- a/cms/dynamic_content/blocks.py +++ b/cms/dynamic_content/blocks.py @@ -30,8 +30,7 @@ class HeadlineNumberBlockTypes(StreamBlock): - headline_number = HeadlineNumberComponent( - help_text=help_texts.HEADLINE_BLOCK_FIELD) + headline_number = HeadlineNumberComponent(help_text=help_texts.HEADLINE_BLOCK_FIELD) trend_number = TrendNumberComponent(help_text=help_texts.TREND_BLOCK_FIELD) percentage_number = PercentageNumberComponent( help_text=help_texts.PERCENTAGE_BLOCK_FIELD @@ -52,8 +51,7 @@ class MetricNumberBlockTypes(StructBlock): required=True, min_num=MINIMUM_ROWS_NUMBER_BLOCK_COUNT, max_num=MAXIMUM_ROWS_NUMBER_BLOCK_COUNT, - help_text=help_texts.NUMBERS_ROW_FIELD.format( - MAXIMUM_ROWS_NUMBER_BLOCK_COUNT), + help_text=help_texts.NUMBERS_ROW_FIELD.format(MAXIMUM_ROWS_NUMBER_BLOCK_COUNT), ) class Meta: @@ -61,8 +59,7 @@ class Meta: class PopularTopicsHeadlineNumberBlockTypes(StreamBlock): - headline_number = HeadlineNumberComponent( - help_text=help_texts.HEADLINE_BLOCK_FIELD) + headline_number = HeadlineNumberComponent(help_text=help_texts.HEADLINE_BLOCK_FIELD) trend_number = TrendNumberComponent(help_text=help_texts.TREND_BLOCK_FIELD) class Meta: @@ -216,8 +213,7 @@ class Meta: class RelatedLink(StructBlock): - link_display_text = CharBlock( - required=True, help_text=help_texts.RELATED_LINK_TEXT) + link_display_text = CharBlock(required=True, help_text=help_texts.RELATED_LINK_TEXT) link = CharBlock(required=True, help_text=help_texts.RELATED_LINK_URL) @@ -262,10 +258,8 @@ class SectionFooterLink(StructBlock): badge_label = CharBlock( help_text=help_texts.SECTION_FOOTER_BADGE_LABEL, required=True ) - text = CharBlock( - help_text=help_texts.SECTION_FOOTER_LINK_TEXT, required=True) - link = SourceLinkBlock( - help_text=help_texts.SECTION_FOOTER_LINK, required=True) + text = CharBlock(help_text=help_texts.SECTION_FOOTER_LINK_TEXT, required=True) + link = SourceLinkBlock(help_text=help_texts.SECTION_FOOTER_LINK, required=True) class Meta: icon = "link" diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index b210fb9bc..28e4f7a25 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -321,7 +321,6 @@ def construct_urlpatterns( ) case enums.AppMode.PRIVATE_API.value: constructed_url_patterns += private_api_urlpatterns - constructed_url_patterns += permission_set_urlpatterns case enums.AppMode.FEEDBACK_API.value: constructed_url_patterns += feedback_urlpatterns case enums.AppMode.INGESTION.value: From 710a86539d77f10489276775a59054d6af419ace Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Wed, 29 Apr 2026 15:37:52 +0100 Subject: [PATCH 116/186] CDD-3172: refactored naming of geography method and updated the tests based on feedback. --- metrics/data/managers/core_models/geography.py | 8 ++++---- tests/integration/metrics/api/views/test_user.py | 7 +++---- .../metrics/data/managers/core_models/test_geography.py | 7 ++++--- tests/unit/metrics/api/serializers/test_user.py | 2 +- .../metrics/data/managers/core_models/test_geography.py | 8 ++++---- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/metrics/data/managers/core_models/geography.py b/metrics/data/managers/core_models/geography.py index 71e221731..5ff2ce8cb 100644 --- a/metrics/data/managers/core_models/geography.py +++ b/metrics/data/managers/core_models/geography.py @@ -25,7 +25,7 @@ def get_all_names(self) -> Self: """ return self.all().values_list("name", flat=True).distinct().order_by("name") - def get_name_by_id(self, geography_code: int) -> str | None: + def get_name_by_code(self, geography_code: str) -> str | None: """ Gets the geography_code name which matches the given theme id. @@ -36,7 +36,7 @@ def get_name_by_id(self, geography_code: int) -> str | None: The geography name if found, None otherwise Examples: - >>> GeographyQuerySet.get_name_by_id(1) + >>> GeographyQuerySet.get_name_by_code("E92000001") 'England' >>> GeographyQuerySet.get_name_by_id(999) None @@ -150,7 +150,7 @@ class GeographyManager(models.Manager): def get_queryset(self) -> GeographyQuerySet: return GeographyQuerySet(model=self.model, using=self.db) - def get_name_by_id(self, geography_code: int) -> str | None: + def get_name_by_code(self, geography_code: int) -> str | None: """Gets the geography name which matches the given geography_code. Args: @@ -165,7 +165,7 @@ def get_name_by_id(self, geography_code: int) -> str | None: >>> GeographyManager.get_name_by_id(999) None """ - return self.get_queryset().get_name_by_id(geography_code) + return self.get_queryset().get_name_by_code(geography_code) def get_all_names(self) -> GeographyQuerySet: """Gets all available deduplicated geography names as a flat list queryset. diff --git a/tests/integration/metrics/api/views/test_user.py b/tests/integration/metrics/api/views/test_user.py index e394a9f54..fb30bad63 100644 --- a/tests/integration/metrics/api/views/test_user.py +++ b/tests/integration/metrics/api/views/test_user.py @@ -47,7 +47,8 @@ def test_get_user_wildcard_permission_set(self): ) UserFactory.create_with_permission_sets( user_id=user_id, - permission_sets=[wildcard_permission, permission_one, permission_two], + permission_sets=[wildcard_permission, + permission_one, permission_two], ) # Retrieve the subthemes @@ -291,6 +292,4 @@ def test_handles_invalid_group_by_parameter(self): response = client.get(path) # Then - # Depending on your implementation, this might be 400 or just ignored - # Adjust based on your actual validation logic - assert response.status_code in [HTTPStatus.OK, HTTPStatus.BAD_REQUEST] + assert response.status_code in [HTTPStatus.BAD_REQUEST] 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 d81d2f878..86df62c77 100644 --- a/tests/integration/metrics/data/managers/core_models/test_geography.py +++ b/tests/integration/metrics/data/managers/core_models/test_geography.py @@ -95,7 +95,7 @@ def test_query_for_get_all_names_and_codes(self): } @pytest.mark.django_db - def test_get_name_by_id(self): + def test_get_name_by_code(self): """ Given a number of existing `geography` records When `get_name_by_id` is called @@ -112,8 +112,9 @@ def test_get_name_by_id(self): ) # When - get_name_by_id = Geography.objects.get_name_by_id(geography_code="E12000007") + get_name_by_code = Geography.objects.get_name_by_code( + geography_code="E12000007") # Access the dictionary returned by .first() - result = get_name_by_id + result = get_name_by_code assert result == geography_two.name diff --git a/tests/unit/metrics/api/serializers/test_user.py b/tests/unit/metrics/api/serializers/test_user.py index 6848b3983..d6ef0657d 100644 --- a/tests/unit/metrics/api/serializers/test_user.py +++ b/tests/unit/metrics/api/serializers/test_user.py @@ -17,7 +17,7 @@ def test_get_all_theme_names_and_ids( # Given user_manager = UserManager() - mock_user_id = 1 + mock_user_id = "1" # When user_manager.get_user_with_permission_sets(mock_user_id) 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 f0817e953..a5eb95383 100644 --- a/tests/unit/metrics/data/managers/core_models/test_geography.py +++ b/tests/unit/metrics/data/managers/core_models/test_geography.py @@ -57,8 +57,8 @@ def test_get_geography_codes_and_names_by_geography_type_id( geography_type_id=fake_geography_type_id, ) - @mock.patch.object(GeographyQuerySet, "get_name_by_id") - def test_get_name_by_id(self, spy_get_name_by_id: mock.MagicMock): + @mock.patch.object(GeographyQuerySet, "get_name_by_code") + def test_get_name_by_code(self, spy_get_name_by_code: mock.MagicMock): """ Given a payload containing the required field When `get_name_by_id` is called, @@ -69,10 +69,10 @@ def test_get_name_by_id(self, spy_get_name_by_id: mock.MagicMock): geography_manager = GeographyManager() # When - GeographyManager.get_name_by_id( + GeographyManager.get_name_by_code( geography_manager, geography_code=fake_geography_code, ) # Then - spy_get_name_by_id.assert_called_with(fake_geography_code) + spy_get_name_by_code.assert_called_with(fake_geography_code) From fb29a382d16067b0035daeb5774091b31470a4a9 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Wed, 29 Apr 2026 17:41:25 +0100 Subject: [PATCH 117/186] CDD-3172: updated test to better name test and updated permission hierarchy error --- metrics/utils/permission_hierarchy.py | 2 +- .../metrics/api/views/test_user.py | 89 +++++++++++-------- .../managers/core_models/test_geography.py | 3 +- 3 files changed, 57 insertions(+), 37 deletions(-) diff --git a/metrics/utils/permission_hierarchy.py b/metrics/utils/permission_hierarchy.py index 8f68b6ffd..53c9861a3 100644 --- a/metrics/utils/permission_hierarchy.py +++ b/metrics/utils/permission_hierarchy.py @@ -390,7 +390,7 @@ def _get_choice_label(field_name: str, value: str) -> str: if manager: name = ( - manager.get_name_by_id(value) + manager.get_name_by_code(value) if field_name == "geography" else manager.get_name_by_id(int(value)) ) diff --git a/tests/integration/metrics/api/views/test_user.py b/tests/integration/metrics/api/views/test_user.py index fb30bad63..0742215d3 100644 --- a/tests/integration/metrics/api/views/test_user.py +++ b/tests/integration/metrics/api/views/test_user.py @@ -20,6 +20,39 @@ def path(self) -> str: @pytest.mark.django_db def test_get_user_wildcard_permission_set(self): + client = APIClient() + + user_id = "f907e591-4c49-4847-89b3-665e3c0133a4" + + # create subthemes + PermissionSetFactory.create_wildcard_permission_set() + UserFactory.create_with_permission_set( + user_id=user_id, + permission_set_name="Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", + ) + + # Retrieve the subthemes + path = f"{self.path}/{user_id}/permissions" + response: Response = client.get(path=path) + result = response.data + + # Should return a wildcard choice + assert result["user_id"] == user_id + assert result["permission_sets"][0] == { + "id": 1, + "name": "Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", + "theme": "-1", + "sub_theme": "-1", + "topic": "-1", + "metric": "-1", + "geography_type": "-1", + "geography": "-1", + } + + @pytest.mark.django_db + def test_returns_all_user_permission_sets_when_user_has_multiple_permission_sets_including_wildcard( + self, + ): client = APIClient() @@ -28,7 +61,6 @@ def test_get_user_wildcard_permission_set(self): # create subthemes wildcard_permission = PermissionSetFactory.create_wildcard_permission_set() permission_one = PermissionSetFactory.create_permission_set( - name="Permission Set 1", theme=1, sub_theme=1, topic=2, @@ -37,7 +69,6 @@ def test_get_user_wildcard_permission_set(self): geography=1, ) permission_two = PermissionSetFactory.create_permission_set( - name=" Permission Set 2", theme=1, sub_theme=2, topic=1, @@ -47,8 +78,7 @@ def test_get_user_wildcard_permission_set(self): ) UserFactory.create_with_permission_sets( user_id=user_id, - permission_sets=[wildcard_permission, - permission_one, permission_two], + permission_sets=[wildcard_permission, permission_one, permission_two], ) # Retrieve the subthemes @@ -69,6 +99,26 @@ def test_get_user_wildcard_permission_set(self): "geography_type": "-1", "geography": "-1", } + assert result["permission_sets"][1] == { + "id": 2, + "name": "Theme: 1 | Sub-theme: 1 | Topic: 2 | Metric: 2 | Geography Type: 2 | Geography: 1", + "theme": "1", + "sub_theme": "1", + "topic": "2", + "metric": "2", + "geography_type": "2", + "geography": "1", + } + assert result["permission_sets"][2] == { + "id": 3, + "name": "Theme: 1 | Sub-theme: 2 | Topic: 1 | Metric: 2 | Geography Type: 1 | Geography: 1", + "theme": "1", + "sub_theme": "2", + "topic": "1", + "metric": "2", + "geography_type": "1", + "geography": "1", + } @pytest.mark.django_db def test_returns_400_for_invalid_uuid(self): @@ -175,37 +225,6 @@ def test_global_wildcard_subsumes_everything(self): assert hierarchy[0]["theme"]["id"] == "-1" assert hierarchy[0]["geography_type"]["id"] == "-1" - @pytest.mark.django_db - def test_get_user_wildcard_permission_set(self): - client = APIClient() - - user_id = "f907e591-4c49-4847-89b3-665e3c0133a4" - - # create subthemes - PermissionSetFactory.create_wildcard_permission_set() - UserFactory.create_with_permission_set( - user_id=user_id, - permission_set_name="Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", - ) - - # Retrieve the subthemes - path = f"{self.path}/{user_id}/permissions" - response: Response = client.get(path=path) - result = response.data - - # Should return a wildcard choice - assert result["user_id"] == user_id - assert result["permission_sets"][0] == { - "id": 1, - "name": "Theme: * (All) | Sub-theme: * (All) | Topic: * (All) | Metric: * (All) | Geography Type: * (All) | Geography: * (All)", - "theme": "-1", - "sub_theme": "-1", - "topic": "-1", - "metric": "-1", - "geography_type": "-1", - "geography": "-1", - } - @pytest.mark.django_db def test_accepts_empty_group_by_parameter(self): """ 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 86df62c77..51eacad90 100644 --- a/tests/integration/metrics/data/managers/core_models/test_geography.py +++ b/tests/integration/metrics/data/managers/core_models/test_geography.py @@ -113,7 +113,8 @@ def test_get_name_by_code(self): # When get_name_by_code = Geography.objects.get_name_by_code( - geography_code="E12000007") + geography_code="E12000007" + ) # Access the dictionary returned by .first() result = get_name_by_code From f588ee22fe20a3f85657bdab3e3273678bbf3d79 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Thu, 30 Apr 2026 13:31:17 +0100 Subject: [PATCH 118/186] CDD-2172: Add examples for each of the potential responses for get permissions sets hierarchy requests --- metrics/api/serializers/user.py | 45 ++++++++++++++- metrics/api/views/user.py | 99 +++++++++++++++++++++++++++++++-- 2 files changed, 136 insertions(+), 8 deletions(-) diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index 6302b0312..021fc2d5b 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -80,7 +80,8 @@ def data(self) -> dict: user_uuid = uuid.UUID(user_id_str) # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user( + user_uuid) # Check if user exists or has permissions if not permission_sets.exists(): @@ -93,7 +94,8 @@ def data(self) -> dict: } # Convert QuerySet to list of dicts - permission_set_list = _queryset_to_permission_set_dicts(permission_sets) + permission_set_list = _queryset_to_permission_set_dicts( + permission_sets) return { "user_id": user_id_str, @@ -172,7 +174,8 @@ def data(self) -> dict: group_by = self.validated_data.get("group_by") # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user( + user_uuid) if not permission_sets.exists(): # Return empty structure rather than raising exception @@ -216,6 +219,42 @@ class UserPermissionSetResponseSerializer(serializers.Serializer): ) +class FlatPermissionHierarchyResponseSerializer(serializers.Serializer): + """Response format when no grouping is applied (default).""" + + permission_sets = serializers.ListField( + child=serializers.DictField(), + help_text="List of deduplicated permission sets with theme and geography details" + ) + summary = serializers.DictField( + help_text="Statistics: total_permission_sets, deduplicated_count, removed_count, has_global_access, wildcard_themes" + ) + + +class GroupedByGeographyTypeResponseSerializer(serializers.Serializer): + """Response format when grouped by geography_type.""" + + permission_sets = serializers.DictField( + help_text=( + "Map of geography_type_id to geography type details. " + "Each geography type contains geographies, which contain consolidated permissions." + ) + ) + total_permissions = serializers.IntegerField() + + +class GroupedByThemeResponseSerializer(serializers.Serializer): + """Response format when grouped by theme.""" + + permission_sets = serializers.DictField( + help_text=( + "Map of theme_id to theme details. " + "Each theme contains sub_themes, which contain topics, which contain geographies with metrics." + ) + ) + total_permissions = serializers.IntegerField() + + def _queryset_to_permission_set_dicts( queryset: QuerySet, ) -> list[dict]: diff --git a/metrics/api/views/user.py b/metrics/api/views/user.py index 9f4609ce8..064cafd33 100644 --- a/metrics/api/views/user.py +++ b/metrics/api/views/user.py @@ -1,10 +1,13 @@ from http import HTTPStatus -from drf_spectacular.utils import OpenApiParameter, extend_schema +from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiResponse, extend_schema from rest_framework.response import Response from rest_framework.views import APIView from metrics.api.serializers.user import ( + FlatPermissionHierarchyResponseSerializer, + GroupedByGeographyTypeResponseSerializer, + GroupedByThemeResponseSerializer, UserHierarchyRequestSerializer, UserPermissionSetResponseSerializer, UserRequestSerializer, @@ -43,7 +46,6 @@ def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 @extend_schema( - request=UserRequestSerializer, tags=[USER_API_TAG], parameters=[ OpenApiParameter( @@ -56,17 +58,104 @@ def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 name="group_by", type=str, location=OpenApiParameter.QUERY, - description="Optional grouping strategy: 'geography_type', or 'theme'", + description=( + "Optional grouping strategy:\n" + "- (omitted): Returns flat list of deduplicated permissions\n" + "- 'geography_type': Groups by geography type → geography → permissions\n" + "- 'theme': Groups by theme → sub_theme → topic → geographies" + ), required=False, enum=["geography_type", "theme"], ), ], responses={ - HTTPStatus.OK.value: UserPermissionSetResponseSerializer, - 400: {"description": "Invalid group_by parameter"}, + 200: FlatPermissionHierarchyResponseSerializer, # Use one as the default schema + 400: {"description": "Invalid group_by parameter or user_id format"}, 403: {"description": "Not authorized to view these permissions"}, 404: {"description": "User not found or has no permissions"}, }, + examples=[ + OpenApiExample( + name="No grouping (default)", + description="Flat list of deduplicated permissions with summary", + value={ + "permission_sets": [ + { + "theme": {"id": "2", "name": "infectious_disease"}, + "sub_theme": {"id": "2", "name": "respiratory"}, + "topic": {"id": "3", "name": "COVID-19"}, + "metric": {"id": "-1", "name": "* (All)"}, + "geography_type": {"id": "3", "name": "Nation"}, + "geography": {"id": "E92000001", "name": "England"} + } + ], + "summary": { + "total_permission_sets": 1, + "deduplicated_count": 1, + "removed_count": 0, + "has_global_access": False, + "wildcard_themes": [] + } + }, + response_only=True, + ), + OpenApiExample( + name="Grouped by geography_type", + description="Nested structure grouped by geography type", + value={ + "permission_sets": { + "3": { + "geography_type_name": "Nation", + "geographies": { + "E92000001": { + "geography_name": "England", + "permissions": [ + { + "themes": [{"id": "2", "name": "infectious_disease"}], + "sub_themes": [{"id": "2", "name": "respiratory"}], + "topics": [{"id": "3", "name": "COVID-19"}], + "metrics": [{"id": "-1", "name": "* (All)"}] + } + ] + } + } + } + }, + "total_permissions": 1 + }, + response_only=True, + ), + OpenApiExample( + name="Grouped by theme", + description="Nested structure grouped by theme hierarchy", + value={ + "permission_sets": { + "2": { + "theme_name": "infectious_disease", + "sub_themes": { + "2": { + "sub_theme_name": "respiratory", + "topics": { + "3": { + "topic_name": "COVID-19", + "geographies": [ + { + "geography_types": [{"id": "3", "name": "Nation"}], + "geographies": [{"id": "E92000001", "name": "England"}], + "metrics": [{"id": "-1", "name": "* (All)"}] + } + ] + } + } + } + } + } + }, + "total_permissions": 1 + }, + response_only=True, + ), + ], ) class UserPermissionHierarchyByUserIdView(APIView): """Get user permission sets filtered by user ID with optional grouping""" From 0584f9924d192e12273035b8c7d1293cafc84794 Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Thu, 30 Apr 2026 13:33:22 +0100 Subject: [PATCH 119/186] CDD-2172: linting --- metrics/api/serializers/user.py | 11 +++----- metrics/api/views/user.py | 49 ++++++++++++++++++++------------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index 021fc2d5b..ceb90ee65 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -80,8 +80,7 @@ def data(self) -> dict: user_uuid = uuid.UUID(user_id_str) # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user( - user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) # Check if user exists or has permissions if not permission_sets.exists(): @@ -94,8 +93,7 @@ def data(self) -> dict: } # Convert QuerySet to list of dicts - permission_set_list = _queryset_to_permission_set_dicts( - permission_sets) + permission_set_list = _queryset_to_permission_set_dicts(permission_sets) return { "user_id": user_id_str, @@ -174,8 +172,7 @@ def data(self) -> dict: group_by = self.validated_data.get("group_by") # Get permission sets for this user - permission_sets = self.user_manager.get_permission_sets_for_user( - user_uuid) + permission_sets = self.user_manager.get_permission_sets_for_user(user_uuid) if not permission_sets.exists(): # Return empty structure rather than raising exception @@ -224,7 +221,7 @@ class FlatPermissionHierarchyResponseSerializer(serializers.Serializer): permission_sets = serializers.ListField( child=serializers.DictField(), - help_text="List of deduplicated permission sets with theme and geography details" + help_text="List of deduplicated permission sets with theme and geography details", ) summary = serializers.DictField( help_text="Statistics: total_permission_sets, deduplicated_count, removed_count, has_global_access, wildcard_themes" diff --git a/metrics/api/views/user.py b/metrics/api/views/user.py index 064cafd33..b66f58b15 100644 --- a/metrics/api/views/user.py +++ b/metrics/api/views/user.py @@ -1,13 +1,11 @@ from http import HTTPStatus -from drf_spectacular.utils import OpenApiExample, OpenApiParameter, OpenApiResponse, extend_schema +from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema from rest_framework.response import Response from rest_framework.views import APIView from metrics.api.serializers.user import ( FlatPermissionHierarchyResponseSerializer, - GroupedByGeographyTypeResponseSerializer, - GroupedByThemeResponseSerializer, UserHierarchyRequestSerializer, UserPermissionSetResponseSerializer, UserRequestSerializer, @@ -86,7 +84,7 @@ def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 "topic": {"id": "3", "name": "COVID-19"}, "metric": {"id": "-1", "name": "* (All)"}, "geography_type": {"id": "3", "name": "Nation"}, - "geography": {"id": "E92000001", "name": "England"} + "geography": {"id": "E92000001", "name": "England"}, } ], "summary": { @@ -94,8 +92,8 @@ def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 "deduplicated_count": 1, "removed_count": 0, "has_global_access": False, - "wildcard_themes": [] - } + "wildcard_themes": [], + }, }, response_only=True, ), @@ -111,17 +109,21 @@ def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 "geography_name": "England", "permissions": [ { - "themes": [{"id": "2", "name": "infectious_disease"}], - "sub_themes": [{"id": "2", "name": "respiratory"}], + "themes": [ + {"id": "2", "name": "infectious_disease"} + ], + "sub_themes": [ + {"id": "2", "name": "respiratory"} + ], "topics": [{"id": "3", "name": "COVID-19"}], - "metrics": [{"id": "-1", "name": "* (All)"}] + "metrics": [{"id": "-1", "name": "* (All)"}], } - ] + ], } - } + }, } }, - "total_permissions": 1 + "total_permissions": 1, }, response_only=True, ), @@ -140,18 +142,27 @@ def get(self, request, user_id, *args, **kwargs): # noqa: PLR6301 "topic_name": "COVID-19", "geographies": [ { - "geography_types": [{"id": "3", "name": "Nation"}], - "geographies": [{"id": "E92000001", "name": "England"}], - "metrics": [{"id": "-1", "name": "* (All)"}] + "geography_types": [ + {"id": "3", "name": "Nation"} + ], + "geographies": [ + { + "id": "E92000001", + "name": "England", + } + ], + "metrics": [ + {"id": "-1", "name": "* (All)"} + ], } - ] + ], } - } + }, } - } + }, } }, - "total_permissions": 1 + "total_permissions": 1, }, response_only=True, ), From 8bc0bbdd8bd1ab7b262d3c841d96e487c6ee99f0 Mon Sep 17 00:00:00 2001 From: Matt Reynolds <18287679+mattjreynolds@users.noreply.github.com> Date: Fri, 17 Apr 2026 09:58:18 +0100 Subject: [PATCH 120/186] 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 --- common/auth/cognito_jwt/backend.py | 7 ++- common/auth/cognito_jwt/user_manager.py | 28 ++++++----- config.py | 1 + metrics/api/settings/default.py | 1 + .../unit/common/auth/cognito_jwt/conftest.py | 1 + .../common/auth/cognito_jwt/test_backend.py | 35 ++++++++++--- .../auth/cognito_jwt/test_user_manager.py | 50 +++++++------------ 7 files changed, 69 insertions(+), 54 deletions(-) diff --git a/common/auth/cognito_jwt/backend.py b/common/auth/cognito_jwt/backend.py index 80500e960..70acc76d7 100644 --- a/common/auth/cognito_jwt/backend.py +++ b/common/auth/cognito_jwt/backend.py @@ -14,11 +14,12 @@ def get_authorization_header(request): """ - Return request's 'X-UHD-AUTH:' header, as a bytestring. + Return request's authentication header, as a bytestring. Hide some test client ickyness where the header can be unicode. """ - auth = request.META.get("HTTP_X_UHD_AUTH", b"") + auth_header = getattr(settings, "COGNITO_JWT_AUTH_HEADER", "Authorization") + auth = request.META.get(auth_header, b"") if isinstance(auth, str): # Work around django test client oddness auth = auth.encode(HTTP_HEADER_ENCODING) @@ -50,6 +51,8 @@ def authenticate(self, request): else: user_model = self.get_user_model() user = user_model.objects.get_or_create_for_cognito(jwt_payload) + if not user: + return None return (user, jwt_token) @staticmethod diff --git a/common/auth/cognito_jwt/user_manager.py b/common/auth/cognito_jwt/user_manager.py index 855dbadc6..8449ffb6e 100644 --- a/common/auth/cognito_jwt/user_manager.py +++ b/common/auth/cognito_jwt/user_manager.py @@ -1,7 +1,7 @@ import logging from django.contrib.auth import get_user_model -from django.contrib.auth.models import BaseUserManager, User +from django.contrib.auth.models import BaseUserManager logger = logging.getLogger(__name__) @@ -10,17 +10,21 @@ class CognitoManager(BaseUserManager): @staticmethod def get_or_create_for_cognito(jwt_payload): - username = jwt_payload["entraObjectId"] + """Create an ephemeral user instance for this request. + We don't need to store or retrieve any info, we use what's in the JWT, + so this speeds up the request by removing the need for any DB access + """ try: - user = get_user_model().objects.get(username=username) - logger.debug("Found existing user %s", user.username) - except User.DoesNotExist: - password = None - user = get_user_model().objects.create_user( - username=username, - password=password, + username = jwt_payload["entraObjectId"] + permission_sets = jwt_payload["permissionSets"] + except KeyError: + logger.exception( + "Error getting entraObjectId and permissionSets from jwt '%s'", + jwt_payload, ) - logger.info("Created user %s", user.username) - user.is_active = True - user.save() + return None + + user_class = get_user_model() + user = user_class(username=username) + user.permission_sets = permission_sets return user diff --git a/config.py b/config.py index 191e91245..5c10fdeb8 100644 --- a/config.py +++ b/config.py @@ -63,6 +63,7 @@ # Cognito configuration COGNITO_AWS_REGION = os.environ.get("COGNITO_AWS_REGION") +COGNITO_JWT_AUTH_HEADER = os.environ.get("COGNITO_JWT_AUTH_HEADER") COGNITO_USER_POOL = os.environ.get("COGNITO_USER_POOL") # Database configuration diff --git a/metrics/api/settings/default.py b/metrics/api/settings/default.py index ef8c84d97..78af6c97d 100644 --- a/metrics/api/settings/default.py +++ b/metrics/api/settings/default.py @@ -115,6 +115,7 @@ COGNITO_USER_MANAGER = "common.auth.cognito_jwt.user_manager.CognitoManager" COGNITO_AWS_REGION = config.COGNITO_AWS_REGION +COGNITO_JWT_AUTH_HEADER = config.COGNITO_JWT_AUTH_HEADER COGNITO_USER_POOL = config.COGNITO_USER_POOL COGNITO_AUDIENCE = None COGNITO_PUBLIC_KEYS_CACHING_ENABLED = True diff --git a/tests/unit/common/auth/cognito_jwt/conftest.py b/tests/unit/common/auth/cognito_jwt/conftest.py index 56f363b74..0f4c2db37 100644 --- a/tests/unit/common/auth/cognito_jwt/conftest.py +++ b/tests/unit/common/auth/cognito_jwt/conftest.py @@ -8,6 +8,7 @@ def cognito_settings(settings): settings.COGNITO_AWS_REGION = "eu-central-1" settings.COGNITO_USER_POOL = "bla" + settings.COGNITO_JWT_AUTH_HEADER = "HTTP_X_UHD_AUTH" settings.COGNITO_AUDIENCE = "my-client-id" settings.COGNITO_PUBLIC_KEYS_CACHING_ENABLED = False settings.CACHES = { diff --git a/tests/unit/common/auth/cognito_jwt/test_backend.py b/tests/unit/common/auth/cognito_jwt/test_backend.py index e2a0ea5df..6bda38e4c 100644 --- a/tests/unit/common/auth/cognito_jwt/test_backend.py +++ b/tests/unit/common/auth/cognito_jwt/test_backend.py @@ -1,7 +1,7 @@ import pytest from django.conf import settings from django.contrib.auth import get_user_model -from django.test import Client +from django.test import Client, override_settings from rest_framework import status from rest_framework.exceptions import AuthenticationFailed from utils import create_jwt_token @@ -14,7 +14,20 @@ def test_get_authorization_header(rf): """test get_authorization_header correctly handles a header that is a string not a bytestring as expected""" - request = rf.get("/", HTTP_X_UHD_AUTH="bearer string_token") + headers = {settings.COGNITO_JWT_AUTH_HEADER: "bearer string_token"} + request = rf.get("/", **headers) + auth = backend.JSONWebTokenAuthentication() + with pytest.raises(AuthenticationFailed): + auth.authenticate(request) + + +@override_settings() +def test_get_default_auth_header(rf): + """test get_authorization_header uses 'Authorization' header if + COGNITO_JWT_AUTH_HEADER is not specified in settings""" + del settings.COGNITO_JWT_AUTH_HEADER + headers = {"Authorization": b"bearer string token"} + request = rf.get("/", **headers) auth = backend.JSONWebTokenAuthentication() with pytest.raises(AuthenticationFailed): auth.authenticate(request) @@ -57,7 +70,8 @@ def func(payload): USER_MODEL.objects, "get_or_create_for_cognito", func, raising=False ) - request = rf.get("/", HTTP_X_UHD_AUTH=b"bearer %s" % token.encode("utf8")) + headers = {settings.COGNITO_JWT_AUTH_HEADER: b"bearer %s" % token.encode("utf8")} + request = rf.get("/", **headers) auth = backend.JSONWebTokenAuthentication() user, auth_token = auth.authenticate(request) assert user @@ -75,7 +89,8 @@ def test_authenticate_invalid(rf, cognito_well_known_keys, jwk_private_key_two): }, ) - request = rf.get("/", HTTP_X_UHD_AUTH=b"bearer %s" % token.encode("utf8")) + headers = {settings.COGNITO_JWT_AUTH_HEADER: b"bearer %s" % token.encode("utf8")} + request = rf.get("/", **headers) auth = backend.JSONWebTokenAuthentication() with pytest.raises(AuthenticationFailed): @@ -83,7 +98,8 @@ def test_authenticate_invalid(rf, cognito_well_known_keys, jwk_private_key_two): def test_authenticate_error_segments(rf): - request = rf.get("/", HTTP_X_UHD_AUTH=b"bearer randomiets") + headers = {settings.COGNITO_JWT_AUTH_HEADER: b"bearer randomiets"} + request = rf.get("/", **headers) auth = backend.JSONWebTokenAuthentication() with pytest.raises(AuthenticationFailed): @@ -91,7 +107,8 @@ def test_authenticate_error_segments(rf): def test_authenticate_error_invalid_header(rf): - request = rf.get("/", HTTP_X_UHD_AUTH=b"bearer") + headers = {settings.COGNITO_JWT_AUTH_HEADER: b"bearer"} + request = rf.get("/", **headers) auth = backend.JSONWebTokenAuthentication() with pytest.raises(AuthenticationFailed): @@ -99,7 +116,8 @@ def test_authenticate_error_invalid_header(rf): def test_authenticate_error_spaces(rf): - request = rf.get("/", HTTP_X_UHD_AUTH=b"bearer random iets") + headers = {settings.COGNITO_JWT_AUTH_HEADER: b"bearer random iets"} + request = rf.get("/", **headers) auth = backend.JSONWebTokenAuthentication() with pytest.raises(AuthenticationFailed): @@ -108,6 +126,7 @@ def test_authenticate_error_spaces(rf): def test_authenticate_error_response_code(): client = Client() - resp = client.get("/", HTTP_X_UHD_AUTH=b"bearer random iets") + headers = {settings.COGNITO_JWT_AUTH_HEADER: b"bearer random iets"} + resp = client.get("/", **headers) assert resp.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/unit/common/auth/cognito_jwt/test_user_manager.py b/tests/unit/common/auth/cognito_jwt/test_user_manager.py index af9a6c9d7..d492e5c98 100644 --- a/tests/unit/common/auth/cognito_jwt/test_user_manager.py +++ b/tests/unit/common/auth/cognito_jwt/test_user_manager.py @@ -1,50 +1,36 @@ -from unittest import mock from django.contrib.auth import get_user_model -from django.contrib.auth.models import User from common.auth.cognito_jwt.user_manager import CognitoManager USER_MODEL = get_user_model() -def test_get_or_create_for_cognito_get_existing_user(): +def test_get_or_create_for_cognito_returns_user(): jwt_payload = { "entraObjectId": "unique_user_id", + "permissionSets": ["all_the_permissions"], } - mock_user = mock.MagicMock() - mock_user.username = jwt_payload["entraObjectId"] - mock_user.is_active = True + user = CognitoManager.get_or_create_for_cognito(jwt_payload) + assert user + assert user.username == jwt_payload["entraObjectId"] + assert user.permission_sets == jwt_payload["permissionSets"] + assert user.is_active is True - with mock.patch.object(USER_MODEL.objects, "get", return_value=mock_user): - user = CognitoManager.get_or_create_for_cognito(jwt_payload) - assert user - assert user.username == jwt_payload["entraObjectId"] - assert user.is_active is True + +def test_get_or_create_for_cognito_returns_none_without_username(): + jwt_payload = { + "permissionSets": ["all_the_permissions"], + } + + user = CognitoManager.get_or_create_for_cognito(jwt_payload) + assert user is None -def test_get_or_create_for_cognito_create_user(): +def test_get_or_create_for_cognito_returns_none_without_permission_sets(): jwt_payload = { "entraObjectId": "unique_user_id", } - def create_user_mock(*args, username, **kwargs): - mock_user = mock.MagicMock() - mock_user.username = username - return mock_user - - with ( - mock.patch.object(USER_MODEL.objects, "get", side_effect=User.DoesNotExist), - mock.patch.object( - USER_MODEL.objects, "create_user", side_effect=create_user_mock - ) as create_user, - ): - user = CognitoManager.get_or_create_for_cognito(jwt_payload) - assert user - assert user.username == jwt_payload["entraObjectId"] - assert user.is_active is True - - create_user.assert_called_once_with( - username=jwt_payload["entraObjectId"], - password=None, - ) + user = CognitoManager.get_or_create_for_cognito(jwt_payload) + assert user is None From 2a79ee3c5a610cf343dea8bf9bc6fc417da2f7e3 Mon Sep 17 00:00:00 2001 From: Matt Reynolds <18287679+mattjreynolds@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:19:41 +0100 Subject: [PATCH 121/186] CDD-3147: Improve logging of JWT --- common/auth/cognito_jwt/backend.py | 7 +++++++ common/auth/cognito_jwt/user_manager.py | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/common/auth/cognito_jwt/backend.py b/common/auth/cognito_jwt/backend.py index 70acc76d7..1fed42fec 100644 --- a/common/auth/cognito_jwt/backend.py +++ b/common/auth/cognito_jwt/backend.py @@ -1,3 +1,5 @@ +import logging + from django.apps import apps as django_apps from django.conf import settings from django.utils.encoding import force_str @@ -8,6 +10,8 @@ from .validator import TokenError, TokenValidator +logger = logging.getLogger(__name__) + # 2 objects expected when parsing Auth Header: 'Bearer' + token VALID_AUTH_HEADER_LENGTH = 2 @@ -52,6 +56,9 @@ def authenticate(self, request): user_model = self.get_user_model() user = user_model.objects.get_or_create_for_cognito(jwt_payload) if not user: + logger.debug( + "Unable to create user from JWT, defaulting to unauthenticated" + ) return None return (user, jwt_token) diff --git a/common/auth/cognito_jwt/user_manager.py b/common/auth/cognito_jwt/user_manager.py index 8449ffb6e..9d8c522c8 100644 --- a/common/auth/cognito_jwt/user_manager.py +++ b/common/auth/cognito_jwt/user_manager.py @@ -18,8 +18,9 @@ def get_or_create_for_cognito(jwt_payload): username = jwt_payload["entraObjectId"] permission_sets = jwt_payload["permissionSets"] except KeyError: - logger.exception( - "Error getting entraObjectId and permissionSets from jwt '%s'", + logger.debug( + "Error getting entraObjectId and/or permissionSets field(s)" + " from jwt payload: '%s'", jwt_payload, ) return None From 2ab8c2ee99498d15d9de0955365903173840186f Mon Sep 17 00:00:00 2001 From: Matt Reynolds <18287679+mattjreynolds@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:17:53 +0100 Subject: [PATCH 122/186] CDD-3147: Update readme for using JWT locally --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index f0d092b8f..b590a15ef 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,13 @@ for the app to collect the necessary static files: uhd server setup-static-files ``` +If using the JWT locally for passing permission sets with API requests from the frontend, +you will need to set up the variables for validating the token via cognito: + +- `export COGNITO_AWS_REGION=eu-west-2` - This is unlikely to change +- `export COGNITO_USER_POOL=eu-west-2_a123bc4DE` - Can be found be checking the `User pool ID` value for your environment on the [AWS console] (https://eu-west-2.console.aws.amazon.com/cognito/v2/idp/user-pools?region=eu-west-2) +- `export COGNITO_JWT_AUTH_HEADER=HTTP_X_UHD_AUTH` - This is unlikely to change + --- ## Using the API From 5f7d315bcafae6cc0bfae496445f644256fdf7d3 Mon Sep 17 00:00:00 2001 From: Matt Reynolds <18287679+mattjreynolds@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:48:52 +0100 Subject: [PATCH 123/186] CDD-3147: Update readme for using JWT locally --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b590a15ef..7c320c9c8 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,12 @@ Once again, you should include this line in the `.env` file at the root level of See the [Django documentation | SECRET_KEY](https://docs.djangoproject.com/en/4.2/ref/settings/#secret-key) for more information. -4. Set up the virtual environment and install the project dependencies via: +4. Set up for running the non-public version locally. + +If you want to run the non-public version (i.e. pass a valid JWT into requests), you'll have to link to a deployed cognito instance by adding env variables as per the [guidance below](#remote-infrastructure) +Once again, you should include these in the `.env` file at the root level of your project structure. + +5. Set up the virtual environment and install the project dependencies via: ```bash uhd venv create ``` @@ -85,12 +90,12 @@ This command will create a virtual environment at the `.venv/` folder at the roo The version of Python which will be used is dictated by the aforementioned `.python-version` file. And finally, the entire project dependencies will be installed within the virtual environment. -5. Apply the database migrations and ensure Django collects all required static files. +6. Apply the database migrations and ensure Django collects all required static files. ```bash uhd server setup-all ``` -6. Run a development server: +7. Run a development server: ```bash uhd server run-local ``` From 3f999f1c6ca5079a3339d43e52ce1dce57e29d4c Mon Sep 17 00:00:00 2001 From: David Logie Date: Thu, 19 Mar 2026 14:48:44 +0000 Subject: [PATCH 124/186] 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. --- .../management/commands/build_cms_site.py | 1 + .../build_cms_site_helpers/__init__.py | 2 +- .../commands/build_cms_site_helpers/menu.py | 32 ++++- cms/snippets/managers/menu.py | 60 +++++++++ cms/snippets/migrations/0014_simplemenu.py | 72 +++++++++++ cms/snippets/models/__init__.py | 2 +- cms/snippets/models/menu_builder/__init__.py | 2 +- .../models/menu_builder/help_texts.py | 4 + cms/snippets/models/menu_builder/menu.py | 29 ++++- cms/snippets/models/menu_builder/menu_link.py | 36 ++++++ cms/snippets/serializers/__init__.py | 9 +- cms/snippets/serializers/menu.py | 31 ++++- cms/snippets/views/__init__.py | 2 +- cms/snippets/views/menu.py | 25 ++++ metrics/api/urls_construction.py | 3 +- tests/factories/cms/snippets/menu.py | 11 +- tests/fakes/managers/cms/menu_manager.py | 21 +++- .../cms/snippets/managers/test_menu.py | 44 ++++++- .../cms/snippets/views/test_menu.py | 59 ++++++++- tests/unit/cms/snippets/managers/test_menu.py | 116 +++++++++++++++++- .../snippets/models/menu_builder/test_menu.py | 116 +++++++++++++++++- .../models/menu_builder/test_menu_link.py | 32 ++++- .../cms/snippets/serializers/test_menu.py | 61 ++++++++- 23 files changed, 747 insertions(+), 23 deletions(-) create mode 100644 cms/snippets/migrations/0014_simplemenu.py diff --git a/cms/dashboard/management/commands/build_cms_site.py b/cms/dashboard/management/commands/build_cms_site.py index cd977d555..c17444260 100644 --- a/cms/dashboard/management/commands/build_cms_site.py +++ b/cms/dashboard/management/commands/build_cms_site.py @@ -82,6 +82,7 @@ def handle(self, *args, **options): ) build_cms_site_helpers.create_menu_snippet() + build_cms_site_helpers.create_simplemenu_snippet() @classmethod def _build_whats_new_section(cls, root_page: UKHSARootPage) -> None: diff --git a/cms/dashboard/management/commands/build_cms_site_helpers/__init__.py b/cms/dashboard/management/commands/build_cms_site_helpers/__init__.py index 5f7b4dccc..63e91403c 100644 --- a/cms/dashboard/management/commands/build_cms_site_helpers/__init__.py +++ b/cms/dashboard/management/commands/build_cms_site_helpers/__init__.py @@ -12,4 +12,4 @@ create_feedback_page, create_authentication_error_page, ) -from .menu import create_menu_snippet +from .menu import create_menu_snippet, create_simplemenu_snippet diff --git a/cms/dashboard/management/commands/build_cms_site_helpers/menu.py b/cms/dashboard/management/commands/build_cms_site_helpers/menu.py index 351c3c30f..4e0e3e8b6 100644 --- a/cms/dashboard/management/commands/build_cms_site_helpers/menu.py +++ b/cms/dashboard/management/commands/build_cms_site_helpers/menu.py @@ -2,7 +2,7 @@ from cms.composite.models import CompositePage from cms.home.models import LandingPage from cms.metrics_documentation.models import MetricsDocumentationParentPage -from cms.snippets.models import Menu +from cms.snippets.models import Menu, SimpleMenu from cms.topic.models import TopicPage from cms.whats_new.models import WhatsNewParentPage @@ -172,3 +172,33 @@ def _create_menu_data() -> list[dict]: "id": "dcd6d76c-a3b3-4b44-8326-8177d609b50b", } ] + + +def create_simplemenu_snippet(): + SimpleMenu.objects.create( + internal_label="Primary navigation", + is_active=True, + body=_create_simplemenu_data(), + ) + + +def _create_simplemenu_data() -> list[dict]: + covid_page = TopicPage.objects.get(slug="covid-19") + flu_page = TopicPage.objects.get(slug="influenza") + + return [ + { + "type": "link", + "value": {"title": "COVID", "page": covid_page.id, "html_url": covid_page.full_url}, + "id": "d8e270c7-f3d7-41cf-8d7c-c2bbe62ed71d", + }, + { + "type": "link", + "value": { + "title": "What's new", + "page": flu_page.id, + "html_url": flu_page.full_url, + }, + "id": "021352b9-d606-48ee-b942-1739ccec9e03", + }, + ] diff --git a/cms/snippets/managers/menu.py b/cms/snippets/managers/menu.py index b00cc0096..cfaa48acb 100644 --- a/cms/snippets/managers/menu.py +++ b/cms/snippets/managers/menu.py @@ -61,3 +61,63 @@ def is_menu_overriding_currently_active_menu(self, menu) -> bool: active_menu = self.get_active_menu() return bool(menu.is_active and menu != active_menu) + + +class SimpleMenuQuerySet(models.QuerySet): + """Custom queryset which can be used by the `SimpleMenu`""" + + def get_active_menus(self) -> Self: + """Gets the all currently active `SimpleMenu`. + + Returns: + QuerySet: A queryset of the active banners: + Examples: + `]>` + """ + return self.filter(is_active=True) + + +class SimpleMenuManager(models.Manager): + """Custom model manager class for the `SimpleMenu` model""" + + def get_queryset(self) -> SimpleMenuQuerySet: + return SimpleMenuQuerySet(model=self.model, using=self.db) + + def has_active_menu(self) -> bool: + """Checks if there is already a `SimpleMenu` which is active + + Returns: + True if there is a `SimpleMenu` which has `is_active` set to True. + False otherwise. + + """ + return self.get_queryset().get_active_menus().exists() + + def get_active_menu(self): + """Gets the currently active `SimpleMenu`. + + Returns: + The currently active `SimpleMenu` if available. + If there is no `SimpleMenu` with `is_active` set to True, + then None is returned. + + """ + return self.get_queryset().get_active_menus().first() + + def is_menu_overriding_currently_active_menu(self, menu) -> bool: + """Determines if the given `menu` is trying to override an existing active `SimpleMenu` + + Args: + menu: The current `SimpleMenu` object which is being evaluated + + Returns: + True if the given `menu` is trying to override + an existing active `SimpleMenu`. False otherwise. + + """ + has_existing_active_menu: bool = self.has_active_menu() + if not has_existing_active_menu: + return False + + active_menu = self.get_active_menu() + return bool(menu.is_active and menu != active_menu) diff --git a/cms/snippets/migrations/0014_simplemenu.py b/cms/snippets/migrations/0014_simplemenu.py new file mode 100644 index 000000000..65cbbe956 --- /dev/null +++ b/cms/snippets/migrations/0014_simplemenu.py @@ -0,0 +1,72 @@ +# Generated by Django 5.2.11 on 2026-03-19 14:48 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("snippets", "0013_remove_geography_code_field_from_wha_button"), + ] + + operations = [ + migrations.CreateModel( + name="SimpleMenu", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "internal_label", + models.TextField( + help_text="\nA label to associate with this particular menu design.\nNote that this label is private / internal and is not used on the dashboard.\nThis is purely to help identify each of the constructed menu designs.\n" + ), + ), + ( + "is_active", + models.BooleanField( + default=False, + help_text="\nWhether to activate this menu. \nNote that only 1 menu can be active at a time.\nTo switch from 1 active menu to another, \nyou must deactivate the 1st menu and save it before activating and saving the 2nd menu.\n", + ), + ), + ( + "body", + wagtail.fields.StreamField( + [("link", 2)], + block_lookup={ + 0: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe title to display for this menu item.\nAs a general rule of thumb, the title length should be no longer than 60 characters.\n", + "required": True, + }, + ), + 1: ( + "wagtail.blocks.PageChooserBlock", + ("wagtailcore.Page",), + { + "on_delete": django.db.models.deletion.CASCADE, + "related_name": "+", + }, + ), + 2: ( + "wagtail.blocks.StructBlock", + [[("title", 0), ("page", 1)]], + {}, + ), + }, + help_text="\nThe menu is constructed from a grid system of rows and columns.\nThere can be any number of rows and columns.\nBut each column should have at least 1 link.\n", + ), + ), + ], + ), + ] diff --git a/cms/snippets/models/__init__.py b/cms/snippets/models/__init__.py index 8fc10feb8..476f6043a 100644 --- a/cms/snippets/models/__init__.py +++ b/cms/snippets/models/__init__.py @@ -2,4 +2,4 @@ from .external_button import ExternalButton, ExternalButtonTypes, ExternalButtonIcons from .wha_button import WeatherAlertButton, WeatherAlertButtonTypes from .global_banner import GlobalBanner -from .menu_builder.menu import Menu +from .menu_builder.menu import Menu, SimpleMenu diff --git a/cms/snippets/models/menu_builder/__init__.py b/cms/snippets/models/menu_builder/__init__.py index 6e28c8a22..ea847ad92 100644 --- a/cms/snippets/models/menu_builder/__init__.py +++ b/cms/snippets/models/menu_builder/__init__.py @@ -1 +1 @@ -from .menu import Menu +from .menu import Menu, SimpleMenu diff --git a/cms/snippets/models/menu_builder/help_texts.py b/cms/snippets/models/menu_builder/help_texts.py index ec82a9bd5..1bdaf01a6 100644 --- a/cms/snippets/models/menu_builder/help_texts.py +++ b/cms/snippets/models/menu_builder/help_texts.py @@ -45,3 +45,7 @@ Note that this label is private / internal and is not used on the dashboard. This is purely to help identify each of the constructed menu designs. """ + +SIMPLEMENU_BODY_TEXT = """ +Links to display in the menu. +""" diff --git a/cms/snippets/models/menu_builder/menu.py b/cms/snippets/models/menu_builder/menu.py index d65508370..166472e07 100644 --- a/cms/snippets/models/menu_builder/menu.py +++ b/cms/snippets/models/menu_builder/menu.py @@ -1,11 +1,13 @@ from django.core.exceptions import ValidationError from django.db import models +from wagtail import fields from wagtail.admin.panels.field_panel import FieldPanel from wagtail.snippets.models import register_snippet -from cms.snippets.managers.menu import MenuManager +from cms.snippets.managers.menu import MenuManager, SimpleMenuManager from cms.snippets.models.menu_builder import help_texts from cms.snippets.models.menu_builder.dynamic_content import ALLOWABLE_BODY_CONTENT +from cms.snippets.models.menu_builder.menu_link import SimpleMenuLink class MultipleMenusActiveError(ValidationError): @@ -39,3 +41,28 @@ def clean(self) -> None: def _raise_error_if_trying_to_enable_multiple_menus(self) -> None: if Menu.objects.is_menu_overriding_currently_active_menu(menu=self): raise MultipleMenusActiveError + + +@register_snippet +class SimpleMenu(models.Model): + internal_label = models.TextField(help_text=help_texts.MENU_INTERNAL_LABEL) + is_active = models.BooleanField(default=False, help_text=help_texts.MENU_IS_ACTIVE) + body = fields.StreamField( + block_types=[("link", SimpleMenuLink())], + use_json_field=True, + help_text=help_texts.SIMPLEMENU_BODY_TEXT, + ) + + objects = SimpleMenuManager() + + def __str__(self) -> str: + prefix = "Active" if self.is_active else "Inactive" + return f"({prefix}) - {self.internal_label}" + + def clean(self) -> None: + super().clean() + self._raise_error_if_trying_to_enable_multiple_menus() + + def _raise_error_if_trying_to_enable_multiple_menus(self) -> None: + if SimpleMenu.objects.is_menu_overriding_currently_active_menu(menu=self): + raise MultipleMenusActiveError diff --git a/cms/snippets/models/menu_builder/menu_link.py b/cms/snippets/models/menu_builder/menu_link.py index 83585d378..4348ab7fa 100644 --- a/cms/snippets/models/menu_builder/menu_link.py +++ b/cms/snippets/models/menu_builder/menu_link.py @@ -56,3 +56,39 @@ def get_prep_value(self, value: StructValue) -> dict[str, str | int]: prep_value["html_url"] = page.full_url return prep_value + + +class SimpleMenuLink(blocks.StructBlock): + title = blocks.TextBlock( + required=True, + help_text=help_texts.MENU_LINK_HELP_TEXT, + ) + page = blocks.PageChooserBlock( + "wagtailcore.Page", + related_name="+", + on_delete=models.CASCADE, + ) + + class Meta: + icon = "link" + + def get_prep_value(self, value: StructValue) -> dict[str, str | int]: + """Adds the `html_url` of each page to the returned value + + Args: + `value`: The inbound enriched `StructValue` + containing the values associated with + this `SimpleMenuLink` object + + Returns: + Dict containing the keys as dictated by the + `SimpleMenuLink`. With the addition of the injected + `html_url` value for the selected page. + + """ + prep_value: dict[str, str | int] = super().get_prep_value(value=value) + page: Page = value["page"] + page: type[UKHSAPage] = page.specific + prep_value["html_url"] = page.full_url + + return prep_value diff --git a/cms/snippets/serializers/__init__.py b/cms/snippets/serializers/__init__.py index 2e65d679f..59504dbbc 100644 --- a/cms/snippets/serializers/__init__.py +++ b/cms/snippets/serializers/__init__.py @@ -4,7 +4,12 @@ InternalButtonSerializer, ) from .global_banner import ( - GlobalBannerSerializer, GlobalBannerResponseSerializer, + GlobalBannerSerializer, +) +from .menu import ( + MenuResponseSerializer, + MenuSerializer, + SimpleMenuResponseSerializer, + SimpleMenuSerializer, ) -from .menu import MenuSerializer, MenuResponseSerializer diff --git a/cms/snippets/serializers/menu.py b/cms/snippets/serializers/menu.py index 1ff2d4742..b9fa1e200 100644 --- a/cms/snippets/serializers/menu.py +++ b/cms/snippets/serializers/menu.py @@ -2,7 +2,7 @@ from rest_framework import serializers from rest_framework.utils.serializer_helpers import ReturnDict -from cms.snippets.models import Menu +from cms.snippets.models import Menu, SimpleMenu class MenuResponseSerializer(serializers.ModelSerializer): @@ -41,3 +41,32 @@ def data(self) -> dict[str, ReturnDict[str, str] | None]: active_menu = self.menu_manager.get_active_menu() serializer = MenuResponseSerializer(instance=active_menu) return serializer.data + + +class SimpleMenuResponseSerializer(MenuResponseSerializer): + class Meta: + model = SimpleMenu + fields = ["body"] + + +class SimpleMenuSerializer(serializers.Serializer): + @property + def menu_manager(self) -> Manager: + return self.context.get("menu_manager", SimpleMenu.objects) + + @property + def data(self) -> dict[str, ReturnDict[str, str] | None]: + """Gets the body associated with the currently active menu. + + Args: + `menu_manager`: The `SimpleMenuManager` + used to query for records + + Returns: + Dict representation the of the active menu. + If no menu is active, then None is returned + + """ + active_menu = self.menu_manager.get_active_menu() + serializer = SimpleMenuResponseSerializer(instance=active_menu) + return serializer.data diff --git a/cms/snippets/views/__init__.py b/cms/snippets/views/__init__.py index 6a5f0131a..b076b1c7d 100644 --- a/cms/snippets/views/__init__.py +++ b/cms/snippets/views/__init__.py @@ -1,2 +1,2 @@ from .global_banner import GlobalBannerView -from .menu import MenuView +from .menu import MenuView, SimpleMenuView diff --git a/cms/snippets/views/menu.py b/cms/snippets/views/menu.py index 40251d1c5..2db0397f5 100644 --- a/cms/snippets/views/menu.py +++ b/cms/snippets/views/menu.py @@ -8,6 +8,8 @@ from cms.snippets.serializers import ( MenuResponseSerializer, MenuSerializer, + SimpleMenuResponseSerializer, + SimpleMenuSerializer, ) @@ -30,3 +32,26 @@ def get(cls, request, *args, **kwargs) -> Response: """ serializer = MenuSerializer() return Response(data=serializer.data, status=HTTPStatus.OK) + + +class SimpleMenuView(APIView): + permission_classes = [] + + @classmethod + @extend_schema( + tags=["cms"], responses={HTTPStatus.OK: SimpleMenuResponseSerializer} + ) + @cache_response() + def get(cls, request, *args, **kwargs) -> Response: + """ + This endpoint returns the state of the currently active `SimpleMenu` + + Note that if there is no active banner then the response will look like: + + ``` + {"active_menu": null} + ``` + + """ + serializer = SimpleMenuSerializer() + return Response(data=serializer.data, status=HTTPStatus.OK) diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index a2f831d33..09afbfb44 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -14,7 +14,7 @@ import config from cms.dashboard.views import LinkBrowseView from cms.dashboard.viewsets import CMSDraftPagesViewSet, CMSPagesAPIViewSet -from cms.snippets.views import GlobalBannerView, MenuView +from cms.snippets.views import GlobalBannerView, MenuView, SimpleMenuView from feedback.api.urls import construct_urlpatterns_for_feedback from metrics.api import enums from metrics.api.views import ( @@ -162,6 +162,7 @@ def construct_public_api_urlpatterns( path(API_PREFIX, cms_api_router.urls), path(f"{API_PREFIX}global-banners/v2", GlobalBannerView.as_view()), path(f"{API_PREFIX}menus/v1", MenuView.as_view()), + path(f"{API_PREFIX}menus/v2", SimpleMenuView.as_view()), path(f"{API_PREFIX}alerts/v1/heat", heat_alert_list, name="heat-alerts-list"), path( f"{API_PREFIX}alerts/v1/heat/", diff --git a/tests/factories/cms/snippets/menu.py b/tests/factories/cms/snippets/menu.py index 9da99fdd0..7f68a8dda 100644 --- a/tests/factories/cms/snippets/menu.py +++ b/tests/factories/cms/snippets/menu.py @@ -1,6 +1,6 @@ import factory -from cms.snippets.models.menu_builder.menu import Menu +from cms.snippets.models.menu_builder.menu import Menu, SimpleMenu class MenuFactory(factory.django.DjangoModelFactory): @@ -10,3 +10,12 @@ class MenuFactory(factory.django.DjangoModelFactory): class Meta: model = Menu + + +class SimpleMenuFactory(factory.django.DjangoModelFactory): + """ + Factory for creating `SimpleMenu` instances for tests + """ + + class Meta: + model = SimpleMenu diff --git a/tests/fakes/managers/cms/menu_manager.py b/tests/fakes/managers/cms/menu_manager.py index 84bd136d8..b20aa5b3e 100644 --- a/tests/fakes/managers/cms/menu_manager.py +++ b/tests/fakes/managers/cms/menu_manager.py @@ -1,5 +1,5 @@ -from cms.snippets.managers.menu import MenuManager -from cms.snippets.models.menu_builder import Menu +from cms.snippets.managers.menu import MenuManager, SimpleMenuManager +from cms.snippets.models.menu_builder import Menu, SimpleMenu class FakeMenuManager(MenuManager): @@ -17,3 +17,20 @@ def get_active_menu(self) -> Menu | None: return next(menu for menu in self.menus if menu.is_active is True) except StopIteration: return None + + +class FakeSimpleMenuManager(SimpleMenuManager): + """ + A fake version of the `SimpleMenuManager` which allows the methods and properties + to be overriden to allow the database to be abstracted away. + """ + + def __init__(self, menus: list[SimpleMenu], **kwargs): + self.menus = menus + super().__init__(**kwargs) + + def get_active_menu(self) -> Menu | None: + try: + return next(menu for menu in self.menus if menu.is_active is True) + except StopIteration: + return None diff --git a/tests/integration/cms/snippets/managers/test_menu.py b/tests/integration/cms/snippets/managers/test_menu.py index c709c8b7f..645feebf9 100644 --- a/tests/integration/cms/snippets/managers/test_menu.py +++ b/tests/integration/cms/snippets/managers/test_menu.py @@ -1,7 +1,7 @@ import pytest -from cms.snippets.models.menu_builder import Menu -from tests.factories.cms.snippets.menu import MenuFactory +from cms.snippets.models.menu_builder import Menu, SimpleMenu +from tests.factories.cms.snippets.menu import MenuFactory, SimpleMenuFactory class TestMenuManager: @@ -42,3 +42,43 @@ def test_get_active_menu(self): # Then assert retrieved_menu == active_menu != inactive_menu + + +class TestSimpleMenuManager: + @pytest.mark.django_db + def test_has_active_menu(self): + """ + Given a number of `SimpleMenu` records + of which 1 has `is_active` set to True + When `has_active_menu()` is called + from the `SimpleMenuManager` + Then True is returned + """ + # Given + SimpleMenuFactory.create(is_active=True) + SimpleMenuFactory.create(is_active=False) + + # When + has_active_menu: bool = SimpleMenu.objects.has_active_menu() + + # Then + assert has_active_menu is True + + @pytest.mark.django_db + def test_get_active_menu(self): + """ + Given a number of `SimpleMenu` records + of which 1 has `is_active` set to True + When `get_active_menu()` is called + from the `SimpleMenuManager` + Then the correct `SimpleMenu` record is returned + """ + # Given + active_menu = SimpleMenuFactory.create(is_active=True) + inactive_menu = SimpleMenuFactory.create(is_active=False) + + # When + retrieved_menu: bool = SimpleMenu.objects.get_active_menu() + + # Then + assert retrieved_menu == active_menu != inactive_menu diff --git a/tests/integration/cms/snippets/views/test_menu.py b/tests/integration/cms/snippets/views/test_menu.py index 5714f1d84..06fd14673 100644 --- a/tests/integration/cms/snippets/views/test_menu.py +++ b/tests/integration/cms/snippets/views/test_menu.py @@ -4,7 +4,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient -from tests.factories.cms.snippets.menu import MenuFactory +from tests.factories.cms.snippets.menu import MenuFactory, SimpleMenuFactory class TestMenuView: @@ -68,3 +68,60 @@ def test_get_request_returns_correct_data(self): == active_menu_body != inactive_menu.body.get_prep_value() ) + + +class TestSimpleMenuView: + @property + def path(self) -> str: + return "/api/menus/v2" + + @pytest.mark.django_db + def test_get_request_returns_correct_data(self): + """ + Given an active `SimpleMenu` record + When a GET request is made to the `/api/menus/v2` endpoint + Then the response is a valid HTTP OK with the correct data + """ + # Given + client = APIClient() + active_menu_body = [ + { + "type": "link", + "value": { + "title": "What's coming", + "page": 3, + "html_url": "https://my-prefix.dev.ukhsa-dashboard.data.gov.uk/whats-coming/", + }, + "id": "d8e270c7-f3d7-41cf-8d7c-c2bbe62ed71d", + }, + { + "type": "link", + "value": { + "title": "What's new", + "page": 14, + "html_url": "https://my-prefix.dev.ukhsa-dashboard.data.gov.uk/whats-new/", + }, + "id": "021352b9-d606-48ee-b942-1739ccec9e03", + }, + ] + SimpleMenuFactory.create( + body=active_menu_body, is_active=True, internal_label="Test simple design" + ) + inactive_menu = SimpleMenuFactory.create( + body=[], is_active=False, internal_label="Test simple design" + ) + + # When + response: Response = client.get( + path=self.path, + format="json", + headers={"Cache-Force-Refresh": True}, + ) + + # Then + assert response.status_code == HTTPStatus.OK + assert ( + response.data["active_menu"] + == active_menu_body + != inactive_menu.body.get_prep_value() + ) diff --git a/tests/unit/cms/snippets/managers/test_menu.py b/tests/unit/cms/snippets/managers/test_menu.py index d76eb6590..f546b6543 100644 --- a/tests/unit/cms/snippets/managers/test_menu.py +++ b/tests/unit/cms/snippets/managers/test_menu.py @@ -1,7 +1,9 @@ +import pytest + from unittest import mock -from cms.snippets.managers.menu import MenuManager -from cms.snippets.models import Menu +from cms.snippets.managers.menu import MenuManager, SimpleMenuManager +from cms.snippets.models import Menu, SimpleMenu class TestMenuManager: @@ -111,3 +113,113 @@ def test_is_menu_overriding_currently_active_menu_returns_true_when_active_menu_ # Then assert menu_is_overriding is True + + +@pytest.mark.django_db +class TestSimpleMenuManager: + @mock.patch.object(SimpleMenuManager, "has_active_menu") + def test_is_menu_overriding_currently_active_menu_returns_false_for_no_active_menu( + self, mocked_has_active_menu: mock.MagicMock + ): + """ + Given the `has_active_menu()` method returns False + When `is_menu_overriding_currently_active_menu()` is called + from the `SimpleMenuManager` + Then False is returned + """ + # Given + mocked_has_active_menu.return_value = False + menu_manager = SimpleMenu.objects + + # When + menu_is_overriding: bool = ( + menu_manager.is_menu_overriding_currently_active_menu(menu=mock.Mock()) + ) + + # Then + assert menu_is_overriding is False + + @mock.patch.object(SimpleMenuManager, "has_active_menu") + @mock.patch.object(SimpleMenuManager, "get_active_menu") + def test_is_menu_overriding_currently_active_menu_returns_false_for_new_inactive_menu( + self, + mocked_get_active_menu: mock.MagicMock, + mocked_has_active_menu: mock.MagicMock, + ): + """ + Given the `has_active_menu()` method returns True + And a new `SimpleMenu` object which is inactive + When `is_menu_overriding_currently_active_menu()` is called + from the `SimpleMenuManager` + Then False is returned + """ + # Given + mocked_get_active_menu.return_value = mock.Mock() + mocked_has_active_menu.return_value = True + menu_manager = SimpleMenu.objects + mocked_new_menu = mock.Mock(is_active=False) + + # When + menu_is_overriding: bool = ( + menu_manager.is_menu_overriding_currently_active_menu(menu=mocked_new_menu) + ) + + # Then + assert menu_is_overriding is False + + @mock.patch.object(SimpleMenuManager, "has_active_menu") + @mock.patch.object(SimpleMenuManager, "get_active_menu") + def test_is_menu_overriding_currently_active_menu_returns_false_when_active_menu_is_being_updated( + self, + mocked_get_active_menu: mock.MagicMock, + mocked_has_active_menu: mock.MagicMock, + ): + """ + Given the `has_active_menu()` method returns True + And that same `SimpleMenu` object which is just being updated + When `is_menu_overriding_currently_active_menu()` is called + from the `SimpleMenuManager` + Then False is returned + """ + # Given + mocked_has_active_menu.return_value = True + mocked_menu = mock.Mock(is_active=True) + mocked_get_active_menu.return_value = mocked_menu + menu_manager = SimpleMenu.objects + + # When + menu_is_overriding: bool = ( + menu_manager.is_menu_overriding_currently_active_menu(menu=mocked_menu) + ) + + # Then + assert menu_is_overriding is False + + @mock.patch.object(SimpleMenuManager, "has_active_menu") + @mock.patch.object(SimpleMenuManager, "get_active_menu") + def test_is_menu_overriding_currently_active_menu_returns_true_when_active_menu_is_being_overriden( + self, + mocked_get_active_menu: mock.MagicMock, + mocked_has_active_menu: mock.MagicMock, + ): + """ + Given the `has_active_menu()` method returns True + And a new `SimpleMenu` object which is active + When `is_menu_overriding_currently_active_menu()` is called + from the `SimpleMenuManager` + Then True is returned + """ + # Given + mocked_has_active_menu.return_value = True + mocked_existing_active_menu = mock.Mock(is_active=True) + mocked_get_active_menu.return_value = mocked_existing_active_menu + new_mocked_menu = mock.Mock(is_active=True) + menu_manager = SimpleMenu.objects + + # When + menu_is_overriding: bool = ( + menu_manager.is_menu_overriding_currently_active_menu(menu=new_mocked_menu) + ) + + # Then + assert menu_is_overriding is True diff --git a/tests/unit/cms/snippets/models/menu_builder/test_menu.py b/tests/unit/cms/snippets/models/menu_builder/test_menu.py index dca43122e..373cf3a95 100644 --- a/tests/unit/cms/snippets/models/menu_builder/test_menu.py +++ b/tests/unit/cms/snippets/models/menu_builder/test_menu.py @@ -2,8 +2,12 @@ import pytest -from cms.snippets.managers.menu import MenuManager -from cms.snippets.models.menu_builder.menu import Menu, MultipleMenusActiveError +from cms.snippets.managers.menu import MenuManager, SimpleMenuManager +from cms.snippets.models.menu_builder.menu import ( + Menu, + MultipleMenusActiveError, + SimpleMenu, +) class TestMenu: @@ -142,3 +146,111 @@ def test_clean_passes_when_current_menu_is_menu_overriding_currently_active_menu # When / Then menu.clean() + + +class TestSimpleMenu: + def test_enabled_set_false_by_default(self): + """ + Given a `SimpleMenu` model + When the object is initialized + Then the `is_active` field is set to False by default + """ + # Given + internal_label = "abc" + body = {} + + # When + menu = SimpleMenu( + internal_label=internal_label, + body=body, + ) + + # Then + assert menu.is_active is False + + def test_menu_dunder_str_references_internal_label(self): + """ + Given a `SimpleMenu` model + which has been given an `internal_label` of True + When the string representation is produced + Then the string references the `internal_label` + """ + # Given + internal_label = "abc" + is_active = True + body = {} + + # When + menu = SimpleMenu( + internal_label=internal_label, + body=body, + is_active=is_active, + ) + + # Then + assert str(menu) == f"(Active) - {internal_label}" + + def test_inactive_menu_produces_correct_dunder_str(self): + """ + Given a `SimpleMenu` model + which has been given an `internal_label` of False + When the string representation is produced + Then the string references the `internal_label` + """ + # Given + internal_label = "abc" + body = {} + is_active = False + + # When + menu = SimpleMenu( + internal_label=internal_label, + body=body, + is_active=is_active, + ) + + # Then + assert str(menu) == f"(Inactive) - {internal_label}" + + @mock.patch.object(SimpleMenuManager, "is_menu_overriding_currently_active_menu") + def test_clean_raises_error_is_menu_overriding_currently_active_menu_returns_true( + self, mocked_is_menu_overriding_currently_active_menu: mock.MagicMock + ): + """ + Given the `is_menu_overriding_currently_active_menu()` call + from the `SimpleMenuManager` returns True + When the `clean()` method is called from the `SimpleMenu` + Then the `MultipleMenusActiveError` is raised + """ + # Given + mocked_is_menu_overriding_currently_active_menu.return_value = True + menu = SimpleMenu( + internal_label="abc", + body={}, + is_active=True, + ) + + # When / Then + with pytest.raises(MultipleMenusActiveError): + menu.clean() + + @mock.patch.object(SimpleMenuManager, "is_menu_overriding_currently_active_menu") + def test_clean_passes_when_current_menu_is_menu_overriding_currently_active_menu_returns_false( + self, mocked_is_menu_overriding_currently_active_menu: mock.MagicMock + ): + """ + Given the `is_menu_overriding_currently_active_menu()` call + from the `SimpleMenuManager` returns False + When the `clean()` method is called from the `SimpleMenu` + Then no error is raised + """ + # Given + mocked_is_menu_overriding_currently_active_menu.return_value = False + menu = SimpleMenu( + internal_label="abc", + body={}, + is_active=False, + ) + + # When / Then + menu.clean() diff --git a/tests/unit/cms/snippets/models/menu_builder/test_menu_link.py b/tests/unit/cms/snippets/models/menu_builder/test_menu_link.py index 1d07851d3..e67409961 100644 --- a/tests/unit/cms/snippets/models/menu_builder/test_menu_link.py +++ b/tests/unit/cms/snippets/models/menu_builder/test_menu_link.py @@ -2,7 +2,7 @@ from wagtail.blocks.struct_block import StructBlock -from cms.snippets.models.menu_builder.menu_link import MenuLink +from cms.snippets.models.menu_builder.menu_link import MenuLink, SimpleMenuLink class TestMenuLink: @@ -31,3 +31,33 @@ def test_get_prep_value_includes_page_full_url( assert prep_value["body"] == block["body"] assert prep_value["page"] == block["page"] assert prep_value["html_url"] == mocked_page.specific.full_url + + +class TestSimpleMenuLink: + @mock.patch.object(StructBlock, "get_prep_value") + def test_get_prep_value_includes_page_full_url( + self, mocked_get_prep_value: mock.MagicMock + ): + """ + Given a block containing a page + When `get_prep_value()` is called from + an instance of `SimpleMenuLink` + Then the `full_url` property is called from + the specific page type associated with the page + """ + # Given + mocked_page = mock.Mock() + block = {"title": "ABC", "body": mock.Mock(), "page": mocked_page} + mocked_get_prep_value.return_value = block + menu_link = SimpleMenuLink( + title=block["title"], body=block["body"], page=mocked_page + ) + + # When + prep_value = menu_link.get_prep_value(value=block) + + # Then + assert prep_value["title"] == block["title"] + assert prep_value["body"] == block["body"] + assert prep_value["page"] == block["page"] + assert prep_value["html_url"] == mocked_page.specific.full_url diff --git a/tests/unit/cms/snippets/serializers/test_menu.py b/tests/unit/cms/snippets/serializers/test_menu.py index ace31e432..34c472c11 100644 --- a/tests/unit/cms/snippets/serializers/test_menu.py +++ b/tests/unit/cms/snippets/serializers/test_menu.py @@ -1,9 +1,11 @@ -from cms.snippets.models.menu_builder import Menu +from cms.snippets.models.menu_builder import Menu, SimpleMenu from cms.snippets.serializers import ( MenuResponseSerializer, MenuSerializer, + SimpleMenuSerializer, + SimpleMenuResponseSerializer, ) -from tests.fakes.managers.cms.menu_manager import FakeMenuManager +from tests.fakes.managers.cms.menu_manager import FakeMenuManager, FakeSimpleMenuManager class TestMenuResponseSerializer: @@ -59,3 +61,58 @@ def test_serialized_data_for_active_menu(self): # Then expected_data = {"active_menu": fake_body} assert serializer.data == expected_data + + +class TestSimpleMenuResponseSerializer: + def test_serializes_model_correctly(self): + """ + Given a `SimpleMenu` model instance + When the model is passed to a `MenuResponseSerializer` + Then the output `data` contains the correct fields + """ + # Given + fake_body = [] + menu = SimpleMenu(internal_label="abc", is_active=True, body=fake_body) + + # When + serializer = SimpleMenuResponseSerializer(instance=menu) + + # Then + expected_data = {"active_menu": fake_body} + assert serializer.data == expected_data + + def test_data_returns_none_if_no_model_instance_is_provided(self): + """ + Given no `SimpleMenu` model instance is provided + When this is passed to a `SimpleMenuResponseSerializer` + Then the output `data` returns None + """ + # Given / When + serializer = SimpleMenuResponseSerializer(instance=None) + + # Then + assert serializer.data == {"active_menu": None} + + +class TestSimpleMenuSerializer: + def test_serialized_data_for_active_menu(self): + """ + Given a `SimpleMenu` model instance which is active + And an inactive `SimpleMenu` model instance + When `data` is called from an instance + of the `SimpleMenuSerializer` + Then the output `data` contains info + about the currently active menu + """ + # Given + fake_body = [] + active_menu = SimpleMenu(internal_label="abc", is_active=True, body=fake_body) + inactive_menu = SimpleMenu(internal_label="abc", is_active=False) + fake_menu_manager = FakeSimpleMenuManager(menus=[active_menu, inactive_menu]) + + # When + serializer = SimpleMenuSerializer(context={"menu_manager": fake_menu_manager}) + + # Then + expected_data = {"active_menu": fake_body} + assert serializer.data == expected_data From 685ab946abedcdfea880ef8ac9603d812be16c95 Mon Sep 17 00:00:00 2001 From: David Logie Date: Tue, 7 Apr 2026 11:49:24 +0100 Subject: [PATCH 125/186] CDD-3119 Add panels attribute to SimpleMenu model. --- cms/snippets/models/menu_builder/menu.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cms/snippets/models/menu_builder/menu.py b/cms/snippets/models/menu_builder/menu.py index 166472e07..3999dad59 100644 --- a/cms/snippets/models/menu_builder/menu.py +++ b/cms/snippets/models/menu_builder/menu.py @@ -53,6 +53,12 @@ class SimpleMenu(models.Model): help_text=help_texts.SIMPLEMENU_BODY_TEXT, ) + panels = [ + FieldPanel("internal_label"), + FieldPanel("is_active"), + FieldPanel("body"), + ] + objects = SimpleMenuManager() def __str__(self) -> str: From b10c77be31b5f398835e42c966008b34b75fa4cf Mon Sep 17 00:00:00 2001 From: David Logie Date: Thu, 9 Apr 2026 09:23:32 +0100 Subject: [PATCH 126/186] CDD-3119 Beef up the SimpleMenu serializer tests. --- .../cms/snippets/serializers/test_menu.py | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/unit/cms/snippets/serializers/test_menu.py b/tests/unit/cms/snippets/serializers/test_menu.py index 34c472c11..0d9090c26 100644 --- a/tests/unit/cms/snippets/serializers/test_menu.py +++ b/tests/unit/cms/snippets/serializers/test_menu.py @@ -2,8 +2,8 @@ from cms.snippets.serializers import ( MenuResponseSerializer, MenuSerializer, - SimpleMenuSerializer, SimpleMenuResponseSerializer, + SimpleMenuSerializer, ) from tests.fakes.managers.cms.menu_manager import FakeMenuManager, FakeSimpleMenuManager @@ -71,7 +71,17 @@ def test_serializes_model_correctly(self): Then the output `data` contains the correct fields """ # Given - fake_body = [] + fake_body = [ + { + "type": "link", + "value": { + "title": "Test link", + "page": 14, + "html_url": "https://localhost/whats-new/", + }, + "id": "93509312-316b-4de8-936b-e7ac57d0aee1", + } + ] menu = SimpleMenu(internal_label="abc", is_active=True, body=fake_body) # When @@ -105,7 +115,17 @@ def test_serialized_data_for_active_menu(self): about the currently active menu """ # Given - fake_body = [] + fake_body = [ + { + "type": "link", + "value": { + "title": "Test link", + "page": 14, + "html_url": "https://localhost/whats-new/", + }, + "id": "93509312-316b-4de8-936b-e7ac57d0aee1", + } + ] active_menu = SimpleMenu(internal_label="abc", is_active=True, body=fake_body) inactive_menu = SimpleMenu(internal_label="abc", is_active=False) fake_menu_manager = FakeSimpleMenuManager(menus=[active_menu, inactive_menu]) From 5fefbfe99e50038a47117843ec49c342c5de3d7b Mon Sep 17 00:00:00 2001 From: David Logie Date: Mon, 13 Apr 2026 16:27:16 +0100 Subject: [PATCH 127/186] CDD-3232 Update chart response styles. Bar and line charts now have borders and grid lines on both axis. --- .../charts/chart_settings/single_category.py | 16 ++++++++++++--- .../domain/charts/bar/test_generation.py | 5 +++-- .../line_multi_coloured/test_generation.py | 2 +- .../test_chart_settings_single_category.py | 20 +++++++++++++++---- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/metrics/domain/charts/chart_settings/single_category.py b/metrics/domain/charts/chart_settings/single_category.py index 2979e5a82..75fca671b 100644 --- a/metrics/domain/charts/chart_settings/single_category.py +++ b/metrics/domain/charts/chart_settings/single_category.py @@ -43,10 +43,11 @@ def _get_x_axis_config(self) -> dict[str, str | bool | DICT_OF_STR_ONLY]: "spikemode": "toaxis+across+marker", "spikesnap": "cursor", "showgrid": True, - "showline": False, + "showline": True, "zeroline": False, "fixedrange": True, "gridcolor": "rgba(0,0,0,0.05)", + "linecolor": "rgba(0,0,0,0.05)", "ticks": "outside", "tickson": "boundaries", "type": "date", @@ -55,6 +56,7 @@ def _get_x_axis_config(self) -> dict[str, str | bool | DICT_OF_STR_ONLY]: "tickformat": "%b %Y", "tickfont": self._get_tick_font_config(), "autotickangles": [0, 90], + "mirror": True, } if self._chart_generation_payload.x_axis_title: @@ -82,10 +84,13 @@ def _get_y_axis_config(self) -> dict[str, bool | DICT_OF_STR_ONLY]: {"dtickrange": [1000, 99999], "value": ",.0f"}, {"dtickrange": [100000, None], "value": ".0s"}, ], - "showgrid": False, + "showgrid": True, "showticklabels": True, + "showline": True, "fixedrange": True, - "gridcolor": "#000", + "gridcolor": "rgba(0,0,0,0.05)", + "linecolor": "rgba(0,0,0,0.05)", + "mirror": True, "tickfont": tick_font, "rangemode": "tozero", } @@ -366,6 +371,8 @@ def get_line_single_simplified_chart_config(self): # x_axis config chart_config["xaxis"]["showgrid"] = False + chart_config["xaxis"]["showline"] = False + chart_config["xaxis"]["mirror"] = False chart_config["xaxis"]["ticks"] = "outside" chart_config["xaxis"]["tickvals"] = axis_params["x_axis_tick_values"] chart_config["xaxis"]["ticktext"] = axis_params["x_axis_tick_text"] @@ -375,6 +382,9 @@ def get_line_single_simplified_chart_config(self): ] = colour_scheme.RGBAColours.LS_DARK_GREY.stringified # y_axis config + chart_config["yaxis"]["showgrid"] = False + chart_config["yaxis"]["showline"] = False + chart_config["yaxis"]["mirror"] = False chart_config["yaxis"]["zeroline"] = False chart_config["yaxis"]["ticks"] = "outside" chart_config["yaxis"]["tickvals"] = axis_params["y_axis_tick_values"] diff --git a/tests/integration/metrics/domain/charts/bar/test_generation.py b/tests/integration/metrics/domain/charts/bar/test_generation.py index 88b4af449..7c5291029 100644 --- a/tests/integration/metrics/domain/charts/bar/test_generation.py +++ b/tests/integration/metrics/domain/charts/bar/test_generation.py @@ -135,7 +135,7 @@ def test_main_bar_plot(self, fake_plot_data: PlotGenerationData): assert x_axis.showgrid assert not x_axis.zeroline - assert not x_axis.showline + assert x_axis.showline # Tick marks should be on the boundary drawn going outwards of the main frame assert x_axis.ticks == "outside" @@ -152,8 +152,9 @@ def test_main_bar_plot(self, fake_plot_data: PlotGenerationData): # ---Y Axis checks--- y_axis = figure.layout.yaxis - assert not y_axis.showgrid + assert y_axis.showgrid assert y_axis.showticklabels + assert y_axis.showline def test_confidence_intervals_all_data(self, fake_plot_data: PlotGenerationData): """ 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 02f2754c8..534ac466a 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 @@ -116,7 +116,7 @@ def test_main_plot_and_axis_properties(self): # ---Y Axis checks--- y_axis = main_layout.yaxis - assert not y_axis.showgrid + assert y_axis.showgrid def test_x_axis_type_is_not_date(self): """ diff --git a/tests/unit/metrics/domain/charts/chart_settings/test_chart_settings_single_category.py b/tests/unit/metrics/domain/charts/chart_settings/test_chart_settings_single_category.py index c36a204ad..9f9ed7ff8 100644 --- a/tests/unit/metrics/domain/charts/chart_settings/test_chart_settings_single_category.py +++ b/tests/unit/metrics/domain/charts/chart_settings/test_chart_settings_single_category.py @@ -76,11 +76,13 @@ def test_get_x_axes_setting_for_date_based_x_axis( "spikethickness": 1, "spikemode": "toaxis+across+marker", "spikesnap": "cursor", - "showline": False, + "showline": True, "fixedrange": True, "gridcolor": "rgba(0,0,0,0.05)", + "linecolor": "rgba(0,0,0,0.05)", "tickcolor": "rgba(0,0,0,0)", "showgrid": True, + "mirror": True, "zeroline": False, "ticks": "outside", "tickson": "boundaries", @@ -127,11 +129,13 @@ def test_get_x_axes_setting_for_text_based_x_axis( "spikethickness": 1, "spikemode": "toaxis+across+marker", "spikesnap": "cursor", - "showline": False, + "showline": True, "showgrid": True, "zeroline": False, "fixedrange": True, + "mirror": True, "gridcolor": "rgba(0,0,0,0.05)", + "linecolor": "rgba(0,0,0,0.05)", "tickcolor": "rgba(0,0,0,0)", "ticks": "outside", "tickson": "boundaries", @@ -162,10 +166,13 @@ def test_get_y_axes_setting(self, fake_chart_settings: SingleCategoryChartSettin # Then expected_y_axis_config = { "rangemode": "tozero", - "showgrid": False, + "showgrid": True, + "showline": True, + "linecolor": "rgba(0,0,0,0.05)", + "mirror": True, "showticklabels": True, "fixedrange": True, - "gridcolor": "#000", + "gridcolor": "rgba(0,0,0,0.05)", "ticks": "outside", "tickson": "boundaries", "tickformatstops": [ @@ -690,6 +697,8 @@ def test_get_line_single_simplified_chart_config( # x_axis settings expected_chart_config["xaxis"]["showgrid"] = False + expected_chart_config["xaxis"]["showline"] = False + expected_chart_config["xaxis"]["mirror"] = False expected_chart_config["xaxis"]["tickvals"] = fake_x_axis_tick_values expected_chart_config["xaxis"]["ticktext"] = fake_x_axis_tick_text expected_chart_config["xaxis"]["ticklen"] = 0 @@ -698,6 +707,9 @@ def test_get_line_single_simplified_chart_config( ] = colour_scheme.RGBAColours.LS_DARK_GREY.stringified # y_axis settings + expected_chart_config["yaxis"]["showgrid"] = False + expected_chart_config["yaxis"]["showline"] = False + expected_chart_config["yaxis"]["mirror"] = False expected_chart_config["yaxis"]["ticks"] = "outside" expected_chart_config["yaxis"]["zeroline"] = False expected_chart_config["yaxis"]["tickvals"] = fake_y_axis_tick_values From f34051ee27c97879f4a9baa33b5909493d05541e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:44:46 +0000 Subject: [PATCH 128/186] 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] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index eb501cf2a..af1849d31 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,7 +13,7 @@ isort==8.0.1 pip-api==0.0.34 pysentry-rs==0.4.5 pip-requirements-parser==32.0.1 -pre-commit==4.5.1 +pre-commit==4.6.0 pylint==4.0.5 pylint-django==2.7.0 pytest==9.0.3 From 8a168738217f1fa19787c13de15fb82ccff45aff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:05:54 +0000 Subject: [PATCH 129/186] 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] --- requirements-prod.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-prod.txt b/requirements-prod.txt index a54284bcb..59f002e94 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -31,7 +31,7 @@ grimp==3.14 gunicorn==25.3.0 html5lib==1.1 identify==2.6.19 -idna==3.11 +idna==3.12 importlib-metadata==9.0.0 inflection==0.5.1 iniconfig==2.3.0 From a613fbf0cf1b9474c5c1e7cf9dc6536a4e376b8e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:14:23 +0000 Subject: [PATCH 130/186] 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] --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index af1849d31..09656f760 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,7 +7,7 @@ factory-boy==3.3.3 freezegun==1.5.5 Faker==40.15.0 gitdb==4.0.12 -GitPython==3.1.46 +GitPython==3.1.47 import-linter==2.11 isort==8.0.1 pip-api==0.0.34 From f637ec3a4af2f256e7f3eb0a27467fb408ad14a7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 06:17:31 +0000 Subject: [PATCH 131/186] 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] --- requirements-prod.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-prod.txt b/requirements-prod.txt index 59f002e94..8108071c7 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -6,7 +6,7 @@ CacheControl==0.14.4 certifi==2026.2.25 cfgv==3.5.0 charset-normalizer==3.4.7 -click==8.3.2 +click==8.3.3 colorama==0.4.6 coreapi==2.3.3 coreschema==0.0.4 From 7342f337243fe69f35889755df21758371fb9e7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:14:51 +0000 Subject: [PATCH 132/186] 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] --- requirements-prod-ingestion.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-prod-ingestion.txt b/requirements-prod-ingestion.txt index b5ad94a47..d5e102df4 100644 --- a/requirements-prod-ingestion.txt +++ b/requirements-prod-ingestion.txt @@ -2,7 +2,7 @@ asgiref==3.11.1 boto3==1.34.68 botocore==1.34.109 Django==5.2.13 -psycopg2-binary==2.9.10 +psycopg2-binary==2.9.12 pydantic==2.13.2 python-dotenv==1.2.2 sqlparse==0.5.5 From 80f8a7ee0e11e098ab38ac0612d0aa544b4d869b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:24:29 +0000 Subject: [PATCH 133/186] 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] --- requirements-prod-ingestion.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-prod-ingestion.txt b/requirements-prod-ingestion.txt index d5e102df4..f59413978 100644 --- a/requirements-prod-ingestion.txt +++ b/requirements-prod-ingestion.txt @@ -3,6 +3,6 @@ boto3==1.34.68 botocore==1.34.109 Django==5.2.13 psycopg2-binary==2.9.12 -pydantic==2.13.2 +pydantic==2.13.3 python-dotenv==1.2.2 sqlparse==0.5.5 From 8e3cef0f9bdfdb40480fc0754a5b0708999dcd73 Mon Sep 17 00:00:00 2001 From: Josh Humphries Date: Tue, 28 Apr 2026 16:20:03 +0100 Subject: [PATCH 134/186] 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... --- requirements-prod.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements-prod.txt b/requirements-prod.txt index 8108071c7..2725de6d5 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -74,7 +74,6 @@ ruamel.yaml.clib==0.2.15 SQLAlchemy==2.0.49 scour==0.38.2 setuptools==82.0.1 -simplejson==3.20.2 six==1.17.0 sortedcontainers==2.4.0 soupsieve==2.8.3 From de89d0b66abdfa280de0cc78b4c2952698d0f4f5 Mon Sep 17 00:00:00 2001 From: Taiwo Kareem <13158672+tushortz@users.noreply.github.com> Date: Wed, 29 Apr 2026 10:25:23 +0100 Subject: [PATCH 135/186] CDD-3313: Add topic page link to headline metrics card (#3151) --- cms/dynamic_content/blocks.py | 23 +- ...r_landingpage_body_headline_metric_card.py | 1138 +++++++++++++++++ ...opicslistpage_body_headline_metric_card.py | 1138 +++++++++++++++++ .../test_popular_topics_card.py | 7 +- 4 files changed, 2296 insertions(+), 10 deletions(-) create mode 100644 cms/home/migrations/0034_alter_landingpage_body_headline_metric_card.py create mode 100644 cms/topics_list/migrations/0007_alter_topicslistpage_body_headline_metric_card.py diff --git a/cms/dynamic_content/blocks.py b/cms/dynamic_content/blocks.py index a37e1f2b0..bc71a71ef 100644 --- a/cms/dynamic_content/blocks.py +++ b/cms/dynamic_content/blocks.py @@ -20,6 +20,15 @@ METRIC_NUMBER_BLOCK_DATE_PREFIX_DEFAULT_TEXT = "Up to" +class PageLinkChooserBlock(blocks.PageChooserBlock): + @classmethod + def get_api_representation(cls, value, context=None) -> str | None: + if value: + return value.full_url + + return None + + class HeadlineNumberBlockTypes(blocks.StreamBlock): headline_number = HeadlineNumberComponent(help_text=help_texts.HEADLINE_BLOCK_FIELD) trend_number = TrendNumberComponent(help_text=help_texts.TREND_BLOCK_FIELD) @@ -64,6 +73,11 @@ class PopularTopicsMetricNumberBlockTypes(blocks.StructBlock): default=METRIC_NUMBER_BLOCK_DATE_PREFIX_DEFAULT_TEXT, help_text=help_texts.HEADLINE_DATE_PREFIX, ) + topic_page = PageLinkChooserBlock( + page_type="topic.TopicPage", + required=True, + help_text=help_texts.TOPIC_PAGE_FIELD, + ) headline_metrics = PopularTopicsHeadlineNumberBlockTypes( required=True, min_num=POPULAR_TOPICS_HEADLINE_NUMBER_BLOCK_COUNT, @@ -170,15 +184,6 @@ def get_api_representation(cls, value, context=None) -> dict | None: return None -class PageLinkChooserBlock(blocks.PageChooserBlock): - @classmethod - def get_api_representation(cls, value, context=None) -> str | None: - if value: - return value.full_url - - return None - - class PageLink(blocks.StructBlock): title = blocks.CharBlock( required=True, diff --git a/cms/home/migrations/0034_alter_landingpage_body_headline_metric_card.py b/cms/home/migrations/0034_alter_landingpage_body_headline_metric_card.py new file mode 100644 index 000000000..7d43206a1 --- /dev/null +++ b/cms/home/migrations/0034_alter_landingpage_body_headline_metric_card.py @@ -0,0 +1,1138 @@ +# Generated by Django 5.2.13 on 2026-04-23 11:19 + +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", "0033_alter_landingpage_body_popular_topics"), + ] + + operations = [ + migrations.AlterField( + model_name="landingpage", + name="body", + field=wagtail.fields.StreamField( + [("section", 108)], + 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_topic_names, + "help_text": "The name of the topic to pull data e.g. COVID-19.", + }, + ), + 22: ( + "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', + }, + ), + 23: ( + "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", + }, + ), + 24: ( + "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", + }, + ), + 25: ( + "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", + }, + ), + 26: ( + "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", + }, + ), + 27: ( + "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", + }, + ), + 28: ( + "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", + }, + ), + 29: ( + "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, + }, + ), + 30: ( + "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, + }, + ), + 31: ( + "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, + }, + ), + 32: ( + "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', + }, + ), + 33: ( + "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, + }, + ), + 34: ( + "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, + }, + ), + 35: ( + "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, + }, + ), + 36: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 22), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("chart_type", 28), + ("date_from", 29), + ("date_to", 30), + ("label", 31), + ("line_colour", 32), + ("line_type", 33), + ("use_markers", 34), + ("use_smooth_lines", 35), + ] + ], + {}, + ), + 37: ( + "wagtail.blocks.StreamBlock", + [[("plot", 36)]], + { + "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" + }, + ), + 38: ( + "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", 37), + ] + ], + {}, + ), + 39: ( + "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, + }, + ), + 40: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nRequired description for the chart.\n", + "required": True, + }, + ), + 41: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nDisplay text for the link.\n", + "required": False, + }, + ), + 42: ( + "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, + }, + ), + 43: ( + "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], + }, + ), + 44: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 41), + ("page", 42), + ("external_url", 43), + ] + ], + { + "help_text": "\nSource link (internal or external).\n", + "required": False, + }, + ), + 45: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("sub_title", 5), + ("topic_page", 39), + ("description", 40), + ("source", 44), + ("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", 37), + ] + ], + {}, + ), + 46: ( + "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", + }, + ), + 47: ( + "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', + }, + ), + 48: ( + "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", + }, + ), + 49: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 47), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("chart_type", 48), + ("line_colour", 32), + ("label", 31), + ] + ], + {}, + ), + 50: ( + "wagtail.blocks.StreamBlock", + [[("plot", 49)]], + { + "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" + }, + ), + 51: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nToggle to enable confidence intervals if they are present in the data set\n", + "required": False, + }, + ), + 52: ( + "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, + }, + ), + 53: ( + "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, + }, + ), + 54: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 6), + ("related_links", 10), + ("tag_manager_event_id", 11), + ("x_axis", 46), + ("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", 50), + ("confidence_intervals", 51), + ("confidence_intervals_description", 52), + ("confidence_colour", 53), + ] + ], + {}, + ), + 55: ( + "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, + }, + ), + 56: ( + "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, + }, + ), + 57: ( + "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', + }, + ), + 58: ( + "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, + }, + ), + 59: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 57), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("body", 58), + ] + ], + { + "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' + }, + ), + 60: ( + "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", + }, + ), + 61: ( + "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", + }, + ), + 62: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 60), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("body", 58), + ("percentage_metric", 61), + ] + ], + { + "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' + }, + ), + 63: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 57), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("body", 58), + ] + ], + { + "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' + }, + ), + 64: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("headline_number", 59), + ("trend_number", 62), + ("percentage_number", 63), + ] + ], + { + "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, + }, + ), + 65: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 55), + ("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", 56), + ("show_timeseries_filter", 20), + ("chart", 37), + ("headline_number_columns", 64), + ] + ], + {}, + ), + 66: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe sub title to display for this component.\n", + "required": False, + }, + ), + 67: ( + "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, + }, + ), + 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 y-axis of the chart.\n", + }, + ), + 69: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_simplified_chart_types + }, + ), + 70: ( + "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, + }, + ), + 71: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 22), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("chart_type", 69), + ("date_from", 29), + ("date_to", 30), + ("use_smooth_lines", 70), + ] + ], + {}, + ), + 72: ( + "wagtail.blocks.StreamBlock", + [[("plot", 71)]], + { + "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, + }, + ), + 73: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("sub_title", 66), + ("tag_manager_event_id", 11), + ("topic_page", 39), + ("x_axis", 67), + ("x_axis_title", 13), + ("y_axis", 68), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("chart", 72), + ] + ], + {}, + ), + 74: ( + "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", + }, + ), + 75: ( + "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, + }, + ), + 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 y-axis of the chart.\nIf nothing is provided, `metric value` will be used by default.\n", + }, + ), + 77: ( + "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, + }, + ), + 78: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 57), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("date_from", 29), + ("date_to", 30), + ] + ], + {}, + ), + 79: ( + "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", + }, + ), + 80: ( + "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", + }, + ), + 81: ( + "wagtail.blocks.StructBlock", + [ + [ + ("secondary_field_value", 80), + ("colour", 32), + ("label", 31), + ] + ], + {}, + ), + 82: ( + "wagtail.blocks.StreamBlock", + [[("segment", 81)]], + {"min_num": 1}, + ), + 83: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 6), + ("related_links", 10), + ("tag_manager_event_id", 11), + ("x_axis", 74), + ("x_axis_title", 13), + ("primary_field_values", 75), + ("y_axis", 76), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("chart_type", 77), + ("static_fields", 78), + ("second_category", 79), + ("segments", 82), + ] + ], + {}, + ), + 84: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("chart_card", 38), + ("chart_with_description_card", 45), + ("headline_chart_card", 54), + ("chart_with_headline_and_trend_card", 65), + ("simplified_chart_with_link", 73), + ("dual_category_chart_card", 83), + ] + ], + { + "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, + }, + ), + 85: ("wagtail.blocks.StructBlock", [[("cards", 84)]], {}), + 86: ( + "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, + }, + ), + 87: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("headline_number", 59), + ("trend_number", 62), + ("percentage_number", 63), + ] + ], + { + "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, + }, + ), + 88: ( + "wagtail.blocks.StructBlock", + [[("title", 4), ("date_prefix", 86), ("rows", 87)]], + {}, + ), + 89: ( + "wagtail.blocks.StreamBlock", + [[("column", 88)]], + { + "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, + }, + ), + 90: ("wagtail.blocks.StructBlock", [[("columns", 89)]], {}), + 91: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe sub title to display for this component.\n", + "required": True, + }, + ), + 92: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nOptional description for the weather health alerts card.\n", + "required": False, + }, + ), + 93: ( + "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", + }, + ), + 94: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 41), + ("page", 42), + ("external_url", 43), + ] + ], + {"help_text": "\nOptional source link.\n", "required": False}, + ), + 95: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("sub_title", 91), + ("description", 92), + ("alert_type", 93), + ("source", 94), + ] + ], + {}, + ), + 96: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("weather_health_alert_card", 95), + ("chart_card_with_description", 45), + ] + ], + { + "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, + }, + ), + 97: ( + "wagtail.blocks.StreamBlock", + [[("chart_card", 73)]], + { + "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, + }, + ), + 98: ( + "wagtail.blocks.StreamBlock", + [[("headline_number", 59), ("trend_number", 62)]], + { + "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, + }, + ), + 99: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("date_prefix", 86), + ("topic_page", 39), + ("headline_metrics", 98), + ] + ], + { + "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, + }, + ), + 100: ( + "wagtail.blocks.StreamBlock", + [[("headline_metric_card", 99)]], + { + "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, + }, + ), + 101: ( + "wagtail.blocks.StructBlock", + [ + [ + ("left_column", 96), + ("right_column_top_row", 97), + ("right_column_bottom_row", 100), + ] + ], + {}, + ), + 102: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("text_card", 3), + ("chart_card_section", 85), + ("headline_numbers_row_card", 90), + ("weather_health_alert_card", 95), + ("popular_topics_card", 101), + ] + ], + { + "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" + }, + ), + 103: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": '\nThis is the label used for the a section footer link badge"\n', + "required": True, + }, + ), + 104: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThis is the text to displayed along side a link in a the sections footer link.\n", + "required": True, + }, + ), + 105: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 41), + ("page", 42), + ("external_url", 43), + ] + ], + { + "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, + }, + ), + 106: ( + "wagtail.blocks.StructBlock", + [[("badge_label", 103), ("text", 104), ("link", 105)]], + {"max_num": 1}, + ), + 107: ( + "wagtail.blocks.StreamBlock", + [[("section_link", 106)]], + { + "help_text": "\nThis is an optional footer for a section to provide a link to further information.\n", + "required": False, + }, + ), + 108: ( + "wagtail.blocks.StructBlock", + [ + [ + ("heading", 0), + ("page_link", 1), + ("content", 102), + ("footer", 107), + ] + ], + {}, + ), + }, + ), + ), + ] diff --git a/cms/topics_list/migrations/0007_alter_topicslistpage_body_headline_metric_card.py b/cms/topics_list/migrations/0007_alter_topicslistpage_body_headline_metric_card.py new file mode 100644 index 000000000..b32d29b11 --- /dev/null +++ b/cms/topics_list/migrations/0007_alter_topicslistpage_body_headline_metric_card.py @@ -0,0 +1,1138 @@ +# Generated by Django 5.2.13 on 2026-04-23 11:19 + +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", "0006_alter_topicslistpage_body_popular_topics"), + ] + + operations = [ + migrations.AlterField( + model_name="topicslistpage", + name="body", + field=wagtail.fields.StreamField( + [("section", 108)], + 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_topic_names, + "help_text": "The name of the topic to pull data e.g. COVID-19.", + }, + ), + 22: ( + "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', + }, + ), + 23: ( + "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", + }, + ), + 24: ( + "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", + }, + ), + 25: ( + "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", + }, + ), + 26: ( + "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", + }, + ), + 27: ( + "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", + }, + ), + 28: ( + "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", + }, + ), + 29: ( + "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, + }, + ), + 30: ( + "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, + }, + ), + 31: ( + "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, + }, + ), + 32: ( + "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', + }, + ), + 33: ( + "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, + }, + ), + 34: ( + "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, + }, + ), + 35: ( + "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, + }, + ), + 36: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 22), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("chart_type", 28), + ("date_from", 29), + ("date_to", 30), + ("label", 31), + ("line_colour", 32), + ("line_type", 33), + ("use_markers", 34), + ("use_smooth_lines", 35), + ] + ], + {}, + ), + 37: ( + "wagtail.blocks.StreamBlock", + [[("plot", 36)]], + { + "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" + }, + ), + 38: ( + "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", 37), + ] + ], + {}, + ), + 39: ( + "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, + }, + ), + 40: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nRequired description for the chart.\n", + "required": True, + }, + ), + 41: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nDisplay text for the link.\n", + "required": False, + }, + ), + 42: ( + "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, + }, + ), + 43: ( + "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], + }, + ), + 44: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 41), + ("page", 42), + ("external_url", 43), + ] + ], + { + "help_text": "\nSource link (internal or external).\n", + "required": False, + }, + ), + 45: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("sub_title", 5), + ("topic_page", 39), + ("description", 40), + ("source", 44), + ("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", 37), + ] + ], + {}, + ), + 46: ( + "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", + }, + ), + 47: ( + "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', + }, + ), + 48: ( + "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", + }, + ), + 49: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 47), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("chart_type", 48), + ("line_colour", 32), + ("label", 31), + ] + ], + {}, + ), + 50: ( + "wagtail.blocks.StreamBlock", + [[("plot", 49)]], + { + "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" + }, + ), + 51: ( + "wagtail.blocks.BooleanBlock", + (), + { + "default": False, + "help_text": "\nToggle to enable confidence intervals if they are present in the data set\n", + "required": False, + }, + ), + 52: ( + "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, + }, + ), + 53: ( + "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, + }, + ), + 54: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 6), + ("related_links", 10), + ("tag_manager_event_id", 11), + ("x_axis", 46), + ("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", 50), + ("confidence_intervals", 51), + ("confidence_intervals_description", 52), + ("confidence_colour", 53), + ] + ], + {}, + ), + 55: ( + "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, + }, + ), + 56: ( + "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, + }, + ), + 57: ( + "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', + }, + ), + 58: ( + "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, + }, + ), + 59: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 57), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("body", 58), + ] + ], + { + "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' + }, + ), + 60: ( + "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", + }, + ), + 61: ( + "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", + }, + ), + 62: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 60), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("body", 58), + ("percentage_metric", 61), + ] + ], + { + "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' + }, + ), + 63: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 57), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("body", 58), + ] + ], + { + "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' + }, + ), + 64: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("headline_number", 59), + ("trend_number", 62), + ("percentage_number", 63), + ] + ], + { + "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, + }, + ), + 65: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 55), + ("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", 56), + ("show_timeseries_filter", 20), + ("chart", 37), + ("headline_number_columns", 64), + ] + ], + {}, + ), + 66: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThe sub title to display for this component.\n", + "required": False, + }, + ), + 67: ( + "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, + }, + ), + 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 y-axis of the chart.\n", + }, + ), + 69: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "choices": cms.metrics_interface.field_choices_callables.get_simplified_chart_types + }, + ), + 70: ( + "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, + }, + ), + 71: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 22), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("chart_type", 69), + ("date_from", 29), + ("date_to", 30), + ("use_smooth_lines", 70), + ] + ], + {}, + ), + 72: ( + "wagtail.blocks.StreamBlock", + [[("plot", 71)]], + { + "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, + }, + ), + 73: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("sub_title", 66), + ("tag_manager_event_id", 11), + ("topic_page", 39), + ("x_axis", 67), + ("x_axis_title", 13), + ("y_axis", 68), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("chart", 72), + ] + ], + {}, + ), + 74: ( + "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", + }, + ), + 75: ( + "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, + }, + ), + 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 y-axis of the chart.\nIf nothing is provided, `metric value` will be used by default.\n", + }, + ), + 77: ( + "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, + }, + ), + 78: ( + "wagtail.blocks.StructBlock", + [ + [ + ("topic", 21), + ("metric", 57), + ("geography", 23), + ("geography_type", 24), + ("sex", 25), + ("age", 26), + ("stratum", 27), + ("date_from", 29), + ("date_to", 30), + ] + ], + {}, + ), + 79: ( + "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", + }, + ), + 80: ( + "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", + }, + ), + 81: ( + "wagtail.blocks.StructBlock", + [ + [ + ("secondary_field_value", 80), + ("colour", 32), + ("label", 31), + ] + ], + {}, + ), + 82: ( + "wagtail.blocks.StreamBlock", + [[("segment", 81)]], + {"min_num": 1}, + ), + 83: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("body", 5), + ("about", 6), + ("related_links", 10), + ("tag_manager_event_id", 11), + ("x_axis", 74), + ("x_axis_title", 13), + ("primary_field_values", 75), + ("y_axis", 76), + ("y_axis_title", 15), + ("y_axis_minimum_value", 16), + ("y_axis_maximum_value", 17), + ("chart_type", 77), + ("static_fields", 78), + ("second_category", 79), + ("segments", 82), + ] + ], + {}, + ), + 84: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("chart_card", 38), + ("chart_with_description_card", 45), + ("headline_chart_card", 54), + ("chart_with_headline_and_trend_card", 65), + ("simplified_chart_with_link", 73), + ("dual_category_chart_card", 83), + ] + ], + { + "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, + }, + ), + 85: ("wagtail.blocks.StructBlock", [[("cards", 84)]], {}), + 86: ( + "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, + }, + ), + 87: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("headline_number", 59), + ("trend_number", 62), + ("percentage_number", 63), + ] + ], + { + "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, + }, + ), + 88: ( + "wagtail.blocks.StructBlock", + [[("title", 4), ("date_prefix", 86), ("rows", 87)]], + {}, + ), + 89: ( + "wagtail.blocks.StreamBlock", + [[("column", 88)]], + { + "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, + }, + ), + 90: ("wagtail.blocks.StructBlock", [[("columns", 89)]], {}), + 91: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nThe sub title to display for this component.\n", + "required": True, + }, + ), + 92: ( + "wagtail.blocks.TextBlock", + (), + { + "help_text": "\nOptional description for the weather health alerts card.\n", + "required": False, + }, + ), + 93: ( + "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", + }, + ), + 94: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 41), + ("page", 42), + ("external_url", 43), + ] + ], + {"help_text": "\nOptional source link.\n", "required": False}, + ), + 95: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("sub_title", 91), + ("description", 92), + ("alert_type", 93), + ("source", 94), + ] + ], + {}, + ), + 96: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("weather_health_alert_card", 95), + ("chart_card_with_description", 45), + ] + ], + { + "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, + }, + ), + 97: ( + "wagtail.blocks.StreamBlock", + [[("chart_card", 73)]], + { + "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, + }, + ), + 98: ( + "wagtail.blocks.StreamBlock", + [[("headline_number", 59), ("trend_number", 62)]], + { + "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, + }, + ), + 99: ( + "wagtail.blocks.StructBlock", + [ + [ + ("title", 4), + ("date_prefix", 86), + ("topic_page", 39), + ("headline_metrics", 98), + ] + ], + { + "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, + }, + ), + 100: ( + "wagtail.blocks.StreamBlock", + [[("headline_metric_card", 99)]], + { + "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, + }, + ), + 101: ( + "wagtail.blocks.StructBlock", + [ + [ + ("left_column", 96), + ("right_column_top_row", 97), + ("right_column_bottom_row", 100), + ] + ], + {}, + ), + 102: ( + "wagtail.blocks.StreamBlock", + [ + [ + ("text_card", 3), + ("chart_card_section", 85), + ("headline_numbers_row_card", 90), + ("weather_health_alert_card", 95), + ("popular_topics_card", 101), + ] + ], + { + "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" + }, + ), + 103: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": '\nThis is the label used for the a section footer link badge"\n', + "required": True, + }, + ), + 104: ( + "wagtail.blocks.CharBlock", + (), + { + "help_text": "\nThis is the text to displayed along side a link in a the sections footer link.\n", + "required": True, + }, + ), + 105: ( + "wagtail.blocks.StructBlock", + [ + [ + ("link_display_text", 41), + ("page", 42), + ("external_url", 43), + ] + ], + { + "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, + }, + ), + 106: ( + "wagtail.blocks.StructBlock", + [[("badge_label", 103), ("text", 104), ("link", 105)]], + {"max_num": 1}, + ), + 107: ( + "wagtail.blocks.StreamBlock", + [[("section_link", 106)]], + { + "help_text": "\nThis is an optional footer for a section to provide a link to further information.\n", + "required": False, + }, + ), + 108: ( + "wagtail.blocks.StructBlock", + [ + [ + ("heading", 0), + ("page_link", 1), + ("content", 102), + ("footer", 107), + ] + ], + {}, + ), + }, + ), + ), + ] diff --git a/tests/unit/cms/dynamic_content/test_popular_topics_card.py b/tests/unit/cms/dynamic_content/test_popular_topics_card.py index 60890fb03..6fbf1c3c8 100644 --- a/tests/unit/cms/dynamic_content/test_popular_topics_card.py +++ b/tests/unit/cms/dynamic_content/test_popular_topics_card.py @@ -3,6 +3,7 @@ from cms.dynamic_content.blocks import ( POPULAR_TOPICS_BOTTOM_RIGHT_COLUMN_COUNT, POPULAR_TOPICS_HEADLINE_NUMBER_BLOCK_COUNT, + PageLinkChooserBlock, PopularTopicsMetricNumberBlockTypes, PopularTopicsRightColumnBottomRowBlockTypes, ) @@ -65,12 +66,16 @@ def test_has_expected_headline_metric_card_block(self) -> None: """ # Given bottom_row_block_types = PopularTopicsRightColumnBottomRowBlockTypes() - # When selected_field = bottom_row_block_types.child_blocks.get("headline_metric_card") # Then assert isinstance(selected_field, PopularTopicsMetricNumberBlockTypes) + assert isinstance( + selected_field.child_blocks.get("topic_page"), PageLinkChooserBlock + ) + assert selected_field.child_blocks.get("topic_page") is not None + assert selected_field.meta.min_num == POPULAR_TOPICS_BOTTOM_RIGHT_COLUMN_COUNT assert selected_field.meta.max_num == POPULAR_TOPICS_BOTTOM_RIGHT_COLUMN_COUNT From c53721cae066c593ea4c89a03834e97103e7a3a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:16:40 +0000 Subject: [PATCH 136/186] 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] --- requirements-prod.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-prod.txt b/requirements-prod.txt index 2725de6d5..3fdfdb152 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -31,7 +31,7 @@ grimp==3.14 gunicorn==25.3.0 html5lib==1.1 identify==2.6.19 -idna==3.12 +idna==3.13 importlib-metadata==9.0.0 inflection==0.5.1 iniconfig==2.3.0 From b336a4536e5665b49704e75a6f3062484e6f146c Mon Sep 17 00:00:00 2001 From: Aidan Skinner Date: Fri, 24 Apr 2026 15:10:53 +0100 Subject: [PATCH 137/186] topics: add HIV topic --- tests/unit/validation/successful/parameters.py | 7 +++++++ tests/unit/validation/unsuccessful/parameters.py | 7 +++++++ validation/enums/theme_and_topic_enums.py | 1 + 3 files changed, 15 insertions(+) diff --git a/tests/unit/validation/successful/parameters.py b/tests/unit/validation/successful/parameters.py index 1bf9d1f60..2f574da23 100644 --- a/tests/unit/validation/successful/parameters.py +++ b/tests/unit/validation/successful/parameters.py @@ -62,6 +62,13 @@ "hepatitis-c_prevention_PWIDproportionNSP", "prevention", ), + ( + "infectious_disease", + "bloodborne", + "HIV", + "HIV_cases_lateDiagnosesByKeyPop", + "cases", + ), ( "climate_and_environment", "chemical_exposure", diff --git a/tests/unit/validation/unsuccessful/parameters.py b/tests/unit/validation/unsuccessful/parameters.py index f72f42a5b..0ee7bcfd9 100644 --- a/tests/unit/validation/unsuccessful/parameters.py +++ b/tests/unit/validation/unsuccessful/parameters.py @@ -48,6 +48,13 @@ "hepatitis-c_cases_prevalenceByYearEstimate", "prevention", ), # Invalid child Topic + ( + "infectious_disease", + "bloodborne", + "HIV", + "HIV_cases_lateDiagnosesByKeyPop", + "infectious_disease", + ), # Invalid child Topic ( "infectious_disease", "chemical_exposure", diff --git a/validation/enums/theme_and_topic_enums.py b/validation/enums/theme_and_topic_enums.py index 99c00790e..4b99c96a2 100644 --- a/validation/enums/theme_and_topic_enums.py +++ b/validation/enums/theme_and_topic_enums.py @@ -69,6 +69,7 @@ class _ChildhoodIllnessTopic(Enum): class _BloodbourneTopic(Enum): HEPATITIS_B = "Hepatitis-B" HEPATITIS_C = "Hepatitis-C" + HIV = "HIV" class _MortalityReportTopic(Enum): From 5f9a0d1ce4e59e858d9450b17e541b8d3e00ccff Mon Sep 17 00:00:00 2001 From: Luke Towell Date: Thu, 30 Apr 2026 09:59:57 +0100 Subject: [PATCH 138/186] CDD-3087: new CMS page for logged-out functionality (#3163) --- .../management/commands/build_cms_site.py | 3 ++ .../cms_starting_pages/logged_out.json | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 cms/dashboard/templates/cms_starting_pages/logged_out.json diff --git a/cms/dashboard/management/commands/build_cms_site.py b/cms/dashboard/management/commands/build_cms_site.py index c17444260..8f630d78a 100644 --- a/cms/dashboard/management/commands/build_cms_site.py +++ b/cms/dashboard/management/commands/build_cms_site.py @@ -184,6 +184,9 @@ def _build_common_pages(cls, root_page: UKHSARootPage) -> None: build_cms_site_helpers.create_common_page( name="compliance", parent_page=root_page ) + build_cms_site_helpers.create_common_page( + name="logged_out", parent_page=root_page + ) @staticmethod def _clear_cms() -> None: diff --git a/cms/dashboard/templates/cms_starting_pages/logged_out.json b/cms/dashboard/templates/cms_starting_pages/logged_out.json new file mode 100644 index 000000000..fdfefed80 --- /dev/null +++ b/cms/dashboard/templates/cms_starting_pages/logged_out.json @@ -0,0 +1,32 @@ +{ + "id": 86, + "meta": { + "seo_title": "logged-out", + "search_description": "", + "type": "common.CommonPage", + "detail_url": "https://localhost/api/pages/86/", + "html_url": "https://localhost/logged-out/", + "slug": "logged-out", + "show_in_menus": false, + "first_published_at": "2026-04-27T15:21:52.230984+01:00", + "alias_of": null, + "parent": { + "id": 3, + "meta": { + "type": "home.UKHSARootPage", + "detail_url": "https://localhost/api/pages/3/", + "html_url": null + }, + "title": "UKHSA Dashboard Root" + } + }, + "title": "Logged out", + "body": "

You have been automatically signed out.

", + "seo_change_frequency": 5, + "seo_priority": "0.1", + "last_updated_at": "2026-04-27T15:21:52.230984+01:00", + "last_published_at": "2026-04-27T15:21:52.230984+01:00", + "active_announcements": [], + "related_links_layout": "Footer", + "related_links": [] +} From f21867e6316830829bdc8f3a5bdbadec5617e948 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:02:23 +0000 Subject: [PATCH 139/186] 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] --- requirements-prod.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-prod.txt b/requirements-prod.txt index 3fdfdb152..838ce7ab8 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -26,7 +26,7 @@ drf-nested-routers==0.95.0 drf-spectacular==0.27.2 et-xmlfile==2.0.0 exceptiongroup==1.3.1 -filelock==3.28.0 +filelock==3.29.0 grimp==3.14 gunicorn==25.3.0 html5lib==1.1 From fc0366ca7b58402217503767c6adbb63f55517ba Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 30 Apr 2026 17:21:27 +0100 Subject: [PATCH 140/186] WIP: pseudo code / note form of solution --- cms/dashboard/log.json | 147 ++++++++++++++++++ cms/dashboard/viewsets.py | 31 +++- ...umentationchildentry_sub_theme_and_more.py | 26 ++++ ...b_theme_topicpage_theme_topicpage_topic.py | 46 ++++++ ...ub_theme_alter_topicpage_theme_and_more.py | 28 ++++ 5 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 cms/dashboard/log.json create mode 100644 cms/metrics_documentation/migrations/0016_metricsdocumentationchildentry_sub_theme_and_more.py create mode 100644 cms/topic/migrations/0032_topicpage_sub_theme_topicpage_theme_topicpage_topic.py create mode 100644 cms/topic/migrations/0033_alter_topicpage_sub_theme_alter_topicpage_theme_and_more.py diff --git a/cms/dashboard/log.json b/cms/dashboard/log.json new file mode 100644 index 000000000..a01207e1a --- /dev/null +++ b/cms/dashboard/log.json @@ -0,0 +1,147 @@ +{ + "BODY_LOGS": { + "formsubmission": "", + "redirect": "", + "sites_rooted_here": ".RelatedManager object at 0x117186ae0>", + "aliases": ".RelatedManager object at 0x117186de0>", + "group_permissions": ".RelatedManager object at 0x11719cb90>", + "view_restrictions": ".RelatedManager object at 0x11719cbc0>", + "workflowpage": "", + "wagtail_admin_comments": ".DeferringRelatedManager object at 0x11719c980>", + "subscribers": ".RelatedManager object at 0x1171b4680>", + "id": 80, + "path": "00010001000I0001", + "depth": 4, + "numchild": 0, + "translation_key": "UUID(a290e958-d915-4025-8fc7-886d82c492b8)", + "locale": "", + "latest_revision": "", + "live": true, + "has_unpublished_changes": false, + "first_published_at": "datetime.datetime(2026, 4, 27, 11, 7, 38, 72815, tzinfo=zoneinfo.ZoneInfo(key=Europe/London))", + "last_published_at": "datetime.datetime(2026, 4, 27, 11, 7, 38, 72815, tzinfo=zoneinfo.ZoneInfo(key=Europe/London))", + "live_revision": "", + "go_live_at": "None", + "expire_at": "None", + "expired": false, + "locked": false, + "locked_at": "None", + "locked_by": "None", + "title": "Childhood vaccinations", + "draft_title": "Childhood vaccinations", + "slug": "childhood-vaccinations", + "content_type": "", + "url_path": "/ukhsa-dashboard-root/cover/childhood-vaccinations/", + "owner": "None", + "seo_title": "Childhood vaccinations", + "show_in_menus": false, + "search_description": "", + "latest_revision_created_at": "datetime.datetime(2026, 4, 27, 11, 7, 38, 42274, tzinfo=zoneinfo.ZoneInfo(key=Europe/London))", + "alias_of": "None", + "related_links": ".DeferringRelatedManager object at 0x115f42390>", + "announcements": ".DeferringRelatedManager object at 0x1171b49b0>", + "page_ptr": "", + "seo_change_frequency": 5, + "seo_priority": "Decimal(0.5)", + "body": "BODY", + "page_description": "PAGE_DESCRIPTION", + "enable_area_selector": false, + "is_public": true, + "page_classification": "None", + "related_links_layout": "Footer", + "_revisions": ".GenericRelatedObjectManager object at 0x1171b5100>", + "_workflow_states": ".GenericRelatedObjectManager object at 0x1171b4c20>", + "_specific_workflow_states": ".GenericRelatedObjectManager object at 0x1171d3980>", + "index_entries": ".GenericRelatedObjectManager object at 0x1171b4740>" + }, + + "USER_OBJECT_PERMISSION_SETS": { + "permission_set_hierarchy": [ + { + "theme": { + "id": "3", + "name": "extreme_event" + }, + "sub_theme": { + "id": "4", + "name": "weather_alert" + }, + "topic": { + "id": "-1", + "name": "* (All)" + }, + "metric": { + "id": "-1", + "name": "* (All)" + }, + "geography_type": { + "id": "6", + "name": "United Kingdom" + }, + "geography": { + "id": "K02000001", + "name": "United Kingdom" + } + }, + { + "theme": { + "id": "2", + "name": "infectious_disease" + }, + "sub_theme": { + "id": "-1", + "name": "* (All)" + }, + "topic": { + "id": "-1", + "name": "* (All)" + }, + "metric": { + "id": "-1", + "name": "* (All)" + }, + "geography_type": { + "id": "1", + "name": "Upper Tier Local Authority" + }, + "geography": { + "id": "E06000014", + "name": "York" + } + }, + { + "theme": { + "id": "1", + "name": "immunisation" + }, + "sub_theme": { + "id": "1", + "name": "childhood-vaccines" + }, + "topic": { + "id": "-1", + "name": "* (All)" + }, + "metric": { + "id": "-1", + "name": "* (All)" + }, + "geography_type": { + "id": "1", + "name": "Upper Tier Local Authority" + }, + "geography": { + "id": "E06000014", + "name": "York" + } + } + ], + "summary": { + "total_permission_sets": 3, + "deduplicated_count": 3, + "removed_count": 0, + "has_global_access": false, + "wildcard_themes": [] + } + } +} diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index 7239f69c9..4192fd3ca 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -50,6 +50,7 @@ def get_queryset(self): queryset = super().get_queryset() req = self.request + print(f"🦊🦊🦊🦊🦊🦊🦊🦊 USER: {req.user} 🦊🦊🦊🦊🦊🦊🦊🦊🦊") if req.auth is None: # Filter pages to find those with the is public field (and where is_public is true) topic_page_id_with_is_public = TopicPage.objects.filter(is_public=True, page_ptr__in=queryset).values_list("page_ptr_id", flat=True) @@ -62,21 +63,45 @@ def get_queryset(self): pages_without_is_public = queryset.not_type(TopicPage, MetricsDocumentationChildEntry) public_pages = is_public_pages | pages_without_is_public - queryset = public_pages - - return queryset.specific() + filtered_queryset = public_pages + + else: + + print(f"🦊🦊🦊🦊🦊🦊🦊🦊 Permission Sets: {req.user.permission_sets} 🦊🦊🦊🦊🦊🦊🦊🦊🦊") + # user permissions = req.user.permission_sets + # allowed_pages = [] + # for each page in queryset: + # if it is a topic or metric doc child page: + # for each permisison set that the user has: + # get the theme id and compare to users permission set themes + # if the theme matches: + # get the page subtheme id and compare to subtheme of matched permission set + # if the subtheme matches: + # get the topic id for page and permission set + # if the id matches: + # allowed_pages.append(page) + # + # else if it is not a topic or metric doc child page: + # allowed_pages.append(page) + # + # filtered_queryset = allowed_pages + # + # + return filtered_queryset.specific() @cache_response() def listing_view(self, request: Request) -> Response: """This endpoint returns a list of published pages from the CMS (Wagtail). The payload includes page `title`, `id` and `meta` data about each page. """ + print(f"REQUEST.USER 🦄 {request.user}") print(f"I AM LISTING VIEW 🦄: {super().listing_view(request=request)}") return super().listing_view(request=request) @cache_response() def detail_view(self, request: Request, pk: int) -> Response: """This end point returns a page from the CMS based on a Page `ID`.""" + print(f"REQUEST.USER 🎯 {request.user}") print(f"I AM DETAIL VIEW 🎯: {super().detail_view(request=request, pk=pk)}") if request.auth is None: print() diff --git a/cms/metrics_documentation/migrations/0016_metricsdocumentationchildentry_sub_theme_and_more.py b/cms/metrics_documentation/migrations/0016_metricsdocumentationchildentry_sub_theme_and_more.py new file mode 100644 index 000000000..7c663a527 --- /dev/null +++ b/cms/metrics_documentation/migrations/0016_metricsdocumentationchildentry_sub_theme_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.13 on 2026-04-29 10:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "metrics_documentation", + "0015_alter_metricsdocumentationchildentry_page_classification", + ), + ] + + operations = [ + migrations.AddField( + model_name="metricsdocumentationchildentry", + name="sub_theme", + field=models.CharField(blank=True, default="", max_length=255, null=True), + ), + migrations.AddField( + model_name="metricsdocumentationchildentry", + name="theme", + field=models.CharField(blank=True, default="", max_length=255, null=True), + ), + ] diff --git a/cms/topic/migrations/0032_topicpage_sub_theme_topicpage_theme_topicpage_topic.py b/cms/topic/migrations/0032_topicpage_sub_theme_topicpage_theme_topicpage_topic.py new file mode 100644 index 000000000..ff9b0ef09 --- /dev/null +++ b/cms/topic/migrations/0032_topicpage_sub_theme_topicpage_theme_topicpage_topic.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.13 on 2026-04-29 10:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("topic", "0031_alter_topicpage_page_classification"), + ] + + operations = [ + migrations.AddField( + model_name="topicpage", + name="sub_theme", + field=models.CharField( + blank=True, + default="", + help_text="\nThe subtheme must be provided for a non-public page.\n", + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="topicpage", + name="theme", + field=models.CharField( + blank=True, + default="", + help_text="\nThe theme must be provided for a non-public page.\n", + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="topicpage", + name="topic", + field=models.CharField( + blank=True, + default="", + help_text="\nThe topic must be provided for a non-public page.\n", + max_length=255, + null=True, + ), + ), + ] diff --git a/cms/topic/migrations/0033_alter_topicpage_sub_theme_alter_topicpage_theme_and_more.py b/cms/topic/migrations/0033_alter_topicpage_sub_theme_alter_topicpage_theme_and_more.py new file mode 100644 index 000000000..bf95c455e --- /dev/null +++ b/cms/topic/migrations/0033_alter_topicpage_sub_theme_alter_topicpage_theme_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.13 on 2026-04-29 10:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("topic", "0032_topicpage_sub_theme_topicpage_theme_topicpage_topic"), + ] + + operations = [ + migrations.AlterField( + model_name="topicpage", + name="sub_theme", + field=models.CharField(blank=True, default="", max_length=255, null=True), + ), + migrations.AlterField( + model_name="topicpage", + name="theme", + field=models.CharField(blank=True, default="", max_length=255, null=True), + ), + migrations.AlterField( + model_name="topicpage", + name="topic", + field=models.CharField(blank=True, default="", max_length=255, null=True), + ), + ] From 168dd316a28a5c71bf47fa317c25449fb3c3e83c Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Fri, 1 May 2026 11:33:17 +0100 Subject: [PATCH 141/186] WIP: filter pages on permission sets --- cms/dashboard/log.json | 2 +- cms/dashboard/viewsets.py | 76 ++++++++++++++++++++++++++------------- 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/cms/dashboard/log.json b/cms/dashboard/log.json index a01207e1a..eeb06d9f9 100644 --- a/cms/dashboard/log.json +++ b/cms/dashboard/log.json @@ -1,5 +1,5 @@ { - "BODY_LOGS": { + "TOPICPAGE.topicpage_LOGS (pre theme)": { "formsubmission": "", "redirect": "", "sites_rooted_here": ".RelatedManager object at 0x117186ae0>", diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index 4192fd3ca..7b30e3b94 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -13,6 +13,20 @@ from django.db.models import Q + +def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: + for permission in user_permissions: + if permission.theme.id == -1: + return True + if permission.theme.id == theme_id and sub_theme_id == -1: + return True + if permission.theme.id == theme_id \ + and (permission.sub_theme.id == sub_theme_id) \ + and (permission.topic.id == -1 or permission.topic.id == topic_id): + return True + + return False + @extend_schema(tags=["cms"]) class CMSPagesAPIViewSet(PagesAPIViewSet): # This is the /pages (or proxy/pages env dependent endpoint) @@ -50,15 +64,21 @@ def get_queryset(self): queryset = super().get_queryset() req = self.request - print(f"🦊🦊🦊🦊🦊🦊🦊🦊 USER: {req.user} 🦊🦊🦊🦊🦊🦊🦊🦊🦊") + + # for page in queryset.type(TopicPage): + # if page.topicpage.theme is not None: + # print(f"page.title: {page.title}") + # print(f"🦊 page.topicpage.theme (id): {page.topicpage.theme}") + + print(f"👤🦊🦊🦊🦊👤 USER: {req.user} 👤🦊🦊🦊🦊👤") if req.auth is None: # Filter pages to find those with the is public field (and where is_public is true) - topic_page_id_with_is_public = TopicPage.objects.filter(is_public=True, page_ptr__in=queryset).values_list("page_ptr_id", flat=True) - metric_doc_child_page_id_with_is_public = MetricsDocumentationChildEntry.objects.filter(is_public=True, page_ptr__in=queryset).values_list("page_ptr_id", flat=True) + topic_page_ids_with_is_public = TopicPage.objects.filter(is_public=True, page_ptr__in=queryset).values_list("page_ptr_id", flat=True) + metric_doc_child_page_ids_with_is_public = MetricsDocumentationChildEntry.objects.filter(is_public=True, page_ptr__in=queryset).values_list("page_ptr_id", flat=True) # Combine all public pages into one queryset - topic_public_pages = queryset.filter(id__in=topic_page_id_with_is_public) - metric_child_public_pages = queryset.filter(id__in=metric_doc_child_page_id_with_is_public) + topic_public_pages = queryset.filter(id__in=topic_page_ids_with_is_public) + metric_child_public_pages = queryset.filter(id__in=metric_doc_child_page_ids_with_is_public) is_public_pages = topic_public_pages | metric_child_public_pages pages_without_is_public = queryset.not_type(TopicPage, MetricsDocumentationChildEntry) public_pages = is_public_pages | pages_without_is_public @@ -68,25 +88,33 @@ def get_queryset(self): else: print(f"🦊🦊🦊🦊🦊🦊🦊🦊 Permission Sets: {req.user.permission_sets} 🦊🦊🦊🦊🦊🦊🦊🦊🦊") - # user permissions = req.user.permission_sets - # allowed_pages = [] - # for each page in queryset: - # if it is a topic or metric doc child page: - # for each permisison set that the user has: - # get the theme id and compare to users permission set themes - # if the theme matches: - # get the page subtheme id and compare to subtheme of matched permission set - # if the subtheme matches: - # get the topic id for page and permission set - # if the id matches: - # allowed_pages.append(page) - # - # else if it is not a topic or metric doc child page: - # allowed_pages.append(page) - # - # filtered_queryset = allowed_pages - # - # + # print(f"🦊 page.topicpage.theme (id): {page.topicpage.theme}") + user_permissions = req.user.permission_sets['permission_set_hierarchy'] + + # Global access check + + allowed_pages = [] + for page in queryset: + if page.type(TopicPage): + if page.topicpage.is_public: + allowed_pages.append(page.id) + else: + # Compare to users permission themes + if check_permissions(user_permissions, page.topicpage.theme.id, page.topicpage.sub_theme.id, page.topicpage.theme.topic.id): + allowed_pages.append(page.id) + + elif page.type(MetricsDocumentationChildEntry): + if page.metricsdocumentationchildentry.is_public: + allowed_pages.append(page.id) + else: + if check_permissions(user_permissions, page.topicpage.theme.id, page.topicpage.sub_theme.id, page.topicpage.theme.topic.id): + allowed_pages.append(page.id) + + else: + allowed_pages.append(page.id) + + filtered_queryset = queryset.filter(id__in=allowed_pages) + return filtered_queryset.specific() @cache_response() From 112e4c7f6e77cbf2a16ba738284b6c3a9fcc5ca6 Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Tue, 5 May 2026 17:47:47 +0100 Subject: [PATCH 142/186] Permission check updates and form handling --- cms/dashboard/constants.py | 8 +++ .../toggle_available_fields_on_is_public.js | 65 +++++++++++++++++-- cms/dashboard/viewsets.py | 46 +++++++------ ...er_metricsdocumentationchildentry_topic.py | 21 ++++++ cms/metrics_documentation/models/child.py | 15 +++-- metrics/api/views/permission_sets.py | 4 +- 6 files changed, 122 insertions(+), 37 deletions(-) create mode 100644 cms/metrics_documentation/migrations/0017_alter_metricsdocumentationchildentry_topic.py diff --git a/cms/dashboard/constants.py b/cms/dashboard/constants.py index 56e8c063f..42634a7d4 100644 --- a/cms/dashboard/constants.py +++ b/cms/dashboard/constants.py @@ -1,5 +1,6 @@ from cms.metrics_interface.field_choices_callables import ( get_all_theme_names_and_ids, + get_all_metric_names_and_ids, ) THEME_FIELDS = [ @@ -24,5 +25,12 @@ "field_choice_wildcard": None, "field_choice_callable": None, }, + { + "field_name": "metric", + "field_label": "Metric", + "field_choice_default": "Select topic first", + "field_choice_wildcard": None, + "field_choice_callable": get_all_metric_names_and_ids, + }, ] diff --git a/cms/dashboard/static/js/toggle_available_fields_on_is_public.js b/cms/dashboard/static/js/toggle_available_fields_on_is_public.js index 01d1d92b1..04b282d76 100644 --- a/cms/dashboard/static/js/toggle_available_fields_on_is_public.js +++ b/cms/dashboard/static/js/toggle_available_fields_on_is_public.js @@ -1,6 +1,7 @@ ;(function () { "use strict" - let theme, subTheme, topic, isPublicCheckbox + let theme, subTheme, topic, metric, isPublicCheckbox; + let originalMetricOptions; function toggleAvailableFields() { /* @@ -15,19 +16,34 @@ theme: theme, subTheme: subTheme, topic: topic, + // metric: metric, } if (isPublicCheckbox.checked) { Object.values(fields).forEach(disableField) clearDropdown(fields.subTheme, "Select theme first") clearDropdown(fields.topic, "Select sub-theme first") + // clearDropdown(fields.metric, "Select topic first") + restoreMetricOptions() fields.theme.value = "" } else { + if (!theme.value && !subTheme.value && !topic.value) { + clearDropdown(metric, "Select topic first") + } Object.values(fields).forEach(enableField) fields.classification.value="official_sensitive" } } + function restoreMetricOptions() { + clearDropdown(metric, "----------") + originalMetricOptions.forEach(option => { + if (option.text !== "Select topic first") { + metric.appendChild(option.cloneNode(true)); + } + }); + } + function disableField(field) { field.disabled = true } @@ -110,11 +126,13 @@ if (!themeValue || themeValue === "") { clearDropdown(subTheme, "Select theme first") clearDropdown(topic, "Select sub-theme first") + clearDropdown(metric, "Select topic first"); return } - clearDropdown(subTheme, "--------") - clearDropdown(topic, "--------") + clearDropdown(subTheme, "Select theme") + clearDropdown(topic, "Select sub-theme") + clearDropdown(metric, "Select topic first"); // Fetch and populate sub-themes const choices = await fetchChoices("subthemes", themeValue) @@ -140,6 +158,7 @@ // Clear dependent dropdowns clearDropdown(topic, "Select sub-theme") + clearDropdown(metric, "Select topic first"); // Fetch and populate topics const choices = await fetchChoices("topics", subThemeValue) @@ -151,6 +170,30 @@ } } + /** + * Handle topic selection change + */ + async function handleTopicChange() { + const topicValue = topic.value; + + if (!topicValue || topicValue === "") { + // No topic selected - clear metrics + clearDropdown(metric, "Select topic first"); + return; + } + + clearDropdown(metric, "--------"); + + // Fetch and populate metrics + const choices = await fetchChoices("metrics", topicValue); + + if (choices.length > 0) { + populateDropdown(metric, choices, "* All metrics"); + } else { + clearDropdown(metric, "No metrics available"); + } + } + /** * Initialize dropdowns for edit mode * Loads the dropdown options based on saved values @@ -160,6 +203,7 @@ const savedTheme = theme.value const savedSubTheme = subTheme.value const savedTopic = topic.value + const savedMetric = metric ? metric.value : undefined // If theme has a value (not empty), load sub-themes if (savedTheme && savedTheme !== "") { @@ -176,6 +220,14 @@ populateDropdown(topic, topicChoices) topic.value = savedTopic // Restore selection } + + if (savedTopic && savedTopic !== "") { + const metricChoices = await fetchChoices("metrics", savedTopic) + if (metricChoices.length > 0) { + populateDropdown(metric, metricChoices) + metric.value = savedMetric // Restore selection + } + } } } } @@ -187,6 +239,9 @@ theme = document.querySelector('select[name="theme"]') subTheme = document.querySelector('select[name="sub_theme"]') topic = document.querySelector('select[name="topic"]') + metric = document.querySelector('select[name="metric"]') + // Take a copy of all available metrics so they can be restored if this becomes a public page + originalMetricOptions = Array.from(metric.options).map(option => option.cloneNode(true)); // Exit if not on page with themes and is_public toggle if (!theme || !subTheme || !topic || !isPublicCheckbox) { @@ -199,14 +254,16 @@ // Add event listeners theme.addEventListener("change", handleThemeChange) subTheme.addEventListener("change", handleSubThemeChange) + topic.addEventListener("change", handleTopicChange); - const isEditMode = theme.value || subTheme.value || topic.value + const isEditMode = theme.value || subTheme.value || topic.value || metric.value if (isEditMode) { initializeEditMode() } else { clearDropdown(subTheme, "Select theme first") clearDropdown(topic, "Select sub-theme first") + clearDropdown(metric, "Select topic first"); } } diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index 7b30e3b94..ec4050d85 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -12,17 +12,17 @@ from django.db.models import Q - - +# TODO: Changed to use dict - not sure if this will also be required when my auth issues are resolved def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: for permission in user_permissions: - if permission.theme.id == -1: + print(permission) + if permission['theme']['id'] == -1: return True - if permission.theme.id == theme_id and sub_theme_id == -1: + if permission['theme']['id'] == theme_id and sub_theme_id == -1: return True - if permission.theme.id == theme_id \ - and (permission.sub_theme.id == sub_theme_id) \ - and (permission.topic.id == -1 or permission.topic.id == topic_id): + if permission['theme']['id'] == theme_id \ + and (permission['sub_theme']['id'] == sub_theme_id) \ + and (permission['topic']['id'] == -1 or permission['topic']['id'] == topic_id): return True return False @@ -91,29 +91,27 @@ def get_queryset(self): # print(f"🦊 page.topicpage.theme (id): {page.topicpage.theme}") user_permissions = req.user.permission_sets['permission_set_hierarchy'] - # Global access check + # TODO: Global access check? allowed_pages = [] - for page in queryset: - if page.type(TopicPage): - if page.topicpage.is_public: + for page in queryset.type(TopicPage): + if page.topicpage.is_public: + allowed_pages.append(page.id) + else: + if check_permissions(user_permissions, page.topicpage.theme, page.topicpage.sub_theme, page.topicpage.topic): allowed_pages.append(page.id) - else: - # Compare to users permission themes - if check_permissions(user_permissions, page.topicpage.theme.id, page.topicpage.sub_theme.id, page.topicpage.theme.topic.id): - allowed_pages.append(page.id) - - elif page.type(MetricsDocumentationChildEntry): - if page.metricsdocumentationchildentry.is_public: + + for page in queryset.type(MetricsDocumentationChildEntry): + if page.metricsdocumentationchildentry.is_public: + allowed_pages.append(page.id) + else: + if check_permissions(user_permissions, page.metricsdocumentationchildentry.theme, page.metricsdocumentationchildentry.sub_theme, page.metricsdocumentationchildentry.topic): allowed_pages.append(page.id) - else: - if check_permissions(user_permissions, page.topicpage.theme.id, page.topicpage.sub_theme.id, page.topicpage.theme.topic.id): - allowed_pages.append(page.id) - else: - allowed_pages.append(page.id) + public_pages = queryset.not_type(TopicPage, MetricsDocumentationChildEntry) + permitted_private_pages = queryset.filter(id__in=allowed_pages) - filtered_queryset = queryset.filter(id__in=allowed_pages) + filtered_queryset = public_pages | permitted_private_pages return filtered_queryset.specific() diff --git a/cms/metrics_documentation/migrations/0017_alter_metricsdocumentationchildentry_topic.py b/cms/metrics_documentation/migrations/0017_alter_metricsdocumentationchildentry_topic.py new file mode 100644 index 000000000..5d23bdbd8 --- /dev/null +++ b/cms/metrics_documentation/migrations/0017_alter_metricsdocumentationchildentry_topic.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.13 on 2026-05-05 09:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "metrics_documentation", + "0016_metricsdocumentationchildentry_sub_theme_and_more", + ), + ] + + operations = [ + migrations.AlterField( + model_name="metricsdocumentationchildentry", + name="topic", + field=models.CharField(blank=True, default="", max_length=255, null=True), + ), + ] diff --git a/cms/metrics_documentation/models/child.py b/cms/metrics_documentation/models/child.py index 0cb3f8862..b96fc4a57 100644 --- a/cms/metrics_documentation/models/child.py +++ b/cms/metrics_documentation/models/child.py @@ -20,7 +20,7 @@ from cms.dynamic_content.announcements import Announcement from cms.metrics_interface.field_choices_callables import ( get_a_list_of_all_topic_names, - get_all_unique_metric_names, + get_all_metric_names_and_ids, ) logger = logging.getLogger(__name__) @@ -47,6 +47,7 @@ def _initialize_dependent_fields(self): dependent_fields = { "sub_theme": ("Select theme first"), "topic": ("Select sub-theme first"), + # "metric": ("Select topic first"), } for field_name, (placeholder) in dependent_fields.items(): @@ -82,28 +83,29 @@ class MetricsDocumentationChildEntry(UKHSAPage): ) theme = models.CharField(max_length=255, blank=True, default="", null=True,) - sub_theme = models.CharField(max_length=255, blank=True, default="", null=True,) + sub_theme = models.CharField(max_length=255, blank=True, default="", null=True,) topic = models.CharField( max_length=255, + blank=True, default="", + null=True ) body = ALLOWABLE_BODY_CONTENT_TEXT_SECTION # Fields to index for searching within the CMS application. search_fields = UKHSAPage.search_fields + [ - index.SearchField("metric"), index.SearchField("body"), ] # Content panels to render for editing within the CMS application. content_panels = UKHSAPage.content_panels + [ FieldPanel("page_description"), - FieldPanel("metric"), FieldPanel("is_public"), FieldPanel("page_classification"), FieldPanel("theme"), FieldPanel("sub_theme"), FieldPanel("topic"), + FieldPanel("metric"), FieldPanel("body"), ] @@ -148,7 +150,7 @@ def __init__(self, *args, **kwargs): load in the names dynamically from the metrics interface. """ super().__init__(*args, **kwargs) - self._meta.get_field("metric").choices = get_all_unique_metric_names() + self._meta.get_field("metric").choices = get_all_metric_names_and_ids() def find_topic(self, *, topics: list[str]) -> str: """Finds the required topic from a list of strings based on the metric name. @@ -191,7 +193,6 @@ def save(self, *args, **kwargs): Notes: This method will not be called when using `bulk_create()` """ - self.topic = self.get_topic() super().save(*args, **kwargs) @property @@ -230,7 +231,7 @@ def clean(self): elif not self.topic: raise ValidationError( { - "topic": "Please select a theme for this non-public page" + "topic": "Please select a topic for this non-public page" } ) diff --git a/metrics/api/views/permission_sets.py b/metrics/api/views/permission_sets.py index 7a76eaf8f..91f94712e 100644 --- a/metrics/api/views/permission_sets.py +++ b/metrics/api/views/permission_sets.py @@ -42,7 +42,7 @@ class TopicsBySubThemeView(APIView): permission_classes = [] def get(self, request, sub_theme_id, *args, **kwargs): # noqa: PLR6301 - """API endpoint to fetch sub-themes based on selected theme.""" + """API endpoint to fetch topics based on selected sub-theme.""" serializer = TopicRequestSerializer(data={"sub_theme_id": sub_theme_id}) serializer.is_valid(raise_exception=True) return Response(serializer.data()) @@ -59,7 +59,7 @@ class MetricsByTopicView(APIView): permission_classes = [] def get(self, request, topic_id, *args, **kwargs): # noqa: PLR6301 - """API endpoint to fetch sub-themes based on selected theme.""" + """API endpoint to fetch metrics based on selected topic.""" serializer = MetricRequestSerializer(data={"topic_id": topic_id}) serializer.is_valid(raise_exception=True) return Response(serializer.data()) From 6069ae6eb50eb716c0096ac41891460f6cb80bbb Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 6 May 2026 12:34:43 +0100 Subject: [PATCH 143/186] WIP: Fix comparison function --- cms/dashboard/viewsets.py | 117 +++++++++++++++++++++++--------------- 1 file changed, 72 insertions(+), 45 deletions(-) diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index ec4050d85..b73183684 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -12,21 +12,37 @@ from django.db.models import Q + # TODO: Changed to use dict - not sure if this will also be required when my auth issues are resolved def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: + print("inside check permissions") + # TODO: the count and log is very useful for debugging but remove when the ticket is complete + # count = 0 for permission in user_permissions: - print(permission) - if permission['theme']['id'] == -1: + # count += 1 + # print( + # f"permission count: {count} ||| permission_theme =? theme id: {permission["theme"]["id"]} =? {theme_id} || permission_subtheme =? subtheme id: {permission["sub_theme"]["id"]} =? {sub_theme_id} || permission_topic =? topic id: {permission["topic"]["id"]} =? {topic_id}" + # ) + if permission["theme"]["id"] == "-1": return True - if permission['theme']['id'] == theme_id and sub_theme_id == -1: + if ( + permission["theme"]["id"] == theme_id + and permission["sub_theme"]["id"] == "-1" + ): return True - if permission['theme']['id'] == theme_id \ - and (permission['sub_theme']['id'] == sub_theme_id) \ - and (permission['topic']['id'] == -1 or permission['topic']['id'] == topic_id): + if ( + permission["theme"]["id"] == theme_id + and (permission["sub_theme"]["id"] == sub_theme_id) + and ( + permission["topic"]["id"] == "-1" + or permission["topic"]["id"] == topic_id + ) + ): return True return False + @extend_schema(tags=["cms"]) class CMSPagesAPIViewSet(PagesAPIViewSet): # This is the /pages (or proxy/pages env dependent endpoint) @@ -64,55 +80,70 @@ def get_queryset(self): queryset = super().get_queryset() req = self.request - - # for page in queryset.type(TopicPage): - # if page.topicpage.theme is not None: - # print(f"page.title: {page.title}") - # print(f"🦊 page.topicpage.theme (id): {page.topicpage.theme}") - print(f"👤🦊🦊🦊🦊👤 USER: {req.user} 👤🦊🦊🦊🦊👤") if req.auth is None: # Filter pages to find those with the is public field (and where is_public is true) - topic_page_ids_with_is_public = TopicPage.objects.filter(is_public=True, page_ptr__in=queryset).values_list("page_ptr_id", flat=True) - metric_doc_child_page_ids_with_is_public = MetricsDocumentationChildEntry.objects.filter(is_public=True, page_ptr__in=queryset).values_list("page_ptr_id", flat=True) + topic_page_ids_with_is_public = TopicPage.objects.filter( + is_public=True, page_ptr__in=queryset + ).values_list("page_ptr_id", flat=True) + metric_doc_child_page_ids_with_is_public = ( + MetricsDocumentationChildEntry.objects.filter( + is_public=True, page_ptr__in=queryset + ).values_list("page_ptr_id", flat=True) + ) # Combine all public pages into one queryset topic_public_pages = queryset.filter(id__in=topic_page_ids_with_is_public) - metric_child_public_pages = queryset.filter(id__in=metric_doc_child_page_ids_with_is_public) + metric_child_public_pages = queryset.filter( + id__in=metric_doc_child_page_ids_with_is_public + ) is_public_pages = topic_public_pages | metric_child_public_pages - pages_without_is_public = queryset.not_type(TopicPage, MetricsDocumentationChildEntry) + pages_without_is_public = queryset.not_type( + TopicPage, MetricsDocumentationChildEntry + ) public_pages = is_public_pages | pages_without_is_public - - filtered_queryset = public_pages - - else: - print(f"🦊🦊🦊🦊🦊🦊🦊🦊 Permission Sets: {req.user.permission_sets} 🦊🦊🦊🦊🦊🦊🦊🦊🦊") - # print(f"🦊 page.topicpage.theme (id): {page.topicpage.theme}") - user_permissions = req.user.permission_sets['permission_set_hierarchy'] + filtered_queryset = public_pages - # TODO: Global access check? + else: + user_permissions = req.user.permission_sets["permission_set_hierarchy"] + + if user_permissions.has_global_access: + filtered_queryset = queryset - allowed_pages = [] - for page in queryset.type(TopicPage): - if page.topicpage.is_public: - allowed_pages.append(page.id) - else: - if check_permissions(user_permissions, page.topicpage.theme, page.topicpage.sub_theme, page.topicpage.topic): + else: + allowed_pages = [] + for page in queryset.type(TopicPage): + if page.topicpage.is_public: allowed_pages.append(page.id) - - for page in queryset.type(MetricsDocumentationChildEntry): - if page.metricsdocumentationchildentry.is_public: - allowed_pages.append(page.id) - else: - if check_permissions(user_permissions, page.metricsdocumentationchildentry.theme, page.metricsdocumentationchildentry.sub_theme, page.metricsdocumentationchildentry.topic): + else: + if check_permissions( + user_permissions, + page.topicpage.theme, + page.topicpage.sub_theme, + page.topicpage.topic, + ): + allowed_pages.append(page.id) + + for page in queryset.type(MetricsDocumentationChildEntry): + if page.metricsdocumentationchildentry.is_public: allowed_pages.append(page.id) + else: + if check_permissions( + user_permissions, + page.metricsdocumentationchildentry.theme, + page.metricsdocumentationchildentry.sub_theme, + page.metricsdocumentationchildentry.topic, + ): + allowed_pages.append(page.id) + + public_pages = queryset.not_type( + TopicPage, MetricsDocumentationChildEntry + ) + permitted_private_pages = queryset.filter(id__in=allowed_pages) + + filtered_queryset = public_pages | permitted_private_pages - public_pages = queryset.not_type(TopicPage, MetricsDocumentationChildEntry) - permitted_private_pages = queryset.filter(id__in=allowed_pages) - - filtered_queryset = public_pages | permitted_private_pages - return filtered_queryset.specific() @cache_response() @@ -120,15 +151,11 @@ def listing_view(self, request: Request) -> Response: """This endpoint returns a list of published pages from the CMS (Wagtail). The payload includes page `title`, `id` and `meta` data about each page. """ - print(f"REQUEST.USER 🦄 {request.user}") - print(f"I AM LISTING VIEW 🦄: {super().listing_view(request=request)}") return super().listing_view(request=request) @cache_response() def detail_view(self, request: Request, pk: int) -> Response: """This end point returns a page from the CMS based on a Page `ID`.""" - print(f"REQUEST.USER 🎯 {request.user}") - print(f"I AM DETAIL VIEW 🎯: {super().detail_view(request=request, pk=pk)}") if request.auth is None: print() # check the is public flag & only return public pages From 7891edf1601e83c771b3370a1e86dde075f0b493 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 6 May 2026 15:22:23 +0100 Subject: [PATCH 144/186] Finish getPages endpoint --- cms/dashboard/viewsets.py | 59 +++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index b73183684..8310bdffd 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -10,19 +10,12 @@ from cms.metrics_documentation.models.child import MetricsDocumentationChildEntry from cms.topic.models import TopicPage -from django.db.models import Q +from django.db.models import Q, Exists, OuterRef # TODO: Changed to use dict - not sure if this will also be required when my auth issues are resolved def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: - print("inside check permissions") - # TODO: the count and log is very useful for debugging but remove when the ticket is complete - # count = 0 for permission in user_permissions: - # count += 1 - # print( - # f"permission count: {count} ||| permission_theme =? theme id: {permission["theme"]["id"]} =? {theme_id} || permission_subtheme =? subtheme id: {permission["sub_theme"]["id"]} =? {sub_theme_id} || permission_topic =? topic id: {permission["topic"]["id"]} =? {topic_id}" - # ) if permission["theme"]["id"] == "-1": return True if ( @@ -82,33 +75,35 @@ def get_queryset(self): req = self.request if req.auth is None: - # Filter pages to find those with the is public field (and where is_public is true) - topic_page_ids_with_is_public = TopicPage.objects.filter( - is_public=True, page_ptr__in=queryset - ).values_list("page_ptr_id", flat=True) - metric_doc_child_page_ids_with_is_public = ( - MetricsDocumentationChildEntry.objects.filter( - is_public=True, page_ptr__in=queryset - ).values_list("page_ptr_id", flat=True) - ) - - # Combine all public pages into one queryset - topic_public_pages = queryset.filter(id__in=topic_page_ids_with_is_public) - metric_child_public_pages = queryset.filter( - id__in=metric_doc_child_page_ids_with_is_public - ) - is_public_pages = topic_public_pages | metric_child_public_pages - pages_without_is_public = queryset.not_type( - TopicPage, MetricsDocumentationChildEntry + filtered_queryset = queryset.annotate( + is_public_topic_page=Exists( + TopicPage.objects.filter( + page_ptr_id=OuterRef("pk"), + is_public=True, + ) + ), + is_public_metrics_doc_child_page=Exists( + MetricsDocumentationChildEntry.objects.filter( + page_ptr_id=OuterRef("pk"), + is_public=True, + ) + ), + ).filter( + Q(is_public_topic_page=True) + | Q(is_public_metrics_doc_child_page=True) + | ~Q( + content_type__model__in=[ + "topicpage", + "metricsdocumentationchildentry", + ] + ) ) - public_pages = is_public_pages | pages_without_is_public - - filtered_queryset = public_pages else: user_permissions = req.user.permission_sets["permission_set_hierarchy"] - - if user_permissions.has_global_access: + print(f"USER PERMISSIONS: {user_permissions}") + + if req.user.permission_sets["has_global_access"]: filtered_queryset = queryset else: @@ -123,6 +118,7 @@ def get_queryset(self): page.topicpage.sub_theme, page.topicpage.topic, ): + print(f"Non Public Page: {page.title} added to allowed pages") allowed_pages.append(page.id) for page in queryset.type(MetricsDocumentationChildEntry): @@ -135,6 +131,7 @@ def get_queryset(self): page.metricsdocumentationchildentry.sub_theme, page.metricsdocumentationchildentry.topic, ): + print(f"Non Public Page: {page.title} added to allowed pages") allowed_pages.append(page.id) public_pages = queryset.not_type( From 828301a279deb8f25a50213ca99e998665f26dd2 Mon Sep 17 00:00:00 2001 From: Matt Reynolds <18287679+mattjreynolds@users.noreply.github.com> Date: Thu, 7 May 2026 09:50:04 +0100 Subject: [PATCH 145/186] CDD-3172: Remove permission_sets from CMS API --- metrics/api/urls_construction.py | 1 - 1 file changed, 1 deletion(-) diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index 28e4f7a25..6caa954cd 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -314,7 +314,6 @@ def construct_urlpatterns( app_mode=app_mode ) constructed_url_patterns += audit_api_urlpatterns - constructed_url_patterns += permission_set_urlpatterns case enums.AppMode.PUBLIC_API.value: constructed_url_patterns += construct_public_api_urlpatterns( app_mode=app_mode From 0152d03494192b3ca2ab19f15258fe4eaaaca745 Mon Sep 17 00:00:00 2001 From: Matt Reynolds <18287679+mattjreynolds@users.noreply.github.com> Date: Thu, 7 May 2026 09:56:31 +0100 Subject: [PATCH 146/186] CDD-3172: Update docstring --- metrics/api/serializers/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index ceb90ee65..66263373a 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -12,7 +12,7 @@ def _validate_user_id(value): - """Validate theme_id is either wildcard or a valid integer""" + """Validate user_id is valid uuid""" try: uuid.UUID(value, version=4) # noqa: F841 except ValueError as err: From 9d3e9b4186650725e958bba67f9a5b67625f5986 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 7 May 2026 09:57:01 +0100 Subject: [PATCH 147/186] remove redundant code --- cms/dashboard/viewsets.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index 8310bdffd..f14a57d6f 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -153,9 +153,6 @@ def listing_view(self, request: Request) -> Response: @cache_response() def detail_view(self, request: Request, pk: int) -> Response: """This end point returns a page from the CMS based on a Page `ID`.""" - if request.auth is None: - print() - # check the is public flag & only return public pages return super().detail_view(request=request, pk=pk) From 230a8935c5eae82a7d679468ed2f8b0c8474d4e6 Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Thu, 7 May 2026 10:32:00 +0100 Subject: [PATCH 148/186] fixes for existing unit tests --- .../models/test_child.py | 39 ++++++++++--------- tests/unit/cms/topic/test_models.py | 3 ++ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/tests/unit/cms/metrics_documentation/models/test_child.py b/tests/unit/cms/metrics_documentation/models/test_child.py index 08cc67a9b..8f214e5d6 100644 --- a/tests/unit/cms/metrics_documentation/models/test_child.py +++ b/tests/unit/cms/metrics_documentation/models/test_child.py @@ -32,10 +32,10 @@ class TestMetricsDocumentationChildEntry: "page_classification", ], ) - @mock.patch(f"{MODULE_PATH}.get_all_unique_metric_names") + @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") def test_has_correct_api_fields( self, - mock_get_all_unique_metric_names: mock.MagicMock(), + mock_get_all_metric_names_and_ids: mock.MagicMock(), expected_api_field: str, ): """ @@ -63,10 +63,10 @@ def test_has_correct_api_fields( "body", ], ) - @mock.patch(f"{MODULE_PATH}.get_all_unique_metric_names") + @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") def test_has_the_correct_content_panels( self, - mock_get_all_unique_metric_names: mock.MagicMock(), + mock_get_all_metric_names_and_ids: mock.MagicMock(), expected_content_panel_name: str, ): """ @@ -91,10 +91,10 @@ def test_has_the_correct_content_panels( @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") @mock.patch.object(child.MetricsDocumentationChildEntry, "find_topic") - @mock.patch(f"{MODULE_PATH}.get_all_unique_metric_names") + @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") def test_get_topic_delegates_calls_correctly( self, - mock_get_all_unique_metric_names: mock.MagicMock, + mock_get_all_metric_names_and_ids: mock.MagicMock, spy_find_topic: mock.MagicMock, spy_get_a_list_of_all_topic_names: mock.MagicMock, ): @@ -122,7 +122,7 @@ def test_get_topic_delegates_calls_correctly( spy_find_topic.assert_called_once() @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") - @mock.patch(f"{MODULE_PATH}.get_all_unique_metric_names") + @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") @pytest.mark.parametrize( "metric_name, metric_group", [ @@ -134,7 +134,7 @@ def test_get_topic_delegates_calls_correctly( ) def test_metric_group_returns_expected_string( self, - mock_get_all_unique_metric_names: mock.MagicMock, + get_all_metric_names_and_ids: mock.MagicMock, mock_get_all_topic_names: mock.MagicMock, metric_name: str, metric_group: str, @@ -155,7 +155,7 @@ def test_metric_group_returns_expected_string( # Then assert fake_metrics_documentation_child_entry_page.metric_group == metric_group - @mock.patch(f"{MODULE_PATH}.get_all_unique_metric_names") + @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") @pytest.mark.parametrize( "selected_metric, extracted_topic", @@ -172,7 +172,7 @@ def test_metric_group_returns_expected_string( def test_find_topic_returns_expected_topic_name( self, spy_get_a_list_of_all_topic_names: mock.MagicMock(), - mock_get_all_unique_metric_names: mock.MagicMock(), + mock_get_all_metric_names_and_ids: mock.MagicMock(), selected_metric: str, extracted_topic: str, ): @@ -206,12 +206,12 @@ def test_find_topic_returns_expected_topic_name( # Then assert return_topic == extracted_topic - @mock.patch(f"{MODULE_PATH}.get_all_unique_metric_names") + @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") def test_find_topic_raises_error( self, mock_get_a_list_of_all_topic_names: mock.MagicMock(), - mock_get_all_unique_metric_names: mock.MagicMock(), + mock_get_all_metric_names_and_ids: mock.MagicMock(), ): """ Given a metric name that does not include a valid topic. @@ -246,12 +246,12 @@ def test_find_topic_raises_error( "cms.dashboard.models.UKHSAPage._raise_error_if_slug_not_unique", return_value=None, ) - @mock.patch(f"{MODULE_PATH}.get_all_unique_metric_names") + @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") def test_public_error_raised_if_invalid_classification( self, mock_get_a_list_of_all_topic_names: mock.MagicMock(), - mock_get_all_unique_metric_names: mock.MagicMock(), + mock_get_all_metric_names_and_ids: mock.MagicMock(), mock_slug_raise_error, mock_seo_title_raise_error, ): @@ -280,12 +280,12 @@ def test_public_error_raised_if_invalid_classification( "cms.dashboard.models.UKHSAPage._raise_error_if_slug_not_unique", return_value=None, ) - @mock.patch(f"{MODULE_PATH}.get_all_unique_metric_names") + @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") def test_public_page_clears_page_classification( self, mock_get_a_list_of_all_topic_names: mock.MagicMock(), - mock_get_all_unique_metric_names: mock.MagicMock(), + mock_get_all_metric_names_and_ids: mock.MagicMock(), mock_slug_raise_error, mock_seo_title_raise_error, ): @@ -316,12 +316,12 @@ def test_public_page_clears_page_classification( "cms.dashboard.models.UKHSAPage._raise_error_if_slug_not_unique", return_value=None, ) - @mock.patch(f"{MODULE_PATH}.get_all_unique_metric_names") + @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") def test_non_public_page_doesnt_clean_page_classification( self, mock_get_a_list_of_all_topic_names: mock.MagicMock(), - mock_get_all_unique_metric_names: mock.MagicMock(), + mock_get_all_metric_names_and_ids: mock.MagicMock(), mock_slug_raise_error, mock_seo_title_raise_error, ): @@ -337,6 +337,9 @@ def test_non_public_page_doesnt_clean_page_classification( fake_metrics_documentation_child_entry_page.is_public = False fake_metrics_documentation_child_entry_page.page_classification = "official" + fake_metrics_documentation_child_entry_page.theme = "infectious_disease" + fake_metrics_documentation_child_entry_page.sub_theme = "respiratory" + fake_metrics_documentation_child_entry_page.topic = "COVID-19" # When fake_metrics_documentation_child_entry_page.clean() diff --git a/tests/unit/cms/topic/test_models.py b/tests/unit/cms/topic/test_models.py index 9b0c1f156..1d9bd39ae 100644 --- a/tests/unit/cms/topic/test_models.py +++ b/tests/unit/cms/topic/test_models.py @@ -772,6 +772,9 @@ def test_non_public_page_doesnt_clean_page_classification( fake_covid_topic_page.is_public = False fake_covid_topic_page.page_classification = "official" + fake_covid_topic_page.theme = "infectious_disease" + fake_covid_topic_page.sub_theme = "respiratory" + fake_covid_topic_page.topic = "COVID-19" # When fake_covid_topic_page.clean() From e0030e583772a7a1e52f7c708bd88ac055a39227 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Thu, 7 May 2026 16:02:28 +0100 Subject: [PATCH 149/186] Update imports --- metrics/data/managers/rbac_models/user.py | 3 ++- metrics/utils/permission_hierarchy.py | 2 +- tests/factories/auth_content/models/permission_sets.py | 2 +- tests/factories/auth_content/models/users.py | 4 ++-- tests/integration/metrics/api/views/test_user.py | 2 +- .../metrics/data/managers/rbac_models/test_user.py | 2 +- tests/integration/metrics/utils/test_permission_hierarchy.py | 2 +- 7 files changed, 9 insertions(+), 8 deletions(-) diff --git a/metrics/data/managers/rbac_models/user.py b/metrics/data/managers/rbac_models/user.py index f4a3194d1..65e6ce0d7 100644 --- a/metrics/data/managers/rbac_models/user.py +++ b/metrics/data/managers/rbac_models/user.py @@ -9,7 +9,8 @@ from django.db import models -from auth_content.models.permission_sets import PermissionSet +from cms.auth_content.models.permission_sets import PermissionSet + class UserQuerySet(models.QuerySet): diff --git a/metrics/utils/permission_hierarchy.py b/metrics/utils/permission_hierarchy.py index 53c9861a3..c5a182204 100644 --- a/metrics/utils/permission_hierarchy.py +++ b/metrics/utils/permission_hierarchy.py @@ -10,7 +10,7 @@ from django.db.models import QuerySet -from auth_content.models.permission_sets import PermissionSet +from cms.auth_content.models.permission_sets import PermissionSet from metrics.data.models.core_models.supporting import ( Geography, GeographyType, diff --git a/tests/factories/auth_content/models/permission_sets.py b/tests/factories/auth_content/models/permission_sets.py index 7f73e093f..633765392 100644 --- a/tests/factories/auth_content/models/permission_sets.py +++ b/tests/factories/auth_content/models/permission_sets.py @@ -1,6 +1,6 @@ import factory -from auth_content.models.permission_sets import PermissionSet +from cms.auth_content.models.permission_sets import PermissionSet class PermissionSetFactory(factory.django.DjangoModelFactory): diff --git a/tests/factories/auth_content/models/users.py b/tests/factories/auth_content/models/users.py index df74effbd..ff76242f1 100644 --- a/tests/factories/auth_content/models/users.py +++ b/tests/factories/auth_content/models/users.py @@ -1,7 +1,7 @@ import factory -from auth_content.models.permission_sets import PermissionSet -from auth_content.models.users import User +from cms.auth_content.models.permission_sets import PermissionSet +from cms.auth_content.models.users import User class UserFactory(factory.django.DjangoModelFactory): diff --git a/tests/integration/metrics/api/views/test_user.py b/tests/integration/metrics/api/views/test_user.py index 0742215d3..1264f058e 100644 --- a/tests/integration/metrics/api/views/test_user.py +++ b/tests/integration/metrics/api/views/test_user.py @@ -5,7 +5,7 @@ from rest_framework.response import Response from rest_framework.test import APIClient -from auth_content.constants import WILDCARD_ID_VALUE +from cms.auth_content.constants import WILDCARD_ID_VALUE from tests.factories.auth_content.models.permission_sets import PermissionSetFactory from tests.factories.auth_content.models.users import UserFactory from tests.factories.metrics.metric import MetricFactory diff --git a/tests/integration/metrics/data/managers/rbac_models/test_user.py b/tests/integration/metrics/data/managers/rbac_models/test_user.py index 8e085950f..7c75ec8ac 100644 --- a/tests/integration/metrics/data/managers/rbac_models/test_user.py +++ b/tests/integration/metrics/data/managers/rbac_models/test_user.py @@ -1,6 +1,6 @@ import pytest -from auth_content.models.users import User +from cms.auth_content.models.users import User from tests.factories.auth_content.models.permission_sets import PermissionSetFactory from tests.factories.auth_content.models.users import UserFactory diff --git a/tests/integration/metrics/utils/test_permission_hierarchy.py b/tests/integration/metrics/utils/test_permission_hierarchy.py index 62ee32dba..f827f6e7d 100644 --- a/tests/integration/metrics/utils/test_permission_hierarchy.py +++ b/tests/integration/metrics/utils/test_permission_hierarchy.py @@ -810,7 +810,7 @@ def test_empty_queryset_returns_empty_hierarchy(self): Then returns empty hierarchy with zero counts """ # Given - from auth_content.models.permission_sets import PermissionSet + from cms.auth_content.models.permission_sets import PermissionSet # When result = build_permission_hierarchy(PermissionSet.objects.none()) From d021645e797c4c86d58e1061c9a92d5d7a304886 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Fri, 8 May 2026 09:38:36 +0100 Subject: [PATCH 150/186] add endpoint back in for testing --- metrics/api/urls_construction.py | 1 + 1 file changed, 1 insertion(+) diff --git a/metrics/api/urls_construction.py b/metrics/api/urls_construction.py index 6caa954cd..28e4f7a25 100644 --- a/metrics/api/urls_construction.py +++ b/metrics/api/urls_construction.py @@ -314,6 +314,7 @@ def construct_urlpatterns( app_mode=app_mode ) constructed_url_patterns += audit_api_urlpatterns + constructed_url_patterns += permission_set_urlpatterns case enums.AppMode.PUBLIC_API.value: constructed_url_patterns += construct_public_api_urlpatterns( app_mode=app_mode From 8f67ea2687b8613836da5843428423cb04ab30b9 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Fri, 8 May 2026 10:18:24 +0100 Subject: [PATCH 151/186] fix import --- metrics/api/serializers/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metrics/api/serializers/user.py b/metrics/api/serializers/user.py index 66263373a..24f0dbcff 100644 --- a/metrics/api/serializers/user.py +++ b/metrics/api/serializers/user.py @@ -3,7 +3,7 @@ from django.db.models import QuerySet from rest_framework import serializers -from auth_content.models.users import User +from cms.auth_content.models.users import User from metrics.utils.permission_grouping import group_by_geography_type, group_by_theme from metrics.utils.permission_hierarchy import ( build_permission_hierarchy, From 69afe07c3625f5564b3043bc1309a0084c0bfa7c Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Fri, 8 May 2026 16:21:54 +0100 Subject: [PATCH 152/186] New tests, and fixes and updates to existing tests --- cms/dashboard/viewsets.py | 31 ++-- .../data_migration/child_entries.py | 8 +- .../metrics_definitions_migration_edit.xlsx | Bin 14270 -> 20329 bytes cms/metrics_documentation/models/child.py | 49 ++---- .../cms/dashboard/test_viewsets.py | 151 +++++++++++++++++ .../dynamic_content/test_page_link_chooser.py | 3 + .../data_migration/test_operations.py | 7 +- .../models/test_child.py | 12 +- tests/integration/cms/topic/test_managers.py | 6 + .../utils/test_permission_hierarchy.py | 20 +-- tests/unit/cms/dashboard/test_viewsets.py | 56 ++++++- .../data_migration/test_child_entries.py | 12 +- .../data_migration/test_operations.py | 7 +- .../models/test_child.py | 152 +++++------------- 14 files changed, 324 insertions(+), 190 deletions(-) create mode 100644 tests/integration/cms/dashboard/test_viewsets.py diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index f14a57d6f..b5bc1421d 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -13,23 +13,25 @@ from django.db.models import Q, Exists, OuterRef -# TODO: Changed to use dict - not sure if this will also be required when my auth issues are resolved def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: + if not isinstance(user_permissions, list): + return False + for permission in user_permissions: - if permission["theme"]["id"] == "-1": + permission_theme_id = permission.get("theme", {}).get("id") + permission_sub_theme_id = permission.get("sub_theme", {}).get("id") + permission_topic_id = permission.get("topic", {}).get("id") + + if permission_theme_id == "-1": return True - if ( - permission["theme"]["id"] == theme_id - and permission["sub_theme"]["id"] == "-1" - ): + + if permission_theme_id == theme_id and permission_sub_theme_id == "-1": return True + if ( - permission["theme"]["id"] == theme_id - and (permission["sub_theme"]["id"] == sub_theme_id) - and ( - permission["topic"]["id"] == "-1" - or permission["topic"]["id"] == topic_id - ) + permission_theme_id == theme_id + and permission_sub_theme_id == sub_theme_id + and (permission_topic_id == "-1" or permission_topic_id == topic_id) ): return True @@ -69,7 +71,6 @@ def get_queryset(self): `, , ...]>` """ - queryset = super().get_queryset() req = self.request @@ -100,13 +101,11 @@ def get_queryset(self): ) else: - user_permissions = req.user.permission_sets["permission_set_hierarchy"] - print(f"USER PERMISSIONS: {user_permissions}") - if req.user.permission_sets["has_global_access"]: filtered_queryset = queryset else: + user_permissions = req.user.permission_sets["permission_set_hierarchy"] allowed_pages = [] for page in queryset.type(TopicPage): if page.topicpage.is_public: diff --git a/cms/metrics_documentation/data_migration/child_entries.py b/cms/metrics_documentation/data_migration/child_entries.py index 8aa39c00f..1cabc006f 100644 --- a/cms/metrics_documentation/data_migration/child_entries.py +++ b/cms/metrics_documentation/data_migration/child_entries.py @@ -3,6 +3,8 @@ from openpyxl import load_workbook from openpyxl.worksheet.worksheet import Worksheet +from metrics.data.models.core_models import Metric + def build_sections(*, sections: list[tuple[str, str]]) -> list[dict]: """Build metric documentation page sections. @@ -37,10 +39,14 @@ def build_entry_from_row_data(*, row: tuple[str, ...]) -> dict[str, str | list[d """ title: str = row[0] page_description: str = row[4] - metric: str = row[1] + metric = row[7] + topic = row[1].split("_")[0] sections: list[tuple[str, str]] = gather_sections_and_omit_if_needed(row=row) return { "title": title, + "topic": topic, + "theme": "test", + "sub_theme": "test", "seo_title": f"{title} | UKHSA data dashboard", "search_description": page_description, "page_description": page_description, diff --git a/cms/metrics_documentation/data_migration/source_data/metrics_definitions_migration_edit.xlsx b/cms/metrics_documentation/data_migration/source_data/metrics_definitions_migration_edit.xlsx index 3efd453c9755b8873828f6e5431ba50c2eea99d7..6d71013d1bb76aea723856efde7e19900896210e 100644 GIT binary patch literal 20329 zcmeFY^LJ)Jw=Npnwr#6p+qP|69XlP{wyloMH%Z5~?c98OpF766W1s)v-1S4PQ8jB6 zX3eLbRdd!-lmP`p1A+j80s;ae1~R_NvD60!0!jb_0zw6X0?`q1uy-}HcQsJ;ax`<% zWAL=IB`O31p~?pW`KkZ^KmQkNpgMU{evk=C>{;?dhvjzKveDkIH8^6VOBpUYXslmNAD5GHc=Uq24-@;3 zv$ocCJ{qndGq!<2ta%ntx>MVrGMnhGkaRVUB{~Lw;C{~9s52eZMf$ct0_jU-;$D*y%5yi@8Ic-&eP(rvWTq z5I{iR-=IK>{~uVks4{{|8eC!#pnSdlL;~^y3MXc%{D7Asu5?e%uyV*yHhop-08&o2o$NytueUmr#Y>fD6 zkF7cy1??AUt4B>(+MAObBn^dgs<>12?hvv&;1TeYAtCKW<=zudQ`KHxATz#AD!zCt z+JH36pn(I8T7nmb!Iu%FJtC*IW&BbL3=meitO=`ciJ+kY>b)rb1&OD~^2rcAk^lKLFwUfe_-4W;|^fJsg~Ej2#?o z{=-|vs!9%7Oi2ED&EJ6^@-e7H)HD^HrSl|qD$TW*CI?uQn&4UgN~KgE`>x@L?K0D9 z#SC4tUq3cCJ^1@pQ$wG}T9BnnBOX8woW@lt4tT`c0!qK2yhfBW=7ptl3-LKmj6WVH zU&T+uOOz1_fFzR&H`ZtdR_n9VmL61x$vxGj)0ToOR#sIr7pD%g*Bkpki6We&LPhC? z3%7q4sM8L-h&FT{EVL43VlILBB}GrTT9W%9>PkI|=)KCOpbOH85nt2-i|LR5`KL`I zmj)ivL!Q_Ma2&l!01ESOLv5FDr2$`@C{MWrX0w;d8&Uv~9aDm7E#vkiRE}T|K&uUd zROk63s(J}T?+Z-aS+oQz23V2@9rz*9^EjRt-M&hreblDZN>rU;((9GL9H1|)p37?H zmRLfSuH6(NcGmU+3V|4*3k?HrUWkr6AV94Hv@~nUTRxA`1Iph~n9LY{V>K>|FZcFM6M>Z{$(OoR z@p`84$A zi}^di3RbiqLan{-TL%r9Q-}oSxc4U2mJ<#pMc;^tqcHGwxA|dy;ycS==<&7*-OJ2) z=2Y6&gUOay?{P~mFDPUQ$&AcNP0hR>YY z98>D}YR_dHVxVL_qm{orm22z0Ka$e}hTzJ{DJ7mOzd_)gnphgK=@5fqTg%7U5h}EQ zG4pXN@ME_B)3Ag1lhf{hB%MFTjROP){A1YvO#uJ9dH=s;0Q~c9_5+XqyPs-hIhi3Q zq#oF>a3+r|cXZ@`u8brXYUhYhMB!r+gaOolTn-B} zy40?BU4`E#sQcFM=q4Yo%@4*$0oy+-{Xb165(bfU5&;MZR2>Ki>!-&5+6!GQ&CFa~ z82{_V{GUy+I$OtqW*j|qoAgkCh~O@n*_Bgc^vSZFErumhoVA13#c^F6bOiXqB~JYH zx}p|V&*u^V6D!Zg%>QDd(ct}fwbp`r)%)GJ-pU^}=+qR@<3P!|>0afjcTLDyH~V}U z@J0GXG}8|2Jl+ zly{EEZ38u;Av#Tuy|mOf9&JsqnFa)I_U{yDeEV(A_~*;*uCIXYjM3tc`5OO_zR$K# z$LDrtiFRL&8j)Mq?JwdEy(h@anZX?Pxjv5JW0MD;^Owf&ZZ2DbfVFOnk=T*ZGC-L@ zv|R%~y7}tRhtKzu%B^cA#(E93#g>89uEP7LgIP5p{33f1!6og4lE(SsQIX@r(A?5M_L?Z&f2%~579cB-3 zlK&+xQ~Xy+q7g$B69PY$X_2%vM#)19Yogn;t(!LA2L4l?4h1XrAl|mRS+0Wpv~D$b zEJ<3%qGnahtqAr(ubr-$tPdOI`l!fr?Y&amS!5(>jMa=NNn{1-6fQSv1(|u~7#Z`5 zr~*nyu7m*!tQ;I!!^8FRiGiFO8;=Nn(S_C)OJ893X?sJjS@L`9?VDFW@yU5a^;Fm{ zk}7K>q*dkb7$HMBGDo~)uprQkUqjuaXNrS~x=3IMhYQ4!>?+DVI>faNNhodGeH~nF z5bvo@#of>^=E6u!GpeNJjL;xknW$h0ZdXVJjCg0@LWT@7;aq-AXGk!9)8i3u=2qS+ z6sP8|1#o2G^2|a=V5}kHiiRj))tr=2nv5QjbdE6Ynn++5^{x@9?FR6~U<`Hc>G*Gh zSw<#7lY&G{TsFc;j?1c~R7^h|Wubx<9w0=5&-5d}1_$D&B8*QAZ;p!8d3OmLu|_mH zuHa>wf6*67da>RBLWp*%D#R7_QNXf)y5eH=h)BSkswaY)I7ec1P&e6eZUKdu$(+;? z2ll#s6G!UqwE{bI3wibGWibn!!Z*ecwkU>Np2A0&TnD|xz=>3@?!jTi-#e^fcF-y` z@g57Cu5&QUes}NIKP~Ws4t^qsN_P=QVmi(yGMw2UHd9i=2fG>k%Nj}VFu{JZz_$-B zSNTl25Hv4Hxv_-~-3ABtCzy~D47g2>eF3s<8VbbzXKn}%S4hNGu5yiWx}GB`+3L#A zF==cy%vRW{Ry-Ga4bt_A#pQmw;mi=2#xX)#*uQOL9 zs8>JMjw5iOSi&;oFhSsPk;g1At4Z%+1<4%S(lJ?GVOP+nyCUan>KJ%;x@k5iphj{U zwXC_Vbxx4`_lX?p{H5Bq7PpbvquNFhe_htlxh1zN_dPGr)Ol7NgZ7OkCN|Ul6PwA1 zYD?6FXbMsDgq>{Xr$)UT`IeohCHV0fs~4rbTwB2SI)BO{MNpJoVF;-gk1?f6`G z<8Epltzq#7Q-PFlKC`F)1Q9)FbO@{w51zdO1eLmT3~n-TqaU?KeP~&BdrE_8EfKj6 z3x~#7*xuV6g?yt47b7L~@15>!7Rl&I5~E%5@WGmJAZRD2dt$+cc7F)_t~PmHsUHf8?J+ z_n~6i5W%5h3!$hkf2p}x%VLx>?-f_+J0m7Q$<;>B4f4eSXC%CbwE=*EWSpiioqOBt zP7+zu{o-g10r}m-cqMHk9wPnkmq1MsNNJiFjebEf+L)VUHLkUyG?=8yt0;u*Yl&eg z($l?11~as$xzd;gkVQq$;eF7`xa@dxu(;N-jW2}=bXYu(UGJ3RlK$dm8Rew8EWLYW zgRh}by+1o*@hYBxeY9)@R{329q_X7j&5KEwUTYtUiNWN*X1ZAOnIu7IfLc?Wa-{O+>|}+$o?6L@6n#3})D3*k zl?-V`r}Rp)@@b}$wwpxCv1f7F#7ZuT7Q=83iBD2o$EHYS+#^Rx{cPJqvP06c9+_04 zXY5%oFG{6}iTNL8X?hW?61B31C7uvv&*P}PG*v#(Svsnc9sYAU?{gUw&)phZau8gA}0554i^VjDX6oogf;He_=q2Od!-NaNC zcDqoKpEwumU`k8A<0)!BV@ti`DM?f#4BoQsKFdPPx=vkX zI`Sq{y*3HYQdvDHRYg=gZn#m8jV|X^RCasdDN}S)cZj}ivrp|pur_O_ZRPya2RMD%vx$@WEB7xJ2MLATz!NY7Y|77(~jw8$> zPf7DhEm$(;3eX}CA@ZVke(}k~nZKx)FxCA20$a@&jRTTd$tk+}vso6?mY`OS^2Axp zLgk|7#95l6*AaCE55o#|Xe^+l%s03~=DeD%hoq(}SeC~jqALvhM=#j_S%~Bn#)3O0 z#)8hXS7(@3*Ro(h@;k z!YZqqDc-g_+5Khbot{BECg^tPCQbdH<&ig`u2Yfr+Eh{gDH1nAbU#hG@#-;$zPIzv z%BWit4aTl&mZwfx#x4;nYf#lLOVF{1T+KrI5HRd&$+1HVF=ufgX=bNTIYAHq4I-=@c&dRh} zn~|WKOB02!YqW}HItvYU(HSoWMk%I}SAKD>rJH=gS&(y1XXf-HL08)&3_!|;<+5<) z((r6Ge+64iwTyJnIww9ti|@w{rp~gJD@Q}ElvPL{CC$9W!WxVe6>R6WQq|kG8jP6H+7M7ooN#@9}VIGY18W;9B}5xXbuJiXsxzoF$*wB zzYJ6^T4r7Y9hVP+P+;~UnJ|N*(Bo{z^{31Dy{l8ZN#tI5XnQXiV);2vwFTUnR0d>V z91M2Rwj_6{GtybQ{X$hHhWwwLWCW&(3>g1*7iMh;dwP%>vA0vL_*6T03mY4#N!=z# zVu#|dSLFQRL6SCJsF7`m=6P;72`GU*<`bfJYq)>Z+z6KUp+Q%XuA%&hP&RIHe^RA$(nS-WhTq-eKUe2STMZLB zHcE5*ORcM*2=hM8svs3$9N;?hlMO)*9eSaj7Rx7z6!B~`t?F2N)Us*xDy%n39%|3s zL93>wR*vCOZjtXqTsvC^fUa9T-ijrYpV;&c&yL%KAU16Jvne0os*`BaBBpFF4A6 zE`Et4p#UN&Ok{&k8wj0Lx<%qV;R?ldO~#>$)B=Rr4RpklBZ6`4 z-Z2#>W1*~)C^if^*>RTtD>!LdyNa63xo(LR4LbZggiwkA7QuOl_Tq9xO-=B$aFqRb zfi#ASj37t|BPfa-V|p-CEbDbm#E)_Y9pzm;H^DQ+V`hCo(b#rDjyg22Bxu+>IBTey1qTAspVUq{5> z%}Smx+dg+)HSOdN{Zo9r>gujDxrmM{2C?s{4Bgd1hFqse<4D9#m1Pvo@i)? zQj|ZOd`%qdk)fdQPxJqn`RYPgZvqkKJS=@MIjrvAD_o(vHVo-A^CBa1Rk2?k-EQPX zF*Jhmvs&iEit&~JrPV=ZNtAN27n18$_V4?5v}QJEMPgNP$rSzlF18= z{c0rQ@5#l_TbrgJEbd=MEO17EhX{kO9->Z?idjR-m9w^-Ork`?-x{S*wrNk@n;1OL zc>7yTv|{PGh}y_t!57AuiXlC&q^ypWpaE&_EiqcAc0a)@?gXD&PTkEGc+Myyx^lEs_$4XT*5N<{>wil?NBv`Slc+CLbE%lzeoRPG{P z{oJzv;~pNJ1~@c=1UXF_od;zvtx7}hMUzIZ5A}bhBE}W5_*jMbO#8*@U$lDZqo0*t zRMEsH4P-?ss!O9F1ln2w4|XZwrD`6PHCru#nc_&*ZZ#JyF{Vv6H}y*tvSjfE9fV@Ls_CF`E7xitiU4F#9u*Pu=mrN0GWSE0Tk| zWS<0Xbvn&*`@2q@upeOkaNQPTi52U? z{7PsU2AKk_k|DnDVDncbCT&es;;8HN$a}ERNykrU<`iux#(~Vv+cgpa-OYG z>h;Q=(sP<*9aZ?0&D_Eh?WhzycsMa9fFNy7Ku{2WSjm-k`kaiJqr^Q?*5O0nk$fev z27N*$w#j}Y4n?1qu3w2IXkly19=?3o<>9F|1Kbx_2d+s+%s7~%sb5y5ZNSm#o_Feg zChAIZ?!;d!ID(51YCaS0q1K>y=Eg6$0FuPubGgfu>zaj+(o60M5k$lT~S0zQGfh2igz#==}2$0%Phqt(___wkMc=)t2$z?F|Et0MK1# zP+cSS8erEzXurgVDM(HGv&Vq=Sn8&OJEr6IN$=A#%KcZ<=gh z8qg1Gsg-NSvVNfy5)m6>_6gMfL2@*agvx3pCMG&mA`2;6Ma~uhHBe>mr+D| z{L~kby|}5{m-m?P%HL4F&hS zK{F(TVw3AbBEK{Ic?KM>;nmx3dr9lj+3#y&=mIm4!$8&EyVoA_OYg=Qv73u#KndCv zs)MU&-kEA5hrtocD-_E~H5hghFXiSp;!dxi!G1ZIg#=#8&c)b~k1rz6*miym{!cEV z2qhc;RsGkUcLmraQJ|TfKS89yY4*Ug=@g{cBx2Kic*@gHZ2JG;Z?4}JS||O9yDiZH z0m1(_-MYAX*_yfh$H{!5ZRfD}AEI6O45!+*$_`Of2ChPhXkM^xQTAEzgC9_|xerEJ z4CUJl=)?X+UC=@en~bpup%%+`JH^UKpn&(xU$ZdeSu8ewSM9K-F~nwxajcR${eEQd zY*b}~LmuB~m3)AGnPsD$nFkT@HBbA>Q{aJ^>$p$(> z$G8w7(v+@qSeQTGJS1dg%pKxd&1RfI&Am~4%P+f7{?e=4yvM62nk}dK_eJ}LC6=xC zm3Q`zB$Eut&8)3xRr6oYUuJbNdeMj8grvsyu}R;Ny1=@WS#!El#@)A62eI2QR7d`p zq)V-#e+zzUcw-fI=nshn?^8k!sTSRAd%hXByhp zm9bWfvB|94V^`Y-9*uIrLp&RXAHVZ9#a#we`P_z37V#UUdhpKy^?`2;!wJ@dp}n)V zDKT5j*_VG$pjL(uPU02DY|q_8cf@ilDX+=6gaNkV5p)(wlvh3eJU>j1YR~+iV=hdl zTwF=!&S#+#bduwREE(kc!|9UdS>uKotzz9gv8Z&h_cyT+9NBy+eOaT=nkIM5U=4S8 zZ|EE+sQKV&!W)5EOsqlf<9cy;ZxxK`dpc>Rh;!uW#CrQdlugc8P`U<2mTo!GhoT7S zzgNeh_Njp+2^aTlK()75_w7p;n!{vs-$D8;BwO={ZkAsNc-fhKClP!UH%3o>(#V*s zhx#(sYVN;vu@}iY2CJW*FQ|=-#kw-F|FOWUd|_K_c!mz(wDg98_jIO=H&}~Ci#2|A z%2GB%4bw(pQF%95{%Z~+5pY=Bj>=w4kA5c45XmYh8-d_m;fC9`rU(jlLYS*1u%lW+ zGn>VjZS7;ufNx6~$6H)YhkNs2O)0!#2c@m5B&XAAvzA%DTo0LUN}d4zP0(#JR(hL& zc|pnacO?ny1Z`_;2S25${oj321je%~`Oq#-dnO+%fZsYOt`ESI)8_b(Z%uT1{xRSA6A?Jo0+|)Dr7GdUshZs{+a? z6XEmkzR$f|RfYhp5}Z?r565u^tEs>la*90GVE;*DXBl{?ifAf|HbDbv@NRG%984pt z7j-W$^c!rUJ|n&QUjyA1dUG3nR>e+Si8nzeVtVD*BOgjJ)$8wrNnil?VsDY#*rzY~ zk5;hSe`_H86s-K)sdghYQ{_cZb@EE!S8Z8lyUXmh>YVP&ZBKwa_f~jb*agnMJ$bK% zh_LZL(w`c8XO*`9aHj$!I zSke5W=ZDQQ_9n!P=6Ecelwfi)6IPqVp3j%e_eNOITA>J5(Ohx@!-s0CILuei5@bze zkt$jsoB%qwMEU58V+F57#C4awL_x(v%LHr)SP&1w4w^3Hx9aL&#c+6ulY@M+7B^S- zMX|aiiA!)j-RnCSOStilv_aV zs9o2tV>K2@>L(~&fGg2W^|pQQnz^PNr7}J|pMsVCD|}DI6wOxp_-+!<>#(L|3c9NE zAeb+|rZxv-mv9vIOK2MxJg*zVAFlYH(aUQC44-cTARr=9U?8;r#U_?U&Ss`6uFh8W z7XM8udXx5eqkr_`fxkt9`*)Oq3<^h6>xgfKQ>$r>=^}~9y`)q<$h&M*^#HYyr@ge_ zgSHkN_f}tjxREnb9wWZJ+ct0fzG%K3Qr{{r<@iI{{=O$yDk>#E zOc_f*Bc~CY6Uv7J8&6zWqU(kyof*q*yn=$fKUXf7em!NF5e^ySQ}6rkzg$Vs>u1$o zpILCNf?ch!Lclk&p4&g0^GM)UsxM5oJ9#JpJpLjKP# zEE7Us#suS&Ar_3;kn<@~6pFa*wId!Imub7&ydHOR!Yu7MuMaeZ9O4;hT!P6H^{X5| zJefpK)YI9J;|Zc$`)*=~1Sp3Gx^7L+BrB}lY*bl@G88hU6O&xh#QY8({ zUAA$+0eiple{HqAoUA+aejfF_29B^^La@qeBQ#t9{mPcSUCH~NzI+X^l;hM7$u+iM zd2A3cp=9p*ZFCCC*q$g-2<&uh=skn!>V_JMx(oI){bGo07QeBmDAq?Oioq}YU~~lsyLmCQO}%ygazc+fy+as zL9QIA6YSK9k~KKKaK(jq4nOFmi{ZF@dlX2Tt0Zv5C3aci$|fVgt;=Rz@!WbM@g%+{ zy*y?0eSP!=bU}Uo6BWmjg&Q@*<}#MZnfMy6nxy0v^(DF**>a58_E+G$m}Y|sXE}P! z4<+@rKoXZn5oZOQmasnRey|IT_O{;+{%Y}W_&(2CN#W0kxy}y4ky4s~(g_~eTT-Z} z4!U8+`V4Lm{4&BZ5+=YB%M1)8zg6y3BfXH-%$!EXc)jFg^y}lrTx;bjJJgxo?Rh<) z{q8vM3F4pPi#e0o)zfHt|0zNL{Gynw*0UWSbHnTpF<7OGiX2i8Uj;4R}Rx>bf8mL)!puDM4;Jom1cP zEJAakOCVr3*(0f!cKh(srJgf$Usp%gr>lo|S*K zz1IN&QLP%648kOn*r`362n4^UvrG(Q56U=b+Iyd}=~DD~w1=Z9Shq+$jfuF(!9}7) z$dyFY0Ue=Ws}kN0GudN-+)Fb&SbUu20(Ox4)Y-l5fh4rT@)oujU;2DT zMbTK*%7vR5jjk^l9#{4e1JvaVF~@`nyy>&oHkkX#GkZdl!6=DH%3{mnPTmqF^m_~0 zHJf&dp#&6YWy)DdvB8m=ucH=bOTOKunqtrVN)w7Q5dtdrGSexlK&<-9$JQmE{D)BG zvbfA2rnV^p&E$k_3fG=UcJSA5B|csv39?bA+Cqu6#Sq>}@DpnEP|4|8z4Cx=`fDZR zYh5v<$Ea^Kze=NOv5L9;s!R)EhGt$!H$a36K|0}%Z*4NpGXCo%!tYv=Ao3y*a9K7J zZiQw>pT{OG?Y8aotKrp35DkbMyeE+g0gOqO$V4nb9wPs%@XM&!g>EAmS|o{Uvqg4Q zXo@;cyvhlJ>;zr~v}{%~;yUl;LNs?F+HvziJ}BpR+Up!>$ob}mp*t`b>@9UB{_IMg z(($5Wrf{yH4i}Z}j$Ol~js=vNR4h;|j}eG8B!9AI39}HPv$46qt4YHks^t*$mFWT4 z?a*&t*x#9*Mqolk#?Xn_G1g}e%cW8czJS$2ExpM$vh~D|d>$*u<}vD80a)BMCCi#N z{dDUdtn5^9Qw{uUM^YU9tklioIeX)9nw3f~?~cFqtT@H!N9ySU;zl!YsaWQy(INIH zFCr-9(pe_>tC9pKN8>qUpAMLZKBb=?0N%=}UQ|*Osb*GbP#cSXL3deE=#ByImI!ID zHQw0VR{?MFx>F}PdcZYmSL@Idik%bRaDAefbA zgrS0n;v8)3TYtQPTchdVL$AitfikZP4VGLZ_{|G9RfyeP6l#JdaLVDLn;>Yg=xUI| zr?i(N7R`sO4))c->3W_K4b6gd#Jboz&)Z?yq}uy$xVEWF8X|ExiUk@ci5F*A*#Zhb zP_#R61_p&&&0Ak`sV4D!L25A-{47dy$$z-yE9XM@g)Mc*mr>?(;BN(-drEEoed~iq zIS(0WA?Zt>UImXqz9p3ngzQAUOq*ZjX6eW2EGw5nmjIhJ!9)iPl~?CZ4$h)!GP3Vv zDsB7er2ajBYZZY^%UV7FqJ~4$)j-E?&iXeRlQICpV+FERa!y$@bu2h|r=l`c9=q;O zl(I47ehCbT_sWe|lMFPoDso1=^KKEDZ$w`cxFoo&)k>Qv7DBcIg*uSGfY7{X{h!O+ zc?qim_E=*z+E$j;2}9c7HEt$ghy9BW}3ygVu#s7oK~}=n+Arq!F)vCnY7(N!r?fTsDcoX!;A@ zdE|5%nl+}3t5fJ?*qS-7D6U+NA2@poA>1&{=MEz&_~J!C|NZW%xTf|9#VEuFVH4=? z0~s2V8u8l|uqQL5 z3el3;zDR)9x{P{OBA$5FF;GwlU%|-dXM@Mzjt24?8oo(0rXq#65H5xj$^ueyfyE*Z zMjo?u5`nCT0LSsJaqo-5GG>P-t(YO@zB3-$Qm(^S;$PQP&>D@H$(6u-(fD8tssa<- zUhLU#Iu-$1CIO)z7bt!=zLt2?d4M2de4M8peTGQmFmj@mit4nWe2mVy*3&-mi_K0Q3eanec`zm0Th`;WKxq!e*LZai0_nbwzbLfv6 z3UTY#=Y|21YCsFbPS@5-oS9AcM-@mju%{_iMS@E{fwkV^Vb_ROn%nCL(fN^K zY$e0Bbg_;jYf7co@A!Or!GElB=x5EORDFR)m4k>Jm0gS>(TQdrdYzvd&G1iBhIwF5 zQa8p9x1jDe4#@9hCsx+=3c)2cZ1tF{lmjEvB2Q9tLEou2N!gvIyDEmY1f`d&3|nzf z&>D<&jb4Uoj3HizE6YJPb3^2W*nUDehod9XfjRN7)QXS}7jO2fm_dHX|CLk^K(@C~ zCi@|~dLk=6&6=frKX!p?JELn+0{gBX%z78g2aA`p9kOS?bG0k%W#<28#r%uj-)0nmx5wCc2i z0cq@C_B+>gWSZRwA6s|EHE-qsPF!h(xsX^~EpFwZN(Xg68%VN$iq4`CC(e2t_QgX1 z9llxyswd7z8{CmNNPJ6Z5c;hgD0{`#BIFhn)XMW4FFJ$a)29jLGNO&XB@m;Jvs;zb z>Sir8Rccx*!1k>)=L}WV9Q8s*5i%#EM%c=g)cN3%F_M z6d%+jh9HJ16KFipAcPChYb_9KWVkX9zXfYFH-G!;ClHB=1 zFT=3i%-|5M`H@nBCZ0sy31xJvLp116GW?i|>A+S{m1M+nzow$@Q+u3K>wp(%+}|4j zhbFm)VBdjqSt&vP6&G<}XUCI2nXiAbxkX_Mh(6MxaKd%3M8w*!R~PhKDw03+iw1BhxmSZ{o# z-5S5IF6&f3(L)`TS~KfI@=8O*)Kf&uR4WnDt2;Ir;FAII2TM+UQk)6QJ7<*(i0F!? zl{vn-mCYm>`*I)8SrPe{_|C(sjJY#STW?cLC54mEb&$Qy0Tq2=GKLXhR5gQkV~IJw zHPSw6Y;PWNbgXq(TNGtFe3VJ23Cp><6CH-Qs7+Jw1@6dT zd8v#J)~bxivr7!XT)D}%O?L$&UCC6@(SY*X5es$HrWtO{JGV{((u6erpARLwnx5t*p3rPK!9R zZq+g^wWw4uVV-tCC?wUAH~HuZG#gI!sak-DG&0`TVXntOO&-8}~_Eht}7RunxWR)_Gr zF?x(KHlS~e=_*{R?)-J#g4iXOF11dxccEbHonPW6%(R21eHc#(QvBc4n|QRl!UllPcSV>#@&^P~}0lSPC6FFt0i!E&6fI$tpi;5SVO7{fEiRX5>sVaq;7&2TEd zGgOLoEFDLMb5i$|3O^ioaM0v88PMx zXJ7zM=!{Gpi%NGOm>7{nM4w*_OgvfV zb5&=_F7?F{b zaFor%SE=FH3pUmgMJsS2RU@+>NOFn!Cdt%QhpFLMgV~3Bm(;0ftf?#(bC-G3jNtVi znt@y%jT*umc~uN3oE7GWdp*eFrwLl_=DAj1XmONr+qvEK`ke#Sbx*Hd( z{|E_i0N7)_XUeWDoUaC*rJEPLhnmkGLlK zNlm27Q2h%ML!Zo;^6~;!7VzG*c?X`Zfg|SYI>mRb=}vl?Rru6;o}Zs`TpN_l%)KPs z@EhFE;liQZAoDrp7Nm&e`&5|iqX~_)CT#$B4_)zSX)B`N9Tv*)VUVasdAlO2{t{|X zn$CPScc#LCd=0KwnF3<;opse4oL;U_!!T0f;U$UcKa`26LpV`+p%9IP&7_$<6I6h` z9nF$?@pQB^yV3qViHi_&^Qz~n8)DwB8wby#KnzE3=Qlkw#!SDWJTHX@NvNu5r~}l( zC0?HKo23IT77U!4h@NAL8;)EB3M-&74j<}mZs1jcpswMR7<;}K=BMqvzAkU)knSa< zKWmev8*Y`QJii_r%jBH67pd^XW=#J=7M3|HLdhEU6%X9UF9z{oRvukN8%rdQz_JGY z4u}72#(9O5x0U1u-Z9VhwBLyxSzVi=h25swqOl&Qlm4Q%Vo>321J{T?b{U{7`Ee{k zI&M0jZvU#iD;P3{vLGy0qq-`;lURn>e&y0a-;%oe*DdArbntEt!-Z1P1OhNW{*5Wn z^}H)c-o_nY7y$t(B`|Lt6`sKz9O@x*@M|y@X!AZrQD1!u{#cgMgN(m_JzMDA3i*S6 zPY=qe&adbow*-K=2sKj$w*9M_?Mvs%)gO8o2y92X*egG$Eb62_a>(e0^MlfVO84sm zT;0ebPCJ&n4(CsJEmIBdmTlZ5y(pelTyT~mKjl`j?=TuAB*@Vnf!3zWsSjzqbdJKH z6$~!o%)8(@uIMl@KW}(=$vu`d}NTZp|dlRjZz;7kjeg`*1Jg|3a;k~p^hw~?rot=9g036lB-$I*9 zx|(bokIEcUzjjD>2A)_PXVppWvxa#y1{Z@&(?sD?wz3)q%WhdrpTIJ!-0y5kyF<01 zX5ON%m_Fy{|YL1}1}?r?La!%~_RE0B}jUvwPf-C^%MURQhse&?~#N>_};A z_18F3SyDEl_Ag(1GLML5sbD>>;ICZiN7UdQs7!8HD*-SNhPhX8<#``JFR*bB-4sw| zQaBHHhwW3BMeF``uhec`lXq!T@5(S^rUWeFhB)pFMi@;eXJ0L3QjcR>tRg#fBe&?trMB);hkfxfLeAq(4^rtVzXA(q6E(5*A~nzumGv#34AdNiS~E(_TL$y_?stOH(uN zLalUYf^Vwqf4n=zx@wokJz{8@5TEbtgx0|=i#4VZP2|m|Dh=hB34H>Q-v)re)j>$o zUsTL@^!l;0-&5p7mE@e4J-gR>9GiJ}(jUz(w(zWKaLQDbYl}T1(^jU)z|mfwcGfg+ z=kd1ZFF?32A!%4+^xD(fnT#z{HySESp0Y6Ol&w_FU1J}IYA)(wFq41!Kccq2^#HC3 zEByNu7hIRGF`t&ow^xQ{7R*mNfvWV+?{`KN%x)Md>%5%5&KB2K@?ajBD$uNC%n_Dw z)u!v@62$-;#r(aZ`vw(;rpowl(mX)9K-8u@$jKk*SO`RYh1k(sbhbmt20OQoq=MQc z@{69w;ArDO$yh6_(}5y_Lr62wRG2^wVQ_;~54HISA(>8xst{B@Fx^VBMM^W`zJ%hp z3OJ@Hpt%PXg;ij7O4RJwM*CtS5XC^A;vs@glV~ME9mu}d(O?ha1OF6d(Jk3e3G#1_ za>fSiV(?)>3^kg+gwydJ=ZB!-=E6senIMC>k|#wNal9}FK61#0_t3|_Y-sT((L&iH z$|xxzDJdzj|CExuMb^G*j~C<++BW1z@le}L>Qq*&bpMt$ScF@TAru_VK< zA9kEzJ_v-8f=%(xr6D>|XafzCKU)gWY-DcJc)=}|i;<3FW3#{)Jq!OcIY#Hby zk1jhaba_yPsKMxTtQv>A2d}%8)d-)}t448R0a+FX3sUh80r|Um&?JaJpMa^=d4T4{ zX7Cklc-}oZx*X)vY9^dGO0$G|YxMj$-3=$@FrY>H7P&OIh}!k9SHUP)T&}#30Y#Gy z=tCj|TQmzbrCKdrVGGFbIcR456*ay;nWAHo!G;Ut>7g8Yd?i2!eMk(d3W)Vm6doly zhWuPzB40gBT!)7lj*&JZGFs%?{E;7vYdM$Ia_nVym!)!#QfE~oz4aIYwmAXb2eLJJNW#0T3Q zjzgwLKsgc(I1*V1rf`OZ;L{p=#LR|d)*o#e9rt}{OqxeU!lR}1@MN;qLzP?2!+`uv z&%oL6k)$@dD#A(Q9wap`-fje#?gRe|RGt@@7sG-ZbS~FjGmR-?$qb3sVfZE*vFTQZ zdS7o_4Z=@y@p7MbdnS)h-uIdUHy3@_&+=~?uB|(*GJ-Ds)}3Tdd-jU6d7Gx}hZ?3< z_#8{4w^ImkDhCt0b(>bZfD3K8Nj#MlhdK&OPkV1)$MOE_j^;K0bJQYNb@1Qx7vd~` zw<#3uR9f%dDqB)AW?iY-S@?;Q@Y*1^UCUpRTTwA>;`wa{c`eAc-LI~nrk>XoHuy$a z(hfHn>OVu@^!eBw2=VJ~)=rbN`VM*cKbpI3^x6-Vv)l<;(hAFv%<48Q3xatSp5+0U z=}G(_cV{jHK}NZ=6GU^myI}Np`Oyfw7}MHw(ge9|ui|NIUy=}bx=Kt?vVKbxHMme? zeAYeJ`VrIr!k#xhm)88QT_>I&JBtwa9T?{VPu*et-Tm%yU`CU^^TpWb(imAmQfdtd z0frWJc`c6@ik7l{lm9+Xyfgfc3^sFEAG$?b?1rWli7&1~NAMP@Fl(Y^`Eo8%y^hbB z6%Rhor8nKo|DXN6D)wf_`&-X6|F`bb+;;SqMwqZoMy`m~@hY)v0owDzHV2ACgq171 zHm2rY^SW53D!*@L(q8TDYqA~}p1T$hsPW24QN5T)^l`a3tM^f-`;xW$*kZG4fa^S} zwrIrNZ~pv2XaB0oHyzbmt%O7MHdzU`mapHdknNiu)UsB#uRGT2OrP0m^{eae9lk!{ z+SatwJ!T#MHbn)NuYGYyaZ3iT*X{sYC)Id;0!Nk8=KRYxu%)-XHVU| zebPs?nZJAh( z2Q)Lil~vwbT;p{fk4Z9)?a)~?+u@B;oxdc5>U}B$_QXCyYzWlRHREr(1Sdd-TwEd zwpbM14$l8=EKw(ODMtR=!WV{<{Wk`kc3sKUqj%Nroz~Yaf2%&aiXIWj=Fm)9+UN1= zR^6Y3YnG)mZYql=39by%E#7`zFk~y8JM-Bg5|lgdRF)E z=EDhJV$xcQo8OsM+ugeKqB7fzkLlhi!EKEW8DD-Muu0RPW+$5 zpdb2Cn1g+T*VhTQE@B59%}kSJtsdpnFZtu7t2Ftd(u}rb%j@T||KHSeZxnfc=wJfV z#G{svt`$4{y0=F0vwg?(;ztt`AI&W=W4>SVY)<~3O>@4qe>azpf3foRGV6qz)9-3B z%yd)MsJ;u{w!E=&!i^#cpSoxaH>h;^q>*v{Ri;dGg<-gSCQBK;I-{RZ$1JfvE zF%ct^E^y%x_Cw8Ju?3^C9B~%ljcNeu@k=m`AbL75OCTM_gsu_&NE(FJJ;24XXb04w zYeqfx1EH0HVJa}2;ywff-5m6@AP^><;D$Q_<75bQQ_wfmAWS(a1vUkJdkwk)=u3nV z23RP84L~dyM%RkI9t@$iO#`A8eRUYR38)n$LO%n;c6SB_jOr3yJ8B7ttX9!BVoox#ZQGdGwrx9^*tTsYN+x+sJ^Ih+M=DgqY_r7-T>b1J= zuBz^xcsg000UA5kT9{(88XE`agR>fbU-& ze{SWm-DbVC(1Dj8kh|`Ab$)b0aase}jr1*GR~AHcAyfpv)gK-h{Hj63&@6;SKR>Ur z>Q*OpfL*WMMCzvi^{}E4us(FkFjxA0sV%N zh{E@0;K2wC_poED^N!&g96g}k;&N6&QwTtPU~jC6>^|?c@ozoi$@B!yUn?-ovC@s z8|-RUuC&M!vE9Yl+E;RKWc?;#g6&7+58|FNzw^mO-J!q4bVhZn5B7)kw#ED1JmEjO zUlgSz9%&nxeO+kmD?iKWOEeva4Aa>|jw$4*EagJltE}PN?axc0YS%NhK=oGrbNZ&! zubpiuVw|ra{^YL8{#h1kaddRmvx7cTkb@9}|N7gX&Ob)R(}aIHzxeh2{!iz1ZEXIV z?->*0kUg~Dfw$bV@b@+f-M*8(HzQ7Bmp;Z?tavZO46B{2V!;0bi0Y-qnT2ij3Wm|M z^UvT^s7Lfp4eu?8Ydn_A+@C@@XOi*gUYN66$$?xtlwr zR-{Y#e&<~l>F6O>#gGjsABaQRsaJd5S&aAooOCfuVIY0qkMXBB#l>}V`(K_oeBDa8 zFK=8dXq>I>%=N6T&HtM-5z?(zUyu3PEfUb9kW>t4g)W)+cSd`h6veOxf0bCg633Uv z0xZ^}r4VP~8<(<2<;7a-@L^CIomy(|9Bbc(jvrSaPQOTXX_W6#clH_lq4I3AN=du+ zI2F>12D0T%u|V(U%=sP2e?(xxS(;;T`X7wuOukixryED+DhDD+vT3QHE0FN0(jijZc)Y94jBKg%?ZlNbap-mu{5R+F_cB_ zG93R*o@`7SX|p5Ua_qs-sdbvEgP>GO|n_p2X`AP)n ze@E%>T=36W{r}{J@vpq_-T%@RviecYNllC_#vUR-WAzTuzpX*SLe!*@$Mw}pUPKQ( z2P~^EypAC&TIuxCRwgUCB&wTNF+Q8NY3VM9;i|*E+1=N%U8Q|TM>R6~@&{Q8gh3ZW zk#YI3D<%~Zoo;jn^TBk@(Au%V`i4_+lHqglts&@KoJ74P^q!@>&k7d82MI@m<)zJ! z^}%4DN(GY}X8qd%Rcd|&3;Q4o9X*s-$QSl8J0(v_fDNfG%bgl z>qJ-kBVm@>Fzo_+dvjPF?Cw684HsaL=!c)ioHk)B!ftS(esp9mdudW_NS!?nT1O7$ zgqn&`@!GqJ7&()Y2J`zyLIlw``mjTG zUP~$l#6IaODb`HR4j&H>k~+dVTwrM%JCpL>4^U1~p6#Jg^5P@AE_LGT9WvT=)LA6x za(+TXw(y&QxD2Dm=%K64;_zvx1W>}!7V4JVmI${E+sTGR|GnZB1Nq(2_Cuue^aI)( z7%;qmV)b^Qm!Yz+fmg6*^)cw#h1;(2s{BJ(GsEM3qbCZzZBak0cEE^ACg!eHpDQDC zqVe?+p!BF#&QI z!T|L=R^H*v5hX*SKNKk=+H75BA!ncgnFCZKa$i1GOiAYtB<%r0Fx`rnszk#@bJP74 zV7n#vS7vU)?b1DyeNX_&xgtu+P+`kQ8E1Vv^JQel87xl4C6hP(o;9E+0n>_2nsG0g z)Y%$hO8Bs&@*XZ!+^k2P7^Q8wM8FOjpg2Z3>-jkfWwSIAB5Vg$hzh($lwZeU4x4|6TwCAWp-d2 z+`p|uh5{7q@pp3*N=ljeH;#DjxGaN`(7bQk)xwD{Xy$sBGt|%p8D-qXQYq(N58vD(t65l(hLCFf)I!l##};?S=NqIVeAG_{||(M2&ilnZ|?V8nY_dk=DS# ztwwg7FWr#}8Co)BDk5)C1r+8LrV-uh#PA*-3X~GT3&_i7RnDV41*?GW4voZH_NO#m zQY^Z#;`5hVdi-!}yUBuqpKDmk(r`!dI+N1TTSc3mC|g165S^qqLIgaQs99_D*CU1! z?fDk1Y@xhBj55rwbpJ9e%}lONSYS;fWN{$KjKM3CN3?`Tw6o>_9s(67;sRbP15U?* zUrPm`9W%er<-)djE2ao7fP*Oo9quiGTE*R-ek*TlS*q^>EB55pyvs$dbuG?LK1#k(yy2izwB?s7PT)x%$j={$-7An%*3bRZ-vDFYdf zmulXYQ2@?2f8R>!_#W)}901^!^qKA44ZtM_{OxWC9(syEn3gvR^Q*z`j|RR-9dB&5 z8xF9e)26l-4@%xk*uixG52ys%9P07dd=H_D+B<5 z`v1L(*qay{I@r_v_48kckA!qh=iLVQz{^_-2Zxy7O8C6qI2g-pF;wJSl5t$e4ebkR zb>%-q9YjZ@K0U}hP5Pa+X0EhcsGhfNuJpj6L8Fm*K3tciwPxFv3K1-)U>db_&hnSa+(o89w9m+ovYRh4LV0-J~7ct9JZKoeT>)ysIKJS{8RJ`*=v+ z50X@EAImye-rc>xYoElH3c6F7s6Pa`SwTBoBrac(%?J*cCZwB9g$Ei)@;S5JqE6FuKKY-`QU{j6P0oMf3bL00(e2`k^M zZOocopS4$Au9+R~Eg`M#-Oo5CL^}euu zL!&}ji?KFRdG*1}-O*8|xe}`qZspW(^uGEf_)*v0<4N*q>UyU5w6-g%gNd7clPh$< zaxCqlGVjt;-pUHa^?|vBz(FoZ4%hE@%WcY2$NeuH zR2h$*i&R>soY%f~!Z+^fSpVZ6A$PqGA@zj5P;`}q%}2Qynhu=!;T`lw=K6Tx1R>!5 zF#^y%B0qgTD13Rr2p2`BaHqbR`#x!4b|MX~q+lfuCTZ~nQ9%H4IUROVe5XV3<^}et z5c{?4cJ!NxPmem<6!@k-^B6uL+(vviPw@M%hOt&ic!H^C3g52VB-H6&Fz?O8!TXTQ z)c!rHthvz%R(zY(5I`R8#+!=4s6HFo@W5q+N{pL99~wUdYy*6m134KerE!1V z`$V=wZ6pMt`c!uyc@z$^+c}ga!S-VqE>H>;dR``e~$?gzr6UVIpDb&y>E zwH=*!GH^4mD2`ctaCMh$U?{tj0BikmP3V0vE4*!=uq~>AO#0B*wIJe*P)LvY%UPRl zO5iZQ3$kngBC!z;rHp8DCW8 zD?%2bC@{&#U-xc59*D^}XZLlgoC<%3emr7uY7hKsh}eq)MKq<@DWA{EnA)+4K#o=@ zdLv6W-xrRV{Yb7rd=C<3kpQ%&{E9glJbyeodU|-6S)dDv*lc=FTdbTMlNl7lD0YFO zcu4i|cP@dFR5`!WWvjF?4B7D-=HTs6IlZPDsZvdOwZ?`%m-)PvLHN;C(X11N-hIe| ziqJKWc_5T|pcy%B3BP!RS)HwX^m?KLKC$gc{iv2BpVr8D^zeFWS!IIbMA#JPCZ-;; zHLRR?g@~1&0JqXV|dyx zh~iYI5GKjCUYG!)A{&jozf84_kjGzOl$Y#F{RlSe5HEWk7NWu+^@WeWpz$9I-L_V|<@rlhlT>QQI5aS9AQwUZx_lG! zR(vqi=-u~fBc5EfEd+DGYbgusukM_`yJ7dgLg0Ob_rUreIsd5zLnPl2|u{w5(Zx(dPnTwU+Whh%<&!k}dSyr|*@tYnBm1p*=` zq%gL4IX&2dulUF{G27%KWX*ll7r)z^;oj0_`k4jJEiobAC#Cn)1LozMtsz&yq!;(O zNWg_NNx_s9YADE$aeRroWpts%LYBH62LePwbX+zXyK~WID}qdN;frFJ42AKmrezrl zc@2F&`Y7ad264Rl@1Bw+eDlA8O7rpVU8y1~?IESxZOM7(NdxRjiv-GIumH;i%9u#( z>(;KB1_bftY8G$a*b4-7&`3gxso+)O5rURQpS4GC&x~a7Ph|?2_6Wm~Ie^l8B*vQ3 zC$|d)zBkK+;E^~mT49upZ^2{zU_E0@@ox6zNRIExyLTSg_lIQ(;7FEI&>OnOh)6>? zWnjvdIl4){r(dsXhq%zOgLI~43t%$mH+w>VL{>^i;1e)=T0!BP!yKEwN%s!XAPiNh z75Gj!5{w&~PKXcUZYh8$D<)x^uTvS3ypzipq8F3__oH?o>^WRck2^0OwM>7(h3@N= zF=M(okul@zXn_U$=pna!)h5}hJ;Vv)cm#y|;)?4g!K$6Fqj!!51YK1x%XYqy_+CFF zBy-LQ-lVf6+wK-R3fqK^d5Skf_#BgMy>cA zwgB@4DJUyP-14XZ^N;-?X ze+zlb#}J!rzhPlNQ*$NWJ7=y*XxFrjv9#K;vu!>!wmq1owPSU177cD{&Ay$Vs6#xL58}RGp_pg~adlOwdLj!pSJ5wuT z`~Ny~qPcFjIuP5T#RVd!*eYBaORBnzxo=>ZWq0tX z1MA-EUK^E2vV4)r;|(n0jZYMsS{hFPk-T~F@xnhTF*)1$$sOWno+%zfJo%6XE%o>< z)8CXZcDnX`GD{2Rv+DEl&|SKK(w82-GvUopjkv$|y%xuF2`<|LKkGay4FC0dPasgA zTh}hyh|`Be*_2xsYswv(5X?t{4c~JTTIb1itTEyi${ji}8wtxjXqG|We0=>3m zN~OZV=`#iM`^=D^eeDQR{S!uJmA!R}bnAyt}N#uXt8Kx%{_s=kn)MbTWG<3lS(fuPzv&2tY@;5jjq5!#pmmo zlu%SsO|FNoX$@WP^s62ZaXueuP)s>9t}s_1d+2b15Ou^z!BA*;u;rj`NSn~YY@jDS zDL@ob_f3b09KpnBlb3ZkZehs(V+uB7l)H9q1;o^r{aFPSzY zhac>JMeLB^9mcF`Bzh9g-$>*B{HulKX)uV!??&C5758Vw9Wlf%{mwKluL`a7&qP8~ zcxbj@_j+CdQ9~N!GgMWeOZ^?xA47_vu|4I)^yth zM&TgYi?Gv>KXm%;Ln+aOoN_GgjM7Wd&vBQ*IR-0qqH!G*TKr`&%h*v&^T8mha?KiC zfHEX}v*KjKf#|@eZme)kIA;e6Nl1lfPCGLYD32$20b?kdYKW>hPi`rcXw9$hMjlI# zFmcV%M0umr`)~@u`=5YY6r%z1(P`D-XpD%{+oY2ZVtnfm_fc=n@3e8v51kRE1Pir| z^y)VF1a*9zP>YGA1nlJ|b`YXZwZ}HAzfJ}{*2K{bO9ejA;zIQ?m9`j!n zfdqFQ_5^nMrRZN;OXQeCPaslLtBd(eSU?v9a4x1~!1wu}o`U?To1gC~f9r+|x&b9N1Zmv2a#8M|ssb!bA#Vze#Uht+2ZIz;7UY&Q8w1NY*MMG&dLD z98R>VJl3W;>@UY~k{C#IU_`hMC2>3?F>|40fiire@Mx@9r5n~eF+MiyqiSsyu*@6% z8Rvk1-^1mjOGlMQo35RV1`B>FjsQV}Zj;vMWx1~clLOchZ}n~ehR+Aj2%SkY;0~@y z1_Wq`4u56vfl{o2!<&lGy&Efeh4j1xm-JTrJzF|gkjaMg98K2eG*JvE`8*X5C#m>T zKJ(UfVzKKPeR_4a6S>n4F?w(gTi=q>?i5@?9?Mqo5g{_?14Ixorm;p% z(CBzGtFZDw>x{n-`G&BM(B@75Yz+BoyuB5z$k-ctp5e$;yB_Zm6NR`-44oDl&LJPDGmVUZzx5?8neXU6-?{i{uoH-gWQz&nf2;kfs> zL+M^dP=eOl*qjrTiVja4q*t#rmcKYGSeWc$L8`VR8Nx; zwn_j`3qijr?zF&FFYim*wraeyywCh+%9QsouurgAnqN z)`CB$2p*(v=7`eUp#mcXh4zqyyq2&AqAsF|cU2b$M;KAH;%^N$);#Mpj!imPudPn6g^6TOYTJTyacxJK^Fp zAYUlHd}|4t&W%gGzAt0ytc88XM0XqHRVX(J;3-4NCRfslvNc}#Bf(PFAoYOl$P0Xx zhk3(Mx(A2$4Ky>r9r;4F2~MLjF3mbM70mo|<9M|LbDQT3+ zkQsZ33^{cmW!_dqdp6zEMnDj;=81Q1WBR#2@w+ML{3wk?f0C`;)cKQJ(4{*&3P%%2 z@MD(*W?R~53>Op7*nk3aA;zmFF6d)Taa6*!Q!yeecQ`cFMxz(ffT!hl5!#xRPVYq& zza*Es(@CN$+J^*pJ1(IWGzX7pRI>LHBqtx^y6m!FfI?H@F9`WY#44nY8m@II4nQh9 zCQ#9uBAlmmFS#t&ZLnyz^QImm4jIi%#mzFZTyAQBQc`w9k5Q7T=3V;d9Hr%5(Rf3$ z9ZL`rVuYf?@nN;O-$EUT4U3B&2k{t_#|xA3U0sl|DV2ex(Jd1OFMW>W`HQ0t#dJhK zrv3x+FTDeKP{WGSS>- zTxoY&#CY0ezFKY#w@H)jPkz0murt#rCoYKvE6=?j=M5X75(nP|OnAA-ugsgx&A6l3 z_^4TT*@xeX!Q(y6?6m~*OMLe>5BOn7Yl@nPRuBW^xdFq+eT2`$6o1rGEUx{n;{5oh zLsdwSdF^TYn|}SZ_|wuH^F>|xbRZPewr7fO3pr7Iotvu{O|AYBiE>t}GMp$Mk2o3F|az6y~S zDi?LEV~MKawQ>D|Sh+6`u{eCb!s%AG^c>}xoOAq^u=hOLIZzxtnzp$T*sLTdV*d&r zA*~&zSAkTq*3hWYonlXn`slPMNf^_L)$PQ)t|8azm81LW-)#RgxtEz;sJwDQH=y;t ze?8n@rZsfOrfSD+R%n2MCt_{Hm$J6T-1LAMJ{_2`vut#pUWOf%0BSA*%f+H&n>}o# zozDI-m3R5EBxP@Ey+$`C&q;QK7NmYKjmF0AN=>lTp(=tU@CanJS)%VH7jq_Ct0WjJ zlSRQ+xH0l{D-a8s?4zv0qfBf}Lq3<0h_ym1kgW*%J-gI=Z%B9kyl2#7tgeuuyhkgM zgtAFmVIg7 ze`~%{6G2}Dzin&+aE-2mCa!u(A43{A`pg|tw)9xdfmLb6YMCup;CjA!T2R-Ea! zD%~r$Q3OQl0$6VF#2TmaxT52o1Y zDR&!DFejh>cE_ggs%y4*tLO?}XAw(BhtHOg@r7$DxFcR)5WUZ4r6nTHDA#J7-#}ZL z&_x{SD!y1GJ|Eyr2?*YIf4hZnGDyh)cDdQn%83cgXw!aC2ram&8**S!*2tbtDJ{&F zCgD;*Aw1s(5Dezz-rIyp-mD`R)1(xmzy;V!m@l)e$g799tPd|C5Fb3}7K%3VI+||GPOg0YQl)ACti}_x6!=Q0K?M+SaB7nhOnRb14ifcxZ)4{pC@T$cJAx zG1-(V0agYUNI9ZyyqYZeH@!aLrTJ1)m^V;g1=CW5g=jufTT-&THPCJ_XmG>%G8O@S z(@XyaWqE6r1Gc+w8^&}o>tgDDTm8WZ9s+M-N0dU)?S&F?on28^Q%(9mwKKT+BL>9G zjiBUO-%%L>DT>8I3Wp+4oc#!ie}_im-l~KH7hVs|K=WaYV3}G7!bpLOimy=6`^tH} zb0^y+wn`qsh?`oIL4fO&<%d$;7jIRjZihPQgyQ$0Rq--b-gLse0d<}AXhupNf2KSd>_F`Aj z5hcXlH2L9bbvk_?lYLqS$QHj0WYh``UeBWf0in3OA+vN8!JaoU5MwjGej7fczfqx= zx#QQqv@aDi`dq1@(l#V5MnuA7>>dkrWGr^Ag~VqrW$e}PZy+QHy6 zoHDGAna0`Kxs=a;xKS(>mx zTt+sMpR!+By;rhzc39?>TbW0`?1*aeI%G>LG_rEAlA4Bwah;tzpIz_zJ?EVM zE?V;h@hAZ`D8O)~k#4IY29Hu|WR z9Wexw1yQ^YlG|ubk^D(>iW%~+Z|h-pK2`{TTSA1ac?T1+`jOeD-&z>e0OA9H9THZ~ zIkHvJr14wa)b-3=cHlRp?Vs}w09|65WeUyV{7cA?*+esL(tcjroXCz}y?Ie~*v?RK z#ur|EJMiYuc1s{I^KTQCQ`_UyRHl(s(Sr%5{JBlE!1pzU{SDK1qXtMmf5){y$f=p) z_(wRvZ98U0W@ajUBW4E^xz=RBd7s)iZyB<5<-*?%I!GG zKHB#!P@Y%@+2c&bkAp3M%TUnynPd0$BO+CJMNkCFA+~P!7F$(cX@QGmM15D4QBhbqQIxYfbY_80iTsQjpmP%Fg@oQK{d$E_5F4@?O260@5Ny~L#&tlm zy|M=a6xIf`w5)FU{VKg~ACRyH3}sVep!z0emhTYB!r1v>M;N3;`;=%SGI zC-|xfl2Tp{C58}}h7AELM}PVwl-(bWZuR$%@}Iw!eYtHe<1M zrxZ(7w2XH2*uqgde`81OH!(7S~bXtMDS+)$U zi)2)9yQ2c5L!z3GgEUQ1GQ(PVnEEV@JqI)lUvXDtUkmTW*Q4Q^R3yx zSR`6B-87@*qQDDMe+;{Ioq(taLqZzG>^XU;!a*ucRZ<67cK@_rm0>{tNo7I-r8~98 zF$dt!Bk5&Hx^4lnLW~2vQJ%?gVw<;)fVRojF@Bh^DKnOT$))?)9h68MWrfRA5guh? z&6xXMa+;yyM9v&{JUxCFNp#b(-*v!Vu&-FDHifj%Q~|IP6y0cWM zmLs6VT_k(8QvFuBQNz;~vu+j{igX**B*+p8L&JV>f`vK@E8OL0eC-WYbbo;bIQW*u zv*Uxinz1tqWTPijZn$-82dpDwfd7TPpysM(uizyV5YNip!KKS3+S#(&?rls4m{}@U zg|uqcww0#JpH&CbYTJ{iT|~P6L6uz%zxT`^2R_!sCy) zVsFLAYypt63uf|ZqsUh5#HF()E|KT1zC=*sflKO+J001l7tp8Kr=9hraa86A>p|Ap z6MZ#c0fpZ|xRY@gK_p}a7h}=j!54`b<7w*^wYicJOTE#?Kh+2QsZlTDjyrWpef<@Vu44YOjYVV0Bz1%2LseYZfVC;Is@ zc;u>I{GlA2uJyGXg5s)j8}kEjz12XetqsROk`nd|K6I|6FZM4^{uZ1-hh^h)SiB=E zw(0cYb9S_qDQ_+y*OywZyF-DIo#&0ue-&PfYv6nOe)}rIB?bV1`M(P!4z3o4|9xdd zP2GBh7SZ#vGWe~79k|C3QUMELRjX9=G{2oan?`x7)Z4zY=AI)7VDsnzX`c>cY z-=TGpDQ$df)D!i{+2D5}5NbvbX~o%ubPMAVfUs6CdV%{9B{*9oj6#OWys1i}wO9y8 z+d6{ualg?G6jJu-Um?kl!2{atbZdC*2!kTP53l& zw?h+8pAc3N@%ETGJ*H;+P@xWyk_J+R?%s;T2v_=|pDiqDL$&R0a>vjHnWopH?IL%5dg$=2OblmWMcREXFG^^f(J3MxB)@+Uf!KJ^v*w1_E1DtM%ZhGG~+ZcGvCPfD83~fxj<^ ze8x2*d95mjRPl6RZhnUPv%qg{E%Y}2i)YXT`gdu#I={7*gQ1m!wt}mTp}ppRTi`5D z7`6H$PY6D~hmx+6(>cg0B1H+BCQ->9gC)DfqV5D){OH+q#)K@5{=r)%8jqIcZag4G zpURvYYDSw#!4?^Gj2p>o6l$v)zMIuAe1jlO0+-h>XM#7X8|Q(J$SK9HpHFb>kHuTiE*5al;H#zMLXc zHpq0RmT(BI*aA*P+(}OJMp-q~pmiWoV|CCfC?RmC&j)IAMGOTiI8nD}u(wa{2zPr| zH+Rc5`s$i?n;m>n)Fkn7Y{9rUUB(eENEH^#VBH-WsI}xK-MCAk0X{f?XcBt;W`d6; z!P3gEY6ZZs0oW#2Y_+xVrH5t626Q?d{ZSO;1i|1y$TaM5lXpd9b!9u-jeVLFc)*qw zdtOU8zGLGmU;av7zl-|Lio8bAs9nL#qt;Pps3cn>^SY6UsjDNS`@PYbW+&^RnSHIo zw-se7G}G4Ui7)Wo(WTal!oEAP5lL-kBhdNcrP!<3uZ|~oxR*vo91w{6D+m9-nNnZP ze{BEYP5oQ(Pb$$rwNSsFl>bF6`nUR@WSoDhBYjad{zv`)Qgi;T{U`b5pW3@$+W(@$ z{9E}?PRKu%A%Oo<{x1xXf8+c~nD{3S;}?zM%ZPu|C;koaXWi^S0j!|@$BX|}Mf-1* zKg$pPiDHTT7s~%CM))_vpX2jC5h7{+M);r6`roR5W`=*NN;3ce{6F%9j5x?w5&-~! P{Q7u*-78Y2{~Y}v@b5

str: - """Finds the required topic from a list of strings based on the metric name. - - Args: - topics: list of strings representing topic names. - - Returns: - A string of the topic checked against the models metric value. - - Raises: - `InvalidTopicForChosenMetricForChildEntry`: If the - selected metric cannot be matched to a `Topic` - - """ - extracted_topic = self.metric.split("_")[0].lower() - try: - return next(topic for topic in topics if extracted_topic == topic.lower()) - except StopIteration as error: - logger.info( - "StopIteration Error: extracted topic not present in the topics list. %s", - extracted_topic, - ) - raise InvalidTopicForChosenMetricForChildEntryError( - topic=extracted_topic, metric=self.metric - ) from error - - def get_topic(self) -> str: - """Finds the required topic name based on the selected metric name. - - Returns: - a topic name as a string - """ - topics = get_a_list_of_all_topic_names() - return self.find_topic(topics=topics) - def save(self, *args, **kwargs): """Retrieves a topic based on the selected metric @@ -197,7 +161,16 @@ def save(self, *args, **kwargs): @property def metric_group(self) -> str: - return self.metric.split("_")[1] + field = self._meta.get_field("metric") + choices = getattr(field, "choices", []) or [] + + display_name = next((item[1] for item in choices if item[0] == self.metric), None) + + if not display_name or "_" not in display_name: + return "" + + parts = display_name.split("_") + return parts[1] if len(parts) > 1 else "" def clean(self): super().clean() diff --git a/tests/integration/cms/dashboard/test_viewsets.py b/tests/integration/cms/dashboard/test_viewsets.py new file mode 100644 index 000000000..b9db40871 --- /dev/null +++ b/tests/integration/cms/dashboard/test_viewsets.py @@ -0,0 +1,151 @@ +import pytest +from unittest.mock import MagicMock +from django.test import RequestFactory +from rest_framework.request import Request +from wagtail.models import Page + +from cms.common.models import CommonPage +from cms.dashboard.viewsets import CMSPagesAPIViewSet +from cms.metrics_documentation.models.child import MetricsDocumentationChildEntry +from cms.topic.models import TopicPage +from metrics.data.models.core_models import Metric, Topic + + +@pytest.mark.django_db +class TestGetQuerySet: + + @pytest.fixture + def setup_pages(self): + influenza_topic = Topic.objects.create(name="Influenza") + metric = Metric.objects.create( + name="influenza_headline_positivityLatest", topic=influenza_topic + ) + + covid_topic = Topic.objects.create(name="COVID-19") + private_metric = Metric.objects.create( + name="COVID-19_headline_cases_7DayTotals", topic=covid_topic + ) + + home = Page.objects.get(id=2) + + public_topic = TopicPage(title="Public Topic", page_description="test", slug="public-topic", is_public=True, theme="1", seo_title="public-topic") + home.add_child(instance=public_topic) + + private_topic = TopicPage(title="Private Topic", page_description="test", slug="private-topic", is_public=False, theme="1", sub_theme="test", topic="test", page_classification="official_sensitive", seo_title="private-topic") + home.add_child(instance=private_topic) + + public_metrics = MetricsDocumentationChildEntry(title="Public Metric", page_description="test", slug="public-metric", metric=metric.pk, is_public=True, seo_title="public-metrics") + home.add_child(instance=public_metrics) + + private_metrics = MetricsDocumentationChildEntry(title="Private Metric", page_description="test", slug="private-metric", theme="2", sub_theme="test", topic="test", metric=private_metric.pk, is_public=False, seo_title="private-metrics") + home.add_child(instance=private_metrics) + + standard_page = CommonPage(title="Standard", body="test", slug="standard", seo_title="standard-page") + home.add_child(instance=standard_page) + + return { + "public_topic": public_topic, + "private_topic": private_topic, + "public_metrics": public_metrics, + "private_metrics": private_metrics, + "standard_page": standard_page + } + + def test_anonymous_user_access(self, setup_pages): + """ + Given a request is made by an unauthenticated user + When the queryset is retrieved + Then only public pages are returned + """ + # Given + rf = RequestFactory() + url = "/api/v2/pages/" + django_request = rf.get(url) + + request = Request(django_request) + + mock_user = MagicMock() + request.user = mock_user + + view = CMSPagesAPIViewSet() + view.request = request + + # When + result = view.get_queryset() + + # Then + titles = [p.title for p in result] + assert "Public Topic" in titles + assert "Public Metric" in titles + assert "Standard" in titles + assert "Private Topic" not in titles + assert "Private Metric" not in titles + + def test_global_access_user(self, setup_pages): + """ + Given a request is made by an authenticated user with global access + When the queryset is retrieved + Then all pages are returned + """ + # Given + rf = RequestFactory() + url = "/api/v2/pages/" + django_request = rf.get(url) + + request = Request(django_request) + + mock_user = MagicMock() + mock_user.permission_sets = { + "has_global_access": True, + "permission_set_hierarchy": [] + } + + request.user = mock_user + request.auth = "token" + + view = CMSPagesAPIViewSet() + view.request = request + + # When + result = view.get_queryset() + + # Then + titles = [p.title for p in result] + assert "Public Topic" in titles + assert "Public Metric" in titles + assert "Standard" in titles + assert "Private Topic" in titles + assert "Private Metric" in titles + + def test_restricted_user_with_permission(self, setup_pages): + """ + Given a request is made by an authenticated user with access to some private pages + When the queryset is retrieved + Then only the pages the user has access to are returned + """ + # Given + rf = RequestFactory() + url = "/api/v2/pages/" + django_request = rf.get(url) + + request = Request(django_request) + + mock_user = MagicMock() + mock_user.permission_sets = { + "has_global_access": False, + "permission_set_hierarchy": [{"theme": {"id": "1"}, "sub_theme": {"id": "-1"}}] + } + + request.user = mock_user + request.auth = "token" + + view = CMSPagesAPIViewSet() + view.request = request + + # When + result = view.get_queryset() + + # Then + titles = [p.title for p in result] + assert "Private Topic" in titles + assert "Private Metrics" not in titles diff --git a/tests/integration/cms/dynamic_content/test_page_link_chooser.py b/tests/integration/cms/dynamic_content/test_page_link_chooser.py index 3853a0067..12fb0681d 100644 --- a/tests/integration/cms/dynamic_content/test_page_link_chooser.py +++ b/tests/integration/cms/dynamic_content/test_page_link_chooser.py @@ -28,6 +28,9 @@ def test_page_chooser_returns_full_url( path="abc", depth=1, title="abc", + theme="test", + sub_theme="test", + topic=1, live=True, seo_title="ABC", ) diff --git a/tests/integration/cms/metrics_documentation/data_migration/test_operations.py b/tests/integration/cms/metrics_documentation/data_migration/test_operations.py index 3c9218c1f..7d5ca61bf 100644 --- a/tests/integration/cms/metrics_documentation/data_migration/test_operations.py +++ b/tests/integration/cms/metrics_documentation/data_migration/test_operations.py @@ -36,9 +36,12 @@ def test_removes_all_child_entries(self): path="abc", depth=1, title="Test", + theme="test", + sub_theme="test", slug="test", page_description="xyz", - metric=metric.name, + metric=metric.pk, + topic=metric.topic, seo_title="Test", ) assert MetricsDocumentationChildEntry.objects.exists() @@ -127,7 +130,7 @@ def test_creates_correct_child_entries(self, dashboard_root_page: UKHSARootPage) # Then healthcare_admission_rate_child_entry = ( MetricsDocumentationChildEntry.objects.get( - metric=healthcare_admission_metric.name + metric=healthcare_admission_metric.pk ) ) diff --git a/tests/integration/cms/metrics_documentation/models/test_child.py b/tests/integration/cms/metrics_documentation/models/test_child.py index 024dc6b53..f1f1877f9 100644 --- a/tests/integration/cms/metrics_documentation/models/test_child.py +++ b/tests/integration/cms/metrics_documentation/models/test_child.py @@ -17,25 +17,29 @@ def test_metric_is_unique(self): """ # Given metric_name = "influenza_headline_positivityLatest" - Metric.objects.create(name=metric_name) + created_metric = Metric.objects.create(name=metric_name) Topic.objects.create(name=metric_name.split("_")[0].title()) - _create_metrics_documentation_child_entry(metric_name=metric_name, path="doc_1") + _create_metrics_documentation_child_entry(metric_name=metric_name, metric_id=created_metric.pk, path="doc_1") # When / Then with pytest.raises(ValidationError): _create_metrics_documentation_child_entry( - metric_name=metric_name, path="doc_2" + metric_name=metric_name, metric_id=created_metric.pk, path="doc_2" ) def _create_metrics_documentation_child_entry( metric_name: str, + metric_id: int, path: str, ) -> MetricsDocumentationChildEntry: MetricsDocumentationChildEntry.objects.create( - metric=metric_name, + metric=metric_id, title=metric_name, + theme="test", + sub_theme="test", + topic=1, path=path, depth=1, slug=metric_name, diff --git a/tests/integration/cms/topic/test_managers.py b/tests/integration/cms/topic/test_managers.py index 0776a59a2..af36e4753 100644 --- a/tests/integration/cms/topic/test_managers.py +++ b/tests/integration/cms/topic/test_managers.py @@ -19,6 +19,9 @@ def test_get_live_pages(self): path="abc", depth=1, title="abc", + theme="test", + topic=1, + sub_theme="test", live=True, seo_title="ABC", ) @@ -26,6 +29,9 @@ def test_get_live_pages(self): path="def", depth=1, title="def", + theme="test2", + topic=2, + sub_theme="test2", live=False, seo_title="DEF", ) diff --git a/tests/integration/metrics/utils/test_permission_hierarchy.py b/tests/integration/metrics/utils/test_permission_hierarchy.py index f827f6e7d..851860656 100644 --- a/tests/integration/metrics/utils/test_permission_hierarchy.py +++ b/tests/integration/metrics/utils/test_permission_hierarchy.py @@ -491,7 +491,7 @@ def test_single_permission_no_deduplication(self): Then no deduplication occurs """ # Given - from auth_content.models.users import User + from cms.auth_content.models.users import User user = User.objects.create(user_id=uuid4()) perm = PermissionSetFactory.create_permission_set( @@ -521,7 +521,7 @@ def test_removes_fully_subsumed_permission(self): Then only permission A is returned """ # Given - from auth_content.models.users import User + from cms.auth_content.models.users import User user = User.objects.create(user_id=uuid4()) @@ -564,7 +564,7 @@ def test_keeps_independent_permissions(self): Then both are kept """ # Given - from auth_content.models.users import User + from cms.auth_content.models.users import User user = User.objects.create(user_id=uuid4()) @@ -603,7 +603,7 @@ def test_complex_multi_level_deduplication(self): Then correct deduplication occurs """ # Given - from auth_content.models.users import User + from cms.auth_content.models.users import User user = User.objects.create(user_id=uuid4()) @@ -658,7 +658,7 @@ def test_summary_contains_correct_statistics(self): Then summary contains accurate statistics """ # Given - from auth_content.models.users import User + from cms.auth_content.models.users import User user = User.objects.create(user_id=uuid4()) @@ -696,7 +696,7 @@ def test_hierarchy_structure_is_correct(self): Then each permission has correct structure """ # Given - from auth_content.models.users import User + from cms.auth_content.models.users import User user = User.objects.create(user_id=uuid4()) perm = PermissionSetFactory.create_permission_set( @@ -743,7 +743,7 @@ def test_returns_normalized_permission_list(self): Then returns list of NormalizedPermission objects """ # Given - from auth_content.models.users import User + from cms.auth_content.models.users import User user = User.objects.create(user_id=uuid4()) perm = PermissionSetFactory.create_permission_set( @@ -772,7 +772,7 @@ def test_deduplicates_permissions(self): Then subsumed permissions are removed """ # Given - from auth_content.models.users import User + from cms.auth_content.models.users import User user = User.objects.create(user_id=uuid4()) @@ -829,7 +829,7 @@ def test_handles_null_fields_gracefully(self): Then handles gracefully without errors """ # Given - from auth_content.models.users import User + from cms.auth_content.models.users import User user = User.objects.create(user_id=uuid4()) perm = PermissionSetFactory.create( @@ -859,7 +859,7 @@ def test_all_wildcards_at_different_levels(self): Then correctly handles all wildcard combinations """ # Given - from auth_content.models.users import User + from cms.auth_content.models.users import User user = User.objects.create(user_id=uuid4()) perm1 = PermissionSetFactory.create_permission_set( diff --git a/tests/unit/cms/dashboard/test_viewsets.py b/tests/unit/cms/dashboard/test_viewsets.py index 0620091aa..9d5398f69 100644 --- a/tests/unit/cms/dashboard/test_viewsets.py +++ b/tests/unit/cms/dashboard/test_viewsets.py @@ -1,5 +1,59 @@ +import pytest + from cms.dashboard.serializers import CMSDraftPagesSerializer, ListablePageSerializer -from cms.dashboard.viewsets import CMSDraftPagesViewSet, CMSPagesAPIViewSet +from cms.dashboard.viewsets import CMSDraftPagesViewSet, CMSPagesAPIViewSet, check_permissions + + +class TestCheckPermissions: + @pytest.mark.parametrize("user_permissions, theme_id, sub_theme_id, topic_id", [ + ([{"theme": {"id": "-1"}}], "10", "20", "30"), + ([{"theme": {"id": "10"}, "sub_theme": {"id": "-1"}}], "10", "20", "30"), + ([{"theme": {"id": "10"}, "sub_theme": {"id": "20"}, "topic": {"id": "-1"}}], "10", "20", "30"), + ([{"theme": {"id": "10"}, "sub_theme": {"id": "20"}, "topic": {"id": "30"}}], "10", "20", "30"), + ([ + {"theme": {"id": "5"}, "sub_theme": {"id": "-1"}}, + {"theme": {"id": "10"}, "sub_theme": {"id": "20"}, "topic": {"id": "30"}} + ], "10", "20", "30"), + ]) + def test_check_permissions_valid_access(self, user_permissions, theme_id, sub_theme_id, topic_id): + """ + Given a permission set that does grant access to the provided ids + When the `check_permissions` function is called + Then the function returns true + """ + assert check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) == True + + @pytest.mark.parametrize("user_permissions, theme_id, sub_theme_id, topic_id", [ + ([{"theme": {"id": "99"}, "sub_theme": {"id": "-1"}}], "10", "20", "30"), + ([{"theme": {"id": "10"}, "sub_theme": {"id": "99"}, "topic": {"id": "-1"}}], "10", "20", "30"), + ([{"theme": {"id": "10"}, "sub_theme": {"id": "20"}, "topic": {"id": "99"}}], "10", "20", "30"), + ([], "10", "20", "30"), + ]) + def test_check_permissions_invalid_access(self, user_permissions, theme_id, sub_theme_id, topic_id): + """ + Given a permission set that does not grant access to the provided ids + When the `check_permissions` function is called + Then the function returns false + """ + assert check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) == False + + @pytest.mark.parametrize("user_permissions, theme_id, sub_theme_id, topic_id", [ + ([{}], "10", "20", "30"), + (None, "10", "20", "30"), + ([{"sub_theme": {"id": "-1"}, "topic": {"id": "-1"}}], "10", "20", "30"), + ([{"theme": {}, "sub_theme": {"id": "-1"}, "topic": {"id": "-1"}}], "10", "20", "30"), + ([{"theme": {"id": "10"}, "topic": {"id": "-1"}}], "10", "20", "30"), + ([{"theme": {"id": "10"}, "sub_theme": {}, "topic": {"id": "-1"}}], "10", "20", "30"), + ([{"theme": {"id": "10"}, "sub_theme": {"id": "20"}}], "10", "20", "30"), + ([{"theme": {"id": "10"}, "sub_theme": {"id": "20"}, "topic": {}}], "10", "20", "30"), + ]) + def test_check_permissions_with_missing_values(self, user_permissions, theme_id, sub_theme_id, topic_id): + """ + Given a permission set that is missing values + When the `check_permissions` function is called + Then the function returns false + """ + assert check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) == False class TestCMSDraftPagesViewSet: diff --git a/tests/unit/cms/metrics_documentation/data_migration/test_child_entries.py b/tests/unit/cms/metrics_documentation/data_migration/test_child_entries.py index 3d813e26b..39df8df1d 100644 --- a/tests/unit/cms/metrics_documentation/data_migration/test_child_entries.py +++ b/tests/unit/cms/metrics_documentation/data_migration/test_child_entries.py @@ -96,14 +96,18 @@ def test_returns_correct_dictionary( "Fake page description", "Fake methodology content", "Fake caveats content", + 1, ) spy_build_sections.return_value = [] expected_response = { "title": "Fake title", + "topic": "Fake", + "theme": "test", + "sub_theme": "test", "seo_title": "Fake title | UKHSA data dashboard", "search_description": "Fake page description", "page_description": "Fake page description", - "metric": "Fake_metric_name", + "metric": 1, "body": [], } @@ -132,6 +136,7 @@ def build_worksheet() -> Worksheet: work_sheet["E2"] = "Fake page description" work_sheet["F2"] = "Fake methodology content" work_sheet["G2"] = "Fake caveats content" + work_sheet["H2"] = 1 return work_sheet @@ -150,10 +155,13 @@ def test_delegates_calls_correctly( expected_response = [ { "title": "Fake title", + "topic": "Fake", + "theme": "test", + "sub_theme": "test", "seo_title": "Fake title | UKHSA data dashboard", "search_description": "Fake page description", "page_description": "Fake page description", - "metric": "Fake_metric_name", + "metric": 1, "body": [ { "type": "section", diff --git a/tests/unit/cms/metrics_documentation/data_migration/test_operations.py b/tests/unit/cms/metrics_documentation/data_migration/test_operations.py index b4741209a..18fdc352b 100644 --- a/tests/unit/cms/metrics_documentation/data_migration/test_operations.py +++ b/tests/unit/cms/metrics_documentation/data_migration/test_operations.py @@ -128,9 +128,10 @@ def test_log_recorded_when_metric_not_available_for_child_page( create_metrics_documentation_parent_page_and_child_entries() # Then - expected_log = ( - f"Metrics Documentation Child Entry for {fake_metric} was not created. " + expected_log_part_one = "Metrics Documentation Child Entry for " + expected_log_part_two = (" was not created. " "Because the corresponding `Metric` was not created beforehand" ) - assert expected_log in caplog.text + assert expected_log_part_one in caplog.text + assert expected_log_part_two in caplog.text diff --git a/tests/unit/cms/metrics_documentation/models/test_child.py b/tests/unit/cms/metrics_documentation/models/test_child.py index 8f214e5d6..01bddcd37 100644 --- a/tests/unit/cms/metrics_documentation/models/test_child.py +++ b/tests/unit/cms/metrics_documentation/models/test_child.py @@ -5,10 +5,6 @@ from wagtail.admin.panels import FieldPanel from wagtail.api.conf import APIField -from cms.metrics_documentation.models import child -from cms.metrics_documentation.models.child import ( - InvalidTopicForChosenMetricForChildEntryError, -) from tests.fakes.factories.cms.metrics_documentation_child_entry_factory import ( FakeMetricsDocumentationChildEntryFactory, ) @@ -89,154 +85,90 @@ def test_has_the_correct_content_panels( fake_metrics_documentation_child_entry_page, expected_content_panel_name ) - @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") - @mock.patch.object(child.MetricsDocumentationChildEntry, "find_topic") - @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") - def test_get_topic_delegates_calls_correctly( - self, - mock_get_all_metric_names_and_ids: mock.MagicMock, - spy_find_topic: mock.MagicMock, - spy_get_a_list_of_all_topic_names: mock.MagicMock, - ): - """ - Given a blank `MetricsDocumentationChildEntryPage` model. - When `get_topic()` is called. - Then the `get_a_list_of_all_topic_names()` method and `find_topic()` - methods are called. - """ - # Given - fake_topics = ["COVID-19", "Influenza"] - fake_metrics_documentation_child_entry_page = ( - FakeMetricsDocumentationChildEntryFactory.build_page_from_template() - ) - fake_metrics_documentation_child_entry_page.metric = ( - "COVID-19_cases_rateRollingMean" - ) - - # When - spy_get_a_list_of_all_topic_names.return_value = fake_topics - fake_metrics_documentation_child_entry_page.get_topic() - - # Then - spy_get_a_list_of_all_topic_names.assert_called_once() - spy_find_topic.assert_called_once() - - @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") @pytest.mark.parametrize( - "metric_name, metric_group", + "metric_id, metric_group", [ - ("COVID-19_cases_rateRollingMean", "cases"), - ("COVID-19_headline_vaccines_autumn23Total", "headline"), - ("COVID-19_vaccinations_autumn22_uptakeByDay", "vaccinations"), - ("COVID-19_deaths_ONSByWeek", "deaths"), + (1, "cases"), + (2, "headline"), + (3, "vaccinations"), + (4, "deaths"), ], ) def test_metric_group_returns_expected_string( self, get_all_metric_names_and_ids: mock.MagicMock, - mock_get_all_topic_names: mock.MagicMock, - metric_name: str, + metric_id: int, metric_group: str, ): """ Given a blank `MetricsDocumentationChildEntryPage` model. - When a metric name is supplied to the `metric` property. + When a metric id is supplied to the `metric` property. Then the metric_group will be correctly extracted from the string. """ # Given + get_all_metric_names_and_ids.return_value = [ + (1, "COVID-19_cases_rateRollingMean"), + (2, "COVID-19_headline_vaccines_autumn23Total"), + (3, "COVID-19_vaccinations_autumn22_uptakeByDay"), + (4, "COVID-19_deaths_ONSByWeek"), + ] fake_metrics_documentation_child_entry_page = ( FakeMetricsDocumentationChildEntryFactory.build_page_from_template() ) # When - fake_metrics_documentation_child_entry_page.metric = metric_name + fake_metrics_documentation_child_entry_page.metric = metric_id # Then assert fake_metrics_documentation_child_entry_page.metric_group == metric_group @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") - @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") @pytest.mark.parametrize( - "selected_metric, extracted_topic", - [ - ("COVID-19_cases_rateRollingMean", "COVID-19"), - ("influenza_headline_ICUHDUadmissionRatePercentChange", "Influenza"), - ("hMPV_testing_positivityByWeek", "hMPV"), - ("parainfluenza_headline_positivityLatest", "Parainfluenza"), - ("rhinovirus_headline_positivityLatest", "Rhinovirus"), - ("RSV_headline_admissionRateLatest", "RSV"), - ("adenovirus_headline_positivityLatest", "Adenovirus"), - ], + "metric_id", + [1,2,3,4,5], ) - def test_find_topic_returns_expected_topic_name( - self, - spy_get_a_list_of_all_topic_names: mock.MagicMock(), - mock_get_all_metric_names_and_ids: mock.MagicMock(), - selected_metric: str, - extracted_topic: str, - ): + def test_metric_group_returns_emptry_string_with_missing_values(self, get_all_metric_names_and_ids: mock.MagicMock, metric_id: int): """ - Given a blank `MetricsDocumentationChildEntryPage` model - a list of topics and a metric name. - When the `find_topic()` method is called. - Then the expected topic name will be matched from the list - using the metric name. + Given a blank `MetricsDocumentationChildEntryPage` model. + When a metric id is supplied to the `metric` property with invalid choices returned. + Then the metric_group will return an empty string. """ # Given + get_all_metric_names_and_ids.return_value = [ + (1, "COVID-19casesrateRollingMean"), + (2, "COVID-19_"), + (3, ""), + (4, None), + ] fake_metrics_documentation_child_entry_page = ( FakeMetricsDocumentationChildEntryFactory.build_page_from_template() ) - fake_topics = [ - "COVID-19", - "Influenza", - "RSV", - "hMPV", - "Parainfluenza", - "Rhinovirus", - "Adenovirus", - ] - fake_metrics_documentation_child_entry_page.metric = selected_metric # When - return_topic = fake_metrics_documentation_child_entry_page.find_topic( - topics=fake_topics - ) - + fake_metrics_documentation_child_entry_page.metric = metric_id + # Then - assert return_topic == extracted_topic + assert fake_metrics_documentation_child_entry_page.metric_group == "" @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") - @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") - def test_find_topic_raises_error( - self, - mock_get_a_list_of_all_topic_names: mock.MagicMock(), - mock_get_all_metric_names_and_ids: mock.MagicMock(), - ): + def test_metric_group_returns_emptry_string_with_empty_metrics(self, get_all_metric_names_and_ids: mock.MagicMock): """ - Given a metric name that does not include a valid topic. - When the `find_topic()` method is called with a list of topics. - Then an `InvalidTopicForChosenMetricForChildEntryError` is raised. + Given a blank `MetricsDocumentationChildEntryPage` model. + When a metric id is supplied to the `metric` property with no choices returned. + Then the metric_group will return an empty string. """ # Given - fake_invalid_metric = "invalid_metric_contains_no_topic" - fake_topics = [ - "COVID-19", - "Influenza", - "RSV", - "hMPV", - "Parainfluenza", - "Rhinovirus", - "Adenovirus", - ] + get_all_metric_names_and_ids.return_value = [] fake_metrics_documentation_child_entry_page = ( FakeMetricsDocumentationChildEntryFactory.build_page_from_template() ) - fake_metrics_documentation_child_entry_page.metric = fake_invalid_metric - # When / Then - with pytest.raises(InvalidTopicForChosenMetricForChildEntryError): - fake_metrics_documentation_child_entry_page.find_topic(topics=fake_topics) + # When + fake_metrics_documentation_child_entry_page.metric = 1 + + # Then + assert fake_metrics_documentation_child_entry_page.metric_group == "" @mock.patch( "cms.dashboard.models.UKHSAPage._raise_error_if_seo_title_tag_not_provided", @@ -247,10 +179,8 @@ def test_find_topic_raises_error( return_value=None, ) @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") - @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") def test_public_error_raised_if_invalid_classification( self, - mock_get_a_list_of_all_topic_names: mock.MagicMock(), mock_get_all_metric_names_and_ids: mock.MagicMock(), mock_slug_raise_error, mock_seo_title_raise_error, @@ -281,10 +211,8 @@ def test_public_error_raised_if_invalid_classification( return_value=None, ) @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") - @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") def test_public_page_clears_page_classification( self, - mock_get_a_list_of_all_topic_names: mock.MagicMock(), mock_get_all_metric_names_and_ids: mock.MagicMock(), mock_slug_raise_error, mock_seo_title_raise_error, @@ -317,10 +245,8 @@ def test_public_page_clears_page_classification( return_value=None, ) @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") - @mock.patch(f"{MODULE_PATH}.get_a_list_of_all_topic_names") def test_non_public_page_doesnt_clean_page_classification( self, - mock_get_a_list_of_all_topic_names: mock.MagicMock(), mock_get_all_metric_names_and_ids: mock.MagicMock(), mock_slug_raise_error, mock_seo_title_raise_error, From aee34c0aeafc5b4a38fd4b7bafa9393ec8a9a283 Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Fri, 8 May 2026 16:34:21 +0100 Subject: [PATCH 153/186] Naming fixes --- tests/integration/cms/dashboard/__init__.py | 0 tests/integration/cms/dashboard/test_viewsets.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 tests/integration/cms/dashboard/__init__.py diff --git a/tests/integration/cms/dashboard/__init__.py b/tests/integration/cms/dashboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/cms/dashboard/test_viewsets.py b/tests/integration/cms/dashboard/test_viewsets.py index b9db40871..925cb17c3 100644 --- a/tests/integration/cms/dashboard/test_viewsets.py +++ b/tests/integration/cms/dashboard/test_viewsets.py @@ -12,7 +12,7 @@ @pytest.mark.django_db -class TestGetQuerySet: +class TestCMSPagesAPIViewSetPermissions: @pytest.fixture def setup_pages(self): From 89025afdf1476778e4e80d73111956a86cfc19a7 Mon Sep 17 00:00:00 2001 From: itsthatianguy Date: Tue, 12 May 2026 09:57:57 +0100 Subject: [PATCH 154/186] test coverage --- .../cms/dashboard/test_viewsets.py | 9 + .../models/test_permission_sets.py | 139 +++++++++ tests/unit/auth_content/test_auth_utils.py | 110 +++++++ tests/unit/auth_content/test_wagtail_hooks.py | 33 ++- .../models/test_child.py | 280 +++++++++++++++++- tests/unit/cms/topic/test_models.py | 201 ++++++++++++- 6 files changed, 767 insertions(+), 5 deletions(-) create mode 100644 tests/unit/auth_content/models/test_permission_sets.py create mode 100644 tests/unit/auth_content/test_auth_utils.py diff --git a/tests/integration/cms/dashboard/test_viewsets.py b/tests/integration/cms/dashboard/test_viewsets.py index 925cb17c3..081fcfe31 100644 --- a/tests/integration/cms/dashboard/test_viewsets.py +++ b/tests/integration/cms/dashboard/test_viewsets.py @@ -25,6 +25,9 @@ def setup_pages(self): private_metric = Metric.objects.create( name="COVID-19_headline_cases_7DayTotals", topic=covid_topic ) + private_metric_two = Metric.objects.create( + name="COVID-19_headline_7DayAdmissionsChange", topic=covid_topic + ) home = Page.objects.get(id=2) @@ -39,6 +42,9 @@ def setup_pages(self): private_metrics = MetricsDocumentationChildEntry(title="Private Metric", page_description="test", slug="private-metric", theme="2", sub_theme="test", topic="test", metric=private_metric.pk, is_public=False, seo_title="private-metrics") home.add_child(instance=private_metrics) + + private_metrics_two = MetricsDocumentationChildEntry(title="Private Metric 2", page_description="test", slug="private-metric-two", theme="1", sub_theme="test", topic="test", metric=private_metric_two.pk, is_public=False, seo_title="private-metrics-two") + home.add_child(instance=private_metrics_two) standard_page = CommonPage(title="Standard", body="test", slug="standard", seo_title="standard-page") home.add_child(instance=standard_page) @@ -80,6 +86,7 @@ def test_anonymous_user_access(self, setup_pages): assert "Standard" in titles assert "Private Topic" not in titles assert "Private Metric" not in titles + assert "Private Metric 2" not in titles def test_global_access_user(self, setup_pages): """ @@ -116,6 +123,7 @@ def test_global_access_user(self, setup_pages): assert "Standard" in titles assert "Private Topic" in titles assert "Private Metric" in titles + assert "Private Metric 2" in titles def test_restricted_user_with_permission(self, setup_pages): """ @@ -148,4 +156,5 @@ def test_restricted_user_with_permission(self, setup_pages): # Then titles = [p.title for p in result] assert "Private Topic" in titles + assert "Private Metric 2" in titles assert "Private Metrics" not in titles diff --git a/tests/unit/auth_content/models/test_permission_sets.py b/tests/unit/auth_content/models/test_permission_sets.py new file mode 100644 index 000000000..c3eaae92e --- /dev/null +++ b/tests/unit/auth_content/models/test_permission_sets.py @@ -0,0 +1,139 @@ +import pytest +from unittest.mock import MagicMock, patch + +from django.core.exceptions import ValidationError +from cms.auth_content.models.permission_sets import PermissionSet, PermissionSetForm + +class TestPermissionSetForm: + MOCK_PERMISSION_SET_FIELDS = [ + {"field_name": "theme", "field_label": "Theme"}, + {"field_name": "sub_theme", "field_label": "Sub Theme"}, + {"field_name": "topic", "field_label": "Topic"}, + {"field_name": "metric", "field_label": "Metric"}, + {"field_name": "geography", "field_label": "Geography"}, + ] + + def _make_form(self, instance=None, queryset_exists=False): + """ + Instantiate PermissionSetForm with all Wagtail + internals patched. + """ + with ( + patch("wagtail.admin.panels.WagtailAdminPageForm.__init__", return_value=None), + patch("cms.auth_content.models.permission_sets.PERMISSION_SET_FIELDS", self.MOCK_PERMISSION_SET_FIELDS), + patch( + "cms.auth_content.models.permission_sets._create_form_field", + side_effect=lambda field, wildcard: MagicMock(name=field["field_name"]), + ), + ): + form = PermissionSetForm.__new__(PermissionSetForm) + form.fields = {} + form.instance = instance or MagicMock(pk=None) + default_data = { + "theme": 1, + "sub_theme": 2, + "topic": 3, + "metric": 4, + "geography_type": 5, + "geography": 6, + } + form.cleaned_data = default_data + form.__init__() + + mock_qs = MagicMock() + mock_qs.exists.return_value = queryset_exists + mock_qs.exclude.return_value = mock_qs + + form._mock_qs = mock_qs + return form + + def test_init_sets_up_fields(self): + """ + When a new form is instantiated + Then a form field is added to `fields` for each entry in `PERMISSION_SET_FIELDS` + """ + form = self._make_form() + + assert len(form.fields) == 5 + assert "theme" in form.fields + assert "sub_theme" in form.fields + assert "topic" in form.fields + assert "metric" in form.fields + assert "geography" in form.fields + + def test_initialize_dependent_fields(self): + """ + Given a new form + When an instance has a pk value set + Then `_initialize_dependent_fields` is called + """ + instance = MagicMock(pk=1, sub_theme=1, topic=2, metric=3, geography=4) + form = self._make_form(instance=instance) + + assert form.fields["sub_theme"].widget.choices == [('', 'Select theme first'), (1, 'Loading... (ID: 1)')] + assert form.fields["topic"].widget.choices == [('', 'Select sub-theme first'), (2, 'Loading... (ID: 2)')] + assert form.fields["metric"].widget.choices == [('', 'Select topic first'), (3, 'Loading... (ID: 3)')] + assert form.fields["geography"].widget.choices == [('', 'Select geography type first'), (4, 'Loading... (ID: 4)')] + + def test_get_field_choices(self): + """ + When the static function `_get_field_choices` is called without a wildcard + Then the result returns the placeholder value + """ + result = PermissionSetForm._get_field_choices("test", "placeholder", None) + assert result == [("", "placeholder"), ("test", "Loading... (ID: test)")] + + def test_get_field_choices_wildcard_match(self): + """ + When the static function `_get_field_choices` is called with a wildcard + Then the result returns the wildcard match + """ + result = PermissionSetForm._get_field_choices("-1", "placeholder", "wildcard") + assert result == [("-1", "wildcard")] + + @patch("cms.auth_content.models.permission_sets.PermissionSet.objects.filter") + def test_validation_error_raised_if_queryset_duplicated(self, mock_query_filter: MagicMock): + """ + Given a form is created with an existing queryset match + When `clean` is called + Then a `ValidationError` is raised + """ + form = self._make_form(queryset_exists=True) + mock_query_filter.return_value = form._mock_qs + + with pytest.raises(ValidationError) as e: + form.clean() + + assert "A permission set with this exact combination already exists. Please modify your selection to create a unique permission set." in str(e.value) + + @patch("cms.auth_content.models.permission_sets.PermissionSet.objects.filter") + def test_returns_cleaned_data_when_no_duplicate_exists(self, mock_query_filter: MagicMock): + """ + Given a form is created without an existing queryset match + When `clean` is called + Then the cleaned data is returned + """ + instance = MagicMock(pk=1) + form = self._make_form(instance=instance, queryset_exists=False) + mock_query_filter.return_value = form._mock_qs + + result = form.clean() + + assert result == form.cleaned_data + +class TestPermissionSet(): + def test_get_choice_label(self): + """ + Given a blank `PermissionSet` + When `_get_choice_label` is called with an unknown field and value + Then the unknown value is returned + """ + test_permission_set = PermissionSet() + unknown_field = "unknown_field_type" + test_value = "12345" + + # When + result = test_permission_set._get_choice_label(unknown_field, test_value) + + # Then + assert result == test_value diff --git a/tests/unit/auth_content/test_auth_utils.py b/tests/unit/auth_content/test_auth_utils.py new file mode 100644 index 000000000..0ac0a0db0 --- /dev/null +++ b/tests/unit/auth_content/test_auth_utils.py @@ -0,0 +1,110 @@ +import pytest +from unittest.mock import MagicMock +from django import forms + +from cms.auth_content.auth_utils import _create_form_field + + +class TestCreateFormField(): + def test_create_form_field_basic(self): + """ + Given no wildcard or callables in data + When `_create_form_field` is called + Then only the default choices are returned + """ + field_data = { + "field_choice_default": "Select an option", + "field_choice_wildcard": None, + "field_choice_callable": None, + "field_label": "My Label" + } + + result = _create_form_field(field_data) + + assert isinstance(result, forms.CharField) + assert result.label == "My Label" + expected_choices = [("", "Select an option")] + assert result.widget.choices == expected_choices + + def test_create_form_field_with_wildcard(self): + """ + Given a wildcard in the data + When `_create_form_field` is called + Then the wildcard choice is added + """ + field_data = { + "field_choice_default": "Default", + "field_choice_wildcard": "All Items", + "field_choice_callable": None, + "field_label": "Label" + } + wildcard_val = "-1" + + result = _create_form_field(field_data, wildcard_id_value=wildcard_val) + + expected_choices = [ + ("", "Default"), + ("-1", "All Items") + ] + assert result.widget.choices == expected_choices + + def test_create_form_field_with_callable(self): + """ + Given a callable in the data + When `_create_form_field` is called + Then the callable choice is added + """ + mock_callable = MagicMock(return_value=[("1", "One"), ("2", "Two")]) + + field_data = { + "field_choice_default": "Default", + "field_choice_wildcard": None, + "field_choice_callable": mock_callable, + "field_label": "Label" + } + + result = _create_form_field(field_data) + + expected_choices = [ + ("", "Default"), + ("1", "One"), + ("2", "Two") + ] + assert result.widget.choices == expected_choices + mock_callable.assert_called_once() + + def test_create_form_field_all_features(self): + """ + Given both a wildcard and callable are in the data + When `_create_form_field` is called + Then both the wildcard and callable choices are added + """ + """Test combined default, wildcard, and callable choices""" + mock_callable = MagicMock(return_value=[("dynamic", "Dynamic")]) + field_data = { + "field_choice_default": "Default", + "field_choice_wildcard": "Wildcard", + "field_choice_callable": mock_callable, + "field_label": "Label" + } + + result = _create_form_field(field_data, wildcard_id_value="999") + + expected_choices = [ + ("", "Default"), + ("999", "Wildcard"), + ("dynamic", "Dynamic") + ] + assert result.widget.choices == expected_choices + + def test_create_form_field_missing_key_error(self): + """ + Given data with missing keys + When `_create_form_field` is called + Then a key error exception is raised + """ + """Test behavior if a required key is missing from the dict""" + field_data = {"field_choice_default": "Missing other keys"} + + with pytest.raises(KeyError): + _create_form_field(field_data) diff --git a/tests/unit/auth_content/test_wagtail_hooks.py b/tests/unit/auth_content/test_wagtail_hooks.py index 3ca362565..6b9d4d177 100644 --- a/tests/unit/auth_content/test_wagtail_hooks.py +++ b/tests/unit/auth_content/test_wagtail_hooks.py @@ -1,11 +1,25 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from django.test import TestCase from django.utils.safestring import SafeData from cms.auth_content.models.permission_sets import PermissionSet -from cms.auth_content.wagtail_hooks import NoEditPermissionPolicy, PermissionSetViewSet +from cms.auth_content.wagtail_hooks import NoEditPermissionPolicy, PermissionSetViewSet, AuthGroup +from cms.auth_content import wagtail_hooks +class TestWagtailHooks(TestCase): + def test_register_auth_viewset(self): + result = wagtail_hooks.register_auth_viewset() + assert result.menu_label == AuthGroup.menu_label + assert result.menu_icon == AuthGroup.menu_icon + assert result.menu_order == AuthGroup.menu_order + assert len(result.items) == 2 + + def test_permission_set_js(self): + result = wagtail_hooks.permission_set_js() + assert '' in result + class TestPermissionSetDetailsProperty(TestCase): def test_single_value_no_pipe(self): @@ -41,6 +55,14 @@ def setUp(self): def test_change_permission_denied(self): self.assertFalse(self.policy.user_has_permission(self.user, "change")) + @patch("wagtail.permission_policies.ModelPermissionPolicy.user_has_permission") + def test_user_has_permission_calls_super(self, spy_user_has_permissions: MagicMock): + spy_user_has_permissions.return_value = "parent_response" + result = self.policy.user_has_permission(self.user, "view") + + spy_user_has_permissions.assert_called_once_with(self.user, "view") + assert result == "parent_response" + def test_change_permission_denied_for_instance(self): self.assertFalse( self.policy.user_has_permission_for_instance( @@ -48,6 +70,13 @@ def test_change_permission_denied_for_instance(self): ) ) + @patch("wagtail.permission_policies.ModelPermissionPolicy.user_has_permission_for_instance") + def test_user_has_permission_for_instance_calls_super(self, spy_user_has_permissions_for_instance: MagicMock): + spy_user_has_permissions_for_instance.return_value = "parent_response" + result = self.policy.user_has_permission_for_instance(self.user, "view", self.instance) + + spy_user_has_permissions_for_instance.assert_called_once_with(self.user, "view", self.instance) + assert result == "parent_response" class TestPermissionSetViewSet(TestCase): diff --git a/tests/unit/cms/metrics_documentation/models/test_child.py b/tests/unit/cms/metrics_documentation/models/test_child.py index 01bddcd37..89e3ec266 100644 --- a/tests/unit/cms/metrics_documentation/models/test_child.py +++ b/tests/unit/cms/metrics_documentation/models/test_child.py @@ -1,16 +1,178 @@ from unittest import mock +from unittest.mock import MagicMock, patch from django.core.exceptions import ValidationError import pytest from wagtail.admin.panels import FieldPanel from wagtail.api.conf import APIField +from cms.dashboard.constants import THEME_FIELDS +from cms.metrics_documentation.models.child import MetricsDocumentationChildEntryAdminForm, InvalidTopicForChosenMetricForChildEntryError from tests.fakes.factories.cms.metrics_documentation_child_entry_factory import ( FakeMetricsDocumentationChildEntryFactory, ) MODULE_PATH = "cms.metrics_documentation.models.child" +class TestInvalidTopicForChosenMetricForChildEntryError: + def test_exception_has_expected_message(self): + actual = InvalidTopicForChosenMetricForChildEntryError("test_topic", "test_metric") + expected = "InvalidTopicForChosenMetricForChildEntryError('The `test_topic` is not available for selected metric of `test_metric`')" + + assert expected == repr(actual) + +class TestMetricsDocumentationChildEntryAdminForm: + MOCK_THEME_FIELDS = [ + {"field_name": "theme", "label": "Theme", "required": True}, + {"field_name": "sub_theme", "label": "Sub Theme", "required": False}, + {"field_name": "topic", "label": "Topic", "required": False}, + ] + + def _make_form(self, instance=None): + """ + Instantiate MetricsDocumentationChildEntryAdminForm with all Wagtail + internals patched. + """ + with ( + patch( + "wagtail.admin.panels.WagtailAdminPageForm.__init__", return_value=None + ), + patch( + "cms.metrics_documentation.models.child.THEME_FIELDS", + self.MOCK_THEME_FIELDS, + ), + patch( + "cms.metrics_documentation.models.child._create_form_field", + side_effect=lambda field: MagicMock(name=field["field_name"]), + ), + ): + form = MetricsDocumentationChildEntryAdminForm.__new__( + MetricsDocumentationChildEntryAdminForm + ) + form.fields = {} + form.instance = instance or MagicMock(pk=None) + form.__init__() + return form + + def _make_form_with_instance(self, sub_theme=None, topic=None): + """ + Instantiate MetricsDocumentationChildEntryAdminForm with all Wagtail + internals patched, and a mocked instance. + """ + instance = MagicMock(pk=1) + instance.sub_theme = sub_theme + instance.topic = topic + + form = self._make_form(instance=instance) + + for field_name in ("sub_theme", "topic"): + mock_widget = MagicMock() + mock_widget.choices = [] + form.fields[field_name] = MagicMock(widget=mock_widget) + + return form + + def test_creates_field_for_every_theme_field(self): + """ + When a new form is instantiated + Then a form field is added to `fields` for each entry in `THEME_FIELDS`. + """ + form = self._make_form() + + assert len(form.fields) == 3 + assert "theme" in form.fields + assert "sub_theme" in form.fields + assert "topic" in form.fields + + @mock.patch("cms.metrics_documentation.models.child._create_form_field") + @mock.patch("wagtail.admin.panels.WagtailAdminPageForm.__init__") + def test_field_creation_uses_create_form_field_helper( + self, + spy_init_admin_form: mock.MagicMock, + spy_create_form_field: mock.MagicMock + ): + """ + Given a new form is created + When init is called on the form + Then `_create_form_field` is called once per `THEME_FIELDS` entry. + """ + form = MetricsDocumentationChildEntryAdminForm.__new__( + MetricsDocumentationChildEntryAdminForm + ) + form.fields = {} + form.instance = MagicMock(pk=None) + form.__init__() + + assert spy_create_form_field.call_count == len(THEME_FIELDS) + spy_create_form_field.assert_any_call(THEME_FIELDS[0]) + spy_create_form_field.assert_any_call(THEME_FIELDS[1]) + spy_create_form_field.assert_any_call(THEME_FIELDS[2]) + spy_create_form_field.assert_any_call(THEME_FIELDS[3]) + + def test_initialize_dependent_fields_called_when_instance_has_pk(self): + """ + Given a new form + When an instance has a pk value set + Then `_initialize_dependent_fields` is called + """ + instance = MagicMock(pk=42) + + with patch.object( + MetricsDocumentationChildEntryAdminForm, + "_initialize_dependent_fields", + ) as mock_init_deps: + self._make_form(instance=instance) + + mock_init_deps.assert_called_once() + + def test_initialize_dependent_fields_called_when_instance_has_no_pk(self): + """ + Given a new form + When an instance does not have a pk value set + Then `_initialize_dependent_fields` is not called + """ + instance = MagicMock(pk=None) + + with patch.object( + MetricsDocumentationChildEntryAdminForm, + "_initialize_dependent_fields", + ) as mock_init_deps: + self._make_form(instance=instance) + + mock_init_deps.assert_not_called() + + def test_both_fields_updated_when_both_have_values(self): + """ + Given a new form with a sub_theme and topic + When `_initialize_dependent_fields` is called + Then both sub_theme and topic choices are set + """ + form = self._make_form_with_instance(sub_theme=3, topic=7) + + form._initialize_dependent_fields() + + assert form.fields["sub_theme"].widget.choices == [ + ("", "Select theme first"), + (3, "Loading... (ID: 3)"), + ] + assert form.fields["topic"].widget.choices == [ + ("", "Select sub-theme first"), + (7, "Loading... (ID: 7)"), + ] + + def test_skips_field_when_value_is_none(self): + """ + Given a new form with no sub_theme or topic + When `_initialize_dependent_fields` is called + Then widget choices are left untouched. + """ + form = self._make_form_with_instance(sub_theme=None, topic=None) + original_choices = form.fields["sub_theme"].widget.choices + + form._initialize_dependent_fields() + + assert form.fields["sub_theme"].widget.choices == original_choices + class TestMetricsDocumentationChildEntry: @pytest.mark.parametrize( @@ -197,11 +359,127 @@ def test_public_error_raised_if_invalid_classification( fake_metrics_documentation_child_entry_page.is_public = False fake_metrics_documentation_child_entry_page.page_classification = None + fake_metrics_documentation_child_entry_page.theme = "test" + fake_metrics_documentation_child_entry_page.sub_theme = "test" + fake_metrics_documentation_child_entry_page.topic = "test" + + # When/Then + with pytest.raises(ValidationError) as e: + fake_metrics_documentation_child_entry_page.clean() + + assert "Please select a classification level for this non-public page" in str(e.value) + + @mock.patch( + "cms.dashboard.models.UKHSAPage._raise_error_if_seo_title_tag_not_provided", + return_value=None, + ) + @mock.patch( + "cms.dashboard.models.UKHSAPage._raise_error_if_slug_not_unique", + return_value=None, + ) + @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") + def test_public_error_raised_if_invalid_theme( + self, + mock_get_all_metric_names_and_ids: mock.MagicMock(), + mock_slug_raise_error, + mock_seo_title_raise_error, + ): + """ + Given is_public is False (i.e the page is a non public page). + When no theme is given. + Then a `ValidationError` is raised. + """ + # Given + fake_metrics_documentation_child_entry_page = ( + FakeMetricsDocumentationChildEntryFactory.build_page_from_template() + ) + + fake_metrics_documentation_child_entry_page.is_public = False + fake_metrics_documentation_child_entry_page.page_classification = "test" + fake_metrics_documentation_child_entry_page.theme = None + fake_metrics_documentation_child_entry_page.sub_theme = "test" + fake_metrics_documentation_child_entry_page.topic = "test" + + # When/Then + with pytest.raises(ValidationError) as e: + fake_metrics_documentation_child_entry_page.clean() + + assert "Please select a theme for this non-public page" in str(e.value) + + @mock.patch( + "cms.dashboard.models.UKHSAPage._raise_error_if_seo_title_tag_not_provided", + return_value=None, + ) + @mock.patch( + "cms.dashboard.models.UKHSAPage._raise_error_if_slug_not_unique", + return_value=None, + ) + @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") + def test_public_error_raised_if_invalid_sub_theme( + self, + mock_get_all_metric_names_and_ids: mock.MagicMock(), + mock_slug_raise_error, + mock_seo_title_raise_error, + ): + """ + Given is_public is False (i.e the page is a non public page). + When no sub theme is given. + Then a `ValidationError` is raised. + """ + # Given + fake_metrics_documentation_child_entry_page = ( + FakeMetricsDocumentationChildEntryFactory.build_page_from_template() + ) + + fake_metrics_documentation_child_entry_page.is_public = False + fake_metrics_documentation_child_entry_page.page_classification = "test" + fake_metrics_documentation_child_entry_page.theme = "None" + fake_metrics_documentation_child_entry_page.sub_theme = None + fake_metrics_documentation_child_entry_page.topic = "test" # When/Then - with pytest.raises(ValidationError): + with pytest.raises(ValidationError) as e: fake_metrics_documentation_child_entry_page.clean() + assert "Please select a subtheme for this non-public page" in str(e.value) + + @mock.patch( + "cms.dashboard.models.UKHSAPage._raise_error_if_seo_title_tag_not_provided", + return_value=None, + ) + @mock.patch( + "cms.dashboard.models.UKHSAPage._raise_error_if_slug_not_unique", + return_value=None, + ) + @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") + def test_public_error_raised_if_invalid_topic( + self, + mock_get_all_metric_names_and_ids: mock.MagicMock(), + mock_slug_raise_error, + mock_seo_title_raise_error, + ): + """ + Given is_public is False (i.e the page is a non public page). + When no topic is given. + Then a `ValidationError` is raised. + """ + # Given + fake_metrics_documentation_child_entry_page = ( + FakeMetricsDocumentationChildEntryFactory.build_page_from_template() + ) + + fake_metrics_documentation_child_entry_page.is_public = False + fake_metrics_documentation_child_entry_page.page_classification = "test" + fake_metrics_documentation_child_entry_page.theme = "test" + fake_metrics_documentation_child_entry_page.sub_theme = "test" + fake_metrics_documentation_child_entry_page.topic = None + + # When/Then + with pytest.raises(ValidationError) as e: + fake_metrics_documentation_child_entry_page.clean() + + assert "Please select a topic for this non-public page" in str(e.value) + @mock.patch( "cms.dashboard.models.UKHSAPage._raise_error_if_seo_title_tag_not_provided", return_value=None, diff --git a/tests/unit/cms/topic/test_models.py b/tests/unit/cms/topic/test_models.py index 1d9bd39ae..b4a269a6f 100644 --- a/tests/unit/cms/topic/test_models.py +++ b/tests/unit/cms/topic/test_models.py @@ -5,7 +5,7 @@ from django.core.exceptions import ValidationError -from cms.topic.models import TopicPage +from cms.topic.models import TopicPage, TopicPageAdminForm from metrics.domain.charts.colour_scheme import RGBAChartLineColours from metrics.domain.charts.common_charts.plots.line_multi_coloured.properties import ( @@ -16,6 +16,99 @@ from wagtail.search.index import SearchField +class TestTopicPageAdminForm: + MOCK_THEME_FIELDS = [ + {"field_name": "theme", "label": "Theme", "required": True}, + {"field_name": "sub_theme", "label": "Sub Theme", "required": False}, + ] + + def _make_form(self, instance=None): + """ + Instantiate TopicPageAdminForm with all Wagtail + internals patched. + """ + with ( + mock.patch("wagtail.admin.panels.WagtailAdminPageForm.__init__", return_value=None), + mock.patch("cms.topic.models.THEME_FIELDS", self.MOCK_THEME_FIELDS), + mock.patch( + "cms.topic.models._create_form_field", + side_effect=lambda field: mock.MagicMock(name=field["field_name"]), + ), + ): + form = TopicPageAdminForm.__new__(TopicPageAdminForm) + form.fields = {} + form.instance = instance or mock.MagicMock(pk=None) + form.__init__() + return form + + def test_theme_fields_are_added_on_init(self): + """ + When a new form is instantieated + Then a form field is added to `fields` for each entry in `THEME_FIELDS`. + """ + form = self._make_form() + + assert len(form.fields) == 2 + assert "theme" in form.fields + assert "sub_theme" in form.fields + + def test_dependent_fields_initialised_for_saved_instance(self): + """ + Given a new form + When an instance has a pk value set + Then `_initialize_dependent_fields` is called + """ + with mock.patch.object(TopicPageAdminForm, "_initialize_dependent_fields") as init_fields_mock: + self._make_form(instance=mock.MagicMock(pk=1)) + init_fields_mock.assert_called_once() + + def test_dependent_fields_not_initialised_for_new_instance(self): + """ + Given a new form + When an instance does not have a pk value set + Then `_initialize_dependent_fields` is not called + """ + with mock.patch.object(TopicPageAdminForm, "_initialize_dependent_fields") as init_fields_mock: + self._make_form(instance=mock.MagicMock(pk=None)) + init_fields_mock.assert_not_called() + + def test_widget_choices_set_when_sub_theme_has_value(self): + """ + Given a new form with a sub_theme + When `_initialize_dependent_fields` is called + Then the sub_theme choices are set + """ + instance = mock.MagicMock(pk=1, sub_theme=5, topic=None) + form = self._make_form(instance=instance) + mock_widget = mock.MagicMock() + form.fields["sub_theme"] = mock.MagicMock(widget=mock_widget) + form.fields["topic"] = mock.MagicMock(widget=mock.MagicMock()) + + form._initialize_dependent_fields() + + assert mock_widget.choices == [("", "Select theme first"), (5, "Loading... (ID: 5)")] + + def test_widget_choices_not_set_when_value_is_none(self): + """ + Given a new form with no sub_theme or topic + When `_initialize_dependent_fields` is called + Then widget choices are left untouched. + """ + instance = mock.MagicMock(pk=1, sub_theme=None, topic=None) + form = self._make_form(instance=instance) + mock_widget = mock.MagicMock(choices=[]) + form.fields["sub_theme"] = mock.MagicMock(widget=mock_widget) + form.fields["topic"] = mock.MagicMock(widget=mock.MagicMock(choices=[])) + + form._initialize_dependent_fields() + + assert mock_widget.choices == [] + + def test_get_field_choices_returns_correct_structure(self): + result = TopicPageAdminForm._get_field_choices(42, "Select theme first") + assert result == [("", "Select theme first"), (42, "Loading... (ID: 42)")] + + class TestTopicPage: @pytest.mark.parametrize( "expected_search_field", @@ -714,11 +807,115 @@ def test_public_error_raised_if_invalid_classification( fake_covid_topic_page.is_public = False fake_covid_topic_page.page_classification = None + fake_covid_topic_page.theme = "test" + fake_covid_topic_page.sub_theme = "test" + fake_covid_topic_page.topic = "test" # When/Then - with pytest.raises(ValidationError): + with pytest.raises(ValidationError) as e: fake_covid_topic_page.clean() + assert "Please select a classification level for this non-public page" in str(e.value) + + @mock.patch( + "cms.dashboard.models.UKHSAPage._raise_error_if_seo_title_tag_not_provided", + return_value=None, + ) + @mock.patch( + "cms.dashboard.models.UKHSAPage._raise_error_if_slug_not_unique", + return_value=None, + ) + def test_public_error_raised_if_invalid_theme( + self, + mock_slug_raise_error, + mock_seo_title_raise_error, + ): + """ + Given is_public is False (i.e the page is a non public page). + When no page theme is given. + Then a `ValidationError` is raised. + """ + # Given + fake_covid_topic_page = FakeTopicPageFactory.build_covid_19_page_from_template() + + fake_covid_topic_page.is_public = False + fake_covid_topic_page.page_classification = "test" + fake_covid_topic_page.theme = None + fake_covid_topic_page.sub_theme = "test" + fake_covid_topic_page.topic = "test" + + # When/Then + with pytest.raises(ValidationError) as e: + fake_covid_topic_page.clean() + + assert "Please select a theme for this non-public page" in str(e.value) + + @mock.patch( + "cms.dashboard.models.UKHSAPage._raise_error_if_seo_title_tag_not_provided", + return_value=None, + ) + @mock.patch( + "cms.dashboard.models.UKHSAPage._raise_error_if_slug_not_unique", + return_value=None, + ) + def test_public_error_raised_if_invalid_sub_theme( + self, + mock_slug_raise_error, + mock_seo_title_raise_error, + ): + """ + Given is_public is False (i.e the page is a non public page). + When no page sub theme is given. + Then a `ValidationError` is raised. + """ + # Given + fake_covid_topic_page = FakeTopicPageFactory.build_covid_19_page_from_template() + + fake_covid_topic_page.is_public = False + fake_covid_topic_page.page_classification = "test" + fake_covid_topic_page.theme = "test" + fake_covid_topic_page.sub_theme = None + fake_covid_topic_page.topic = "test" + + # When/Then + with pytest.raises(ValidationError) as e: + fake_covid_topic_page.clean() + + assert "Please select a sub theme for this non-public page" in str(e.value) + + @mock.patch( + "cms.dashboard.models.UKHSAPage._raise_error_if_seo_title_tag_not_provided", + return_value=None, + ) + @mock.patch( + "cms.dashboard.models.UKHSAPage._raise_error_if_slug_not_unique", + return_value=None, + ) + def test_public_error_raised_if_invalid_topic( + self, + mock_slug_raise_error, + mock_seo_title_raise_error, + ): + """ + Given is_public is False (i.e the page is a non public page). + When no page topic is given. + Then a `ValidationError` is raised. + """ + # Given + fake_covid_topic_page = FakeTopicPageFactory.build_covid_19_page_from_template() + + fake_covid_topic_page.is_public = False + fake_covid_topic_page.page_classification = "test" + fake_covid_topic_page.theme = "test" + fake_covid_topic_page.sub_theme = "test" + fake_covid_topic_page.topic = None + + # When/Then + with pytest.raises(ValidationError) as e: + fake_covid_topic_page.clean() + + assert "Please select a topic for this non-public page" in str(e.value) + @mock.patch( "cms.dashboard.models.UKHSAPage._raise_error_if_seo_title_tag_not_provided", return_value=None, From ccd0cc17fd2d8fc644d558d1dedfc59c54494fd8 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 19 May 2026 13:26:08 +0100 Subject: [PATCH 155/186] CDD-3171: Tweaks --- cms/dashboard/viewsets.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index b5bc1421d..7602c2ee0 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -46,9 +46,6 @@ class CMSPagesAPIViewSet(PagesAPIViewSet): listing_default_fields = PagesAPIViewSet.listing_default_fields + ["show_in_menus"] detail_only_fields = [] - # ** - # TODO: Is this endpoint used for nonpublic data? - # I would assume so, which means we need to change the caching - use the decorator? def get_queryset(self): """Returns the queryset as per the individual models @@ -101,11 +98,14 @@ def get_queryset(self): ) else: - if req.user.permission_sets["has_global_access"]: + user_permissions = req.user.permission_sets["permission_sets"] + has_global_access = req.user.permission_sets['summary']["has_global_access"] + + if has_global_access: filtered_queryset = queryset else: - user_permissions = req.user.permission_sets["permission_set_hierarchy"] + user_permissions = req.user.permission_sets["permission_sets"] allowed_pages = [] for page in queryset.type(TopicPage): if page.topicpage.is_public: From c6ce9c2048d763f6ec8c627cc526758e72fc297a Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 19 May 2026 13:26:22 +0100 Subject: [PATCH 156/186] CDD-3171: Add display name to permission sets --- .../0003_permissionset_display_name.py | 24 +++++++++++++++++++ cms/auth_content/models/permission_sets.py | 7 +++++- cms/dynamic_content/help_texts.py | 3 +++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 cms/auth_content/migrations/0003_permissionset_display_name.py diff --git a/cms/auth_content/migrations/0003_permissionset_display_name.py b/cms/auth_content/migrations/0003_permissionset_display_name.py new file mode 100644 index 000000000..3d1fe4485 --- /dev/null +++ b/cms/auth_content/migrations/0003_permissionset_display_name.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.13 on 2026-05-19 09:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth_content", "0002_alter_permissionset_geography_type_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="permissionset", + name="display_name", + field=models.CharField( + blank=True, + default="", + help_text="\nThis is an (optional) user readable name for the permission set. If not set, a default autogenerated name will be used.\n", + max_length=255, + unique=True, + ), + ), + ] diff --git a/cms/auth_content/models/permission_sets.py b/cms/auth_content/models/permission_sets.py index 1260c539e..a18553352 100644 --- a/cms/auth_content/models/permission_sets.py +++ b/cms/auth_content/models/permission_sets.py @@ -8,6 +8,7 @@ from cms.auth_content.auth_utils import _create_form_field from cms.auth_content.constants import PERMISSION_SET_FIELDS, WILDCARD_ID_VALUE +from cms.dynamic_content import help_texts from cms.metrics_interface.field_choices_callables import ( get_all_geography_names_and_codes, get_all_geography_type_names_and_ids, @@ -88,6 +89,9 @@ class PermissionSet(models.Model): editable=False, help_text="Auto-generated display name", ) + display_name = models.CharField( + max_length=255, blank=True, default="", help_text=help_texts.PERMISSION_SET_DISPLAY_NAME, unique=True + ) theme = models.CharField(max_length=255, blank=False, default="") sub_theme = models.CharField(max_length=255, blank=False, default="") topic = models.CharField(max_length=255, blank=False, default="") @@ -103,6 +107,7 @@ def permission_set_details(self): return mark_safe("
".join(parts)) panels = [ + FieldPanel("display_name"), FieldPanel("theme"), FieldPanel("sub_theme"), FieldPanel("topic"), @@ -211,4 +216,4 @@ def _find_label_in_choices(choices: list[tuple], value: str) -> str: ) def __str__(self): - return self.name or f"Permission Set {self.id}" + return self.display_name or self.name or f"Permission Set {self.id}" diff --git a/cms/dynamic_content/help_texts.py b/cms/dynamic_content/help_texts.py index 638f99eb8..6d959eefb 100644 --- a/cms/dynamic_content/help_texts.py +++ b/cms/dynamic_content/help_texts.py @@ -645,3 +645,6 @@ SECTION_FOOTER_LINK: str = """ This 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. """ +PERMISSION_SET_DISPLAY_NAME: str = """ +This is an (optional) user readable name for the permission set. If not set, a default autogenerated name will be used. +""" From b186842f59abace039147241a2ebbd8fff856ab1 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 19 May 2026 13:27:15 +0100 Subject: [PATCH 157/186] remove log file --- cms/dashboard/log.json | 147 ----------------------------------------- 1 file changed, 147 deletions(-) delete mode 100644 cms/dashboard/log.json diff --git a/cms/dashboard/log.json b/cms/dashboard/log.json deleted file mode 100644 index eeb06d9f9..000000000 --- a/cms/dashboard/log.json +++ /dev/null @@ -1,147 +0,0 @@ -{ - "TOPICPAGE.topicpage_LOGS (pre theme)": { - "formsubmission": "", - "redirect": "", - "sites_rooted_here": ".RelatedManager object at 0x117186ae0>", - "aliases": ".RelatedManager object at 0x117186de0>", - "group_permissions": ".RelatedManager object at 0x11719cb90>", - "view_restrictions": ".RelatedManager object at 0x11719cbc0>", - "workflowpage": "", - "wagtail_admin_comments": ".DeferringRelatedManager object at 0x11719c980>", - "subscribers": ".RelatedManager object at 0x1171b4680>", - "id": 80, - "path": "00010001000I0001", - "depth": 4, - "numchild": 0, - "translation_key": "UUID(a290e958-d915-4025-8fc7-886d82c492b8)", - "locale": "", - "latest_revision": "", - "live": true, - "has_unpublished_changes": false, - "first_published_at": "datetime.datetime(2026, 4, 27, 11, 7, 38, 72815, tzinfo=zoneinfo.ZoneInfo(key=Europe/London))", - "last_published_at": "datetime.datetime(2026, 4, 27, 11, 7, 38, 72815, tzinfo=zoneinfo.ZoneInfo(key=Europe/London))", - "live_revision": "", - "go_live_at": "None", - "expire_at": "None", - "expired": false, - "locked": false, - "locked_at": "None", - "locked_by": "None", - "title": "Childhood vaccinations", - "draft_title": "Childhood vaccinations", - "slug": "childhood-vaccinations", - "content_type": "", - "url_path": "/ukhsa-dashboard-root/cover/childhood-vaccinations/", - "owner": "None", - "seo_title": "Childhood vaccinations", - "show_in_menus": false, - "search_description": "", - "latest_revision_created_at": "datetime.datetime(2026, 4, 27, 11, 7, 38, 42274, tzinfo=zoneinfo.ZoneInfo(key=Europe/London))", - "alias_of": "None", - "related_links": ".DeferringRelatedManager object at 0x115f42390>", - "announcements": ".DeferringRelatedManager object at 0x1171b49b0>", - "page_ptr": "", - "seo_change_frequency": 5, - "seo_priority": "Decimal(0.5)", - "body": "BODY", - "page_description": "PAGE_DESCRIPTION", - "enable_area_selector": false, - "is_public": true, - "page_classification": "None", - "related_links_layout": "Footer", - "_revisions": ".GenericRelatedObjectManager object at 0x1171b5100>", - "_workflow_states": ".GenericRelatedObjectManager object at 0x1171b4c20>", - "_specific_workflow_states": ".GenericRelatedObjectManager object at 0x1171d3980>", - "index_entries": ".GenericRelatedObjectManager object at 0x1171b4740>" - }, - - "USER_OBJECT_PERMISSION_SETS": { - "permission_set_hierarchy": [ - { - "theme": { - "id": "3", - "name": "extreme_event" - }, - "sub_theme": { - "id": "4", - "name": "weather_alert" - }, - "topic": { - "id": "-1", - "name": "* (All)" - }, - "metric": { - "id": "-1", - "name": "* (All)" - }, - "geography_type": { - "id": "6", - "name": "United Kingdom" - }, - "geography": { - "id": "K02000001", - "name": "United Kingdom" - } - }, - { - "theme": { - "id": "2", - "name": "infectious_disease" - }, - "sub_theme": { - "id": "-1", - "name": "* (All)" - }, - "topic": { - "id": "-1", - "name": "* (All)" - }, - "metric": { - "id": "-1", - "name": "* (All)" - }, - "geography_type": { - "id": "1", - "name": "Upper Tier Local Authority" - }, - "geography": { - "id": "E06000014", - "name": "York" - } - }, - { - "theme": { - "id": "1", - "name": "immunisation" - }, - "sub_theme": { - "id": "1", - "name": "childhood-vaccines" - }, - "topic": { - "id": "-1", - "name": "* (All)" - }, - "metric": { - "id": "-1", - "name": "* (All)" - }, - "geography_type": { - "id": "1", - "name": "Upper Tier Local Authority" - }, - "geography": { - "id": "E06000014", - "name": "York" - } - } - ], - "summary": { - "total_permission_sets": 3, - "deduplicated_count": 3, - "removed_count": 0, - "has_global_access": false, - "wildcard_themes": [] - } - } -} From 4e83f825c2cd0183dfc279cfd2a9e6ddd298f47b Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 19 May 2026 13:29:19 +0100 Subject: [PATCH 158/186] Fix js file --- cms/dashboard/static/js/toggle_available_fields_on_is_public.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/dashboard/static/js/toggle_available_fields_on_is_public.js b/cms/dashboard/static/js/toggle_available_fields_on_is_public.js index 04b282d76..8ad1f004e 100644 --- a/cms/dashboard/static/js/toggle_available_fields_on_is_public.js +++ b/cms/dashboard/static/js/toggle_available_fields_on_is_public.js @@ -82,7 +82,7 @@ * @param {HTMLSelectElement} dropdown - The select element to populate * @param {Array} choices - Array of [id, name] tuples */ - function populateDropdown(dropdown, choices) { + function populateDropdown(dropdown, choices, metrics = null) { const currentValue = dropdown.value dropdown.disabled = false dropdown.innerHTML = "" From d7883c6cc65c6f4c4b5aac90d1485e37bbd92beb Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 19 May 2026 14:13:55 +0100 Subject: [PATCH 159/186] Linting --- cms/auth_content/auth_utils.py | 11 +- cms/auth_content/models/permission_sets.py | 12 +- cms/dashboard/constants.py | 3 +- cms/dashboard/viewsets.py | 49 +++--- .../data_migration/child_entries.py | 2 - cms/metrics_documentation/models/child.py | 32 ++-- cms/topic/models.py | 18 +- metrics/data/managers/rbac_models/user.py | 1 - .../cms/dashboard/test_viewsets.py | 82 +++++++-- .../models/test_child.py | 4 +- .../models/test_permission_sets.py | 52 ++++-- tests/unit/auth_content/test_auth_utils.py | 43 ++--- tests/unit/auth_content/test_wagtail_hooks.py | 28 ++- tests/unit/cms/dashboard/test_viewsets.py | 166 ++++++++++++++---- .../data_migration/test_operations.py | 3 +- .../models/test_child.py | 39 ++-- tests/unit/cms/topic/test_models.py | 23 ++- 17 files changed, 382 insertions(+), 186 deletions(-) diff --git a/cms/auth_content/auth_utils.py b/cms/auth_content/auth_utils.py index dcbb0afb7..56735c2da 100644 --- a/cms/auth_content/auth_utils.py +++ b/cms/auth_content/auth_utils.py @@ -5,7 +5,9 @@ from cms.dynamic_content import help_texts -def _create_form_field(field: dict[str, str | Callable | None], wildcard_id_value=None) -> forms.CharField: +def _create_form_field( + field: dict[str, str | Callable | None], wildcard_id_value=None +) -> forms.CharField: choices = [ ("", field["field_choice_default"]), ] @@ -17,5 +19,8 @@ def _create_form_field(field: dict[str, str | Callable | None], wildcard_id_valu choices += field["field_choice_callable"]() return forms.CharField( - required=False, label=field["field_label"], widget=forms.Select(choices=choices), help_text=help_texts.NON_PUBLIC_PAGE_REQUIRED - ) \ No newline at end of file + required=False, + label=field["field_label"], + widget=forms.Select(choices=choices), + help_text=help_texts.NON_PUBLIC_PAGE_REQUIRED, + ) diff --git a/cms/auth_content/models/permission_sets.py b/cms/auth_content/models/permission_sets.py index a18553352..1fd10582c 100644 --- a/cms/auth_content/models/permission_sets.py +++ b/cms/auth_content/models/permission_sets.py @@ -1,4 +1,3 @@ -from collections.abc import Callable from itertools import starmap from django.core.exceptions import ValidationError @@ -18,12 +17,15 @@ get_all_topic_names_and_ids, ) + class PermissionSetForm(WagtailAdminPageForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for field in PERMISSION_SET_FIELDS: - self.fields[field["field_name"]] = _create_form_field(field, WILDCARD_ID_VALUE) + self.fields[field["field_name"]] = _create_form_field( + field, WILDCARD_ID_VALUE + ) if self.instance and self.instance.pk: self._initialize_dependent_fields() @@ -90,7 +92,11 @@ class PermissionSet(models.Model): help_text="Auto-generated display name", ) display_name = models.CharField( - max_length=255, blank=True, default="", help_text=help_texts.PERMISSION_SET_DISPLAY_NAME, unique=True + max_length=255, + blank=True, + default="", + help_text=help_texts.PERMISSION_SET_DISPLAY_NAME, + unique=True, ) theme = models.CharField(max_length=255, blank=False, default="") sub_theme = models.CharField(max_length=255, blank=False, default="") diff --git a/cms/dashboard/constants.py b/cms/dashboard/constants.py index 42634a7d4..6eed766d7 100644 --- a/cms/dashboard/constants.py +++ b/cms/dashboard/constants.py @@ -1,6 +1,6 @@ from cms.metrics_interface.field_choices_callables import ( - get_all_theme_names_and_ids, get_all_metric_names_and_ids, + get_all_theme_names_and_ids, ) THEME_FIELDS = [ @@ -33,4 +33,3 @@ "field_choice_callable": get_all_metric_names_and_ids, }, ] - diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index 7602c2ee0..e65d525ad 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -1,3 +1,4 @@ +from django.db.models import Exists, OuterRef, Q from django.urls import path from django.urls.resolvers import RoutePattern from drf_spectacular.utils import extend_schema @@ -10,13 +11,11 @@ from cms.metrics_documentation.models.child import MetricsDocumentationChildEntry from cms.topic.models import TopicPage -from django.db.models import Q, Exists, OuterRef - def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: if not isinstance(user_permissions, list): return False - + for permission in user_permissions: permission_theme_id = permission.get("theme", {}).get("id") permission_sub_theme_id = permission.get("sub_theme", {}).get("id") @@ -24,14 +23,14 @@ def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> boo if permission_theme_id == "-1": return True - + if permission_theme_id == theme_id and permission_sub_theme_id == "-1": return True - + if ( - permission_theme_id == theme_id + permission_theme_id == theme_id and permission_sub_theme_id == sub_theme_id - and (permission_topic_id == "-1" or permission_topic_id == topic_id) + and (permission_topic_id in {"-1", topic_id}) ): return True @@ -99,8 +98,8 @@ def get_queryset(self): else: user_permissions = req.user.permission_sets["permission_sets"] - has_global_access = req.user.permission_sets['summary']["has_global_access"] - + has_global_access = req.user.permission_sets["summary"]["has_global_access"] + if has_global_access: filtered_queryset = queryset @@ -110,28 +109,24 @@ def get_queryset(self): for page in queryset.type(TopicPage): if page.topicpage.is_public: allowed_pages.append(page.id) - else: - if check_permissions( - user_permissions, - page.topicpage.theme, - page.topicpage.sub_theme, - page.topicpage.topic, - ): - print(f"Non Public Page: {page.title} added to allowed pages") - allowed_pages.append(page.id) + elif check_permissions( + user_permissions, + page.topicpage.theme, + page.topicpage.sub_theme, + page.topicpage.topic, + ): + allowed_pages.append(page.id) for page in queryset.type(MetricsDocumentationChildEntry): if page.metricsdocumentationchildentry.is_public: allowed_pages.append(page.id) - else: - if check_permissions( - user_permissions, - page.metricsdocumentationchildentry.theme, - page.metricsdocumentationchildentry.sub_theme, - page.metricsdocumentationchildentry.topic, - ): - print(f"Non Public Page: {page.title} added to allowed pages") - allowed_pages.append(page.id) + elif check_permissions( + user_permissions, + page.metricsdocumentationchildentry.theme, + page.metricsdocumentationchildentry.sub_theme, + page.metricsdocumentationchildentry.topic, + ): + allowed_pages.append(page.id) public_pages = queryset.not_type( TopicPage, MetricsDocumentationChildEntry diff --git a/cms/metrics_documentation/data_migration/child_entries.py b/cms/metrics_documentation/data_migration/child_entries.py index 1cabc006f..3d08bc33a 100644 --- a/cms/metrics_documentation/data_migration/child_entries.py +++ b/cms/metrics_documentation/data_migration/child_entries.py @@ -3,8 +3,6 @@ from openpyxl import load_workbook from openpyxl.worksheet.worksheet import Worksheet -from metrics.data.models.core_models import Metric - def build_sections(*, sections: list[tuple[str, str]]) -> list[dict]: """Build metric documentation page sections. diff --git a/cms/metrics_documentation/models/child.py b/cms/metrics_documentation/models/child.py index fbadc9721..618eff3fd 100644 --- a/cms/metrics_documentation/models/child.py +++ b/cms/metrics_documentation/models/child.py @@ -46,7 +46,6 @@ def _initialize_dependent_fields(self): dependent_fields = { "sub_theme": ("Select theme first"), "topic": ("Select sub-theme first"), - # "metric": ("Select topic first"), } for field_name, (placeholder) in dependent_fields.items(): @@ -81,14 +80,19 @@ class MetricsDocumentationChildEntry(UKHSAPage): blank=True, ) - theme = models.CharField(max_length=255, blank=True, default="", null=True,) - sub_theme = models.CharField(max_length=255, blank=True, default="", null=True,) - topic = models.CharField( + theme = models.CharField( max_length=255, blank=True, default="", - null=True + null=True, ) + sub_theme = models.CharField( + max_length=255, + blank=True, + default="", + null=True, + ) + topic = models.CharField(max_length=255, blank=True, default="", null=True) body = ALLOWABLE_BODY_CONTENT_TEXT_SECTION # Fields to index for searching within the CMS application. @@ -164,7 +168,9 @@ def metric_group(self) -> str: field = self._meta.get_field("metric") choices = getattr(field, "choices", []) or [] - display_name = next((item[1] for item in choices if item[0] == self.metric), None) + display_name = next( + (item[1] for item in choices if item[0] == self.metric), None + ) if not display_name or "_" not in display_name: return "" @@ -181,7 +187,7 @@ def clean(self): self.theme = None self.sub_theme = None self.topic = None - + # If not public page, non-public fields must be set elif not self.page_classification: raise ValidationError( @@ -191,21 +197,15 @@ def clean(self): ) elif not self.theme: raise ValidationError( - { - "theme": "Please select a theme for this non-public page" - } + {"theme": "Please select a theme for this non-public page"} ) elif not self.sub_theme: raise ValidationError( - { - "sub_theme": "Please select a subtheme for this non-public page" - } + {"sub_theme": "Please select a subtheme for this non-public page"} ) elif not self.topic: raise ValidationError( - { - "topic": "Please select a topic for this non-public page" - } + {"topic": "Please select a topic for this non-public page"} ) diff --git a/cms/topic/models.py b/cms/topic/models.py index 8a80d00cc..f39ecd3d9 100644 --- a/cms/topic/models.py +++ b/cms/topic/models.py @@ -2,7 +2,6 @@ from django.core.exceptions import ValidationError from django.db import models - from modelcluster.fields import ParentalKey from wagtail.admin.panels import ( FieldPanel, @@ -16,6 +15,7 @@ from wagtail.search import index from cms.auth_content.auth_utils import _create_form_field +from cms.dashboard.constants import THEME_FIELDS from cms.dashboard.enums import ( DEFAULT_RELATED_LINKS_LAYOUT_FIELD_LENGTH, RelatedLinksLayoutEnum, @@ -31,7 +31,6 @@ from cms.dynamic_content.announcements import Announcement from cms.dynamic_content.blocks_deconstruction import CMSBlockParser from cms.metrics_interface import MetricsAPIInterface -from cms.dashboard.constants import THEME_FIELDS from cms.topic.managers import TopicPageManager DEFAULT_CORE_TIME_SERIES_MANGER = MetricsAPIInterface().core_time_series_manager @@ -65,7 +64,7 @@ def _initialize_dependent_fields(self): def _get_field_choices(value, placeholder): """Generate choices list based on field value""" return [("", placeholder), (value, f"Loading... (ID: {value})")] - + class Media: js = ["js/toggle_available_fields_on_is_public.js"] @@ -283,23 +282,16 @@ def clean(self): ) elif not self.theme: raise ValidationError( - { - "theme": "Please select a theme for this non-public page" - } + {"theme": "Please select a theme for this non-public page"} ) elif not self.sub_theme: raise ValidationError( - { - "sub_theme": "Please select a sub theme for this non-public page" - } + {"sub_theme": "Please select a sub theme for this non-public page"} ) elif not self.topic: raise ValidationError( - { - "topic": "Please select a topic for this non-public page" - } + {"topic": "Please select a topic for this non-public page"} ) - class TopicPageRelatedLink(UKHSAPageRelatedLink): diff --git a/metrics/data/managers/rbac_models/user.py b/metrics/data/managers/rbac_models/user.py index 65e6ce0d7..b2967685f 100644 --- a/metrics/data/managers/rbac_models/user.py +++ b/metrics/data/managers/rbac_models/user.py @@ -12,7 +12,6 @@ from cms.auth_content.models.permission_sets import PermissionSet - class UserQuerySet(models.QuerySet): """Custom queryset for User model operations.""" diff --git a/tests/integration/cms/dashboard/test_viewsets.py b/tests/integration/cms/dashboard/test_viewsets.py index 081fcfe31..2847dc545 100644 --- a/tests/integration/cms/dashboard/test_viewsets.py +++ b/tests/integration/cms/dashboard/test_viewsets.py @@ -13,7 +13,7 @@ @pytest.mark.django_db class TestCMSPagesAPIViewSetPermissions: - + @pytest.fixture def setup_pages(self): influenza_topic = Topic.objects.create(name="Influenza") @@ -30,31 +30,77 @@ def setup_pages(self): ) home = Page.objects.get(id=2) - - public_topic = TopicPage(title="Public Topic", page_description="test", slug="public-topic", is_public=True, theme="1", seo_title="public-topic") + + public_topic = TopicPage( + title="Public Topic", + page_description="test", + slug="public-topic", + is_public=True, + theme="1", + seo_title="public-topic", + ) home.add_child(instance=public_topic) - - private_topic = TopicPage(title="Private Topic", page_description="test", slug="private-topic", is_public=False, theme="1", sub_theme="test", topic="test", page_classification="official_sensitive", seo_title="private-topic") + + private_topic = TopicPage( + title="Private Topic", + page_description="test", + slug="private-topic", + is_public=False, + theme="1", + sub_theme="test", + topic="test", + page_classification="official_sensitive", + seo_title="private-topic", + ) home.add_child(instance=private_topic) - - public_metrics = MetricsDocumentationChildEntry(title="Public Metric", page_description="test", slug="public-metric", metric=metric.pk, is_public=True, seo_title="public-metrics") + + public_metrics = MetricsDocumentationChildEntry( + title="Public Metric", + page_description="test", + slug="public-metric", + metric=metric.pk, + is_public=True, + seo_title="public-metrics", + ) home.add_child(instance=public_metrics) - private_metrics = MetricsDocumentationChildEntry(title="Private Metric", page_description="test", slug="private-metric", theme="2", sub_theme="test", topic="test", metric=private_metric.pk, is_public=False, seo_title="private-metrics") + private_metrics = MetricsDocumentationChildEntry( + title="Private Metric", + page_description="test", + slug="private-metric", + theme="2", + sub_theme="test", + topic="test", + metric=private_metric.pk, + is_public=False, + seo_title="private-metrics", + ) home.add_child(instance=private_metrics) - private_metrics_two = MetricsDocumentationChildEntry(title="Private Metric 2", page_description="test", slug="private-metric-two", theme="1", sub_theme="test", topic="test", metric=private_metric_two.pk, is_public=False, seo_title="private-metrics-two") + private_metrics_two = MetricsDocumentationChildEntry( + title="Private Metric 2", + page_description="test", + slug="private-metric-two", + theme="1", + sub_theme="test", + topic="test", + metric=private_metric_two.pk, + is_public=False, + seo_title="private-metrics-two", + ) home.add_child(instance=private_metrics_two) - - standard_page = CommonPage(title="Standard", body="test", slug="standard", seo_title="standard-page") + + standard_page = CommonPage( + title="Standard", body="test", slug="standard", seo_title="standard-page" + ) home.add_child(instance=standard_page) - + return { "public_topic": public_topic, "private_topic": private_topic, "public_metrics": public_metrics, "private_metrics": private_metrics, - "standard_page": standard_page + "standard_page": standard_page, } def test_anonymous_user_access(self, setup_pages): @@ -104,7 +150,7 @@ def test_global_access_user(self, setup_pages): mock_user = MagicMock() mock_user.permission_sets = { "has_global_access": True, - "permission_set_hierarchy": [] + "permission_set_hierarchy": [], } request.user = mock_user @@ -115,7 +161,7 @@ def test_global_access_user(self, setup_pages): # When result = view.get_queryset() - + # Then titles = [p.title for p in result] assert "Public Topic" in titles @@ -141,7 +187,9 @@ def test_restricted_user_with_permission(self, setup_pages): mock_user = MagicMock() mock_user.permission_sets = { "has_global_access": False, - "permission_set_hierarchy": [{"theme": {"id": "1"}, "sub_theme": {"id": "-1"}}] + "permission_set_hierarchy": [ + {"theme": {"id": "1"}, "sub_theme": {"id": "-1"}} + ], } request.user = mock_user @@ -152,7 +200,7 @@ def test_restricted_user_with_permission(self, setup_pages): # When result = view.get_queryset() - + # Then titles = [p.title for p in result] assert "Private Topic" in titles diff --git a/tests/integration/cms/metrics_documentation/models/test_child.py b/tests/integration/cms/metrics_documentation/models/test_child.py index f1f1877f9..da59bd2a9 100644 --- a/tests/integration/cms/metrics_documentation/models/test_child.py +++ b/tests/integration/cms/metrics_documentation/models/test_child.py @@ -20,7 +20,9 @@ def test_metric_is_unique(self): created_metric = Metric.objects.create(name=metric_name) Topic.objects.create(name=metric_name.split("_")[0].title()) - _create_metrics_documentation_child_entry(metric_name=metric_name, metric_id=created_metric.pk, path="doc_1") + _create_metrics_documentation_child_entry( + metric_name=metric_name, metric_id=created_metric.pk, path="doc_1" + ) # When / Then with pytest.raises(ValidationError): diff --git a/tests/unit/auth_content/models/test_permission_sets.py b/tests/unit/auth_content/models/test_permission_sets.py index c3eaae92e..1ca0eb2d0 100644 --- a/tests/unit/auth_content/models/test_permission_sets.py +++ b/tests/unit/auth_content/models/test_permission_sets.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from cms.auth_content.models.permission_sets import PermissionSet, PermissionSetForm + class TestPermissionSetForm: MOCK_PERMISSION_SET_FIELDS = [ {"field_name": "theme", "field_label": "Theme"}, @@ -19,8 +20,13 @@ def _make_form(self, instance=None, queryset_exists=False): internals patched. """ with ( - patch("wagtail.admin.panels.WagtailAdminPageForm.__init__", return_value=None), - patch("cms.auth_content.models.permission_sets.PERMISSION_SET_FIELDS", self.MOCK_PERMISSION_SET_FIELDS), + patch( + "wagtail.admin.panels.WagtailAdminPageForm.__init__", return_value=None + ), + patch( + "cms.auth_content.models.permission_sets.PERMISSION_SET_FIELDS", + self.MOCK_PERMISSION_SET_FIELDS, + ), patch( "cms.auth_content.models.permission_sets._create_form_field", side_effect=lambda field, wildcard: MagicMock(name=field["field_name"]), @@ -46,14 +52,14 @@ def _make_form(self, instance=None, queryset_exists=False): form._mock_qs = mock_qs return form - + def test_init_sets_up_fields(self): """ When a new form is instantiated Then a form field is added to `fields` for each entry in `PERMISSION_SET_FIELDS` """ form = self._make_form() - + assert len(form.fields) == 5 assert "theme" in form.fields assert "sub_theme" in form.fields @@ -70,10 +76,22 @@ def test_initialize_dependent_fields(self): instance = MagicMock(pk=1, sub_theme=1, topic=2, metric=3, geography=4) form = self._make_form(instance=instance) - assert form.fields["sub_theme"].widget.choices == [('', 'Select theme first'), (1, 'Loading... (ID: 1)')] - assert form.fields["topic"].widget.choices == [('', 'Select sub-theme first'), (2, 'Loading... (ID: 2)')] - assert form.fields["metric"].widget.choices == [('', 'Select topic first'), (3, 'Loading... (ID: 3)')] - assert form.fields["geography"].widget.choices == [('', 'Select geography type first'), (4, 'Loading... (ID: 4)')] + assert form.fields["sub_theme"].widget.choices == [ + ("", "Select theme first"), + (1, "Loading... (ID: 1)"), + ] + assert form.fields["topic"].widget.choices == [ + ("", "Select sub-theme first"), + (2, "Loading... (ID: 2)"), + ] + assert form.fields["metric"].widget.choices == [ + ("", "Select topic first"), + (3, "Loading... (ID: 3)"), + ] + assert form.fields["geography"].widget.choices == [ + ("", "Select geography type first"), + (4, "Loading... (ID: 4)"), + ] def test_get_field_choices(self): """ @@ -82,7 +100,7 @@ def test_get_field_choices(self): """ result = PermissionSetForm._get_field_choices("test", "placeholder", None) assert result == [("", "placeholder"), ("test", "Loading... (ID: test)")] - + def test_get_field_choices_wildcard_match(self): """ When the static function `_get_field_choices` is called with a wildcard @@ -92,7 +110,9 @@ def test_get_field_choices_wildcard_match(self): assert result == [("-1", "wildcard")] @patch("cms.auth_content.models.permission_sets.PermissionSet.objects.filter") - def test_validation_error_raised_if_queryset_duplicated(self, mock_query_filter: MagicMock): + def test_validation_error_raised_if_queryset_duplicated( + self, mock_query_filter: MagicMock + ): """ Given a form is created with an existing queryset match When `clean` is called @@ -104,10 +124,15 @@ def test_validation_error_raised_if_queryset_duplicated(self, mock_query_filter: with pytest.raises(ValidationError) as e: form.clean() - assert "A permission set with this exact combination already exists. Please modify your selection to create a unique permission set." in str(e.value) + assert ( + "A permission set with this exact combination already exists. Please modify your selection to create a unique permission set." + in str(e.value) + ) @patch("cms.auth_content.models.permission_sets.PermissionSet.objects.filter") - def test_returns_cleaned_data_when_no_duplicate_exists(self, mock_query_filter: MagicMock): + def test_returns_cleaned_data_when_no_duplicate_exists( + self, mock_query_filter: MagicMock + ): """ Given a form is created without an existing queryset match When `clean` is called @@ -121,7 +146,8 @@ def test_returns_cleaned_data_when_no_duplicate_exists(self, mock_query_filter: assert result == form.cleaned_data -class TestPermissionSet(): + +class TestPermissionSet: def test_get_choice_label(self): """ Given a blank `PermissionSet` diff --git a/tests/unit/auth_content/test_auth_utils.py b/tests/unit/auth_content/test_auth_utils.py index 0ac0a0db0..ba64ad56c 100644 --- a/tests/unit/auth_content/test_auth_utils.py +++ b/tests/unit/auth_content/test_auth_utils.py @@ -5,7 +5,7 @@ from cms.auth_content.auth_utils import _create_form_field -class TestCreateFormField(): +class TestCreateFormField: def test_create_form_field_basic(self): """ Given no wildcard or callables in data @@ -16,11 +16,11 @@ def test_create_form_field_basic(self): "field_choice_default": "Select an option", "field_choice_wildcard": None, "field_choice_callable": None, - "field_label": "My Label" + "field_label": "My Label", } - + result = _create_form_field(field_data) - + assert isinstance(result, forms.CharField) assert result.label == "My Label" expected_choices = [("", "Select an option")] @@ -36,16 +36,13 @@ def test_create_form_field_with_wildcard(self): "field_choice_default": "Default", "field_choice_wildcard": "All Items", "field_choice_callable": None, - "field_label": "Label" + "field_label": "Label", } wildcard_val = "-1" - + result = _create_form_field(field_data, wildcard_id_value=wildcard_val) - - expected_choices = [ - ("", "Default"), - ("-1", "All Items") - ] + + expected_choices = [("", "Default"), ("-1", "All Items")] assert result.widget.choices == expected_choices def test_create_form_field_with_callable(self): @@ -55,21 +52,17 @@ def test_create_form_field_with_callable(self): Then the callable choice is added """ mock_callable = MagicMock(return_value=[("1", "One"), ("2", "Two")]) - + field_data = { "field_choice_default": "Default", "field_choice_wildcard": None, "field_choice_callable": mock_callable, - "field_label": "Label" + "field_label": "Label", } - + result = _create_form_field(field_data) - - expected_choices = [ - ("", "Default"), - ("1", "One"), - ("2", "Two") - ] + + expected_choices = [("", "Default"), ("1", "One"), ("2", "Two")] assert result.widget.choices == expected_choices mock_callable.assert_called_once() @@ -85,15 +78,15 @@ def test_create_form_field_all_features(self): "field_choice_default": "Default", "field_choice_wildcard": "Wildcard", "field_choice_callable": mock_callable, - "field_label": "Label" + "field_label": "Label", } - + result = _create_form_field(field_data, wildcard_id_value="999") - + expected_choices = [ ("", "Default"), ("999", "Wildcard"), - ("dynamic", "Dynamic") + ("dynamic", "Dynamic"), ] assert result.widget.choices == expected_choices @@ -105,6 +98,6 @@ def test_create_form_field_missing_key_error(self): """ """Test behavior if a required key is missing from the dict""" field_data = {"field_choice_default": "Missing other keys"} - + with pytest.raises(KeyError): _create_form_field(field_data) diff --git a/tests/unit/auth_content/test_wagtail_hooks.py b/tests/unit/auth_content/test_wagtail_hooks.py index 6b9d4d177..bd51b0a80 100644 --- a/tests/unit/auth_content/test_wagtail_hooks.py +++ b/tests/unit/auth_content/test_wagtail_hooks.py @@ -3,7 +3,11 @@ from django.utils.safestring import SafeData from cms.auth_content.models.permission_sets import PermissionSet -from cms.auth_content.wagtail_hooks import NoEditPermissionPolicy, PermissionSetViewSet, AuthGroup +from cms.auth_content.wagtail_hooks import ( + NoEditPermissionPolicy, + PermissionSetViewSet, + AuthGroup, +) from cms.auth_content import wagtail_hooks @@ -20,6 +24,7 @@ def test_permission_set_js(self): assert '' in result + class TestPermissionSetDetailsProperty(TestCase): def test_single_value_no_pipe(self): @@ -59,7 +64,7 @@ def test_change_permission_denied(self): def test_user_has_permission_calls_super(self, spy_user_has_permissions: MagicMock): spy_user_has_permissions.return_value = "parent_response" result = self.policy.user_has_permission(self.user, "view") - + spy_user_has_permissions.assert_called_once_with(self.user, "view") assert result == "parent_response" @@ -70,14 +75,23 @@ def test_change_permission_denied_for_instance(self): ) ) - @patch("wagtail.permission_policies.ModelPermissionPolicy.user_has_permission_for_instance") - def test_user_has_permission_for_instance_calls_super(self, spy_user_has_permissions_for_instance: MagicMock): + @patch( + "wagtail.permission_policies.ModelPermissionPolicy.user_has_permission_for_instance" + ) + def test_user_has_permission_for_instance_calls_super( + self, spy_user_has_permissions_for_instance: MagicMock + ): spy_user_has_permissions_for_instance.return_value = "parent_response" - result = self.policy.user_has_permission_for_instance(self.user, "view", self.instance) - - spy_user_has_permissions_for_instance.assert_called_once_with(self.user, "view", self.instance) + result = self.policy.user_has_permission_for_instance( + self.user, "view", self.instance + ) + + spy_user_has_permissions_for_instance.assert_called_once_with( + self.user, "view", self.instance + ) assert result == "parent_response" + class TestPermissionSetViewSet(TestCase): def setUp(self): diff --git a/tests/unit/cms/dashboard/test_viewsets.py b/tests/unit/cms/dashboard/test_viewsets.py index 9d5398f69..165ccb266 100644 --- a/tests/unit/cms/dashboard/test_viewsets.py +++ b/tests/unit/cms/dashboard/test_viewsets.py @@ -1,59 +1,155 @@ import pytest from cms.dashboard.serializers import CMSDraftPagesSerializer, ListablePageSerializer -from cms.dashboard.viewsets import CMSDraftPagesViewSet, CMSPagesAPIViewSet, check_permissions +from cms.dashboard.viewsets import ( + CMSDraftPagesViewSet, + CMSPagesAPIViewSet, + check_permissions, +) class TestCheckPermissions: - @pytest.mark.parametrize("user_permissions, theme_id, sub_theme_id, topic_id", [ - ([{"theme": {"id": "-1"}}], "10", "20", "30"), - ([{"theme": {"id": "10"}, "sub_theme": {"id": "-1"}}], "10", "20", "30"), - ([{"theme": {"id": "10"}, "sub_theme": {"id": "20"}, "topic": {"id": "-1"}}], "10", "20", "30"), - ([{"theme": {"id": "10"}, "sub_theme": {"id": "20"}, "topic": {"id": "30"}}], "10", "20", "30"), - ([ - {"theme": {"id": "5"}, "sub_theme": {"id": "-1"}}, - {"theme": {"id": "10"}, "sub_theme": {"id": "20"}, "topic": {"id": "30"}} - ], "10", "20", "30"), - ]) - def test_check_permissions_valid_access(self, user_permissions, theme_id, sub_theme_id, topic_id): + @pytest.mark.parametrize( + "user_permissions, theme_id, sub_theme_id, topic_id", + [ + ([{"theme": {"id": "-1"}}], "10", "20", "30"), + ([{"theme": {"id": "10"}, "sub_theme": {"id": "-1"}}], "10", "20", "30"), + ( + [ + { + "theme": {"id": "10"}, + "sub_theme": {"id": "20"}, + "topic": {"id": "-1"}, + } + ], + "10", + "20", + "30", + ), + ( + [ + { + "theme": {"id": "10"}, + "sub_theme": {"id": "20"}, + "topic": {"id": "30"}, + } + ], + "10", + "20", + "30", + ), + ( + [ + {"theme": {"id": "5"}, "sub_theme": {"id": "-1"}}, + { + "theme": {"id": "10"}, + "sub_theme": {"id": "20"}, + "topic": {"id": "30"}, + }, + ], + "10", + "20", + "30", + ), + ], + ) + def test_check_permissions_valid_access( + self, user_permissions, theme_id, sub_theme_id, topic_id + ): """ Given a permission set that does grant access to the provided ids When the `check_permissions` function is called Then the function returns true """ - assert check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) == True - - @pytest.mark.parametrize("user_permissions, theme_id, sub_theme_id, topic_id", [ - ([{"theme": {"id": "99"}, "sub_theme": {"id": "-1"}}], "10", "20", "30"), - ([{"theme": {"id": "10"}, "sub_theme": {"id": "99"}, "topic": {"id": "-1"}}], "10", "20", "30"), - ([{"theme": {"id": "10"}, "sub_theme": {"id": "20"}, "topic": {"id": "99"}}], "10", "20", "30"), - ([], "10", "20", "30"), - ]) - def test_check_permissions_invalid_access(self, user_permissions, theme_id, sub_theme_id, topic_id): + assert ( + check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) + == True + ) + + @pytest.mark.parametrize( + "user_permissions, theme_id, sub_theme_id, topic_id", + [ + ([{"theme": {"id": "99"}, "sub_theme": {"id": "-1"}}], "10", "20", "30"), + ( + [ + { + "theme": {"id": "10"}, + "sub_theme": {"id": "99"}, + "topic": {"id": "-1"}, + } + ], + "10", + "20", + "30", + ), + ( + [ + { + "theme": {"id": "10"}, + "sub_theme": {"id": "20"}, + "topic": {"id": "99"}, + } + ], + "10", + "20", + "30", + ), + ([], "10", "20", "30"), + ], + ) + def test_check_permissions_invalid_access( + self, user_permissions, theme_id, sub_theme_id, topic_id + ): """ Given a permission set that does not grant access to the provided ids When the `check_permissions` function is called Then the function returns false """ - assert check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) == False - - @pytest.mark.parametrize("user_permissions, theme_id, sub_theme_id, topic_id", [ - ([{}], "10", "20", "30"), - (None, "10", "20", "30"), - ([{"sub_theme": {"id": "-1"}, "topic": {"id": "-1"}}], "10", "20", "30"), - ([{"theme": {}, "sub_theme": {"id": "-1"}, "topic": {"id": "-1"}}], "10", "20", "30"), - ([{"theme": {"id": "10"}, "topic": {"id": "-1"}}], "10", "20", "30"), - ([{"theme": {"id": "10"}, "sub_theme": {}, "topic": {"id": "-1"}}], "10", "20", "30"), - ([{"theme": {"id": "10"}, "sub_theme": {"id": "20"}}], "10", "20", "30"), - ([{"theme": {"id": "10"}, "sub_theme": {"id": "20"}, "topic": {}}], "10", "20", "30"), - ]) - def test_check_permissions_with_missing_values(self, user_permissions, theme_id, sub_theme_id, topic_id): + assert ( + check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) + == False + ) + + @pytest.mark.parametrize( + "user_permissions, theme_id, sub_theme_id, topic_id", + [ + ([{}], "10", "20", "30"), + (None, "10", "20", "30"), + ([{"sub_theme": {"id": "-1"}, "topic": {"id": "-1"}}], "10", "20", "30"), + ( + [{"theme": {}, "sub_theme": {"id": "-1"}, "topic": {"id": "-1"}}], + "10", + "20", + "30", + ), + ([{"theme": {"id": "10"}, "topic": {"id": "-1"}}], "10", "20", "30"), + ( + [{"theme": {"id": "10"}, "sub_theme": {}, "topic": {"id": "-1"}}], + "10", + "20", + "30", + ), + ([{"theme": {"id": "10"}, "sub_theme": {"id": "20"}}], "10", "20", "30"), + ( + [{"theme": {"id": "10"}, "sub_theme": {"id": "20"}, "topic": {}}], + "10", + "20", + "30", + ), + ], + ) + def test_check_permissions_with_missing_values( + self, user_permissions, theme_id, sub_theme_id, topic_id + ): """ Given a permission set that is missing values When the `check_permissions` function is called Then the function returns false """ - assert check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) == False + assert ( + check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) + == False + ) class TestCMSDraftPagesViewSet: diff --git a/tests/unit/cms/metrics_documentation/data_migration/test_operations.py b/tests/unit/cms/metrics_documentation/data_migration/test_operations.py index 18fdc352b..274b78e5c 100644 --- a/tests/unit/cms/metrics_documentation/data_migration/test_operations.py +++ b/tests/unit/cms/metrics_documentation/data_migration/test_operations.py @@ -129,7 +129,8 @@ def test_log_recorded_when_metric_not_available_for_child_page( # Then expected_log_part_one = "Metrics Documentation Child Entry for " - expected_log_part_two = (" was not created. " + expected_log_part_two = ( + " was not created. " "Because the corresponding `Metric` was not created beforehand" ) diff --git a/tests/unit/cms/metrics_documentation/models/test_child.py b/tests/unit/cms/metrics_documentation/models/test_child.py index 89e3ec266..571be0a50 100644 --- a/tests/unit/cms/metrics_documentation/models/test_child.py +++ b/tests/unit/cms/metrics_documentation/models/test_child.py @@ -7,20 +7,27 @@ from wagtail.api.conf import APIField from cms.dashboard.constants import THEME_FIELDS -from cms.metrics_documentation.models.child import MetricsDocumentationChildEntryAdminForm, InvalidTopicForChosenMetricForChildEntryError +from cms.metrics_documentation.models.child import ( + MetricsDocumentationChildEntryAdminForm, + InvalidTopicForChosenMetricForChildEntryError, +) from tests.fakes.factories.cms.metrics_documentation_child_entry_factory import ( FakeMetricsDocumentationChildEntryFactory, ) MODULE_PATH = "cms.metrics_documentation.models.child" + class TestInvalidTopicForChosenMetricForChildEntryError: def test_exception_has_expected_message(self): - actual = InvalidTopicForChosenMetricForChildEntryError("test_topic", "test_metric") + actual = InvalidTopicForChosenMetricForChildEntryError( + "test_topic", "test_metric" + ) expected = "InvalidTopicForChosenMetricForChildEntryError('The `test_topic` is not available for selected metric of `test_metric`')" assert expected == repr(actual) + class TestMetricsDocumentationChildEntryAdminForm: MOCK_THEME_FIELDS = [ {"field_name": "theme", "label": "Theme", "required": True}, @@ -53,7 +60,7 @@ def _make_form(self, instance=None): form.instance = instance or MagicMock(pk=None) form.__init__() return form - + def _make_form_with_instance(self, sub_theme=None, topic=None): """ Instantiate MetricsDocumentationChildEntryAdminForm with all Wagtail @@ -71,7 +78,7 @@ def _make_form_with_instance(self, sub_theme=None, topic=None): form.fields[field_name] = MagicMock(widget=mock_widget) return form - + def test_creates_field_for_every_theme_field(self): """ When a new form is instantiated @@ -87,9 +94,7 @@ def test_creates_field_for_every_theme_field(self): @mock.patch("cms.metrics_documentation.models.child._create_form_field") @mock.patch("wagtail.admin.panels.WagtailAdminPageForm.__init__") def test_field_creation_uses_create_form_field_helper( - self, - spy_init_admin_form: mock.MagicMock, - spy_create_form_field: mock.MagicMock + self, spy_init_admin_form: mock.MagicMock, spy_create_form_field: mock.MagicMock ): """ Given a new form is created @@ -288,9 +293,11 @@ def test_metric_group_returns_expected_string( @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") @pytest.mark.parametrize( "metric_id", - [1,2,3,4,5], + [1, 2, 3, 4, 5], ) - def test_metric_group_returns_emptry_string_with_missing_values(self, get_all_metric_names_and_ids: mock.MagicMock, metric_id: int): + def test_metric_group_returns_emptry_string_with_missing_values( + self, get_all_metric_names_and_ids: mock.MagicMock, metric_id: int + ): """ Given a blank `MetricsDocumentationChildEntryPage` model. When a metric id is supplied to the `metric` property with invalid choices returned. @@ -309,12 +316,14 @@ def test_metric_group_returns_emptry_string_with_missing_values(self, get_all_me # When fake_metrics_documentation_child_entry_page.metric = metric_id - + # Then assert fake_metrics_documentation_child_entry_page.metric_group == "" @mock.patch(f"{MODULE_PATH}.get_all_metric_names_and_ids") - def test_metric_group_returns_emptry_string_with_empty_metrics(self, get_all_metric_names_and_ids: mock.MagicMock): + def test_metric_group_returns_emptry_string_with_empty_metrics( + self, get_all_metric_names_and_ids: mock.MagicMock + ): """ Given a blank `MetricsDocumentationChildEntryPage` model. When a metric id is supplied to the `metric` property with no choices returned. @@ -328,7 +337,7 @@ def test_metric_group_returns_emptry_string_with_empty_metrics(self, get_all_met # When fake_metrics_documentation_child_entry_page.metric = 1 - + # Then assert fake_metrics_documentation_child_entry_page.metric_group == "" @@ -367,7 +376,9 @@ def test_public_error_raised_if_invalid_classification( with pytest.raises(ValidationError) as e: fake_metrics_documentation_child_entry_page.clean() - assert "Please select a classification level for this non-public page" in str(e.value) + assert "Please select a classification level for this non-public page" in str( + e.value + ) @mock.patch( "cms.dashboard.models.UKHSAPage._raise_error_if_seo_title_tag_not_provided", @@ -477,7 +488,7 @@ def test_public_error_raised_if_invalid_topic( # When/Then with pytest.raises(ValidationError) as e: fake_metrics_documentation_child_entry_page.clean() - + assert "Please select a topic for this non-public page" in str(e.value) @mock.patch( diff --git a/tests/unit/cms/topic/test_models.py b/tests/unit/cms/topic/test_models.py index b4a269a6f..d6458a5ae 100644 --- a/tests/unit/cms/topic/test_models.py +++ b/tests/unit/cms/topic/test_models.py @@ -28,7 +28,9 @@ def _make_form(self, instance=None): internals patched. """ with ( - mock.patch("wagtail.admin.panels.WagtailAdminPageForm.__init__", return_value=None), + mock.patch( + "wagtail.admin.panels.WagtailAdminPageForm.__init__", return_value=None + ), mock.patch("cms.topic.models.THEME_FIELDS", self.MOCK_THEME_FIELDS), mock.patch( "cms.topic.models._create_form_field", @@ -40,7 +42,7 @@ def _make_form(self, instance=None): form.instance = instance or mock.MagicMock(pk=None) form.__init__() return form - + def test_theme_fields_are_added_on_init(self): """ When a new form is instantieated @@ -58,7 +60,9 @@ def test_dependent_fields_initialised_for_saved_instance(self): When an instance has a pk value set Then `_initialize_dependent_fields` is called """ - with mock.patch.object(TopicPageAdminForm, "_initialize_dependent_fields") as init_fields_mock: + with mock.patch.object( + TopicPageAdminForm, "_initialize_dependent_fields" + ) as init_fields_mock: self._make_form(instance=mock.MagicMock(pk=1)) init_fields_mock.assert_called_once() @@ -68,7 +72,9 @@ def test_dependent_fields_not_initialised_for_new_instance(self): When an instance does not have a pk value set Then `_initialize_dependent_fields` is not called """ - with mock.patch.object(TopicPageAdminForm, "_initialize_dependent_fields") as init_fields_mock: + with mock.patch.object( + TopicPageAdminForm, "_initialize_dependent_fields" + ) as init_fields_mock: self._make_form(instance=mock.MagicMock(pk=None)) init_fields_mock.assert_not_called() @@ -86,7 +92,10 @@ def test_widget_choices_set_when_sub_theme_has_value(self): form._initialize_dependent_fields() - assert mock_widget.choices == [("", "Select theme first"), (5, "Loading... (ID: 5)")] + assert mock_widget.choices == [ + ("", "Select theme first"), + (5, "Loading... (ID: 5)"), + ] def test_widget_choices_not_set_when_value_is_none(self): """ @@ -815,7 +824,9 @@ def test_public_error_raised_if_invalid_classification( with pytest.raises(ValidationError) as e: fake_covid_topic_page.clean() - assert "Please select a classification level for this non-public page" in str(e.value) + assert "Please select a classification level for this non-public page" in str( + e.value + ) @mock.patch( "cms.dashboard.models.UKHSAPage._raise_error_if_seo_title_tag_not_provided", From 376c782dac90aee88e5fd15dda6f79da9f9c67f9 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 19 May 2026 14:23:49 +0100 Subject: [PATCH 160/186] Update migration --- .../migrations/0003_permissionset_display_name.py | 5 ++--- cms/auth_content/models/permission_sets.py | 12 ++++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/cms/auth_content/migrations/0003_permissionset_display_name.py b/cms/auth_content/migrations/0003_permissionset_display_name.py index 3d1fe4485..3ade7df9c 100644 --- a/cms/auth_content/migrations/0003_permissionset_display_name.py +++ b/cms/auth_content/migrations/0003_permissionset_display_name.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.13 on 2026-05-19 09:44 +# Generated by Django 5.2.13 on 2026-05-19 13:23 from django.db import migrations, models @@ -15,10 +15,9 @@ class Migration(migrations.Migration): name="display_name", field=models.CharField( blank=True, - default="", help_text="\nThis is an (optional) user readable name for the permission set. If not set, a default autogenerated name will be used.\n", max_length=255, - unique=True, + null=True, ), ), ] diff --git a/cms/auth_content/models/permission_sets.py b/cms/auth_content/models/permission_sets.py index 1fd10582c..24ae24b18 100644 --- a/cms/auth_content/models/permission_sets.py +++ b/cms/auth_content/models/permission_sets.py @@ -94,9 +94,8 @@ class PermissionSet(models.Model): display_name = models.CharField( max_length=255, blank=True, - default="", + null=True, help_text=help_texts.PERMISSION_SET_DISPLAY_NAME, - unique=True, ) theme = models.CharField(max_length=255, blank=False, default="") sub_theme = models.CharField(max_length=255, blank=False, default="") @@ -106,6 +105,15 @@ class PermissionSet(models.Model): geography = models.CharField(max_length=255, blank=False, default="") base_form_class = PermissionSetForm + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["display_name"], + condition=models.Q(display_name__isnull=False), + name="unique_non_null_display_name", + )] + @property def permission_set_details(self): From c63e77899ebd415857f3be9219b6f2b92be5803f Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 19 May 2026 15:54:55 +0100 Subject: [PATCH 161/186] refactor for sonarqube checks --- cms/dashboard/viewsets.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index e65d525ad..dce4c7224 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -104,29 +104,31 @@ def get_queryset(self): filtered_queryset = queryset else: - user_permissions = req.user.permission_sets["permission_sets"] + user_permissions = req.user.permission_sets allowed_pages = [] - for page in queryset.type(TopicPage): - if page.topicpage.is_public: - allowed_pages.append(page.id) - elif check_permissions( + allowed_pages = [ + page.id + for page in queryset.type(TopicPage) + if page.topicpage.is_public + or check_permissions( user_permissions, page.topicpage.theme, page.topicpage.sub_theme, page.topicpage.topic, - ): - allowed_pages.append(page.id) + ) + ] - for page in queryset.type(MetricsDocumentationChildEntry): - if page.metricsdocumentationchildentry.is_public: - allowed_pages.append(page.id) - elif check_permissions( + allowed_pages = [ + page.id + for page in queryset.type(MetricsDocumentationChildEntry) + if page.metricsdocumentationchildentry.is_public + or check_permissions( user_permissions, page.metricsdocumentationchildentry.theme, page.metricsdocumentationchildentry.sub_theme, page.metricsdocumentationchildentry.topic, - ): - allowed_pages.append(page.id) + ) + ] public_pages = queryset.not_type( TopicPage, MetricsDocumentationChildEntry From 27fc8d9dffffdd00a180a29a7bccf8da539c8478 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 19 May 2026 15:55:11 +0100 Subject: [PATCH 162/186] Fix constraints on permission sets --- cms/auth_content/models/permission_sets.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/cms/auth_content/models/permission_sets.py b/cms/auth_content/models/permission_sets.py index 24ae24b18..af945cb35 100644 --- a/cms/auth_content/models/permission_sets.py +++ b/cms/auth_content/models/permission_sets.py @@ -105,15 +105,6 @@ class PermissionSet(models.Model): geography = models.CharField(max_length=255, blank=False, default="") base_form_class = PermissionSetForm - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["display_name"], - condition=models.Q(display_name__isnull=False), - name="unique_non_null_display_name", - )] - @property def permission_set_details(self): @@ -142,7 +133,12 @@ class Meta: "geography", ], name="unique_permission_set", - ) + ), + models.UniqueConstraint( + fields=["display_name"], + condition=models.Q(display_name__isnull=False), + name="unique_non_null_display_name", + ), ] def save(self, *args, **kwargs): From e328d6d5339d45be683fcc059734663849799db5 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Tue, 19 May 2026 15:57:34 +0100 Subject: [PATCH 163/186] update unit tests --- tests/integration/cms/dashboard/test_viewsets.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/integration/cms/dashboard/test_viewsets.py b/tests/integration/cms/dashboard/test_viewsets.py index 2847dc545..9de2d088a 100644 --- a/tests/integration/cms/dashboard/test_viewsets.py +++ b/tests/integration/cms/dashboard/test_viewsets.py @@ -149,8 +149,8 @@ def test_global_access_user(self, setup_pages): mock_user = MagicMock() mock_user.permission_sets = { - "has_global_access": True, - "permission_set_hierarchy": [], + "permission_sets": [], + "summary": {"has_global_access": True}, } request.user = mock_user @@ -186,10 +186,8 @@ def test_restricted_user_with_permission(self, setup_pages): mock_user = MagicMock() mock_user.permission_sets = { - "has_global_access": False, - "permission_set_hierarchy": [ - {"theme": {"id": "1"}, "sub_theme": {"id": "-1"}} - ], + "permission_sets": [{"theme": {"id": "1"}, "sub_theme": {"id": "-1"}}], + "summary": {"has_global_access": False}, } request.user = mock_user From 35802ee51811f35b572d776c415c251daff1bfc2 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 20 May 2026 14:09:23 +0100 Subject: [PATCH 164/186] fix allowed_pages overwrite --- cms/dashboard/viewsets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index dce4c7224..d861c8f54 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -106,7 +106,7 @@ def get_queryset(self): else: user_permissions = req.user.permission_sets allowed_pages = [] - allowed_pages = [ + allowed_pages += [ page.id for page in queryset.type(TopicPage) if page.topicpage.is_public @@ -118,7 +118,7 @@ def get_queryset(self): ) ] - allowed_pages = [ + allowed_pages += [ page.id for page in queryset.type(MetricsDocumentationChildEntry) if page.metricsdocumentationchildentry.is_public From 834cfdcc36e0ca1cc374da8dc7f851ecca324718 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 20 May 2026 14:21:33 +0100 Subject: [PATCH 165/186] Fix unit test --- .../cms/dashboard/test_viewsets.py | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/integration/cms/dashboard/test_viewsets.py b/tests/integration/cms/dashboard/test_viewsets.py index 9de2d088a..ae5eefa79 100644 --- a/tests/integration/cms/dashboard/test_viewsets.py +++ b/tests/integration/cms/dashboard/test_viewsets.py @@ -11,6 +11,19 @@ from metrics.data.models.core_models import Metric, Topic +class MockPermissionSets(list): + def __init__(self, permissions, has_global_access=False): + super().__init__(permissions) + self._summary = {"has_global_access": has_global_access} + + def __getitem__(self, key): + if key == "permission_sets": + return list(self) + if key == "summary": + return self._summary + return super().__getitem__(key) + + @pytest.mark.django_db class TestCMSPagesAPIViewSetPermissions: @@ -148,10 +161,10 @@ def test_global_access_user(self, setup_pages): request = Request(django_request) mock_user = MagicMock() - mock_user.permission_sets = { - "permission_sets": [], - "summary": {"has_global_access": True}, - } + mock_user.permission_sets = MockPermissionSets( + [], + has_global_access=True, + ) request.user = mock_user request.auth = "token" @@ -185,10 +198,10 @@ def test_restricted_user_with_permission(self, setup_pages): request = Request(django_request) mock_user = MagicMock() - mock_user.permission_sets = { - "permission_sets": [{"theme": {"id": "1"}, "sub_theme": {"id": "-1"}}], - "summary": {"has_global_access": False}, - } + mock_user.permission_sets = MockPermissionSets( + [{"theme": {"id": "1"}, "sub_theme": {"id": "-1"}}], + has_global_access=False, + ) request.user = mock_user request.auth = "token" From 6e130fbb74dea963a656b6de93ca6f22ee785580 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 20 May 2026 16:40:46 +0100 Subject: [PATCH 166/186] Fix test --- .../data_migration/test_operations.py | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/tests/integration/cms/metrics_documentation/data_migration/test_operations.py b/tests/integration/cms/metrics_documentation/data_migration/test_operations.py index de5090f15..5ce9cfccd 100644 --- a/tests/integration/cms/metrics_documentation/data_migration/test_operations.py +++ b/tests/integration/cms/metrics_documentation/data_migration/test_operations.py @@ -149,47 +149,43 @@ def test_creates_correct_child_entries(self, dashboard_root_page: UKHSARootPage) Then the correct child entries are created for the corresponding `Metric` records """ + # Given - _seed_truncated_test_data_with_split_auth() - healthcare_admission_metric = Metric.objects.get( - name="RSV_healthcare_admissionRateByWeek" + + entries = get_metrics_definitions() + assert entries, "No metric definitions found" + + test_entry = entries[0] + + topic = Topic.objects.create(name=test_entry["topic"]) + + metric = Metric.objects.create( + id=test_entry["metric"], # ✅ critical fix + name=f"metric-{test_entry['metric']}", + topic=topic, ) # When create_metrics_documentation_parent_page_and_child_entries() # Then - healthcare_admission_rate_child_entry = ( - MetricsDocumentationChildEntry.objects.get( - metric=healthcare_admission_metric.pk - ) - ) + + child_entry = MetricsDocumentationChildEntry.objects.get(metric=metric.pk) - assert healthcare_admission_rate_child_entry.metric_group == "healthcare" - expected_title = "RSV healthcare admission rate by week" - assert ( - healthcare_admission_rate_child_entry.slug - == expected_title.lower().replace(" ", "-") - ) - assert healthcare_admission_rate_child_entry.topic == "RSV" - assert healthcare_admission_rate_child_entry.title == expected_title - assert ( - healthcare_admission_rate_child_entry.seo_title - == f"{expected_title} | UKHSA data dashboard" - ) + expected_title = test_entry["title"] + + assert child_entry.title == expected_title + assert child_entry.slug == expected_title.lower().replace(" ", "-") + assert child_entry.topic == test_entry["topic"] + assert child_entry.seo_title == test_entry["seo_title"] - expected_page_description = ( - "This metric shows the rate per 100,000 people of the total number of people " - "with confirmed RSV admitted to hospital " - "(general admissions plus admissions to ICU and HDU) " - "in the 7 days up to and including the date shown." - ) assert ( - healthcare_admission_rate_child_entry.search_description - == healthcare_admission_rate_child_entry.page_description - == expected_page_description + child_entry.search_description + == child_entry.page_description + == test_entry["page_description"] ) + @pytest.mark.django_db def test_existing_child_entries_are_removed_correctly( self, dashboard_root_page: UKHSARootPage From 6de2df73e80783945bf5727d76e95147c6cbd177 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 20 May 2026 17:25:24 +0100 Subject: [PATCH 167/186] linting --- .../data_migration/test_operations.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/integration/cms/metrics_documentation/data_migration/test_operations.py b/tests/integration/cms/metrics_documentation/data_migration/test_operations.py index 5ce9cfccd..0ff1baac3 100644 --- a/tests/integration/cms/metrics_documentation/data_migration/test_operations.py +++ b/tests/integration/cms/metrics_documentation/data_migration/test_operations.py @@ -149,9 +149,9 @@ def test_creates_correct_child_entries(self, dashboard_root_page: UKHSARootPage) Then the correct child entries are created for the corresponding `Metric` records """ - + # Given - + entries = get_metrics_definitions() assert entries, "No metric definitions found" @@ -169,7 +169,7 @@ def test_creates_correct_child_entries(self, dashboard_root_page: UKHSARootPage) create_metrics_documentation_parent_page_and_child_entries() # Then - + child_entry = MetricsDocumentationChildEntry.objects.get(metric=metric.pk) expected_title = test_entry["title"] @@ -185,7 +185,6 @@ def test_creates_correct_child_entries(self, dashboard_root_page: UKHSARootPage) == test_entry["page_description"] ) - @pytest.mark.django_db def test_existing_child_entries_are_removed_correctly( self, dashboard_root_page: UKHSARootPage From 222acd2d3953708cb42b8e620c554ee7c9e69cb3 Mon Sep 17 00:00:00 2001 From: Matt Reynolds <18287679+mattjreynolds@users.noreply.github.com> Date: Wed, 20 May 2026 17:36:05 +0100 Subject: [PATCH 168/186] CDD-3171: Move permission_set.js insert to Media class --- cms/auth_content/models/permission_sets.py | 3 +++ cms/auth_content/wagtail_hooks.py | 7 ------- tests/unit/{ => cms}/auth_content/__init__.py | 0 .../{ => cms}/auth_content/models/test_permission_sets.py | 0 tests/unit/{ => cms}/auth_content/test_auth_utils.py | 0 tests/unit/{ => cms}/auth_content/test_wagtail_hooks.py | 5 ----- 6 files changed, 3 insertions(+), 12 deletions(-) rename tests/unit/{ => cms}/auth_content/__init__.py (100%) rename tests/unit/{ => cms}/auth_content/models/test_permission_sets.py (100%) rename tests/unit/{ => cms}/auth_content/test_auth_utils.py (100%) rename tests/unit/{ => cms}/auth_content/test_wagtail_hooks.py (95%) diff --git a/cms/auth_content/models/permission_sets.py b/cms/auth_content/models/permission_sets.py index af945cb35..ba5e2ca8b 100644 --- a/cms/auth_content/models/permission_sets.py +++ b/cms/auth_content/models/permission_sets.py @@ -83,6 +83,9 @@ def clean(self): return cleaned_data + class Media: + js = ["js/permission_set.js"] + class PermissionSet(models.Model): name = models.CharField( diff --git a/cms/auth_content/wagtail_hooks.py b/cms/auth_content/wagtail_hooks.py index 7df8bfa8d..77852a1d1 100644 --- a/cms/auth_content/wagtail_hooks.py +++ b/cms/auth_content/wagtail_hooks.py @@ -1,5 +1,3 @@ -from django.templatetags.static import static -from django.utils.html import format_html from wagtail import hooks from wagtail.admin.viewsets.model import ( ModelPermissionPolicy, @@ -48,8 +46,3 @@ class AuthGroup(ModelViewSetGroup): @hooks.register("register_admin_viewset") def register_auth_viewset(): return AuthGroup() - - -@hooks.register("insert_editor_js") -def permission_set_js(): - return format_html('', static("js/permission_set.js")) diff --git a/tests/unit/auth_content/__init__.py b/tests/unit/cms/auth_content/__init__.py similarity index 100% rename from tests/unit/auth_content/__init__.py rename to tests/unit/cms/auth_content/__init__.py diff --git a/tests/unit/auth_content/models/test_permission_sets.py b/tests/unit/cms/auth_content/models/test_permission_sets.py similarity index 100% rename from tests/unit/auth_content/models/test_permission_sets.py rename to tests/unit/cms/auth_content/models/test_permission_sets.py diff --git a/tests/unit/auth_content/test_auth_utils.py b/tests/unit/cms/auth_content/test_auth_utils.py similarity index 100% rename from tests/unit/auth_content/test_auth_utils.py rename to tests/unit/cms/auth_content/test_auth_utils.py diff --git a/tests/unit/auth_content/test_wagtail_hooks.py b/tests/unit/cms/auth_content/test_wagtail_hooks.py similarity index 95% rename from tests/unit/auth_content/test_wagtail_hooks.py rename to tests/unit/cms/auth_content/test_wagtail_hooks.py index bd51b0a80..60d944907 100644 --- a/tests/unit/auth_content/test_wagtail_hooks.py +++ b/tests/unit/cms/auth_content/test_wagtail_hooks.py @@ -19,11 +19,6 @@ def test_register_auth_viewset(self): assert result.menu_order == AuthGroup.menu_order assert len(result.items) == 2 - def test_permission_set_js(self): - result = wagtail_hooks.permission_set_js() - assert '' in result - class TestPermissionSetDetailsProperty(TestCase): From 5219d1697f11d9f575febe1ec72ef661bb2879d0 Mon Sep 17 00:00:00 2001 From: Matt Reynolds <18287679+mattjreynolds@users.noreply.github.com> Date: Fri, 22 May 2026 07:33:35 +0100 Subject: [PATCH 169/186] CDD-3171: Add ignores for importlint This could do with some more time spent on it to simplify the imports being ignored --- pyproject.toml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 170a4e864..cc5843054 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -225,6 +225,12 @@ ignore_imports = [ "metrics.api.urls_construction -> cms.snippets.views", "metrics.api.urls_construction -> feedback.api.urls", "cms.dynamic_content.elements -> validation.data_transfer_models.base", + "cms.auth_content.models.users -> metrics.data.managers.rbac_models.user", # Allow auth_content to be moved into CMS, consider refactor + "metrics.api.serializers.geographies -> cms.auth_content.constants", # Allow auth_content to be moved into CMS, consider refactor + "metrics.api.serializers.user -> cms.auth_content.models.users", # Allow auth_content to be moved into CMS, consider refactor + "metrics.api.serializers.permission_sets -> cms.auth_content.constants", # Allow auth_content to be moved into CMS, consider refactor + "metrics.data.managers.rbac_models.user -> cms.auth_content.models.permission_sets", # Allow auth_content to be moved into CMS, consider refactor + "metrics.utils.permission_hierarchy -> cms.auth_content.models.permission_sets", # Allow auth_content to be moved into CMS, consider refactor ] [[tool.importlinter.contracts]] @@ -237,7 +243,14 @@ layers = [ ] ignore_imports = [ "metrics.data.managers.core_models.time_series -> metrics.api.permissions.fluent_permissions", - "metrics.data.managers.core_models.headline -> metrics.api.permissions.fluent_permissions" + "metrics.data.managers.core_models.headline -> metrics.api.permissions.fluent_permissions", + "metrics.data.managers.rbac_models.user -> cms.auth_content.models.permission_sets", # Allow auth_content to be moved into CMS, consider refactor + "cms.auth_content.models.permission_sets -> cms.metrics_interface.field_choices_callables", # Allow auth_content to be moved into CMS, consider refactor + "cms.metrics_interface.field_choices_callables -> cms.metrics_interface", # Allow auth_content to be moved into CMS, consider refactor + "cms.metrics_interface -> cms.metrics_interface.interface", # Allow auth_content to be moved into CMS, consider refactor + "cms.metrics_interface.interface -> metrics.domain.charts.colour_scheme", # Allow auth_content to be moved into CMS, consider refactor + "cms.metrics_interface.interface -> metrics.domain.charts.common_charts.plots.line_multi_coloured.properties", # Allow auth_content to be moved into CMS, consider refactor + "cms.metrics_interface.interface -> metrics.domain.common.utils", # Allow auth_content to be moved into CMS, consider refactor ] [[tool.importlinter.contracts]] From f55076ef34c713d8f04a65b2713708813ccea60c Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Fri, 22 May 2026 10:22:33 +0100 Subject: [PATCH 170/186] Update architectural constraints --- ... 0003_permissionset_display_name_and_more.py} | 10 +++++++++- metrics/api/serializers/geographies.py | 8 ++++---- metrics/api/serializers/permission_sets.py | 16 ++++++++-------- metrics/data/models/constants.py | 1 + pyproject.toml | 2 -- 5 files changed, 22 insertions(+), 15 deletions(-) rename cms/auth_content/migrations/{0003_permissionset_display_name.py => 0003_permissionset_display_name_and_more.py} (63%) diff --git a/cms/auth_content/migrations/0003_permissionset_display_name.py b/cms/auth_content/migrations/0003_permissionset_display_name_and_more.py similarity index 63% rename from cms/auth_content/migrations/0003_permissionset_display_name.py rename to cms/auth_content/migrations/0003_permissionset_display_name_and_more.py index 3ade7df9c..1b66d2218 100644 --- a/cms/auth_content/migrations/0003_permissionset_display_name.py +++ b/cms/auth_content/migrations/0003_permissionset_display_name_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.13 on 2026-05-19 13:23 +# Generated by Django 5.2.13 on 2026-05-22 08:25 from django.db import migrations, models @@ -20,4 +20,12 @@ class Migration(migrations.Migration): null=True, ), ), + migrations.AddConstraint( + model_name="permissionset", + constraint=models.UniqueConstraint( + condition=models.Q(("display_name__isnull", False)), + fields=("display_name",), + name="unique_non_null_display_name", + ), + ), ] diff --git a/metrics/api/serializers/geographies.py b/metrics/api/serializers/geographies.py index d702761d9..d580e3939 100644 --- a/metrics/api/serializers/geographies.py +++ b/metrics/api/serializers/geographies.py @@ -3,12 +3,12 @@ from django.db.models import QuerySet from rest_framework import serializers -from cms.auth_content.constants import WILDCARD_ID_VALUE from metrics.api.serializers import help_texts from metrics.data.in_memory_models.geography_relationships.handlers import ( get_upstream_relationships_for_geography, ) from metrics.data.managers.core_models.time_series import CoreTimeSeriesQuerySet +from metrics.data.models.constants import PERMISSION_SET_WILDCARD_ID_VALUE from metrics.data.models.core_models import ( CoreTimeSeries, Geography, @@ -234,7 +234,7 @@ def geography_manager(self): @staticmethod def validate_geography_type_id(value: str) -> str | int: """Validate geography_type_id is either wildcard or a valid integer""" - if value == WILDCARD_ID_VALUE: + if value == PERMISSION_SET_WILDCARD_ID_VALUE: return value try: @@ -253,8 +253,8 @@ def data(self) -> dict[str, list[list[str, str]]]: geography_type_id = self.validated_data["geography_type_id"] # Handle wildcard - if geography_type_id == WILDCARD_ID_VALUE: - return {"choices": [[WILDCARD_ID_VALUE, "* (All geographies)"]]} + if geography_type_id == PERMISSION_SET_WILDCARD_ID_VALUE: + return {"choices": [[PERMISSION_SET_WILDCARD_ID_VALUE, "* (All geographies)"]]} parent_geography_type_id = int(geography_type_id) geographies = ( diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py index ae7605d1b..220495db0 100644 --- a/metrics/api/serializers/permission_sets.py +++ b/metrics/api/serializers/permission_sets.py @@ -1,13 +1,13 @@ from django.db.models import QuerySet from rest_framework import serializers -from cms.auth_content.constants import WILDCARD_ID_VALUE +from metrics.data.models.constants import PERMISSION_SET_WILDCARD_ID_VALUE from metrics.data.models.core_models.supporting import Metric, SubTheme, Topic def _validate_input_id(value, field_name): """Validate theme_id is either wildcard or a valid integer""" - if value == WILDCARD_ID_VALUE: + if value == PERMISSION_SET_WILDCARD_ID_VALUE: return value try: @@ -46,8 +46,8 @@ def data(self) -> dict: """ theme_id = self.validated_data["theme_id"] - if theme_id == WILDCARD_ID_VALUE: - return {"choices": [[WILDCARD_ID_VALUE, "* (All sub-themes)"]]} + if theme_id == PERMISSION_SET_WILDCARD_ID_VALUE: + return {"choices": [[PERMISSION_SET_WILDCARD_ID_VALUE, "* (All sub-themes)"]]} parent_theme_id = int(theme_id) sub_theme_tuples = _queryset_to_id_name_tuples( @@ -87,8 +87,8 @@ def data(self) -> dict: """ sub_theme_id = self.validated_data["sub_theme_id"] - if sub_theme_id == WILDCARD_ID_VALUE: - return {"choices": [[WILDCARD_ID_VALUE, "* (All topics)"]]} + if sub_theme_id == PERMISSION_SET_WILDCARD_ID_VALUE: + return {"choices": [[PERMISSION_SET_WILDCARD_ID_VALUE, "* (All topics)"]]} parent_sub_theme_id = int(sub_theme_id) topic_tuples = _queryset_to_id_name_tuples( @@ -129,8 +129,8 @@ def data(self) -> dict: """ topic_id = self.validated_data["topic_id"] - if topic_id == WILDCARD_ID_VALUE: - return {"choices": [[WILDCARD_ID_VALUE, "* (All metrics)"]]} + if topic_id == PERMISSION_SET_WILDCARD_ID_VALUE: + return {"choices": [[PERMISSION_SET_WILDCARD_ID_VALUE, "* (All metrics)"]]} parent_topic_id = int(topic_id) metric_tuples = _queryset_to_id_name_tuples( diff --git a/metrics/data/models/constants.py b/metrics/data/models/constants.py index d5fcfba29..51fd39d5e 100644 --- a/metrics/data/models/constants.py +++ b/metrics/data/models/constants.py @@ -5,3 +5,4 @@ METRIC_FREQUENCY_MAX_CHAR_CONSTRAINT: int = 1 METRIC_VALUE_MAX_DIGITS: int = 11 METRIC_VALUE_DECIMAL_PLACES: int = 4 +PERMISSION_SET_WILDCARD_ID_VALUE : str = "-1" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index cc5843054..2ce7e729a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -226,9 +226,7 @@ ignore_imports = [ "metrics.api.urls_construction -> feedback.api.urls", "cms.dynamic_content.elements -> validation.data_transfer_models.base", "cms.auth_content.models.users -> metrics.data.managers.rbac_models.user", # Allow auth_content to be moved into CMS, consider refactor - "metrics.api.serializers.geographies -> cms.auth_content.constants", # Allow auth_content to be moved into CMS, consider refactor "metrics.api.serializers.user -> cms.auth_content.models.users", # Allow auth_content to be moved into CMS, consider refactor - "metrics.api.serializers.permission_sets -> cms.auth_content.constants", # Allow auth_content to be moved into CMS, consider refactor "metrics.data.managers.rbac_models.user -> cms.auth_content.models.permission_sets", # Allow auth_content to be moved into CMS, consider refactor "metrics.utils.permission_hierarchy -> cms.auth_content.models.permission_sets", # Allow auth_content to be moved into CMS, consider refactor ] From 39226d0cdadb0db8d87dd197c4b2419bf8d0fd31 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Fri, 22 May 2026 10:29:07 +0100 Subject: [PATCH 171/186] linting --- metrics/api/serializers/geographies.py | 4 +++- metrics/api/serializers/permission_sets.py | 4 +++- metrics/data/models/constants.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/metrics/api/serializers/geographies.py b/metrics/api/serializers/geographies.py index d580e3939..d2c07d8d3 100644 --- a/metrics/api/serializers/geographies.py +++ b/metrics/api/serializers/geographies.py @@ -254,7 +254,9 @@ def data(self) -> dict[str, list[list[str, str]]]: # Handle wildcard if geography_type_id == PERMISSION_SET_WILDCARD_ID_VALUE: - return {"choices": [[PERMISSION_SET_WILDCARD_ID_VALUE, "* (All geographies)"]]} + return { + "choices": [[PERMISSION_SET_WILDCARD_ID_VALUE, "* (All geographies)"]] + } parent_geography_type_id = int(geography_type_id) geographies = ( diff --git a/metrics/api/serializers/permission_sets.py b/metrics/api/serializers/permission_sets.py index 220495db0..f75cb6923 100644 --- a/metrics/api/serializers/permission_sets.py +++ b/metrics/api/serializers/permission_sets.py @@ -47,7 +47,9 @@ def data(self) -> dict: theme_id = self.validated_data["theme_id"] if theme_id == PERMISSION_SET_WILDCARD_ID_VALUE: - return {"choices": [[PERMISSION_SET_WILDCARD_ID_VALUE, "* (All sub-themes)"]]} + return { + "choices": [[PERMISSION_SET_WILDCARD_ID_VALUE, "* (All sub-themes)"]] + } parent_theme_id = int(theme_id) sub_theme_tuples = _queryset_to_id_name_tuples( diff --git a/metrics/data/models/constants.py b/metrics/data/models/constants.py index 51fd39d5e..2388368e7 100644 --- a/metrics/data/models/constants.py +++ b/metrics/data/models/constants.py @@ -5,4 +5,4 @@ METRIC_FREQUENCY_MAX_CHAR_CONSTRAINT: int = 1 METRIC_VALUE_MAX_DIGITS: int = 11 METRIC_VALUE_DECIMAL_PLACES: int = 4 -PERMISSION_SET_WILDCARD_ID_VALUE : str = "-1" \ No newline at end of file +PERMISSION_SET_WILDCARD_ID_VALUE: str = "-1" From 084949c2822b4cc844a531ab7369fc5a419fad1f Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 27 May 2026 10:04:03 +0100 Subject: [PATCH 172/186] Update wildcard value in viewsets --- cms/dashboard/viewsets.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index d861c8f54..15993d9ae 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -10,6 +10,7 @@ from cms.dashboard.serializers import CMSDraftPagesSerializer, ListablePageSerializer from cms.metrics_documentation.models.child import MetricsDocumentationChildEntry from cms.topic.models import TopicPage +from cms.auth_content.constants import WILDCARD_ID_VALUE def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: @@ -21,16 +22,16 @@ def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> boo permission_sub_theme_id = permission.get("sub_theme", {}).get("id") permission_topic_id = permission.get("topic", {}).get("id") - if permission_theme_id == "-1": + if permission_theme_id == WILDCARD_ID_VALUE: return True - if permission_theme_id == theme_id and permission_sub_theme_id == "-1": + if permission_theme_id == theme_id and permission_sub_theme_id == WILDCARD_ID_VALUE: return True if ( permission_theme_id == theme_id and permission_sub_theme_id == sub_theme_id - and (permission_topic_id in {"-1", topic_id}) + and (permission_topic_id in {WILDCARD_ID_VALUE, topic_id}) ): return True From 6b88ea7bab6d397b94fd845c3ddebbc459df8aee Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 27 May 2026 10:04:32 +0100 Subject: [PATCH 173/186] Update test --- tests/integration/cms/dashboard/test_viewsets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/cms/dashboard/test_viewsets.py b/tests/integration/cms/dashboard/test_viewsets.py index ae5eefa79..5a9ef4e6a 100644 --- a/tests/integration/cms/dashboard/test_viewsets.py +++ b/tests/integration/cms/dashboard/test_viewsets.py @@ -216,4 +216,4 @@ def test_restricted_user_with_permission(self, setup_pages): titles = [p.title for p in result] assert "Private Topic" in titles assert "Private Metric 2" in titles - assert "Private Metrics" not in titles + assert "Private Metric" not in titles From 147916a6b94babb9e05014dde92b74c9b32fcd67 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 27 May 2026 10:07:45 +0100 Subject: [PATCH 174/186] remove comment --- .../cms/metrics_documentation/data_migration/test_operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/cms/metrics_documentation/data_migration/test_operations.py b/tests/integration/cms/metrics_documentation/data_migration/test_operations.py index 0ff1baac3..b8f464414 100644 --- a/tests/integration/cms/metrics_documentation/data_migration/test_operations.py +++ b/tests/integration/cms/metrics_documentation/data_migration/test_operations.py @@ -160,7 +160,7 @@ def test_creates_correct_child_entries(self, dashboard_root_page: UKHSARootPage) topic = Topic.objects.create(name=test_entry["topic"]) metric = Metric.objects.create( - id=test_entry["metric"], # ✅ critical fix + id=test_entry["metric"], name=f"metric-{test_entry['metric']}", topic=topic, ) From ebc6fcd667682631273f26ded77cdcdb4005a1fa Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 27 May 2026 10:08:38 +0100 Subject: [PATCH 175/186] Update import --- tests/unit/cms/auth_content/test_wagtail_hooks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/cms/auth_content/test_wagtail_hooks.py b/tests/unit/cms/auth_content/test_wagtail_hooks.py index 60d944907..cf938da71 100644 --- a/tests/unit/cms/auth_content/test_wagtail_hooks.py +++ b/tests/unit/cms/auth_content/test_wagtail_hooks.py @@ -8,12 +8,12 @@ PermissionSetViewSet, AuthGroup, ) -from cms.auth_content import wagtail_hooks +from cms.auth_content.wagtail_hooks import register_auth_viewset class TestWagtailHooks(TestCase): def test_register_auth_viewset(self): - result = wagtail_hooks.register_auth_viewset() + result = register_auth_viewset() assert result.menu_label == AuthGroup.menu_label assert result.menu_icon == AuthGroup.menu_icon assert result.menu_order == AuthGroup.menu_order From efb3ff865da35f7dbb3bdd024f315c2b841ba64f Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 27 May 2026 10:09:57 +0100 Subject: [PATCH 176/186] fix test name --- tests/unit/cms/metrics_documentation/models/test_child.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/cms/metrics_documentation/models/test_child.py b/tests/unit/cms/metrics_documentation/models/test_child.py index 571be0a50..8f2b60a90 100644 --- a/tests/unit/cms/metrics_documentation/models/test_child.py +++ b/tests/unit/cms/metrics_documentation/models/test_child.py @@ -130,7 +130,7 @@ def test_initialize_dependent_fields_called_when_instance_has_pk(self): mock_init_deps.assert_called_once() - def test_initialize_dependent_fields_called_when_instance_has_no_pk(self): + def test_initialize_dependent_fields_not_called_when_instance_has_no_pk(self): """ Given a new form When an instance does not have a pk value set From 9d66874961311798c8a189bffe9a036e362f7f2a Mon Sep 17 00:00:00 2001 From: Matt Reynolds <18287679+mattjreynolds@users.noreply.github.com> Date: Wed, 27 May 2026 10:25:34 +0100 Subject: [PATCH 177/186] linting --- cms/dashboard/viewsets.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index 15993d9ae..58aef29ad 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -7,10 +7,10 @@ from wagtail.api.v2.views import PagesAPIViewSet from caching.private_api.decorators import cache_response +from cms.auth_content.constants import WILDCARD_ID_VALUE from cms.dashboard.serializers import CMSDraftPagesSerializer, ListablePageSerializer from cms.metrics_documentation.models.child import MetricsDocumentationChildEntry from cms.topic.models import TopicPage -from cms.auth_content.constants import WILDCARD_ID_VALUE def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: @@ -25,7 +25,10 @@ def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> boo if permission_theme_id == WILDCARD_ID_VALUE: return True - if permission_theme_id == theme_id and permission_sub_theme_id == WILDCARD_ID_VALUE: + if ( + permission_theme_id == theme_id + and permission_sub_theme_id == WILDCARD_ID_VALUE + ): return True if ( From a23fd654b99974133a31081ce8416fdfdb4587e5 Mon Sep 17 00:00:00 2001 From: Matt Reynolds <18287679+mattjreynolds@users.noreply.github.com> Date: Wed, 27 May 2026 10:28:07 +0100 Subject: [PATCH 178/186] combine imports --- tests/unit/cms/auth_content/test_wagtail_hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/cms/auth_content/test_wagtail_hooks.py b/tests/unit/cms/auth_content/test_wagtail_hooks.py index cf938da71..0a2b19f04 100644 --- a/tests/unit/cms/auth_content/test_wagtail_hooks.py +++ b/tests/unit/cms/auth_content/test_wagtail_hooks.py @@ -7,8 +7,8 @@ NoEditPermissionPolicy, PermissionSetViewSet, AuthGroup, + register_auth_viewset, ) -from cms.auth_content.wagtail_hooks import register_auth_viewset class TestWagtailHooks(TestCase): From fd5306ea434681f35260bc70016a34ce69306227 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 27 May 2026 16:49:28 +0100 Subject: [PATCH 179/186] refactor for simplicity --- cms/dashboard/viewsets.py | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index 58aef29ad..279cbf3e2 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -1,3 +1,5 @@ +from itertools import chain + from django.db.models import Exists, OuterRef, Q from django.urls import path from django.urls.resolvers import RoutePattern @@ -101,7 +103,6 @@ def get_queryset(self): ) else: - user_permissions = req.user.permission_sets["permission_sets"] has_global_access = req.user.permission_sets["summary"]["has_global_access"] if has_global_access: @@ -109,35 +110,27 @@ def get_queryset(self): else: user_permissions = req.user.permission_sets - allowed_pages = [] - allowed_pages += [ - page.id - for page in queryset.type(TopicPage) - if page.topicpage.is_public - or check_permissions( - user_permissions, - page.topicpage.theme, - page.topicpage.sub_theme, - page.topicpage.topic, - ) - ] - - allowed_pages += [ - page.id - for page in queryset.type(MetricsDocumentationChildEntry) - if page.metricsdocumentationchildentry.is_public + pages_to_check = chain( + ((page.id, page.topicpage) for page in queryset.type(TopicPage)), + ((page.id, page.metricsdocumentationchildentry) for page in queryset.type(MetricsDocumentationChildEntry) + ), +) + allowed_page_ids = [ + page_id + for page_id, page in pages_to_check + if page.is_public or check_permissions( user_permissions, - page.metricsdocumentationchildentry.theme, - page.metricsdocumentationchildentry.sub_theme, - page.metricsdocumentationchildentry.topic, + page.theme, + page.sub_theme, + page.topic, ) ] public_pages = queryset.not_type( TopicPage, MetricsDocumentationChildEntry ) - permitted_private_pages = queryset.filter(id__in=allowed_pages) + permitted_private_pages = queryset.filter(id__in=allowed_page_ids) filtered_queryset = public_pages | permitted_private_pages From 81e36789b3a59d5d5ace95ce7fc1b672bf7228b5 Mon Sep 17 00:00:00 2001 From: Kathryn Dale Date: Wed, 27 May 2026 16:49:58 +0100 Subject: [PATCH 180/186] linting --- cms/dashboard/viewsets.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index 279cbf3e2..89c41446f 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -111,10 +111,12 @@ def get_queryset(self): else: user_permissions = req.user.permission_sets pages_to_check = chain( - ((page.id, page.topicpage) for page in queryset.type(TopicPage)), - ((page.id, page.metricsdocumentationchildentry) for page in queryset.type(MetricsDocumentationChildEntry) - ), -) + ((page.id, page.topicpage) for page in queryset.type(TopicPage)), + ( + (page.id, page.metricsdocumentationchildentry) + for page in queryset.type(MetricsDocumentationChildEntry) + ), + ) allowed_page_ids = [ page_id for page_id, page in pages_to_check From 8708ada9dacf38df76b2dd2cd93953e46b683fa3 Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Wed, 27 May 2026 17:11:20 +0100 Subject: [PATCH 181/186] CDD-3173: prototype authorization curl call on /api/downloads/v2 --- cms/auth_content/auth_utils.py | 74 ++++++++++++++++++- cms/auth_content/constants.py | 2 + metrics/api/middleware/__init__.py | 0 metrics/api/middleware/sql_debug.py | 24 ++++++ metrics/api/settings/local.py | 1 + .../data/managers/core_models/time_series.py | 56 +++++++++----- metrics/domain/models/charts/common.py | 8 ++ metrics/interfaces/plots/access.py | 6 +- 8 files changed, 151 insertions(+), 20 deletions(-) create mode 100644 metrics/api/middleware/__init__.py create mode 100644 metrics/api/middleware/sql_debug.py diff --git a/cms/auth_content/auth_utils.py b/cms/auth_content/auth_utils.py index 56735c2da..ff2bb2491 100644 --- a/cms/auth_content/auth_utils.py +++ b/cms/auth_content/auth_utils.py @@ -1,9 +1,81 @@ +import logging from collections.abc import Callable - +# from cms.auth_content.constants import WILDCARD_ID_VALUE, WILDCARD_NAME_VALUES from django import forms from cms.dynamic_content import help_texts +WILDCARD_ID_VALUE = "-1" +WILDCARD_NAME_VALUES = ["* (All themes)", "* (All sub-themes)", "* (All topics)"] + +logger = logging.getLogger(__name__) + +def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: + if not isinstance(user_permissions, list): + return False + + for permission in user_permissions: + permission_theme_id = permission.get("theme", {}).get("id") + permission_sub_theme_id = permission.get("sub_theme", {}).get("id") + permission_topic_id = permission.get("topic", {}).get("id") + + if permission_theme_id == WILDCARD_ID_VALUE: + return True + + if ( + permission_theme_id == theme_id + and permission_sub_theme_id == WILDCARD_ID_VALUE + ): + return True + + if ( + permission_theme_id == theme_id + and permission_sub_theme_id == sub_theme_id + and (permission_topic_id in {WILDCARD_ID_VALUE, topic_id}) + ): + return True + + return False + + +def check_permissions_by_name(permission_sets, theme_name, sub_theme_name, topic_name) -> bool: + if not isinstance(permission_sets, dict): + return False + if not isinstance(permission_sets.get("permission_sets"), list): + return False + if not isinstance(permission_sets.get("summary"), dict): + return False + if not isinstance(permission_sets.get("summary").get("has_global_access"), bool): + return False + + logger.info(f'Entered check_permissions_by_name() with theme "{theme_name}" and sub_theme "{sub_theme_name}" and topic "{topic_name}"') + + if permission_sets.get("summary").get("has_global_access"): + return True + else: + for permission in permission_sets.get("permission_sets"): + permission_theme_name = permission.get("theme", {}).get("name") + permission_sub_theme_name = permission.get("sub_theme", {}).get("name") + permission_topic_name = permission.get("topic", {}).get("name") + + if permission_theme_name in WILDCARD_NAME_VALUES: + return True + + if ( + permission_theme_name == theme_name + and permission_sub_theme_name in WILDCARD_NAME_VALUES + ): + return True + + if ( + permission_theme_name == theme_name + and permission_sub_theme_name == sub_theme_name + and (permission_topic_name in WILDCARD_NAME_VALUES + [topic_name]) + ): + return True + + return False + def _create_form_field( field: dict[str, str | Callable | None], wildcard_id_value=None diff --git a/cms/auth_content/constants.py b/cms/auth_content/constants.py index 0920faa1f..9c04bcf3d 100644 --- a/cms/auth_content/constants.py +++ b/cms/auth_content/constants.py @@ -4,6 +4,8 @@ ) WILDCARD_ID_VALUE = "-1" +WILDCARD_NAME_VALUES = ["* (All themes)", "* (All sub-themes)", "* (All topics)"] + PERMISSION_SET_FIELDS = [ { "field_name": "theme", diff --git a/metrics/api/middleware/__init__.py b/metrics/api/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/metrics/api/middleware/sql_debug.py b/metrics/api/middleware/sql_debug.py new file mode 100644 index 000000000..dc00d0762 --- /dev/null +++ b/metrics/api/middleware/sql_debug.py @@ -0,0 +1,24 @@ +from django.db import connection + + +def _print_sql(execute, sql, params, many, context): + print(f"\n[SQL] {sql}") + if params: + print(f"[PARAMS] {params}") + return execute(sql, params, many, context) + + +class SQLDebugMiddleware: + """ + Middleware that prints the raw SQL and params for every DB query made + during a request/response cycle. + + Only intended for local development — add to MIDDLEWARE in local.py. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + with connection.execute_wrapper(_print_sql): + return self.get_response(request) diff --git a/metrics/api/settings/local.py b/metrics/api/settings/local.py index 8d0cf9636..2a3f10dfa 100644 --- a/metrics/api/settings/local.py +++ b/metrics/api/settings/local.py @@ -29,6 +29,7 @@ MIDDLEWARE += [ "debug_toolbar.middleware.DebugToolbarMiddleware", + "metrics.api.middleware.sql_debug.SQLDebugMiddleware", ] INTERNAL_IPS = ["127.0.0.1"] diff --git a/metrics/data/managers/core_models/time_series.py b/metrics/data/managers/core_models/time_series.py index aed8df7f6..0f948b965 100644 --- a/metrics/data/managers/core_models/time_series.py +++ b/metrics/data/managers/core_models/time_series.py @@ -6,9 +6,10 @@ """ import datetime +import logging from collections.abc import Iterable from typing import Self - +from cms.auth_content.auth_utils import check_permissions_by_name from django.db import models from django.db.models.query_utils import Q from django.utils import timezone @@ -21,6 +22,7 @@ ALLOWABLE_METRIC_VALUE_RANGE_TYPE = tuple[str | float | int, str | float | int] +logger = logging.getLogger(__name__) class CoreTimeSeriesQuerySet(models.QuerySet): """Custom queryset which can be used by the `CoreTimeSeriesManager`""" @@ -160,6 +162,8 @@ def filter_for_audit_list_view( def query_for_data( self, *, + theme: str, + sub_theme: str, topic: str, metric: str, date_from: datetime.date, @@ -172,7 +176,7 @@ def query_for_data( sex: str | None = None, age: str | None = None, metric_value_ranges: list[tuple[str | float | int]] | None = None, - restrict_to_public: bool = True, + permission_sets: dict, ) -> models.QuerySet: """Filters for a N-item list of dicts by the given params if `fields_to_export` is used. @@ -217,9 +221,7 @@ def query_for_data( i.e. to filter for all record with values between 0 -> 80 AND 90 -> 100, this can be provided as `[(0, 80), (90, 100)]`. - restrict_to_public: Boolean switch to restrict the query - to only return public records. - If False, then non-public records will be included. + permission_sets: The JWT permissions extracted from the Cognito token. Returns: QuerySet: An ordered queryset from lowest -> highest @@ -231,6 +233,8 @@ def query_for_data( ]>` """ + logger.info('Entered query_for_data()') + queryset = self.filter( metric__topic__name=topic, metric__name=metric, @@ -245,9 +249,31 @@ def query_for_data( sex=sex, age=age, ) + public_queryset = queryset.filter( + is_public=True + ) - if restrict_to_public: - queryset = queryset.filter(is_public=True) + if permission_sets: + logger.info('Entered if permission_sets clause') + + if check_permissions_by_name( + permission_sets, + theme, + sub_theme, + topic, + # metric, + # geography_type, + # geography, + ): + logger.info('Entered check_permissions_by_name() if clause') + + queryset = public_queryset + queryset.filter( + is_public=False + ) + else: + logger.info('Entered else permission_sets clause') + + queryset = public_queryset queryset = self._exclude_data_under_embargo(queryset=queryset) queryset = self._filter_for_metric_value_ranges( @@ -533,6 +559,7 @@ def query_for_data( sub_theme: str = "", metric_value_ranges: list[str | float | int] | None = None, rbac_permissions: Iterable[RBACPermission] | None = None, + permission_sets: dict, ) -> CoreTimeSeriesQuerySet: """Filters for a 2-item object by the given params. Slices all values older than the `date_from`. @@ -582,6 +609,7 @@ def query_for_data( rbac_permissions: The RBAC permissions available to the given request. This dictates whether the given request is permitted access to non-public data or not. + permission_sets: The JWT permissions extracted from the Cognito token. Notes: If we have the following input `queryset`: @@ -611,20 +639,12 @@ def query_for_data( ]>` """ - rbac_permissions: Iterable[RBACPermission] = rbac_permissions or [] - has_access_to_non_public_data: bool = validate_permissions_for_non_public( - theme=theme, - sub_theme=sub_theme, - topic=topic, - metric=metric, - geography_type=geography_type, - geography=geography, - rbac_permissions=rbac_permissions, - ) return self.get_queryset().query_for_data( fields_to_export=fields_to_export, field_to_order_by=field_to_order_by, + theme=theme, + sub_theme=sub_theme, topic=topic, metric=metric, date_from=date_from, @@ -635,7 +655,7 @@ def query_for_data( sex=sex, age=age, metric_value_ranges=metric_value_ranges, - restrict_to_public=not has_access_to_non_public_data, + permission_sets=permission_sets, ) def query_for_superseded_data( diff --git a/metrics/domain/models/charts/common.py b/metrics/domain/models/charts/common.py index 317301450..faaa30a0c 100644 --- a/metrics/domain/models/charts/common.py +++ b/metrics/domain/models/charts/common.py @@ -1,3 +1,4 @@ +import logging from collections.abc import Iterable from decimal import Decimal from typing import Literal @@ -5,6 +6,7 @@ from pydantic.main import BaseModel from rest_framework.request import Request +logger = logging.getLogger(__name__) class BaseChartRequestParams(BaseModel): file_format: Literal["png", "svg", "jpg", "jpeg", "json", "csv"] @@ -27,3 +29,9 @@ class Config: @property def rbac_permissions(self) -> Iterable["RBACPermission"]: return getattr(self.request, "rbac_permissions", []) + + @property + def permission_sets(self) -> dict: + logger.info(f'Entered BaseChartRequestParams.permission_sets') + + return getattr(self.request.user, "permission_sets", {}) diff --git a/metrics/interfaces/plots/access.py b/metrics/interfaces/plots/access.py index 6e4eac34d..efad04938 100644 --- a/metrics/interfaces/plots/access.py +++ b/metrics/interfaces/plots/access.py @@ -161,8 +161,12 @@ def get_queryset_from_core_model_manager( plot_params["fields_to_export"].append("upper_confidence") plot_params["fields_to_export"].append("lower_confidence") + logger.info('Entered access.py') + return self.core_model_manager.query_for_data( - **plot_params, rbac_permissions=self.chart_request_params.rbac_permissions + **plot_params, + rbac_permissions=self.chart_request_params.rbac_permissions, + permission_sets = self.chart_request_params.permission_sets, ) def build_plot_data_from_parameters_with_complete_queryset( From e98220f1a4774fdd6adf9eb06bf6d44c05876cf0 Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Thu, 28 May 2026 17:51:23 +0100 Subject: [PATCH 182/186] CDD-3173: get rid of check_permissions_by_name() and make /api/downloads/v2 use check_permissions() instead --- cms/auth_content/auth_utils.py | 144 +++++++++++++----- cms/auth_content/constants.py | 2 - .../data/managers/core_models/geography.py | 30 ++++ .../managers/core_models/geography_type.py | 30 ++++ metrics/data/managers/core_models/metric.py | 30 ++++ .../data/managers/core_models/time_series.py | 20 ++- metrics/data/managers/core_models/topic.py | 50 ++++++ metrics/domain/models/charts/common.py | 12 +- metrics/interfaces/plots/access.py | 4 +- 9 files changed, 273 insertions(+), 49 deletions(-) diff --git a/cms/auth_content/auth_utils.py b/cms/auth_content/auth_utils.py index ff2bb2491..c724e78a3 100644 --- a/cms/auth_content/auth_utils.py +++ b/cms/auth_content/auth_utils.py @@ -1,24 +1,41 @@ import logging from collections.abc import Callable -# from cms.auth_content.constants import WILDCARD_ID_VALUE, WILDCARD_NAME_VALUES -from django import forms +from cms.auth_content.constants import WILDCARD_ID_VALUE +from django import forms from cms.dynamic_content import help_texts - -WILDCARD_ID_VALUE = "-1" -WILDCARD_NAME_VALUES = ["* (All themes)", "* (All sub-themes)", "* (All topics)"] +from metrics.data.models.core_models.supporting import Geography, GeographyType, Metric, Topic logger = logging.getLogger(__name__) -def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: - if not isinstance(user_permissions, list): +def check_permissions( + permission_sets, + theme_id, + sub_theme_id, + topic_id, + metric_id=0, + geography_type=0, + geography_id=0, +) -> bool: + if not isinstance(permission_sets, list): return False - for permission in user_permissions: + # TODO: Can we make them all ID at source? + theme_id = str(theme_id) + sub_theme_id = str(sub_theme_id) + topic_id = str(topic_id) + metric_id = str(metric_id) + geography_type = str(geography_type) + geography_id = str(geography_id) + + for permission in permission_sets: permission_theme_id = permission.get("theme", {}).get("id") permission_sub_theme_id = permission.get("sub_theme", {}).get("id") permission_topic_id = permission.get("topic", {}).get("id") - + permission_metric_id = permission.get("metric", {}).get("id") + permission_geography_type = permission.get("geography_type", {}).get("id") + permission_geography_id = permission.get("geography", {}).get("id") + if permission_theme_id == WILDCARD_ID_VALUE: return True @@ -31,14 +48,86 @@ def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> boo if ( permission_theme_id == theme_id and permission_sub_theme_id == sub_theme_id - and (permission_topic_id in {WILDCARD_ID_VALUE, topic_id}) + and permission_topic_id in {WILDCARD_ID_VALUE, topic_id} + ): + return True + + if ( + permission_theme_id == theme_id + and permission_sub_theme_id == sub_theme_id + and permission_topic_id == topic_id + and permission_metric_id in {WILDCARD_ID_VALUE, metric_id} + ): + return True + + if ( + permission_theme_id == theme_id + and permission_sub_theme_id == sub_theme_id + and permission_topic_id == topic_id + and permission_metric_id == metric_id + and permission_geography_type in {WILDCARD_ID_VALUE, geography_type} ): return True + if ( + permission_theme_id == theme_id + and permission_sub_theme_id == sub_theme_id + and permission_topic_id == topic_id + and permission_metric_id == metric_id + and permission_geography_type == geography_type + and permission_geography_id in {WILDCARD_ID_VALUE, geography_id} + ): + return True + return False -def check_permissions_by_name(permission_sets, theme_name, sub_theme_name, topic_name) -> bool: + +def check_permissions_by_name( + permission_sets, + theme_name, + sub_theme_name, + topic_name, + metric_name, + geography_type, + geography_name, +) -> bool: + logger.info(f'Entered check_permissions_by_name()') + + theme_id, sub_theme_id, topic_id = Topic.objects.get_id_by_name(theme_name, sub_theme_name, topic_name) + metric_id = Metric.objects.get_id_by_name(metric_name) + geography_type_id = GeographyType.objects.get_id_by_name(geography_type) + geography_id = Geography.objects.get_id_by_name(geography_name) + + # In case a NAME doesn't have an ID + if any( + value == -2 + for value in (theme_id, sub_theme_id, topic_id, metric_id, geography_type_id, geography_id) + ): + return False + + return check_permission_set( + permission_sets, + theme_id, + sub_theme_id, + topic_id, + metric_id, + geography_type_id, + geography_id, + ) + + +def check_permission_set( + permission_sets, + theme_id, + sub_theme_id, + topic_id, + metric_id, + geography_type, + geography_id, +) -> bool: + logger.info(f'Entered check_permission_set()') + if not isinstance(permission_sets, dict): return False if not isinstance(permission_sets.get("permission_sets"), list): @@ -48,33 +137,18 @@ def check_permissions_by_name(permission_sets, theme_name, sub_theme_name, topic if not isinstance(permission_sets.get("summary").get("has_global_access"), bool): return False - logger.info(f'Entered check_permissions_by_name() with theme "{theme_name}" and sub_theme "{sub_theme_name}" and topic "{topic_name}"') - if permission_sets.get("summary").get("has_global_access"): return True else: - for permission in permission_sets.get("permission_sets"): - permission_theme_name = permission.get("theme", {}).get("name") - permission_sub_theme_name = permission.get("sub_theme", {}).get("name") - permission_topic_name = permission.get("topic", {}).get("name") - - if permission_theme_name in WILDCARD_NAME_VALUES: - return True - - if ( - permission_theme_name == theme_name - and permission_sub_theme_name in WILDCARD_NAME_VALUES - ): - return True - - if ( - permission_theme_name == theme_name - and permission_sub_theme_name == sub_theme_name - and (permission_topic_name in WILDCARD_NAME_VALUES + [topic_name]) - ): - return True - - return False + return check_permissions( + permission_sets.get("permission_sets"), + theme_id, + sub_theme_id, + topic_id, + metric_id, + geography_type, + geography_id, + ) def _create_form_field( diff --git a/cms/auth_content/constants.py b/cms/auth_content/constants.py index 9c04bcf3d..0920faa1f 100644 --- a/cms/auth_content/constants.py +++ b/cms/auth_content/constants.py @@ -4,8 +4,6 @@ ) WILDCARD_ID_VALUE = "-1" -WILDCARD_NAME_VALUES = ["* (All themes)", "* (All sub-themes)", "* (All topics)"] - PERMISSION_SET_FIELDS = [ { "field_name": "theme", diff --git a/metrics/data/managers/core_models/geography.py b/metrics/data/managers/core_models/geography.py index 5ff2ce8cb..046721983 100644 --- a/metrics/data/managers/core_models/geography.py +++ b/metrics/data/managers/core_models/geography.py @@ -47,6 +47,19 @@ def get_name_by_code(self, geography_code: str) -> str | None: .first() ) + def get_id_by_name(self, geography_name: str) -> int: + """ + Gets the geography ID for a given geography name. + + Args: + geography_name: The name of the geography to look up + + Returns: + The geography ID if found, or -2 otherwise + """ + record = self.filter(name=geography_name).first() + return int(record.id) if record else -2 + def get_all_geography_codes_by_geography_type( self, geography_type_name: str ) -> Self: @@ -167,6 +180,23 @@ def get_name_by_code(self, geography_code: int) -> str | None: """ return self.get_queryset().get_name_by_code(geography_code) + def get_id_by_name(self, geography_name: str) -> int: + """Gets the geography ID which matches the given geography name. + + Args: + geography_name: The name of the geography to look up + + Returns: + The geography ID if found, -2 otherwise + + Examples: + >>> GeographyManager.get_id_by_name("England") + 6 + >>> GeographyManager.get_id_by_name("Unknown geography") + -2 + """ + return self.get_queryset().get_id_by_name(geography_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/geography_type.py b/metrics/data/managers/core_models/geography_type.py index ae45b36b7..df85ccd5b 100644 --- a/metrics/data/managers/core_models/geography_type.py +++ b/metrics/data/managers/core_models/geography_type.py @@ -41,6 +41,19 @@ def get_name_by_id(self, geography_type_id: int) -> str | None: """ return self.filter(id=geography_type_id).values_list("name", flat=True).first() + def get_id_by_name(self, geography_type_name: str) -> int: + """ + Gets the geography type ID for a given geography type name. + + Args: + geography_type_name: The name of the geography type to look up + + Returns: + The geography type ID if found, or -2 otherwise + """ + record = self.filter(name=geography_type_name).first() + return int(record.id) if record else -2 + def get_all_names_and_ids(self) -> models.QuerySet: """Gets all available geography_type names as a flat list queryset. @@ -77,6 +90,23 @@ def get_name_by_id(self, geography_type_id: int) -> str | None: """ return self.get_queryset().get_name_by_id(geography_type_id) + def get_id_by_name(self, geography_type_name: str) -> int: + """Gets the geography type ID which matches the given geography type name. + + Args: + geography_type_name: The name of the geography type to look up + + Returns: + The geography type ID if found, -2 otherwise + + Examples: + >>> GeographyTypeManager.get_id_by_name("Nation") + 5 + >>> GeographyTypeManager.get_id_by_name("Unknown type") + -2 + """ + return self.get_queryset().get_id_by_name(geography_type_name) + def get_all_names(self) -> GeographyTypeQuerySet: """Gets all available geography_type names as a flat list queryset. diff --git a/metrics/data/managers/core_models/metric.py b/metrics/data/managers/core_models/metric.py index e9fcb961d..66a3ec60c 100644 --- a/metrics/data/managers/core_models/metric.py +++ b/metrics/data/managers/core_models/metric.py @@ -29,6 +29,19 @@ def get_name_by_id(self, metric_id: int) -> str | None: """ return self.filter(id=metric_id).values_list("name", flat=True).first() + def get_id_by_name(self, metric_name: str) -> int: + """ + Gets the metric ID for a given metric name. + + Args: + metric_name: The name of the metric to look up + + Returns: + The metric ID if found, or -2 otherwise + """ + record = self.filter(name=metric_name).first() + return int(record.id) if record else -2 + def get_all_names(self) -> models.QuerySet: """Gets all available metric names as a flat list queryset. @@ -146,6 +159,23 @@ def get_name_by_id(self, metric_id: int) -> str | None: """ return self.get_queryset().get_name_by_id(metric_id) + def get_id_by_name(self, metric_name: str) -> int: + """Gets the metric ID which matches the given metric name. + + Args: + metric_name: The name of the metric to look up + + Returns: + The metric ID if found, -2 otherwise + + Examples: + >>> MetricManager.get_id_by_name("COVID-19_cases_countRollingMean") + 4 + >>> MetricManager.get_id_by_name("Unknown metric") + -2 + """ + return self.get_queryset().get_id_by_name(metric_name) + def get_all_names(self) -> MetricQuerySet: """Gets all available metric names as a flat list queryset. diff --git a/metrics/data/managers/core_models/time_series.py b/metrics/data/managers/core_models/time_series.py index 0f948b965..8612e6462 100644 --- a/metrics/data/managers/core_models/time_series.py +++ b/metrics/data/managers/core_models/time_series.py @@ -9,7 +9,6 @@ import logging from collections.abc import Iterable from typing import Self -from cms.auth_content.auth_utils import check_permissions_by_name from django.db import models from django.db.models.query_utils import Q from django.utils import timezone @@ -162,8 +161,6 @@ def filter_for_audit_list_view( def query_for_data( self, *, - theme: str, - sub_theme: str, topic: str, metric: str, date_from: datetime.date, @@ -175,6 +172,8 @@ def query_for_data( stratum: str | None = None, sex: str | None = None, age: str | None = None, + theme: str, + sub_theme: str, metric_value_ranges: list[tuple[str | float | int]] | None = None, permission_sets: dict, ) -> models.QuerySet: @@ -216,6 +215,12 @@ def query_for_data( Note that options are `M`, `F`, or `ALL`. age: The age range to apply additional filtering to. E.g. `0_4` would be used to capture the age of 0-4 years old + theme: The name of the theme being queried. + This is only used to determine permissions for + the non-public portion of the requested dataset. + sub_theme: The name of the sub theme being queried. + This is only used to determine permissions for + the non-public portion of the requested dataset. metric_value_ranges: List of tuples whereby each tuple represents a permissible metric value range. i.e. to filter for all record with values @@ -256,14 +261,17 @@ def query_for_data( if permission_sets: logger.info('Entered if permission_sets clause') + # TODO: Workaround cos circular import error when at the top of the file + from cms.auth_content.auth_utils import check_permissions_by_name + if check_permissions_by_name( permission_sets, theme, sub_theme, topic, - # metric, - # geography_type, - # geography, + metric, + geography_type, + geography, ): logger.info('Entered check_permissions_by_name() if clause') diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index 00b6f0632..fb654334f 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -40,6 +40,34 @@ 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, int, int]: + """ + Gets the theme, sub-theme and topic IDs matching the given names. + + Args: + theme_name: The name of the parent theme + sub_theme_name: The name of the parent sub-theme + topic_name: The name of the topic to look up + + Returns: + A tuple of (theme_id, sub_theme_id, topic_id) if found, + or the tuple (-2, -2, -2) otherwise + """ + record = ( + self.filter( + sub_theme__theme__name=theme_name, + sub_theme__name=sub_theme_name, + name=topic_name, + ).first() + ) + + if record: + return int(record.sub_theme.theme_id), int(record.sub_theme_id), int(record.id) + + return -2, -2, -2 + def get_all_unique_names(self) -> models.QuerySet: """Gets all available unique topic names as a flat list queryset. @@ -113,6 +141,28 @@ 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, int, int]: + """Gets the theme, sub-theme and topic IDs matching the given names. + + Args: + theme_name: The name of the parent theme + sub_theme_name: The name of the parent sub-theme + topic_name: The name of the topic to look up + + Returns: + A tuple of (theme_id, sub_theme_id, topic_id) if found, + or (-2, -2, -2) if not found. + + Examples: + >>> TopicManager.get_id_by_name("Infectious disease", "Respiratory", "COVID-19") + (1, 2, 3) + >>> TopicManager.get_id_by_name("Unknown", "Unknown", "Unknown") + (-2, -2, -2) + """ + return self.get_queryset().get_id_by_name(theme_name, sub_theme_name, topic_name) + def get_all_names(self) -> TopicQuerySet: """Gets all available topic names as a flat list queryset. diff --git a/metrics/domain/models/charts/common.py b/metrics/domain/models/charts/common.py index faaa30a0c..0ba1d3579 100644 --- a/metrics/domain/models/charts/common.py +++ b/metrics/domain/models/charts/common.py @@ -26,12 +26,16 @@ class BaseChartRequestParams(BaseModel): class Config: arbitrary_types_allowed = True - @property - def rbac_permissions(self) -> Iterable["RBACPermission"]: - return getattr(self.request, "rbac_permissions", []) - @property def permission_sets(self) -> dict: + """Extract JWT permissions from the authenticated request""" + logger.info(f'Entered BaseChartRequestParams.permission_sets') return getattr(self.request.user, "permission_sets", {}) + + @property + def rbac_permissions(self) -> Iterable["RBACPermission"]: + """TODO: RBAC-based permissions are legacy and will be removed in a future release""" + + return getattr(self.request, "rbac_permissions", []) diff --git a/metrics/interfaces/plots/access.py b/metrics/interfaces/plots/access.py index efad04938..28e8a5eb0 100644 --- a/metrics/interfaces/plots/access.py +++ b/metrics/interfaces/plots/access.py @@ -165,8 +165,8 @@ def get_queryset_from_core_model_manager( return self.core_model_manager.query_for_data( **plot_params, - rbac_permissions=self.chart_request_params.rbac_permissions, - permission_sets = self.chart_request_params.permission_sets, + rbac_permissions=self.chart_request_params.rbac_permissions, # TODO: Remove old permissions + permission_sets = self.chart_request_params.permission_sets, # new permissions ) def build_plot_data_from_parameters_with_complete_queryset( From bf2dac15cc27dfab8bfc0d9c863cd5af76832f1a Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Fri, 29 May 2026 10:27:18 +0100 Subject: [PATCH 183/186] CDD-3173: let cms/dashboard/viewsets.py from CDD-3171 use my fully equivalent check_permissions() function instead --- cms/dashboard/viewsets.py | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/cms/dashboard/viewsets.py b/cms/dashboard/viewsets.py index 89c41446f..89b2c1ddc 100644 --- a/cms/dashboard/viewsets.py +++ b/cms/dashboard/viewsets.py @@ -9,40 +9,12 @@ from wagtail.api.v2.views import PagesAPIViewSet from caching.private_api.decorators import cache_response -from cms.auth_content.constants import WILDCARD_ID_VALUE +from cms.auth_content.auth_utils import check_permissions from cms.dashboard.serializers import CMSDraftPagesSerializer, ListablePageSerializer from cms.metrics_documentation.models.child import MetricsDocumentationChildEntry from cms.topic.models import TopicPage -def check_permissions(user_permissions, theme_id, sub_theme_id, topic_id) -> bool: - if not isinstance(user_permissions, list): - return False - - for permission in user_permissions: - permission_theme_id = permission.get("theme", {}).get("id") - permission_sub_theme_id = permission.get("sub_theme", {}).get("id") - permission_topic_id = permission.get("topic", {}).get("id") - - if permission_theme_id == WILDCARD_ID_VALUE: - return True - - if ( - permission_theme_id == theme_id - and permission_sub_theme_id == WILDCARD_ID_VALUE - ): - return True - - if ( - permission_theme_id == theme_id - and permission_sub_theme_id == sub_theme_id - and (permission_topic_id in {WILDCARD_ID_VALUE, topic_id}) - ): - return True - - return False - - @extend_schema(tags=["cms"]) class CMSPagesAPIViewSet(PagesAPIViewSet): # This is the /pages (or proxy/pages env dependent endpoint) From f6dc30038066cf922a259f2f135b4fca6f8eb5f0 Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Fri, 29 May 2026 10:28:30 +0100 Subject: [PATCH 184/186] CDD-3173: add debugging code to user_manager.py to be able to test this JIRA ticket in isolation --- common/auth/cognito_jwt/user_manager.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/common/auth/cognito_jwt/user_manager.py b/common/auth/cognito_jwt/user_manager.py index 3a69470a4..0c4d2550d 100644 --- a/common/auth/cognito_jwt/user_manager.py +++ b/common/auth/cognito_jwt/user_manager.py @@ -17,6 +17,23 @@ def get_or_create_for_cognito(jwt_payload): try: username = jwt_payload["entraObjectId"] permission_sets = jwt_payload["permissionSets"] + + # DEBUGGING: Manual testing (just for now) + # username = "{YOUR_ENTRA_OBJECT_ID}" + # permission_sets = { + # "permission_sets": [ + # { + # "theme": {"id": "100", "name": "immunisation"}, + # "sub_theme": {"id": "133", "name": "childhood-vaccines"}, + # "topic": {"id": "-1", "name": "* (All)"}, + # "metric": {"id": "-1", "name": "* (All)"}, + # "geography_type": {"id": "-1", "name": "* (All)"}, + # "geography": {"id": "-1", "name": "* (All)"}, + # } + # ], + # "summary": {"has_global_access": False}, + # } + if not permission_sets: logger.debug( "Empty permissionSets in token for user: '%s'", From 6d65dde1e0033ef69ac0cdf844bd69cd4398a18d Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Fri, 29 May 2026 16:17:34 +0100 Subject: [PATCH 185/186] CDD-3173: evaluate metric- and geography-related permissions separately --- cms/auth_content/auth_utils.py | 258 +++++++++++++++++------- common/auth/cognito_jwt/user_manager.py | 6 +- 2 files changed, 183 insertions(+), 81 deletions(-) diff --git a/cms/auth_content/auth_utils.py b/cms/auth_content/auth_utils.py index c724e78a3..98e5a3fd4 100644 --- a/cms/auth_content/auth_utils.py +++ b/cms/auth_content/auth_utils.py @@ -8,81 +8,6 @@ logger = logging.getLogger(__name__) -def check_permissions( - permission_sets, - theme_id, - sub_theme_id, - topic_id, - metric_id=0, - geography_type=0, - geography_id=0, -) -> bool: - if not isinstance(permission_sets, list): - return False - - # TODO: Can we make them all ID at source? - theme_id = str(theme_id) - sub_theme_id = str(sub_theme_id) - topic_id = str(topic_id) - metric_id = str(metric_id) - geography_type = str(geography_type) - geography_id = str(geography_id) - - for permission in permission_sets: - permission_theme_id = permission.get("theme", {}).get("id") - permission_sub_theme_id = permission.get("sub_theme", {}).get("id") - permission_topic_id = permission.get("topic", {}).get("id") - permission_metric_id = permission.get("metric", {}).get("id") - permission_geography_type = permission.get("geography_type", {}).get("id") - permission_geography_id = permission.get("geography", {}).get("id") - - if permission_theme_id == WILDCARD_ID_VALUE: - return True - - if ( - permission_theme_id == theme_id - and permission_sub_theme_id == WILDCARD_ID_VALUE - ): - return True - - if ( - permission_theme_id == theme_id - and permission_sub_theme_id == sub_theme_id - and permission_topic_id in {WILDCARD_ID_VALUE, topic_id} - ): - return True - - if ( - permission_theme_id == theme_id - and permission_sub_theme_id == sub_theme_id - and permission_topic_id == topic_id - and permission_metric_id in {WILDCARD_ID_VALUE, metric_id} - ): - return True - - if ( - permission_theme_id == theme_id - and permission_sub_theme_id == sub_theme_id - and permission_topic_id == topic_id - and permission_metric_id == metric_id - and permission_geography_type in {WILDCARD_ID_VALUE, geography_type} - ): - return True - - if ( - permission_theme_id == theme_id - and permission_sub_theme_id == sub_theme_id - and permission_topic_id == topic_id - and permission_metric_id == metric_id - and permission_geography_type == geography_type - and permission_geography_id in {WILDCARD_ID_VALUE, geography_id} - ): - return True - - return False - - - def check_permissions_by_name( permission_sets, theme_name, @@ -92,6 +17,11 @@ def check_permissions_by_name( geography_type, geography_name, ) -> bool: + """ + This is a wrapper that converts permission resource names + into ids. It is only used to check CHART permissions. + """ + logger.info(f'Entered check_permissions_by_name()') theme_id, sub_theme_id, topic_id = Topic.objects.get_id_by_name(theme_name, sub_theme_name, topic_name) @@ -99,10 +29,10 @@ def check_permissions_by_name( geography_type_id = GeographyType.objects.get_id_by_name(geography_type) geography_id = Geography.objects.get_id_by_name(geography_name) - # In case a NAME doesn't have an ID + # Be safe, just in case a NAME doesn't have an ID if any( - value == -2 - for value in (theme_id, sub_theme_id, topic_id, metric_id, geography_type_id, geography_id) + value == -2 + for value in (theme_id, sub_theme_id, topic_id, metric_id, geography_type_id, geography_id) ): return False @@ -126,6 +56,27 @@ def check_permission_set( geography_type, geography_id, ) -> bool: + """ + This is a wrapper that only checks for global permissions, and + delegates further checks to our core permission checking function. + It is only used to check CHART permissions. + + @param {dict} permission_sets which contains a permission_sets list, eg: + { + "permission_sets": [ + { + "theme": {"id": "100", "name": "immunisation"}, + "sub_theme": {"id": "200", "name": "childhood-vaccines"}, + "topic": {"id": "-1", "name": "* (All)"}, + "metric": {"id": "-1", "name": "* (All)"}, + "geography_type": {"id": "300", "name": "Nation"}, + "geography": {"id": "-1", "name": "* (All)"}, + } + ], + "summary": {"has_global_access": False}, + } + """ + logger.info(f'Entered check_permission_set()') if not isinstance(permission_sets, dict): @@ -150,6 +101,157 @@ def check_permission_set( geography_id, ) +def check_permissions( + permission_sets, + theme_id, + sub_theme_id, + topic_id, + metric_id=0, + geography_type=0, + geography_id=0, +) -> bool: + """ + This is our core permission-checking function It is + used to check both PAGE & CHART permissions. + + Metric- and geography-related permissions must be + evaluated separately (spec says). + + @param {list} permission_sets, eg: + [ + { + "theme": {"id": "100", "name": "immunisation"}, + "sub_theme": {"id": "200", "name": "childhood-vaccines"}, + "topic": {"id": "-1", "name": "* (All)"}, + "metric": {"id": "-1", "name": "* (All)"}, + "geography_type": {"id": "300", "name": "Nation"}, + "geography": {"id": "-1", "name": "* (All)"}, + } + ] + """ + + logger.info(f'Entered check_permissions()') + + if not isinstance(permission_sets, list): + return False + + for permission_set in permission_sets: + if ( + check_metric_permissions(permission_set, theme_id, sub_theme_id, topic_id, metric_id) and + check_geography_permissions(permission_set, geography_type, geography_id) + ): + return True + + return False + + +def check_metric_permissions( + permission_set, + theme_id, + sub_theme_id, + topic_id, + metric_id=0, +) -> bool: + """ + Make sure that every theme, sub_theme, topic and metric + match or have a wildcard at the end (only look at the + first 4 attributes of permission_set). + + @param {dict} permission_set, eg: + { + "theme": {"id": "100", "name": "immunisation"}, + "sub_theme": {"id": "200", "name": "childhood-vaccines"}, + "topic": {"id": "-1", "name": "* (All)"}, + "metric": {"id": "-1", "name": "* (All)"}, + "geography_type": {"id": "300", "name": "Nation"}, + "geography": {"id": "-1", "name": "* (All)"}, + } + """ + + logger.info(f'Entered check_metric_permissions()') + + if not isinstance(permission_set, dict): + return False + + theme_id = str(theme_id) + sub_theme_id = str(sub_theme_id) + topic_id = str(topic_id) + metric_id = str(metric_id) + + permission_theme_id = str(permission_set.get("theme", {}).get("id")) + permission_sub_theme_id = str(permission_set.get("sub_theme", {}).get("id")) + permission_topic_id = str(permission_set.get("topic", {}).get("id")) + permission_metric_id = str(permission_set.get("metric", {}).get("id")) + + if permission_theme_id == WILDCARD_ID_VALUE: + return True + + if ( + permission_theme_id == theme_id + and permission_sub_theme_id == WILDCARD_ID_VALUE + ): + return True + + if ( + permission_theme_id == theme_id + and permission_sub_theme_id == sub_theme_id + and permission_topic_id in {WILDCARD_ID_VALUE, topic_id} + ): + return True + + if ( + permission_theme_id == theme_id + and permission_sub_theme_id == sub_theme_id + and permission_topic_id == topic_id + and permission_metric_id in {WILDCARD_ID_VALUE, metric_id} + ): + return True + + return False + + +def check_geography_permissions( + permission_set, + geography_type, + geography_id, +) -> bool: + """ + Make sure that both geography_type and geography + match or have a wildcard at the end (only look at the + first 2 attributes of permission_set). + + @param {dict} permission_set, eg: + { + "theme": {"id": "100", "name": "immunisation"}, + "sub_theme": {"id": "200", "name": "childhood-vaccines"}, + "topic": {"id": "-1", "name": "* (All)"}, + "metric": {"id": "-1", "name": "* (All)"}, + "geography_type": {"id": "300", "name": "Nation"}, + "geography": {"id": "-1", "name": "* (All)"}, + } + """ + + logger.info(f'Entered check_geography_permissions()') + + if not isinstance(permission_set, dict): + return False + + geography_type = str(geography_type) + geography_id = str(geography_id) + + permission_geography_type = str(permission_set.get("geography_type", {}).get("id")) + permission_geography_id = str(permission_set.get("geography", {}).get("id")) + + if permission_geography_type == WILDCARD_ID_VALUE: + return True + + if ( + permission_geography_type == geography_type + and permission_geography_id in {WILDCARD_ID_VALUE, geography_id} + ): + return True + + return False def _create_form_field( field: dict[str, str | Callable | None], wildcard_id_value=None diff --git a/common/auth/cognito_jwt/user_manager.py b/common/auth/cognito_jwt/user_manager.py index 0c4d2550d..a101168e0 100644 --- a/common/auth/cognito_jwt/user_manager.py +++ b/common/auth/cognito_jwt/user_manager.py @@ -24,11 +24,11 @@ def get_or_create_for_cognito(jwt_payload): # "permission_sets": [ # { # "theme": {"id": "100", "name": "immunisation"}, - # "sub_theme": {"id": "133", "name": "childhood-vaccines"}, + # "sub_theme": {"id": "200", "name": "childhood-vaccines"}, # "topic": {"id": "-1", "name": "* (All)"}, # "metric": {"id": "-1", "name": "* (All)"}, - # "geography_type": {"id": "-1", "name": "* (All)"}, - # "geography": {"id": "-1", "name": "* (All)"}, + # "geography_type": {"id": "300", "name": "Nation"}, + # "geography": {"id": "400", "name": "England"}, # } # ], # "summary": {"has_global_access": False}, From 2d3677e180980485a83cd7a5072d30a8382b8153 Mon Sep 17 00:00:00 2001 From: Dan Dammann Date: Fri, 29 May 2026 17:14:55 +0100 Subject: [PATCH 186/186] CDD-3173: lint --- cms/auth_content/auth_utils.py | 102 +++++++++++------- .../data/managers/core_models/time_series.py | 22 ++-- metrics/data/managers/core_models/topic.py | 28 +++-- metrics/domain/models/charts/common.py | 3 +- metrics/interfaces/plots/access.py | 6 +- 5 files changed, 96 insertions(+), 65 deletions(-) diff --git a/cms/auth_content/auth_utils.py b/cms/auth_content/auth_utils.py index 98e5a3fd4..cb54ffd23 100644 --- a/cms/auth_content/auth_utils.py +++ b/cms/auth_content/auth_utils.py @@ -1,13 +1,20 @@ import logging from collections.abc import Callable -from cms.auth_content.constants import WILDCARD_ID_VALUE from django import forms + +from cms.auth_content.constants import WILDCARD_ID_VALUE from cms.dynamic_content import help_texts -from metrics.data.models.core_models.supporting import Geography, GeographyType, Metric, Topic +from metrics.data.models.core_models.supporting import ( + Geography, + GeographyType, + Metric, + Topic, +) logger = logging.getLogger(__name__) + def check_permissions_by_name( permission_sets, theme_name, @@ -22,9 +29,11 @@ def check_permissions_by_name( into ids. It is only used to check CHART permissions. """ - logger.info(f'Entered check_permissions_by_name()') + logger.info("Entered check_permissions_by_name()") - theme_id, sub_theme_id, topic_id = Topic.objects.get_id_by_name(theme_name, sub_theme_name, topic_name) + theme_id, sub_theme_id, topic_id = Topic.objects.get_id_by_name( + theme_name, sub_theme_name, topic_name + ) metric_id = Metric.objects.get_id_by_name(metric_name) geography_type_id = GeographyType.objects.get_id_by_name(geography_type) geography_id = Geography.objects.get_id_by_name(geography_name) @@ -32,7 +41,14 @@ def check_permissions_by_name( # Be safe, just in case a NAME doesn't have an ID if any( value == -2 - for value in (theme_id, sub_theme_id, topic_id, metric_id, geography_type_id, geography_id) + for value in ( + theme_id, + sub_theme_id, + topic_id, + metric_id, + geography_type_id, + geography_id, + ) ): return False @@ -77,7 +93,7 @@ def check_permission_set( } """ - logger.info(f'Entered check_permission_set()') + logger.info("Entered check_permission_set()") if not isinstance(permission_sets, dict): return False @@ -90,25 +106,26 @@ def check_permission_set( if permission_sets.get("summary").get("has_global_access"): return True - else: - return check_permissions( - permission_sets.get("permission_sets"), - theme_id, - sub_theme_id, - topic_id, - metric_id, - geography_type, - geography_id, - ) + + return check_permissions( + permission_sets.get("permission_sets"), + theme_id, + sub_theme_id, + topic_id, + metric_id, + geography_type, + geography_id, + ) + def check_permissions( permission_sets, theme_id, sub_theme_id, topic_id, - metric_id=0, - geography_type=0, - geography_id=0, + metric_id=None, + geography_type=None, + geography_id=None, ) -> bool: """ This is our core permission-checking function It is @@ -130,27 +147,36 @@ def check_permissions( ] """ - logger.info(f'Entered check_permissions()') + logger.info("Entered check_permissions()") if not isinstance(permission_sets, list): return False for permission_set in permission_sets: - if ( - check_metric_permissions(permission_set, theme_id, sub_theme_id, topic_id, metric_id) and - check_geography_permissions(permission_set, geography_type, geography_id) - ): - return True + if geography_type and geography_id: + # CHART permissions + if check_metric_related_permissions( + permission_set, theme_id, sub_theme_id, topic_id, metric_id + ) and check_geography_permissions( + permission_set, geography_type, geography_id + ): + return True + else: + # PAGE permissions + if check_metric_related_permissions( + permission_set, theme_id, sub_theme_id, topic_id, metric_id + ): + return True return False -def check_metric_permissions( +def check_metric_related_permissions( permission_set, theme_id, sub_theme_id, topic_id, - metric_id=0, + metric_id=None, ) -> bool: """ Make sure that every theme, sub_theme, topic and metric @@ -168,7 +194,7 @@ def check_metric_permissions( } """ - logger.info(f'Entered check_metric_permissions()') + logger.info("Entered check_metric_related_permissions()") if not isinstance(permission_set, dict): return False @@ -186,10 +212,7 @@ def check_metric_permissions( if permission_theme_id == WILDCARD_ID_VALUE: return True - if ( - permission_theme_id == theme_id - and permission_sub_theme_id == WILDCARD_ID_VALUE - ): + if permission_theme_id == theme_id and permission_sub_theme_id == WILDCARD_ID_VALUE: return True if ( @@ -212,8 +235,8 @@ def check_metric_permissions( def check_geography_permissions( permission_set, - geography_type, - geography_id, + geography_type=None, + geography_id=None, ) -> bool: """ Make sure that both geography_type and geography @@ -231,7 +254,7 @@ def check_geography_permissions( } """ - logger.info(f'Entered check_geography_permissions()') + logger.info("Entered check_geography_permissions()") if not isinstance(permission_set, dict): return False @@ -245,14 +268,15 @@ def check_geography_permissions( if permission_geography_type == WILDCARD_ID_VALUE: return True - if ( - permission_geography_type == geography_type - and permission_geography_id in {WILDCARD_ID_VALUE, geography_id} - ): + if permission_geography_type == geography_type and permission_geography_id in { + WILDCARD_ID_VALUE, + geography_id, + }: return True return False + def _create_form_field( field: dict[str, str | Callable | None], wildcard_id_value=None ) -> forms.CharField: diff --git a/metrics/data/managers/core_models/time_series.py b/metrics/data/managers/core_models/time_series.py index 8612e6462..5b63aaca2 100644 --- a/metrics/data/managers/core_models/time_series.py +++ b/metrics/data/managers/core_models/time_series.py @@ -9,13 +9,13 @@ import logging from collections.abc import Iterable from typing import Self + from django.db import models from django.db.models.query_utils import Q from django.utils import timezone from metrics.api.permissions.fluent_permissions import ( is_public_data_only_enforced, - validate_permissions_for_non_public, ) from metrics.data.models import RBACPermission @@ -23,6 +23,7 @@ logger = logging.getLogger(__name__) + class CoreTimeSeriesQuerySet(models.QuerySet): """Custom queryset which can be used by the `CoreTimeSeriesManager`""" @@ -238,7 +239,8 @@ def query_for_data( ]>` """ - logger.info('Entered query_for_data()') + + logger.info("Entered query_for_data()") queryset = self.filter( metric__topic__name=topic, @@ -254,14 +256,12 @@ def query_for_data( sex=sex, age=age, ) - public_queryset = queryset.filter( - is_public=True - ) + public_queryset = queryset.filter(is_public=True) if permission_sets: - logger.info('Entered if permission_sets clause') + logger.info("Entered if permission_sets clause") - # TODO: Workaround cos circular import error when at the top of the file + # WORKAROUND: Cos circular import error when at the top of the file from cms.auth_content.auth_utils import check_permissions_by_name if check_permissions_by_name( @@ -273,13 +273,11 @@ def query_for_data( geography_type, geography, ): - logger.info('Entered check_permissions_by_name() if clause') + logger.info("Entered check_permissions_by_name() if clause") - queryset = public_queryset + queryset.filter( - is_public=False - ) + queryset = public_queryset + queryset.filter(is_public=False) else: - logger.info('Entered else permission_sets clause') + logger.info("Entered else permission_sets clause") queryset = public_queryset diff --git a/metrics/data/managers/core_models/topic.py b/metrics/data/managers/core_models/topic.py index fb654334f..cd67e9f1a 100644 --- a/metrics/data/managers/core_models/topic.py +++ b/metrics/data/managers/core_models/topic.py @@ -55,18 +55,24 @@ def get_id_by_name( A tuple of (theme_id, sub_theme_id, topic_id) if found, or the tuple (-2, -2, -2) otherwise """ - record = ( - self.filter( - sub_theme__theme__name=theme_name, - sub_theme__name=sub_theme_name, - name=topic_name, - ).first() - ) + record = self.filter( + sub_theme__theme__name=theme_name, + sub_theme__name=sub_theme_name, + name=topic_name, + ).first() if record: - return int(record.sub_theme.theme_id), int(record.sub_theme_id), int(record.id) + return ( + int(record.sub_theme.theme_id), + int(record.sub_theme_id), + int(record.id), + ) - return -2, -2, -2 + return ( + -2, + -2, + -2, + ) def get_all_unique_names(self) -> models.QuerySet: """Gets all available unique topic names as a flat list queryset. @@ -161,7 +167,9 @@ def get_id_by_name( >>> TopicManager.get_id_by_name("Unknown", "Unknown", "Unknown") (-2, -2, -2) """ - return self.get_queryset().get_id_by_name(theme_name, sub_theme_name, topic_name) + return self.get_queryset().get_id_by_name( + theme_name, sub_theme_name, topic_name + ) def get_all_names(self) -> TopicQuerySet: """Gets all available topic names as a flat list queryset. diff --git a/metrics/domain/models/charts/common.py b/metrics/domain/models/charts/common.py index 0ba1d3579..7608749d3 100644 --- a/metrics/domain/models/charts/common.py +++ b/metrics/domain/models/charts/common.py @@ -8,6 +8,7 @@ logger = logging.getLogger(__name__) + class BaseChartRequestParams(BaseModel): file_format: Literal["png", "svg", "jpg", "jpeg", "json", "csv"] chart_width: int @@ -30,7 +31,7 @@ class Config: def permission_sets(self) -> dict: """Extract JWT permissions from the authenticated request""" - logger.info(f'Entered BaseChartRequestParams.permission_sets') + logger.info("Entered BaseChartRequestParams.permission_sets") return getattr(self.request.user, "permission_sets", {}) diff --git a/metrics/interfaces/plots/access.py b/metrics/interfaces/plots/access.py index 28e8a5eb0..e8bcb0406 100644 --- a/metrics/interfaces/plots/access.py +++ b/metrics/interfaces/plots/access.py @@ -161,12 +161,12 @@ def get_queryset_from_core_model_manager( plot_params["fields_to_export"].append("upper_confidence") plot_params["fields_to_export"].append("lower_confidence") - logger.info('Entered access.py') + logger.info("Entered access.py") return self.core_model_manager.query_for_data( **plot_params, - rbac_permissions=self.chart_request_params.rbac_permissions, # TODO: Remove old permissions - permission_sets = self.chart_request_params.permission_sets, # new permissions + rbac_permissions=self.chart_request_params.rbac_permissions, # old permissions (remove) + permission_sets=self.chart_request_params.permission_sets, # new permissions ) def build_plot_data_from_parameters_with_complete_queryset(