diff --git a/cms/core/blocks/base.py b/cms/core/blocks/base.py new file mode 100644 index 00000000..4e3ce4ad --- /dev/null +++ b/cms/core/blocks/base.py @@ -0,0 +1,82 @@ +from django.core.exceptions import ValidationError +from django.forms.utils import ErrorList +from django.utils.functional import cached_property +from django.utils.translation import gettext as _ +from wagtail.blocks import ( + CharBlock, + PageChooserBlock, + StreamBlockValidationError, + StructBlock, + StructValue, + URLBlock, +) + + +class LinkBlockStructValue(StructValue): + """Custom StructValue for link blocks.""" + + @cached_property + def link(self) -> dict | None: + """A convenience property that returns the block value in a consistent way, + regardless of the chosen values (be it a Wagtail page or external link). + """ + value = None + title = self.get("title") + desc = self.get("description") + has_description = "description" in self + + if external_url := self.get("external_url"): + value = {"url": external_url, "text": title} + if has_description: + value["description"] = desc + + if (page := self.get("page")) and page.live: + value = {"url": page.url, "text": title or page.title} + if has_description: + value["description"] = desc or getattr(page.specific_deferred, "summary", "") + + return value + + +class LinkBlock(StructBlock): + """Related link block with page or link validation.""" + + page = PageChooserBlock(required=False) + external_url = URLBlock(required=False, label="or External Link") + title = CharBlock( + help_text="Populate when adding an external link. " + "When choosing a page, you can leave it blank to use the page's own title", + required=False, + ) + + class Meta: + icon = "link" + value_class = LinkBlockStructValue + + def clean(self, value: LinkBlockStructValue) -> LinkBlockStructValue: + """Validate that either a page or external link is provided, and that external links have a title.""" + value = super().clean(value) + page = value["page"] + external_url = value["external_url"] + errors = {} + non_block_errors = ErrorList() + + # Require exactly one link + if not page and not external_url: + error = ValidationError(_("Either Page or External Link is required."), code="invalid") + errors["page"] = ErrorList([error]) + errors["external_url"] = ErrorList([error]) + non_block_errors.append(ValidationError(_("Missing required fields"))) + elif page and external_url: + error = ValidationError(_("Please select either a page or a URL, not both."), code="invalid") + errors["page"] = ErrorList([error]) + errors["external_url"] = ErrorList([error]) + + # Require title for external links + if not page and external_url and not value["title"]: + errors["title"] = ErrorList([ValidationError(_("Title is required for external links."), code="invalid")]) + + if errors: + raise StreamBlockValidationError(block_errors=errors, non_block_errors=non_block_errors) + + return value diff --git a/cms/core/blocks/related.py b/cms/core/blocks/related.py index 59325592..3036e512 100644 --- a/cms/core/blocks/related.py +++ b/cms/core/blocks/related.py @@ -1,94 +1,15 @@ from typing import TYPE_CHECKING, Any -from django.core.exceptions import ValidationError -from django.forms.utils import ErrorList -from django.utils.functional import cached_property from django.utils.text import slugify from django.utils.translation import gettext as _ -from wagtail.blocks import ( - CharBlock, - ListBlock, - PageChooserBlock, - StreamBlockValidationError, - StructBlock, - StructValue, - URLBlock, -) +from wagtail.blocks import CharBlock, ListBlock + +from .base import LinkBlock if TYPE_CHECKING: from wagtail.blocks.list_block import ListValue -class LinkBlockStructValue(StructValue): - """Custom StructValue for link blocks.""" - - @cached_property - def link(self) -> dict | None: - """A convenience property that returns the block value in a consistent way, - regardless of the chosen values (be it a Wagtail page or external link). - """ - value = None - title = self.get("title") - desc = self.get("description") - has_description = "description" in self - - if external_url := self.get("external_url"): - value = {"url": external_url, "text": title} - if has_description: - value["description"] = desc - - if (page := self.get("page")) and page.live: - value = {"url": page.url, "text": title or page.title} - if has_description: - value["description"] = desc or getattr(page.specific_deferred, "summary", "") - - return value - - -class LinkBlock(StructBlock): - """Related link block with page or link validation.""" - - page = PageChooserBlock(required=False) - external_url = URLBlock(required=False, label="or External Link") - title = CharBlock( - help_text="Populate when adding an external link. " - "When choosing a page, you can leave it blank to use the page's own title", - required=False, - ) - - class Meta: - icon = "link" - value_class = LinkBlockStructValue - - def clean(self, value: LinkBlockStructValue) -> LinkBlockStructValue: - """Validate that either a page or external link is provided, and that external links have a title.""" - value = super().clean(value) - page = value["page"] - external_url = value["external_url"] - errors = {} - non_block_errors = ErrorList() - - # Require exactly one link - if not page and not external_url: - error = ValidationError(_("Either Page or External Link is required."), code="invalid") - errors["page"] = ErrorList([error]) - errors["external_url"] = ErrorList([error]) - non_block_errors.append(ValidationError(_("Missing required fields"))) - elif page and external_url: - error = ValidationError(_("Please select either a page or a URL, not both."), code="invalid") - errors["page"] = ErrorList([error]) - errors["external_url"] = ErrorList([error]) - - # Require title for external links - if not page and external_url and not value["title"]: - errors["title"] = ErrorList([ValidationError(_("Title is required for external links."), code="invalid")]) - - if errors: - raise StreamBlockValidationError(block_errors=errors, non_block_errors=non_block_errors) - - return value - - class RelatedContentBlock(LinkBlock): """Related content block with page or link validation.""" diff --git a/cms/jinja2/templates/base.html b/cms/jinja2/templates/base.html index 363824f7..29f595f8 100644 --- a/cms/jinja2/templates/base.html +++ b/cms/jinja2/templates/base.html @@ -35,6 +35,59 @@ {%- endwith -%} {%- endset -%} +{% set navigation_settings = settings.navigation.NavigationSettings %} + +{% set keyLinksList = [] %} + +{% if navigation_settings.main_menu and navigation_settings.main_menu.highlights %} + {% for highlight in navigation_settings.main_menu.highlights %} + {% if highlight.value.page %} + {% set _ = keyLinksList.append({ + 'text': highlight.value.title if highlight.value.title else highlight.value.page.title, + 'description': highlight.value.description, + 'url': highlight.value.page.url, + }) %} + {% elif highlight.value.external_url %} + {% set _ = keyLinksList.append({ + 'text': highlight.value.title, + 'description': highlight.value.description, + 'url': highlight.value.external_url, + }) %} + {% endif %} + {% endfor %} +{% endif %} + +{% set itemsList = [] %} + +{% if navigation_settings.main_menu and navigation_settings.main_menu.columns %} + {% for column in navigation_settings.main_menu.columns %} + {% set columnData = { + 'column': loop.index, + 'linksList': [] + } %} + + {% for section in column.value.sections %} + {% set sectionData = { + 'text': section.section_link.title or (section.section_link.page and section.section_link.page.title) or '', + 'url': section.section_link.external_url or (section.section_link.page and section.section_link.page.url) or False, + 'children': [] + } %} + + {% for link in section.links %} + {% set _ = sectionData.children.append({ + 'text': link.title or (link.page and link.page.title) or '', + 'url': link.external_url or (link.page and link.page.url) or False, + }) %} + {% endfor %} + + {% set _ = columnData.linksList.append(sectionData) %} + {% endfor %} + + {% set _ = itemsList.append(columnData) %} + {% endfor %} +{% endif %} + + {# navlinks are temporarily hard-coded until the back-end menu code is merged #} {% set pageConfig = { "bodyClasses": body_class, @@ -56,194 +109,8 @@ "text": 'Menu', "ariaLabel": 'Toggle main menu' }, - "keyLinksList": [ - { - 'text': 'Taking part in a survey?', - 'description': 'It’s never been more important.', - "url": False, - }, - { - 'text': 'Release calendar', - 'description': 'View our latest and upcoming releases.', - "url": False, - }, - { - 'text': 'Explore local statistics', - 'description': 'Explore statistics across the UK.', - "url": False, - } - ], - "itemsList": [ - { - "column": "1", - "linksList": [ - { - "text": "People, population and community", - "url": False, - "children": [ - { - "text": "Armed forces community", - "url": False, - }, - { - "text": "Births, deaths and marriages", - "url": False, - }, - { - "text": "Crime and justice", - "url": False, - }, - { - "text": "Cultural identity", - "url": False, - }, - { - "text": "Education and childcare", - "url": False, - }, - { - "text": "Elections", - "url": False, - }, - { - "text": "Health and social care", - "url": False, - }, - { - "text": "Household characteristics", - "url": False, - }, - { - "text": "Housing", - "url": False, - }, - { - "text": "Leisure and tourism", - "url": False, - }, - { - "text": "Personal and household finances", - "url": False, - }, - { - "text": "Population and migration", - "url": False, - }, - { - "text": "Well-being", - "url": False, - } - ] - }, - ], - }, - { - "column": "2", - "linksList": [ - { - "text": "Business, industry and trade", - "url": False, - "children": [ - { - "text": "Business", - "url": False, - }, - { - "text": "Changes to business", - "url": False, - }, - { - "text": "Construction industry", - "url": False, - }, - { - "text": "International trade", - "url": False, - }, - { - "text": "IT and internet industry", - "url": False, - }, - { - "text": "Manufacturing and production industry", - "url": False, - }, - { - "text": "Retail industry", - "url": TOPIC_PAGE_URL, - }, - { - "text": "Tourism industry", - "url": False, - } - ] - }, - { - "text": "Employment and labour market", - "url": False, - "children": - [ - { - "text": "People in work", - "url": False, - }, - { - "text": "People not in work", - "url": False, - } - ] - - }, - ] - }, - { - "column": "3", - "linksList": [ - { - "text": "Economy", - "url": False, - "children": [ - { - "text": "Economic output and productivity", - "url": False, - }, - { - "text": "Government, public sector and taxes", - "url": False, - }, - { - "text": "Gross Value Added (GVA)", - "url": False, - }, - { - "text": "Investments, pensions and trusts", - "url": False, - }, - { - "text": "Regional accounts", - "url": False, - }, - { - "text": "Environmental accounts", - "url": False, - }, - { - "text": "Gross Domestic Product (GDP)", - "url": False, - }, - { - "text": "Inflation and price indices", - "url": False, - }, - { - "text": "National accounts", - "url": False, - } - ] - } - ] - } - ] + "keyLinksList": keyLinksList, + "itemsList": itemsList } }, "footer": { diff --git a/cms/navigation/__init__.py b/cms/navigation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cms/navigation/apps.py b/cms/navigation/apps.py new file mode 100644 index 00000000..cbdd4912 --- /dev/null +++ b/cms/navigation/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NavigationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "cms.navigation" diff --git a/cms/navigation/migrations/0001_initial.py b/cms/navigation/migrations/0001_initial.py new file mode 100644 index 00000000..3305dfa6 --- /dev/null +++ b/cms/navigation/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 5.1.3 on 2024-12-09 22:41 + +import django.db.models.deletion +import wagtail.models +from django.db import migrations, models + +import cms.core.fields + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("wagtailcore", "0094_alter_page_locale"), + ] + + operations = [ + migrations.CreateModel( + name="MainMenu", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ("highlights", cms.core.fields.StreamField(blank=True, block_lookup={})), + ("columns", cms.core.fields.StreamField(blank=True, block_lookup={})), + ], + bases=(wagtail.models.PreviewableMixin, models.Model), + ), + migrations.CreateModel( + name="NavigationSettings", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ( + "main_menu", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="navigation.mainmenu", + ), + ), + ( + "site", + models.OneToOneField( + editable=False, on_delete=django.db.models.deletion.CASCADE, to="wagtailcore.site" + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/cms/navigation/migrations/__init__.py b/cms/navigation/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cms/navigation/models.py b/cms/navigation/models.py new file mode 100644 index 00000000..2e019719 --- /dev/null +++ b/cms/navigation/models.py @@ -0,0 +1,103 @@ +from typing import TYPE_CHECKING, ClassVar + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from wagtail.admin.panels import FieldPanel +from wagtail.blocks import CharBlock, ListBlock, PageChooserBlock, StructBlock +from wagtail.contrib.settings.models import BaseSiteSetting, register_setting +from wagtail.models import PreviewableMixin + +from cms.core.blocks.base import LinkBlock +from cms.core.fields import StreamField + +if TYPE_CHECKING: + from django.http import HttpRequest + + +class ThemeLinkBlock(LinkBlock): + page = PageChooserBlock(required=False, page_type="themes.ThemePage") + + class Meta: + label = _("Theme Link") + + +class TopicLinkBlock(LinkBlock): + page = PageChooserBlock(required=False, page_type="topics.TopicPage") + + class Meta: + label = _("Topic Link") + + +class HighlightsBlock(LinkBlock): + description = CharBlock( + required=True, max_length=50, help_text=_("For example: 'View our latest and upcoming releases.'") + ) + + class Meta: + icon = "star" + label = _("Highlight") + + +# Section StructBlock for columns +class SectionBlock(StructBlock): + section_link = ThemeLinkBlock(help_text=_("Main link for this section (Theme pages or external URLs).")) + links = ListBlock( + TopicLinkBlock(), help_text=_("Sub-links for this section (Topic pages or external URLs)."), max_num=15 + ) + + class Meta: + icon = "folder" + label = _("Section") + + +# Column StructBlock for the main menu +class ColumnBlock(StructBlock): + sections = ListBlock(SectionBlock(), label="Sections", max_num=3) + + class Meta: + icon = "list-ul" + label = _("Column") + + +# MainMenu model +class MainMenu(PreviewableMixin, models.Model): + highlights = StreamField( + [("highlight", HighlightsBlock())], + blank=True, + max_num=3, + help_text=_("Up to 3 highlights. Each highlight must have either a page or a URL."), + ) + columns = StreamField( + [("column", ColumnBlock())], + blank=True, + max_num=3, + help_text=_("Up to 3 columns. Each column contains sections with links."), + ) + + panels: ClassVar[list] = [ + FieldPanel("highlights"), + FieldPanel("columns"), + ] + + def get_preview_template(self, request: "HttpRequest", mode_name: str) -> str: + return "templates/base_page.html" + + def __str__(self) -> str: + return "Main Menu" + + +# NavigationSettings model +@register_setting(icon="list-ul") +class NavigationSettings(BaseSiteSetting): + main_menu: models.ForeignKey = models.ForeignKey( + MainMenu, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + help_text=_("Select the main menu to display on the site."), + ) + + panels: ClassVar[list] = [ + FieldPanel("main_menu"), + ] diff --git a/cms/navigation/tests/__init__.py b/cms/navigation/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cms/navigation/tests/factories.py b/cms/navigation/tests/factories.py new file mode 100644 index 00000000..bc53cac8 --- /dev/null +++ b/cms/navigation/tests/factories.py @@ -0,0 +1,55 @@ +import factory +from wagtail_factories import StreamFieldFactory, StructBlockFactory, ListBlockFactory +from cms.navigation.models import MainMenu, NavigationSettings + + +class ThemeLinkBlockFactory(StructBlockFactory): + url = factory.Faker("url") + title = factory.Faker("sentence", nb_words=3) + page = factory.SubFactory( + "wagtail_factories.PageFactory", + page_type="themes.ThemePage", + ) + + +class TopicLinkBlockFactory(StructBlockFactory): + url = factory.Faker("url") + title = factory.Faker("sentence", nb_words=3) + page = factory.SubFactory( + "wagtail_factories.PageFactory", + page_type="topic.TopicPage", + ) + + +class HighlightsBlockFactory(StructBlockFactory): + url = factory.Faker("url") + title = factory.Faker("sentence", nb_words=3) + description = factory.Faker("sentence", nb_words=10) + + +class SectionBlockFactory(StructBlockFactory): + section_link = factory.SubFactory(ThemeLinkBlockFactory) + links = ListBlockFactory(TopicLinkBlockFactory, size=3) + + +class ColumnBlockFactory(StructBlockFactory): + sections = ListBlockFactory(SectionBlockFactory, size=3) + + +class MainMenuFactory(factory.django.DjangoModelFactory): + class Meta: + model = MainMenu + + highlights = StreamFieldFactory({"highlight": HighlightsBlockFactory}) + columns = StreamFieldFactory( + { + "column": ColumnBlockFactory, + } + ) + + +class NavigationSettingsFactory(factory.django.DjangoModelFactory): + class Meta: + model = NavigationSettings + + main_menu = factory.SubFactory(MainMenuFactory) diff --git a/cms/navigation/tests/test_blocks.py b/cms/navigation/tests/test_blocks.py new file mode 100644 index 00000000..2c350417 --- /dev/null +++ b/cms/navigation/tests/test_blocks.py @@ -0,0 +1,63 @@ +from django.test import TestCase +from wagtail.test.utils import WagtailTestUtils + +from cms.navigation.tests.factories import ( + ColumnBlockFactory, + HighlightsBlockFactory, + MainMenuFactory, +) + + +class MainMenuBlockTestCase(WagtailTestUtils, TestCase): + """Test custom blocks in MainMenu.""" + + def setUp(self): + self.main_menu = MainMenuFactory() + + def test_highlights_block(self): + """Test HighlightsBlock properties.""" + for block in self.main_menu.highlights: + print("block.block_type", block) + self.assertIn("highlight", block.block_type) + self.assertTrue(block.value["description"]) + + def test_highlights_block_description_length(self): + """Test that HighlightsBlock enforces max_length on description.""" + for block in self.main_menu.highlights: + description = block.value["description"] + self.assertLessEqual(len(description), 50, "Description exceeds max length") + + def test_highlights_streamfield_limit(self): + """Ensure highlights StreamField does not exceed the maximum limit of 3.""" + # highlights = HighlightsBlockFactory.create_batch(4) + value = {"url": "https://ons.gov.uk", "title": "Highlight", "description": "desc"} + highlights = [ + {"type": "highlight", "value": HighlightsBlockFactory()}, # or the dict form + {"type": "highlight", "value": value}, + {"type": "highlight", "value": value}, + {"type": "highlight", "value": value}, + ] + self.main_menu.highlights = highlights + with self.assertRaises(ValueError): + self.main_menu.full_clean() + + def test_column_block(self): + """Test ColumnBlock properties.""" + for column in self.main_menu.columns: + self.assertIn("column", column.block_type) + for section in column.value["sections"]: + self.assertTrue(section.value["section_link"]) + self.assertLessEqual(len(section.value["links"]), 15) + + def test_column_streamfield_limit(self): + """Ensure columns StreamField does not exceed the maximum limit of 3.""" + columns = ColumnBlockFactory.create_batch(4) + self.main_menu.columns = columns + with self.assertRaises(ValueError): + self.main_menu.full_clean() + + def test_section_block_links_limit(self): + """Test SectionBlock links do not exceed the maximum limit.""" + for column in self.main_menu.columns: + for section in column.value["sections"]: + self.assertLessEqual(len(section.value["links"]), 15, "Section links exceed limit") diff --git a/cms/navigation/tests/test_models.py b/cms/navigation/tests/test_models.py new file mode 100644 index 00000000..b3ac4799 --- /dev/null +++ b/cms/navigation/tests/test_models.py @@ -0,0 +1,91 @@ +from django.test import TestCase +from wagtail.models import Page +from cms.navigation.models import MainMenu, NavigationSettings +from cms.navigation.tests.factories import ( + MainMenuFactory, + NavigationSettingsFactory, + HighlightsBlockFactory, + SectionBlockFactory, + ColumnBlockFactory, + TopicLinkBlockFactory, +) +from django.urls import reverse + + +# Test for MainMenu model +class MainMenuModelTest(TestCase): + def test_main_menu_creation(self): + main_menu = MainMenuFactory() + self.assertIsInstance(main_menu, MainMenu) + self.assertEqual(main_menu.highlights.count, 3) + self.assertEqual(main_menu.columns.count, 3) + + def test_main_menu_str(self): + main_menu = MainMenuFactory() + self.assertEqual(str(main_menu), "Main Menu") + + def test_main_menu_highlights_limit(self): + with self.assertRaises(ValueError): + MainMenuFactory(highlights=[HighlightsBlockFactory() for _ in range(4)]) + + def test_main_menu_columns_limit(self): + with self.assertRaises(ValueError): + MainMenuFactory(columns=[ColumnBlockFactory() for _ in range(4)]) + + +# Test for NavigationSettings model +class NavigationSettingsModelTest(TestCase): + def test_navigation_settings_creation(self): + navigation_settings = NavigationSettingsFactory() + self.assertIsInstance(navigation_settings, NavigationSettings) + self.assertIsInstance(navigation_settings.main_menu, MainMenu) + + +# Test for HighlightsBlock +class HighlightsBlockTest(TestCase): + def test_highlights_block(self): + highlight_block = HighlightsBlockFactory() + self.assertIn("description", highlight_block) + self.assertIn("url", highlight_block) + self.assertIn("title", highlight_block) + + +# Test for SectionBlock +class SectionBlockTest(TestCase): + def test_section_block(self): + section_block = SectionBlockFactory() + self.assertIn("section_link", section_block) + self.assertEqual(len(section_block["links"]), 3) + + def test_section_links_limit(self): + with self.assertRaises(ValueError): + SectionBlockFactory(links=[TopicLinkBlockFactory() for _ in range(16)]) + + +# Test for ColumnBlock +class ColumnBlockTest(TestCase): + def test_column_block(self): + column_block = ColumnBlockFactory() + self.assertEqual(len(column_block["sections"]), 3) + + def test_column_sections_limit(self): + with self.assertRaises(ValueError): + ColumnBlockFactory(sections=[SectionBlockFactory() for _ in range(4)]) + + +# Test for views +class NavigationViewTest(TestCase): + def setUp(self): + self.main_menu = MainMenuFactory() + self.navigation_settings = NavigationSettingsFactory(main_menu=self.main_menu) + self.home_page = Page.objects.first() + + def test_main_menu_rendering(self): + response = self.client.get(reverse("wagtail_serve", args=[self.home_page.url])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Main Menu") + + def test_navigation_settings_in_context(self): + response = self.client.get(reverse("wagtail_serve", args=[self.home_page.url])) + self.assertEqual(response.status_code, 200) + self.assertIn("main_menu", response.context) diff --git a/cms/navigation/tests/test_views.py b/cms/navigation/tests/test_views.py new file mode 100644 index 00000000..83a57368 --- /dev/null +++ b/cms/navigation/tests/test_views.py @@ -0,0 +1,16 @@ +from django.test import TestCase +from cms.navigation.tests.factories import MainMenuFactory +from wagtail.test.utils import WagtailTestUtils + + +class MainMenuViewTestCase(WagtailTestUtils, TestCase): + """Test views related to MainMenu.""" + + def setUp(self): + self.main_menu = MainMenuFactory() + + def test_main_menu_rendering(self): + """Test main menu renders correctly in the frontend.""" + response = self.client.get("/") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Main Menu") diff --git a/cms/navigation/wagtail_hooks.py b/cms/navigation/wagtail_hooks.py new file mode 100644 index 00000000..c5e02d62 --- /dev/null +++ b/cms/navigation/wagtail_hooks.py @@ -0,0 +1,32 @@ +from typing import TYPE_CHECKING, Optional + +from wagtail.permissions import ModelPermissionPolicy +from wagtail.snippets.models import register_snippet +from wagtail.snippets.views.snippets import SnippetViewSet + +from .models import MainMenu + +if TYPE_CHECKING: + from cms.users.models import User + + +class NoAddModelPermissionPolicy(ModelPermissionPolicy): + """Model permission that doesn't allow creating more than one main menu instance.""" + + def user_has_permission(self, user: Optional["User"] = None, action: str | None = None) -> bool: + if action == "add" and MainMenu.objects.exists(): + return False + return user is not None and user.has_perm(self._get_permission_name(action)) + + +class MainMenuViewSet(SnippetViewSet): + """A snippet viewset for MainMenu.""" + + model = MainMenu + + @property + def permission_policy(self) -> NoAddModelPermissionPolicy: + return NoAddModelPermissionPolicy(self.model) + + +register_snippet(MainMenuViewSet) diff --git a/cms/settings/base.py b/cms/settings/base.py index 321c5280..c55232a2 100644 --- a/cms/settings/base.py +++ b/cms/settings/base.py @@ -67,6 +67,7 @@ "cms.topics", "cms.users", "cms.standard_pages", + "cms.navigation", "wagtail.embeds", "wagtail.sites", "wagtail.users",