diff --git a/poetry.lock b/poetry.lock index 7283684e..2d22e17b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2807,4 +2807,4 @@ gunicorn = ["gunicorn"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "d9d6169e366ec79c49f3c7d629a063e5d84f826df3379c23903203e07f96eee4" +content-hash = "bee797e229653ab172e11cdcad1196a4a88aa507259302fc857ff10a178691fd" diff --git a/pyproject.toml b/pyproject.toml index 1f651c7d..17af4983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,8 @@ mkdocs-material = "^9.5.41" pymdown-extensions = "^10.11.2" # Testing +factory-boy = "^3.3.0" +faker = "^24.11.0" wagtail-factories = "^4.2.1" diff --git a/tbx/core/blocks.py b/tbx/core/blocks.py index 9c3e5c90..77a8fa83 100644 --- a/tbx/core/blocks.py +++ b/tbx/core/blocks.py @@ -10,6 +10,7 @@ from django.utils import timezone from django.utils.functional import cached_property from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from tbx.images.models import CustomImage from wagtail import blocks @@ -262,6 +263,85 @@ class Meta: template = "patterns/molecules/streamfield/blocks/contact_call_to_action.html" +class DynamicHeroBlock(blocks.StructBlock): + """ + This block displays text that will be cycled through. + """ + + static_text = blocks.CharBlock(required=False) + dynamic_text = blocks.ListBlock( + blocks.CharBlock(), + help_text=_( + "The hero will cycle through these texts on larger screen sizes " + "and only show the first text on smaller screen sizes." + ), + required=False, + ) + + class Meta: + icon = "title" + template = "patterns/molecules/streamfield/blocks/dynamic_hero_block.html" + + +class FourPhotoCollageBlock(blocks.StructBlock): + """Accepts 4 photos shown as a collage + text below. Used on the division page.""" + + images = blocks.ListBlock( + ImageWithAltTextBlock(label="Photo"), + min_num=4, + max_num=4, + label="Photos", + help_text=_("Exactly four required."), + default=[{"image": None, "alt_text": ""}] * 4, + ) + caption = blocks.RichTextBlock( + features=settings.PARAGRAPH_RICH_TEXT_FEATURES, required=False + ) + small_caption = blocks.RichTextBlock( + features=settings.PARAGRAPH_RICH_TEXT_FEATURES, required=False + ) + + class Meta: + group = "Custom" + icon = "image" + template = "patterns/molecules/streamfield/blocks/four_photo_collage_block.html" + + +class IntroductionWithImagesBlock(blocks.StructBlock): + """Used on the division page.""" + + introduction = blocks.RichTextBlock(features=settings.PARAGRAPH_RICH_TEXT_FEATURES) + description = blocks.RichTextBlock( + blank=True, features=settings.NO_HEADING_RICH_TEXT_FEATURES + ) + images = blocks.ListBlock( + ImageWithAltTextBlock(label="Photo"), + min_num=2, + max_num=2, + label="Photos", + help_text=_("Exactly two required."), + default=[{"image": None, "alt_text": ""}] * 2, + ) + + class Meta: + group = "Custom" + icon = "pilcrow" + template = ( + "patterns/molecules/streamfield/blocks/introduction_with_images_block.html" + ) + + +class PartnersBlock(blocks.StructBlock): + title = blocks.CharBlock(max_length=255, required=False) + partner_logos = blocks.ListBlock(CustomImageChooserBlock(), label="Logos") + + class Meta: + icon = "openquote" + label = "Partner logos" + template = "patterns/molecules/streamfield/blocks/partners_block.html" + group = "Custom" + + class ShowcaseBlock(blocks.StructBlock): """ This block is a standard ShowcaseBlock, available on the home page and diff --git a/tbx/core/factories.py b/tbx/core/factories.py index f31a8b9b..9ec00c80 100644 --- a/tbx/core/factories.py +++ b/tbx/core/factories.py @@ -1,13 +1,30 @@ import factory import wagtail_factories -from tbx.core.blocks import StoryBlock +from faker import Faker +from tbx.core.blocks import DynamicHeroBlock, StoryBlock from tbx.core.models import HomePage, StandardPage -from wagtail.blocks import RichTextBlock +from wagtail import blocks + +fake = Faker() + + +class DynamicHeroBlockFactory(wagtail_factories.StructBlockFactory): + class Meta: + model = DynamicHeroBlock + + static_text = fake.sentence() + + @factory.post_generation + def dynamic_text(obj, create, extracted, **kwargs): + values = extracted or fake.sentences(nb=5) + obj["dynamic_text"] = blocks.list_block.ListValue( + blocks.ListBlock(blocks.CharBlock()), values + ) class RichTextBlockFactory(wagtail_factories.blocks.BlockFactory): class Meta: - model = RichTextBlock + model = blocks.RichTextBlock class StoryBlockFactory(wagtail_factories.StreamBlockFactory): diff --git a/tbx/core/tests/test_models.py b/tbx/core/tests/test_models.py index d8022608..57c9fabe 100644 --- a/tbx/core/tests/test_models.py +++ b/tbx/core/tests/test_models.py @@ -1,6 +1,10 @@ +from django.apps import apps +from django.test import TestCase +from django.utils.module_loading import import_string, module_has_submodule + from tbx.core.factories import HomePageFactory, StandardPageFactory from tbx.core.models import HomePage, StandardPage -from wagtail.models import Site +from wagtail.models import Page, Site from wagtail.test.utils import WagtailPageTestCase from wagtail.test.utils.form_data import ( nested_form_data, @@ -9,14 +13,37 @@ ) -class TestHomePageFactory(WagtailPageTestCase): - def test_create(self): - HomePageFactory() +class TestPageFactory(TestCase): + """Sanity tests to make sure all pages have a factory.""" + + # Exclude these modules from the check. + # (They currently don't have factories. Un-exclude once they have factories.) + EXCLUDE = ["tbx.events", "tbx.impact_reports", "tbx.services"] + + def test_pages(self): + app_configs = apps.get_app_configs() + home_page = HomePageFactory() + + # Create one of every page type using their factory. + for app in app_configs: + for model in app.models.values(): + if issubclass(model, Page) and model not in [Page, HomePage]: + if app.name in self.EXCLUDE: + continue + + with self.subTest(model=model.__name__): + # Get the model's factory + assert module_has_submodule( + app.module, "factories" + ), f"App '{app.name}' does not have a factories module." + + page_factory = import_string( + f"{app.module.__name__}.factories.{model.__name__}Factory" + ) + page = page_factory(parent=home_page) -class TestStandardPageFactory(WagtailPageTestCase): - def test_create(self): - StandardPageFactory() + self.assertIsInstance(page, model) class TestStandardPage(WagtailPageTestCase): diff --git a/tbx/divisions/__init__.py b/tbx/divisions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tbx/divisions/blocks.py b/tbx/divisions/blocks.py new file mode 100644 index 00000000..643f6a4b --- /dev/null +++ b/tbx/divisions/blocks.py @@ -0,0 +1,12 @@ +from tbx.core.blocks import ( + FourPhotoCollageBlock, + IntroductionWithImagesBlock, + PartnersBlock, + StoryBlock, +) + + +class DivisionStoryBlock(StoryBlock): + four_photo_collage = FourPhotoCollageBlock() + introduction_with_images = IntroductionWithImagesBlock() + partners_block = PartnersBlock() diff --git a/tbx/divisions/factories.py b/tbx/divisions/factories.py new file mode 100644 index 00000000..d606dbb6 --- /dev/null +++ b/tbx/divisions/factories.py @@ -0,0 +1,36 @@ +import factory +import wagtail_factories +from tbx.core.blocks import DynamicHeroBlock +from tbx.core.factories import DynamicHeroBlockFactory, StoryBlockFactory +from wagtail import blocks + +from .models import DivisionPage + + +class DynamicHeroStreamBlock(blocks.StreamBlock): + hero = DynamicHeroBlock() + + +class DynamicHeroStreamBlockFactory(wagtail_factories.StreamBlockFactory): + class Meta: + model = DynamicHeroStreamBlock + + hero = factory.SubFactory(DynamicHeroBlockFactory) + + +class DivisionPageFactory(wagtail_factories.PageFactory): + class Meta: + model = DivisionPage + + title = "Charity" + label = "Charity" + + @factory.post_generation + def hero(obj, create, extracted, **kwargs): + blocks = kwargs or {"0": "hero"} + obj.hero = DynamicHeroStreamBlockFactory(**blocks) + + @factory.post_generation + def body(obj, create, extracted, **kwargs): + blocks = kwargs or {"0": "paragraph"} + obj.body = StoryBlockFactory(**blocks) diff --git a/tbx/divisions/migrations/0001_initial.py b/tbx/divisions/migrations/0001_initial.py new file mode 100644 index 00000000..42069f64 --- /dev/null +++ b/tbx/divisions/migrations/0001_initial.py @@ -0,0 +1,89 @@ +# Generated by Django 4.2.16 on 2024-12-18 08:18 + +from django.db import migrations, models +import django.db.models.deletion +import tbx.core.utils.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("wagtailcore", "0094_alter_page_locale"), + ("people", "0011_update_theme_colour_choices"), + ("images", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="DivisionPage", + 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", + ), + ), + ( + "navigation_text", + models.CharField( + blank=True, + help_text="\n Text entered here will appear instead of the page title in the navigation menu.\n For top-level menu items do this in the navigaiton settings instead.\n ", + max_length=255, + ), + ), + ("social_text", models.CharField(blank=True, max_length=255)), + ( + "theme", + models.CharField( + blank=True, + choices=[ + ("", "None"), + ("theme-coral", "Coral"), + ("theme-nebuline", "Nebuline"), + ("theme-lagoon", "Lagoon"), + ("theme-green", "Green"), + ], + max_length=25, + ), + ), + ("label", models.CharField(blank=True, max_length=50)), + ("hero", tbx.core.utils.fields.StreamField(block_lookup={})), + ( + "body", + tbx.core.utils.fields.StreamField(blank=True, block_lookup={}), + ), + ( + "contact", + models.ForeignKey( + blank=True, + help_text="The contact will be applied to this page's footer and all of its descendants.\nIf no contact is selected, it will be derived from this page's ancestors, eventually falling back to the default contact.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="people.contact", + ), + ), + ( + "social_image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="images.customimage", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("wagtailcore.page", models.Model), + ), + ] diff --git a/tbx/divisions/migrations/__init__.py b/tbx/divisions/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tbx/divisions/models.py b/tbx/divisions/models.py new file mode 100644 index 00000000..3a3804d6 --- /dev/null +++ b/tbx/divisions/models.py @@ -0,0 +1,50 @@ +from django.db import models + +from tbx.core.blocks import DynamicHeroBlock +from tbx.core.utils.fields import StreamField +from tbx.core.utils.models import ( + ColourThemeMixin, + NavigationFields, + SocialFields, +) +from tbx.people.models import ContactMixin +from wagtail.admin.panels import FieldPanel, MultiFieldPanel +from wagtail.models import Page + +from .blocks import DivisionStoryBlock + + +class DivisionPage( + ColourThemeMixin, ContactMixin, SocialFields, NavigationFields, Page +): + template = "patterns/pages/divisions/division_page.html" + + label = models.CharField(blank=True, max_length=50) + + hero = StreamField([("hero", DynamicHeroBlock())], max_num=1, min_num=1) + body = StreamField(DivisionStoryBlock(), blank=True) + + content_panels = Page.content_panels + [ + FieldPanel( + "label", + heading="Division label", + help_text=( + "Label displayed beside the logo for this page and any other pages" + " under this division. (e.g. Charity)" + ), + ), + FieldPanel("hero"), + FieldPanel("body"), + ] + + promote_panels = ( + [ + MultiFieldPanel(Page.promote_panels, "Common page configuration"), + ] + + NavigationFields.promote_panels + + ColourThemeMixin.promote_panels + + ContactMixin.promote_panels + + [ + MultiFieldPanel(SocialFields.promote_panels, "Social fields"), + ] + ) diff --git a/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/division_story_container.html b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/division_story_container.html new file mode 100644 index 00000000..8b47d7a5 --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/division_story_container.html @@ -0,0 +1,2 @@ + +{% include "patterns/molecules/streamfield/blocks/introduction_with_images_block.html" %} diff --git a/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/division_story_container.yaml b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/division_story_container.yaml new file mode 100644 index 00000000..c24ff6b3 --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/_pattern_library_only/streamfield/division_story_container.yaml @@ -0,0 +1,5 @@ +tags: + srcset_image: + item.image format-webp loading="lazy" fill-{100x100} alt=item.image.alt_text: + raw: | + diff --git a/tbx/project_styleguide/templates/patterns/molecules/streamfield/blocks/dynamic_hero_block.html b/tbx/project_styleguide/templates/patterns/molecules/streamfield/blocks/dynamic_hero_block.html new file mode 100644 index 00000000..60ff7d77 --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/molecules/streamfield/blocks/dynamic_hero_block.html @@ -0,0 +1,18 @@ +
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
' + small_caption: 'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
' + images: + - image: + - image: + - image: + - image: + +tags: + srcset_image: + item.image format-webp loading="lazy" fill-{100x100} alt=item.image.alt_text: + raw: | + diff --git a/tbx/project_styleguide/templates/patterns/molecules/streamfield/blocks/introduction_with_images_block.html b/tbx/project_styleguide/templates/patterns/molecules/streamfield/blocks/introduction_with_images_block.html new file mode 100644 index 00000000..65607a65 --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/molecules/streamfield/blocks/introduction_with_images_block.html @@ -0,0 +1,12 @@ +{% load wagtailcore_tags wagtailimages_tags %} +Wagtail is the leading open-source Python/Django CMS, designed to empower teams to do their best work.
' + description: 'As its creators, we know Wagtail inside out and our specialist services can help you get the most out of it.
' + images: + - image: + - image: + +tags: + srcset_image: + item.image format-webp loading="lazy" fill-{100x100} alt=item.image.alt_text: + raw: | + diff --git a/tbx/project_styleguide/templates/patterns/molecules/streamfield/stream_block_division.html b/tbx/project_styleguide/templates/patterns/molecules/streamfield/stream_block_division.html new file mode 100644 index 00000000..2d497788 --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/molecules/streamfield/stream_block_division.html @@ -0,0 +1,7 @@ +{% load wagtailcore_tags %} + +{% if value %} + {% for block in value %} + {% include_block block with unique_id=block.id %} + {% endfor %} +{% endif %} diff --git a/tbx/project_styleguide/templates/patterns/molecules/streamfield/stream_block_division.yaml b/tbx/project_styleguide/templates/patterns/molecules/streamfield/stream_block_division.yaml new file mode 100644 index 00000000..a84df542 --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/molecules/streamfield/stream_block_division.yaml @@ -0,0 +1,8 @@ +context: + value: + - dummy + +tags: + include_block: + block with unique_id=block.id: + template_name: 'patterns/_pattern_library_only/streamfield/division_story_container.html' diff --git a/tbx/project_styleguide/templates/patterns/pages/divisions/division_page.html b/tbx/project_styleguide/templates/patterns/pages/divisions/division_page.html new file mode 100644 index 00000000..b455ff84 --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/pages/divisions/division_page.html @@ -0,0 +1,14 @@ +{% extends "patterns/base_page.html" %} +{% load wagtailcore_tags %} + +{% block content %} + +Title: {{ page.title }}
+ +Label for logo: {{ page.label }}
+ + {% include_block page.hero %} + + {% include_block page.body %} + +{% endblock content %} diff --git a/tbx/project_styleguide/templates/patterns/pages/divisions/division_page.yaml b/tbx/project_styleguide/templates/patterns/pages/divisions/division_page.yaml new file mode 100644 index 00000000..652f254b --- /dev/null +++ b/tbx/project_styleguide/templates/patterns/pages/divisions/division_page.yaml @@ -0,0 +1,13 @@ +context: + page: + title: Charity + label: Charity + +tags: + include_block: + page.hero: + template_name: 'patterns/molecules/streamfield/blocks/dynamic_hero_block.html' + page.introduction: + template_name: 'patterns/molecules/streamfield/blocks/introduction_section_block.html' + page.body: + template_name: 'patterns/molecules/streamfield/stream_block_division.html' diff --git a/tbx/services/blocks.py b/tbx/services/blocks.py index 82f47c28..600a1350 100644 --- a/tbx/services/blocks.py +++ b/tbx/services/blocks.py @@ -1,9 +1,9 @@ from tbx.core.blocks import ( BlogChooserBlock, - CustomImageChooserBlock, EventBlock, FeaturedCaseStudyBlock, ImageWithAltTextBlock, + PartnersBlock, PhotoCollageBlock, PromoBlock, ShowcaseBlock, @@ -14,19 +14,6 @@ from wagtail import blocks -class PartnersBlock(blocks.StructBlock): - title = blocks.CharBlock( - max_length=255, - ) - partner_logos = blocks.ListBlock(CustomImageChooserBlock(), label="Logos") - - class Meta: - icon = "openquote" - label = "Partner logos" - template = "patterns/molecules/streamfield/blocks/partners_block.html" - group = "Custom" - - class ValuesBlock(blocks.StructBlock): title = blocks.CharBlock(max_length=255) intro = blocks.TextBlock(label="Introduction") diff --git a/tbx/settings/base.py b/tbx/settings/base.py index c7da504c..4103ce7c 100644 --- a/tbx/settings/base.py +++ b/tbx/settings/base.py @@ -40,6 +40,7 @@ "scout_apm.django", "tbx.blog", "tbx.core.apps.TorchboxCoreAppConfig", + "tbx.divisions", "tbx.events", "tbx.impact_reports", "tbx.navigation", @@ -646,6 +647,7 @@ "document-link", ] NO_HEADING_RICH_TEXT_FEATURES = ["bold", "italic", "ul", "ol", "link", "document-link"] +PARAGRAPH_RICH_TEXT_FEATURES = ["bold", "italic", "link", "document-link"] WAGTAILADMIN_RICH_TEXT_EDITORS = { "default": { "WIDGET": "wagtail.admin.rich_text.DraftailRichTextArea",