From 18f872e2c529ab44bd4ba1af009e1ff8740c696b Mon Sep 17 00:00:00 2001 From: Raghad Dahi Date: Thu, 5 Mar 2026 22:08:23 +0200 Subject: [PATCH] feat: add block-based footer menu using StreamField (refs #554) --- bakerydemo/base/blocks.py | 36 +++++++++++++++ bakerydemo/base/migrations/0027_footermenu.py | 44 +++++++++++++++++++ .../0028_alter_footermenu_sections.py | 19 ++++++++ bakerydemo/base/models.py | 31 ++++++++++++- .../base/templatetags/navigation_tags.py | 16 ++++++- bakerydemo/base/wagtail_hooks.py | 8 +++- .../templates/base/include/footer_menu.html | 29 ++++++++++++ bakerydemo/templates/includes/footer.html | 3 +- 8 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 bakerydemo/base/migrations/0027_footermenu.py create mode 100644 bakerydemo/base/migrations/0028_alter_footermenu_sections.py create mode 100644 bakerydemo/templates/base/include/footer_menu.html diff --git a/bakerydemo/base/blocks.py b/bakerydemo/base/blocks.py index 224d4cc13..826c0ce53 100644 --- a/bakerydemo/base/blocks.py +++ b/bakerydemo/base/blocks.py @@ -2,11 +2,15 @@ from wagtail.blocks import ( CharBlock, ChoiceBlock, + ListBlock, + PageChooserBlock, RichTextBlock, StreamBlock, StructBlock, TextBlock, + URLBlock, ) +from wagtail.documents.blocks import DocumentChooserBlock from wagtail.embeds.blocks import EmbedBlock from wagtail.images import get_image_model from wagtail.images.blocks import ImageChooserBlock @@ -158,3 +162,35 @@ class BaseStreamBlock(StreamBlock): preview_value="https://www.youtube.com/watch?v=mwrGSfiB1Mg", description="An embedded video or other media", ) + + +class PageLinkBlock(StructBlock): + page = PageChooserBlock(required=False) + + +class DocumentBlock(StructBlock): + document = DocumentChooserBlock(required=False) + + +class ExternalLinkBlock(StructBlock): + url = URLBlock(required=False) + + +class LinkStreamBlock(StreamBlock): + page = PageLinkBlock() + document = DocumentBlock() + external_url = ExternalLinkBlock() + + class Meta: + min_num = 1 + max_num = 1 + + +class MenuItemBlock(StructBlock): + title = CharBlock(required=False) + link = LinkStreamBlock() + + +class SectionBlock(StructBlock): + heading = CharBlock() + links = ListBlock(MenuItemBlock()) diff --git a/bakerydemo/base/migrations/0027_footermenu.py b/bakerydemo/base/migrations/0027_footermenu.py new file mode 100644 index 000000000..8a1e1920b --- /dev/null +++ b/bakerydemo/base/migrations/0027_footermenu.py @@ -0,0 +1,44 @@ +# Generated by Django 6.0.2 on 2026-03-05 00:43 + +import django.db.models.deletion +import uuid +import wagtail.fields +import wagtail.models.preview +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0026_alter_formpage_body_alter_gallerypage_body_and_more'), + ('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='FooterMenu', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('translation_key', models.UUIDField(default=uuid.uuid4, editable=False)), + ('live', models.BooleanField(default=True, editable=False, verbose_name='live')), + ('has_unpublished_changes', models.BooleanField(default=False, editable=False, verbose_name='has unpublished changes')), + ('first_published_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='first published at')), + ('last_published_at', models.DateTimeField(editable=False, null=True, verbose_name='last published at')), + ('go_live_at', models.DateTimeField(blank=True, null=True, verbose_name='go live date/time')), + ('expire_at', models.DateTimeField(blank=True, null=True, verbose_name='expiry date/time')), + ('expired', models.BooleanField(default=False, editable=False, verbose_name='expired')), + ('name', models.CharField(max_length=255)), + ('sections', wagtail.fields.StreamField([('section', 7)], block_lookup={0: ('wagtail.blocks.CharBlock', (), {}), 1: ('wagtail.blocks.CharBlock', (), {'required': False}), 2: ('wagtail.blocks.PageChooserBlock', (), {'required': False}), 3: ('wagtail.blocks.URLBlock', (), {'required': False}), 4: ('wagtail.documents.blocks.DocumentChooserBlock', (), {'required': False}), 5: ('wagtail.blocks.StructBlock', [[('title', 1), ('page', 2), ('external_url', 3), ('document', 4)]], {}), 6: ('wagtail.blocks.ListBlock', (5,), {}), 7: ('wagtail.blocks.StructBlock', [[('heading', 0), ('links', 6)]], {})})), + ('latest_revision', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.revision', verbose_name='latest revision')), + ('live_revision', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.revision', verbose_name='live revision')), + ('locale', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='wagtailcore.locale', verbose_name='locale')), + ], + options={ + 'verbose_name': 'footer menu', + 'verbose_name_plural': 'footer menus', + 'abstract': False, + 'unique_together': {('translation_key', 'locale')}, + }, + bases=(wagtail.models.preview.PreviewableMixin, models.Model), + ), + ] diff --git a/bakerydemo/base/migrations/0028_alter_footermenu_sections.py b/bakerydemo/base/migrations/0028_alter_footermenu_sections.py new file mode 100644 index 000000000..431a82fd6 --- /dev/null +++ b/bakerydemo/base/migrations/0028_alter_footermenu_sections.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.2 on 2026-03-06 22:03 + +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0027_footermenu'), + ] + + operations = [ + migrations.AlterField( + model_name='footermenu', + name='sections', + field=wagtail.fields.StreamField([('section', 11)], block_lookup={0: ('wagtail.blocks.CharBlock', (), {}), 1: ('wagtail.blocks.CharBlock', (), {'required': False}), 2: ('wagtail.blocks.PageChooserBlock', (), {'required': False}), 3: ('wagtail.blocks.StructBlock', [[('page', 2)]], {}), 4: ('wagtail.documents.blocks.DocumentChooserBlock', (), {'required': False}), 5: ('wagtail.blocks.StructBlock', [[('document', 4)]], {}), 6: ('wagtail.blocks.URLBlock', (), {'required': False}), 7: ('wagtail.blocks.StructBlock', [[('url', 6)]], {}), 8: ('wagtail.blocks.StreamBlock', [[('page', 3), ('document', 5), ('external_url', 7)]], {}), 9: ('wagtail.blocks.StructBlock', [[('title', 1), ('link', 8)]], {}), 10: ('wagtail.blocks.ListBlock', (9,), {}), 11: ('wagtail.blocks.StructBlock', [[('heading', 0), ('links', 10)]], {})}), + ), + ] diff --git a/bakerydemo/base/models.py b/bakerydemo/base/models.py index dfd73147c..84d795a81 100644 --- a/bakerydemo/base/models.py +++ b/bakerydemo/base/models.py @@ -36,7 +36,7 @@ ) from wagtail.search import index -from .blocks import BaseStreamBlock +from .blocks import BaseStreamBlock, SectionBlock # Allow filtering by collection Image.api_fields = [APIField("collection")] @@ -226,6 +226,35 @@ class Meta(TranslatableMixin.Meta): verbose_name_plural = "footer text" +class FooterMenu( + DraftStateMixin, + RevisionMixin, + PreviewableMixin, + TranslatableMixin, + models.Model, +): + name = models.CharField(max_length=255) + sections = StreamField([("section", SectionBlock())]) + + panels = [ + FieldPanel("name"), + FieldPanel("sections"), + ] + + def __str__(self): + return self.name + + def get_preview_template(self, request, mode_name): + return "base.html" + + def get_preview_context(self, request, mode_name): + return {"footer_menu": self} + + class Meta(TranslatableMixin.Meta): + verbose_name = "footer menu" + verbose_name_plural = "footer menus" + + class StandardPage(Page): """ A generic content page. On this demo site we use it for an about page but diff --git a/bakerydemo/base/templatetags/navigation_tags.py b/bakerydemo/base/templatetags/navigation_tags.py index d22a8e62b..085a0cee5 100644 --- a/bakerydemo/base/templatetags/navigation_tags.py +++ b/bakerydemo/base/templatetags/navigation_tags.py @@ -1,7 +1,7 @@ from django import template from wagtail.models import Page, Site -from bakerydemo.base.models import FooterText +from bakerydemo.base.models import FooterMenu, FooterText register = template.Library() # https://docs.djangoproject.com/en/stable/howto/custom-template-tags/ @@ -75,3 +75,17 @@ def get_footer_text(context): return { "footer_text": footer_text, } + + +@register.inclusion_tag("base/include/footer_menu.html", takes_context=True) +def get_footer_menu(context): + + footer_menu = context.get("footer_menu", None) + + if not footer_menu: + instance = FooterMenu.objects.filter(live=True).first() + footer_menu = instance if instance else None + + return { + "footer_menu": footer_menu, + } diff --git a/bakerydemo/base/wagtail_hooks.py b/bakerydemo/base/wagtail_hooks.py index 1133fe78e..96575115a 100644 --- a/bakerydemo/base/wagtail_hooks.py +++ b/bakerydemo/base/wagtail_hooks.py @@ -5,7 +5,7 @@ from wagtail.snippets.views.snippets import SnippetViewSet, SnippetViewSetGroup from bakerydemo.base.filters import RevisionFilterSetMixin -from bakerydemo.base.models import FooterText, Person +from bakerydemo.base.models import FooterMenu, FooterText, Person """ N.B. To see what icons are available for use in Wagtail menus and StreamField block types, @@ -82,11 +82,15 @@ class FooterTextViewSet(SnippetViewSet): filterset_class = FooterTextFilterSet +class FooterMenuViewSet(SnippetViewSet): + model = FooterMenu + + class BakerySnippetViewSetGroup(SnippetViewSetGroup): menu_label = "Bakery Misc" menu_icon = "utensils" # change as required menu_order = 300 # will put in 4th place (000 being 1st, 100 2nd) - items = (PersonViewSet, FooterTextViewSet) + items = (PersonViewSet, FooterTextViewSet, FooterMenuViewSet) # When using a SnippetViewSetGroup class to group several SnippetViewSet classes together, diff --git a/bakerydemo/templates/base/include/footer_menu.html b/bakerydemo/templates/base/include/footer_menu.html new file mode 100644 index 000000000..2984427ee --- /dev/null +++ b/bakerydemo/templates/base/include/footer_menu.html @@ -0,0 +1,29 @@ +{% load wagtailcore_tags %} + +{% if footer_menu %} +
+ {% for section in footer_menu.sections %} +
+

{{ section.value.heading }}

+ +
+ {% endfor %} +
+{% endif %} diff --git a/bakerydemo/templates/includes/footer.html b/bakerydemo/templates/includes/footer.html index c6bf30ec6..869ed55d9 100644 --- a/bakerydemo/templates/includes/footer.html +++ b/bakerydemo/templates/includes/footer.html @@ -3,6 +3,8 @@