diff --git a/cms/dashboard/management/commands/build_cms_site.py b/cms/dashboard/management/commands/build_cms_site.py index 890dd29a4..f16a2144a 100644 --- a/cms/dashboard/management/commands/build_cms_site.py +++ b/cms/dashboard/management/commands/build_cms_site.py @@ -77,6 +77,7 @@ def handle(self, *args, **options): name="feedback", parent_page=root_page ) 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 41dd23926..351a95eb4 100644 --- a/cms/dashboard/management/commands/build_cms_site_helpers/__init__.py +++ b/cms/dashboard/management/commands/build_cms_site_helpers/__init__.py @@ -11,4 +11,4 @@ create_whats_new_child_entry, create_feedback_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 4b76f90e9..58f8042f2 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 ( @@ -130,6 +130,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