diff --git a/cms/dashboard/management/commands/build_cms_site.py b/cms/dashboard/management/commands/build_cms_site.py index e7311509c..cd977d555 100644 --- a/cms/dashboard/management/commands/build_cms_site.py +++ b/cms/dashboard/management/commands/build_cms_site.py @@ -76,6 +76,11 @@ def handle(self, *args, **options): build_cms_site_helpers.create_feedback_page( name="feedback", parent_page=root_page ) + + build_cms_site_helpers.create_authentication_error_page( + name="auth_error_page", parent_page=root_page + ) + build_cms_site_helpers.create_menu_snippet() @classmethod @@ -140,8 +145,10 @@ def _build_respiratory_viruses_section(cls, root_page: UKHSARootPage) -> None: other_respiratory_viruses_page.move( target=respiratory_viruses_index_page, pos="last-child" ) - influenza_page.move(target=respiratory_viruses_index_page, pos="last-child") - covid_19_page.move(target=respiratory_viruses_index_page, pos="last-child") + influenza_page.move( + target=respiratory_viruses_index_page, pos="last-child") + covid_19_page.move( + target=respiratory_viruses_index_page, pos="last-child") @classmethod def _build_cover_section(cls, root_page: UKHSARootPage) -> None: @@ -155,7 +162,8 @@ def _build_cover_section(cls, root_page: UKHSARootPage) -> None: name="childhood_vaccinations_index", parent_page=root_page ) - childhood_vaccinations_page.move(target=cover_index_page, pos="last-child") + childhood_vaccinations_page.move( + target=cover_index_page, pos="last-child") @classmethod def _build_common_pages(cls, root_page: UKHSARootPage) -> None: @@ -167,7 +175,8 @@ def _build_common_pages(cls, root_page: UKHSARootPage) -> None: build_cms_site_helpers.create_common_page( name="whats_coming", parent_page=root_page ) - build_cms_site_helpers.create_common_page(name="cookies", parent_page=root_page) + build_cms_site_helpers.create_common_page( + name="cookies", parent_page=root_page) build_cms_site_helpers.create_common_page( name="accessibility_statement", parent_page=root_page ) @@ -180,4 +189,5 @@ def _clear_cms() -> None: # Wipe the existing site, pages & badges Site.objects.all().delete() Badge.objects.all().delete() - Page.objects.filter(pk__gte=2).delete() # Wagtail welcome page and all others + # Wagtail welcome page and all others + Page.objects.filter(pk__gte=2).delete() 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 41dd23926..5f7b4dccc 100644 --- a/cms/dashboard/management/commands/build_cms_site_helpers/__init__.py +++ b/cms/dashboard/management/commands/build_cms_site_helpers/__init__.py @@ -10,5 +10,6 @@ create_whats_new_parent_page, create_whats_new_child_entry, create_feedback_page, + create_authentication_error_page, ) from .menu import create_menu_snippet diff --git a/cms/dashboard/management/commands/build_cms_site_helpers/pages.py b/cms/dashboard/management/commands/build_cms_site_helpers/pages.py index 1888ffa55..bba598431 100644 --- a/cms/dashboard/management/commands/build_cms_site_helpers/pages.py +++ b/cms/dashboard/management/commands/build_cms_site_helpers/pages.py @@ -14,6 +14,7 @@ from cms.dashboard.management.commands.build_cms_site_helpers.landing_page import ( create_landing_page_body_wih_page_links, ) +from cms.error.models import ErrorPage, ErrorPageRelatedLink from cms.forms.models import FormField, FormPage from cms.home.models import LandingPage from cms.home.models.landing_page import LandingPageRelatedLink @@ -189,6 +190,30 @@ def create_common_page(*, name: str, parent_page: Page) -> CommonPage: return page +def create_authentication_error_page(*, name: str, parent_page: Page) -> ErrorPage: + data = open_example_page_response(page_name=name) + + page = ErrorPage( + body=data["body"], + error_line=data["error_line"], + error_text=data["error_text"], + sub_text=data["sub_text"], + title=data["title"], + slug=data["meta"]["slug"], + seo_title=data["meta"]["seo_title"], + search_description=data["meta"]["search_description"], + ) + _add_page_to_parent(page=page, parent_page=parent_page) + + _create_related_links( + related_link_class=ErrorPageRelatedLink, + response_data=data, + page=page, + ) + + return page + + def _remove_comment_from_body(*, body: dict[list[dict]]) -> list[dict]: return [item for item in body if "_comment" not in item] diff --git a/cms/dashboard/templates/cms_starting_pages/auth_error_page.json b/cms/dashboard/templates/cms_starting_pages/auth_error_page.json new file mode 100644 index 000000000..f020bcce0 --- /dev/null +++ b/cms/dashboard/templates/cms_starting_pages/auth_error_page.json @@ -0,0 +1,72 @@ +{ + "id": 243, + "meta": { + "seo_title": "Authentication Error", + "search_description": "", + "type": "error.ErrorPage", + "detail_url": "https://localhost/api/pages/243/", + "html_url": "https://localhost/authentication-error/", + "slug": "authentication-error", + "show_in_menus": false, + "first_published_at": "2026-03-12T11:37:14.391237Z", + "alias_of": null, + "parent": { + "id": 163, + "meta": { + "type": "home.UKHSARootPage", + "detail_url": "https://localhost/api/pages/163/", + "html_url": null + }, + "title": "UKHSA Dashboard Root" + } + }, + "title": "Failed to sign in", + "body": null, + "seo_change_frequency": 5, + "seo_priority": "0.1", + "last_updated_at": "2026-03-12T14:35:25.243922Z", + "last_published_at": "2026-03-12T14:35:25.243922Z", + "active_announcements": [], + "error_line": "An error occurred that meant we were unable to authenticate you.", + "error_text": "

Reason sign in may have failed:

", + "sub_text": "

If you think you have the required authorisation to access the dashboard, please try again and make sure your internet connection is stable. If the problem continues, contact support.

", + "related_links_layout": "Sidebar", + "related_links": [ + { + "id": 1, + "meta": { + "type": "error.ErrorPageRelatedLink" + }, + "title": "Link 1", + "url": "https://www.google.com", + "body": "

This is a link

" + }, + { + "id": 2, + "meta": { + "type": "error.ErrorPageRelatedLink" + }, + "title": "Link 2", + "url": "https://www.google.com", + "body": "

sfsfsdf

" + }, + { + "id": 3, + "meta": { + "type": "error.ErrorPageRelatedLink" + }, + "title": "Link 3", + "url": "https://www.google.com", + "body": "

sdfsdfsdf

" + }, + { + "id": 4, + "meta": { + "type": "error.ErrorPageRelatedLink" + }, + "title": "Link 4", + "url": "https://www.google.com", + "body": "

sfsdfsdf

" + } + ] +} diff --git a/cms/dynamic_content/help_texts.py b/cms/dynamic_content/help_texts.py index 351e88efd..36628f0dd 100644 --- a/cms/dynamic_content/help_texts.py +++ b/cms/dynamic_content/help_texts.py @@ -585,6 +585,18 @@ For linking to external url. (Only one of page or external_url must be filled not both). """ +ERROR_PAGE_LINE_FIELD: str = """ +This is the error summary text that will be displayed in red at the top of the error view. +""" + +ERROR_PAGE_TEXT_FIELD: str = """ +This is a rich text field that can be used to display detailed information about the error that the user is experiencing +""" + +ERROR_PAGE_SUB_TEXT_FIELD: str = """ +This is a rich text field that can be used to display further information to the user. This text will appear below the error at the top of the page. +""" + PAGE_CLASSIFICATION: str = """ The classification level of all data on this page (only applies to non-public pages). Defaults to `Official-Sensitive`. """ diff --git a/cms/error/__init__.py b/cms/error/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cms/error/apps.py b/cms/error/apps.py new file mode 100644 index 000000000..b9bfcd745 --- /dev/null +++ b/cms/error/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ErrorConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "cms.error" diff --git a/cms/error/managers.py b/cms/error/managers.py new file mode 100644 index 000000000..bb9543f1f --- /dev/null +++ b/cms/error/managers.py @@ -0,0 +1,41 @@ +""" +This file contains the custom QuerySet and Manager classes associated with the `ErrorPage` model. + +Note that the application layer should only call into the `Manager` class. +The application should not interact directly with the `QuerySet` class. +""" + +from django.db import models +from wagtail.models import PageManager +from wagtail.query import PageQuerySet + + +class ErrorPageQuerySet(PageQuerySet): + """Custom queryset which can be used by the `ErrorPageManager`""" + + def get_live_pages(self) -> models.QuerySet: + """Gets the all currently live pages. + + Returns: + QuerySet: A queryset of the live pages: + Examples: + `, , ...]>` + """ + return self.filter(live=True) + + +class ErrorPageManager(PageManager): + """Custom model manager class for the `ErrorPage` model.""" + + def get_queryset(self) -> ErrorPageQuerySet: + return ErrorPageQuerySet(model=self.model, using=self.db) + + def get_live_pages(self) -> models.QuerySet: + """Gets the all currently live pages. + + Returns: + QuerySet: A queryset of the live pages: + Examples: + `, , ...]>` + """ + return self.get_queryset().get_live_pages() diff --git a/cms/error/migrations/0001_initial.py b/cms/error/migrations/0001_initial.py new file mode 100644 index 000000000..e6c356adf --- /dev/null +++ b/cms/error/migrations/0001_initial.py @@ -0,0 +1,197 @@ +# Generated by Django 5.2.12 on 2026-04-01 15:09 + +import django.core.validators +import django.db.models.deletion +import modelcluster.fields +import wagtail.fields +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("wagtailcore", "0096_referenceindex_referenceindex_source_object_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ErrorPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ( + "seo_change_frequency", + models.IntegerField( + choices=[ + (1, "Always"), + (2, "Hourly"), + (3, "Daily"), + (4, "Weekly"), + (5, "Monthly"), + (6, "Yearly"), + (7, "Never"), + ], + default=5, + help_text="

This value tells search engines how often a page’s content updates, offering a hint for crawling prioritization.

\n

Always: This means the page is constantly changing with important, up-to-the-minute updates. \nA subreddit index page, a stock market data page, and the index page of a major news site might use this tag.

\n

Hourly: The page is updated on an hourly basis or thereabouts.\n Major news sites, weather sites, and active web forums might use this tag.

\n

Daily: The page is updated with new content on average once a day. \nSmall web forums, classified ad pages, daily newspapers, and daily blogs might use this tag for their homepage.

\n

Weekly: The page is updated around once a week with new content. \nProduct info pages with daily pricing information, small blogs, and website directories use this tag.

\n

Monthly: The page is updated around once a month; maybe more, maybe less. \nCategory pages, evergreen guides with updated information, and FAQs often use this tag.

\n

Yearly: The page is rarely updated but may receive updates once or twice a year. \nMany static pages, such as registration pages, About pages, and privacy policies, fall into this category.

\n

Never: The page is never going to be updated. \nOld blog entries, old news stories, and completely static pages fall into this category.

", + verbose_name="SEO change frequency", + ), + ), + ( + "seo_priority", + models.DecimalField( + decimal_places=1, + default=0.5, + help_text="\nThis value signals the importance of a page to search engines. \nAssigning accurate priority values to key pages of your site can help search engines understand \nthe structure and hierarchy of your content.\nThis must be a number between 0.1 - 1.0.\n", + max_digits=2, + validators=[ + django.core.validators.MaxValueValidator(Decimal("1.0")), + django.core.validators.MinValueValidator(Decimal("0.1")), + ], + verbose_name="SEO priority", + ), + ), + ("body", models.TextField(blank=True, null=True)), + ( + "error_line", + models.TextField( + help_text="\nThis is the error summary text that will be displayed in red at the top of the error view.\n" + ), + ), + ( + "error_text", + wagtail.fields.RichTextField( + help_text="\nThis is a rich text field that can be used to display detailed information about the error that the user is experiencing\n" + ), + ), + ( + "sub_text", + wagtail.fields.RichTextField( + help_text="\nThis is a rich text field that can be used to display further information to the user. This text will appear below the error at the top of the page.\n" + ), + ), + ( + "related_links_layout", + models.CharField( + choices=[("Sidebar", "Sidebar"), ("Footer", "Footer")], + default="Footer", + help_text="\nThis dictates where the related links for this page will be positioned.\n", + max_length=10, + verbose_name="Layout", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("wagtailcore.page",), + ), + migrations.CreateModel( + name="ErrorPageAnnouncement", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "sort_order", + models.IntegerField(blank=True, editable=False, null=True), + ), + ( + "title", + models.CharField( + help_text="\nThe title to associate with the announcement. This must be provided.\n", + max_length=255, + ), + ), + ( + "body", + wagtail.fields.RichTextField( + help_text="\nA body of text to be displayed by the announcement. There is a limit of 255 characters for this field.\n", + max_length=255, + ), + ), + ( + "banner_type", + models.CharField( + choices=[ + ("Information", "Information"), + ("Warning", "Warning"), + ], + default="Information", + help_text="\nThe type to associate with the announcement. Defaults to `Information`.\n", + max_length=50, + ), + ), + ( + "is_active", + models.BooleanField( + default=False, + help_text="\nWhether to activate this banner only on this individual page. \nNote that multiple page banners can be active on one page. Consider \ncarefully if you need multiple announcements to be active at once as\nthis can have an impact on user experience of the dashboard page.\n", + ), + ), + ( + "page", + modelcluster.fields.ParentalKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="announcements", + to="error.errorpage", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="ErrorPageRelatedLink", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "sort_order", + models.IntegerField(blank=True, editable=False, null=True), + ), + ("title", models.CharField(max_length=255)), + ("url", models.URLField(max_length=400, verbose_name="URL")), + ("body", wagtail.fields.RichTextField()), + ( + "page", + modelcluster.fields.ParentalKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="related_links", + to="error.errorpage", + ), + ), + ], + options={ + "ordering": ["sort_order"], + "abstract": False, + }, + ), + ] diff --git a/cms/error/migrations/__init__.py b/cms/error/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cms/error/models.py b/cms/error/models.py new file mode 100644 index 000000000..fee897ba0 --- /dev/null +++ b/cms/error/models.py @@ -0,0 +1,102 @@ +from django.db import models +from modelcluster.fields import ParentalKey +from wagtail.admin.panels import FieldPanel, InlinePanel, ObjectList, TabbedInterface +from wagtail.api import APIField +from wagtail.fields import RichTextField +from wagtail.search import index + +from cms.dashboard.enums import ( + DEFAULT_RELATED_LINKS_LAYOUT_FIELD_LENGTH, + RelatedLinksLayoutEnum, +) +from cms.dashboard.models import ( + AVAILABLE_RICH_TEXT_FEATURES, + UKHSAPage, + UKHSAPageRelatedLink, +) +from cms.dynamic_content import help_texts +from cms.dynamic_content.announcements import Announcement +from cms.error.managers import ErrorPageManager + + +class ErrorPage(UKHSAPage): + body = models.TextField(null=True, blank=True) + error_line = models.TextField( + help_text=help_texts.ERROR_PAGE_LINE_FIELD, blank=False, null=False + ) + error_text = RichTextField( + features=AVAILABLE_RICH_TEXT_FEATURES, + blank=False, + null=False, + help_text=help_texts.ERROR_PAGE_TEXT_FIELD, + ) + sub_text = RichTextField( + features=AVAILABLE_RICH_TEXT_FEATURES, + blank=False, + null=False, + help_text=help_texts.ERROR_PAGE_SUB_TEXT_FIELD, + ) + related_links_layout = models.CharField( + verbose_name="Layout", + help_text=help_texts.RELATED_LINKS_LAYOUT_FIELD, + default=RelatedLinksLayoutEnum.Footer.value, + max_length=DEFAULT_RELATED_LINKS_LAYOUT_FIELD_LENGTH, + choices=RelatedLinksLayoutEnum.choices(), + ) + + search_fields = UKHSAPage.search_fields + [ + index.SearchField("body"), + ] + + content_panels = UKHSAPage.content_panels + [ + FieldPanel("error_line"), + FieldPanel("error_text"), + FieldPanel("sub_text"), + ] + + sidebar_content_panels = [ + FieldPanel("related_links_layout"), + InlinePanel("related_links", heading="Related links", label="Related link"), + ] + + # Sets which fields to expose on the API + api_fields = UKHSAPage.api_fields + [ + APIField("error_line"), + APIField("error_text"), + APIField("sub_text"), + APIField("related_links_layout"), + APIField("related_links"), + APIField("search_description"), + ] + + # Tabs to position at the top of the view + edit_handler = TabbedInterface( + [ + ObjectList(content_panels, heading="Content"), + ObjectList(sidebar_content_panels, heading="Related Links"), + ObjectList(UKHSAPage.announcement_content_panels, heading="Announcements"), + ObjectList(UKHSAPage.promote_panels, heading="Promote"), + ] + ) + + objects = ErrorPageManager() + + @classmethod + def is_previewable(cls) -> bool: + """Returns False. Since this is a headless CMS the preview panel is not supported""" + return False + + +class ErrorPageRelatedLink(UKHSAPageRelatedLink): + page = ParentalKey( + ErrorPage, on_delete=models.SET_NULL, null=True, related_name="related_links" + ) + + +class ErrorPageAnnouncement(Announcement): + page = ParentalKey( + ErrorPage, + on_delete=models.SET_NULL, + null=True, + related_name="announcements", + ) diff --git a/metrics/api/settings/default.py b/metrics/api/settings/default.py index 519b1b4e3..00d7da671 100644 --- a/metrics/api/settings/default.py +++ b/metrics/api/settings/default.py @@ -61,6 +61,7 @@ "cms.metrics_documentation", "cms.snippets", "cms.forms", + "cms.error", "wagtail.api.v2", "wagtail.contrib.forms", "wagtail.contrib.redirects", diff --git a/tests/fakes/factories/cms/error_page_factory.py b/tests/fakes/factories/cms/error_page_factory.py new file mode 100644 index 000000000..abada21b7 --- /dev/null +++ b/tests/fakes/factories/cms/error_page_factory.py @@ -0,0 +1,16 @@ +import factory + +from tests.fakes.models.cms.error import FakeErrorPage + + +class FakeErrorPageFactory(factory.Factory): + """ + Factory for creating `FakeErrorPage` instances for tests + """ + + class Meta: + model = FakeErrorPage + + @classmethod + def build_blank_page(cls, **kwargs): + return cls.build(**kwargs) diff --git a/tests/fakes/models/cms/error.py b/tests/fakes/models/cms/error.py new file mode 100644 index 000000000..4d049560a --- /dev/null +++ b/tests/fakes/models/cms/error.py @@ -0,0 +1,17 @@ +from cms.error.models import ErrorPage +from tests.fakes.models.fake_model_meta import FakeMeta + + +class FakeErrorPage(ErrorPage): + """ + A fake version of the Django model `ErrorPage` + which has had its dependencies altered so that it does not interact with the database + """ + + Meta = FakeMeta + + def __init__(self, **kwargs): + """ + Constructor takes the same arguments as a normal `ErrorPage` model. + """ + super().__init__(content_type_id=1, **kwargs) diff --git a/tests/integration/cms/error/__init__.py b/tests/integration/cms/error/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/cms/error/test_managers.py b/tests/integration/cms/error/test_managers.py new file mode 100644 index 000000000..ba4c89dad --- /dev/null +++ b/tests/integration/cms/error/test_managers.py @@ -0,0 +1,43 @@ +import pytest + +from cms.error.models import ErrorPage + + +class TestErrorPageManager: + @pytest.mark.django_db + def test_get_live_pages(self): + """ + Given 2 `ErrorPage` records of which only 1 is live + When `get_live_pages()` is called from the `ErrorPageManager` + Then the correct `ErrorPage` record is returned + """ + # Given + live_page = ErrorPage.objects.create( + path="abc", + depth=1, + title="abc", + body="", + error_line="This is an error line", + sub_text="This is some subtext that would be displayed on the page", + error_text="This is some error text", + live=True, + seo_title="ABC", + ) + unpublished_page = ErrorPage.objects.create( + path="def", + depth=1, + title="def", + body="", + error_line="This is an error line", + sub_text="This is some subtext that would be displayed on the page", + error_text="This is some error text", + live=False, + seo_title="DEF", + ) + + # When + retrieved_live_pages = ErrorPage.objects.get_live_pages() + + # Then + assert live_page in retrieved_live_pages + assert unpublished_page not in retrieved_live_pages diff --git a/tests/unit/cms/error/__init__.py b/tests/unit/cms/error/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/cms/error/test_models.py b/tests/unit/cms/error/test_models.py new file mode 100644 index 000000000..eee456e72 --- /dev/null +++ b/tests/unit/cms/error/test_models.py @@ -0,0 +1,92 @@ +import pytest +from wagtail.admin.panels.field_panel import FieldPanel +from wagtail.api.conf import APIField + +from tests.fakes.factories.cms.error_page_factory import FakeErrorPageFactory + + +class TestBlankErrorPage: + + @pytest.mark.parametrize( + "expected_api_field_name", + [ + "body", + "last_published_at", + "last_updated_at", + "related_links_layout", + "related_links", + "seo_title", + "search_description", + "error_text", + "error_line", + "sub_text", + ], + ) + def test_has_correct_api_fields(self, expected_api_field_name: str): + """ + Given a blank `ErrorPage` model + When `api_fields` is called + Then the expected names are on the returned `APIField` objects + """ + # Given + blank_page = FakeErrorPageFactory.build_blank_page() + + # When + api_fields: list[APIField] = blank_page.api_fields + + # Then + api_field_names: set[str] = {api_field.name for api_field in api_fields} + assert expected_api_field_name in api_field_names + + @pytest.mark.parametrize( + "expected_content_panel_name", + ["title", "error_text", "error_line", "sub_text"], + ) + def test_has_correct_content_panels(self, expected_content_panel_name: str): + """ + Given a blank `ErrorPage` model + When the expected content panel name is called + Then the panel value can be accessed from the page model + """ + # Given + blank_page = FakeErrorPageFactory.build_blank_page() + + # When / Then + assert hasattr(blank_page, expected_content_panel_name) + + def test_has_correct_sidebar_panels(self): + """ + Given a blank `ErrorPage` model + When `sidebar_content_panels` is called + Then the expected names are on the returned `InlinePanel` objects + """ + # Given + blank_page = FakeErrorPageFactory.build_blank_page() + + # When + sidebar_content_panels = blank_page.sidebar_content_panels + + # Then + expected_sidebar_content_panel_names: set[str] = { + "related_links", + "related_links_layout", + } + sidebar_content_panel_names: set[str] = { + p.clean_name for p in sidebar_content_panels + } + assert sidebar_content_panel_names == expected_sidebar_content_panel_names + + def test_is_previewable_returns_false(self): + """ + Given a blank `ErrorPage` model + When `is_previewable()` is called + Then False is returned + """ + # Given + blank_page = FakeErrorPageFactory.build_blank_page() + + # When + page_is_previewable: bool = blank_page.is_previewable() + + # Then + assert not page_is_previewable