Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TWE-11 - BE - Service area page #335

Merged
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e4abc32
Move PartersBlock to core.blocks
SharmaineLim Dec 18, 2024
84a7048
Add divisions app
SharmaineLim Dec 19, 2024
7144e99
Prepare division page blocks
SharmaineLim Dec 19, 2024
643ff22
Add DivisionPage
SharmaineLim Dec 19, 2024
a38b9d1
Make schema migration
SharmaineLim Dec 19, 2024
aa3dfd6
Add faker & factory-boy as explicit dependencies since we use them
SharmaineLim Dec 19, 2024
6663d23
Create DivisionPage factory
SharmaineLim Dec 19, 2024
bc24e99
Do something different if there's only 1 dynamic text
SharmaineLim Dec 19, 2024
685f6cc
Add 'caption' to four-photo collage
SharmaineLim Dec 20, 2024
7f5d684
Merge branch 'integration/2024-evolution' into feature/twe-7-division…
SharmaineLim Jan 6, 2025
aa12999
Add small_caption to FourPhotoCollageBlock
SharmaineLim Jan 9, 2025
6bb1c7d
Create ServiceAreaPage model
SharmaineLim Jan 10, 2025
911c8d0
Create DivisionMixin
SharmaineLim Jan 10, 2025
7cc3fca
Create story components for ServiceAreaPage
SharmaineLim Jan 10, 2025
4460bb5
Add page factories for the services module
SharmaineLim Jan 10, 2025
c7116a0
Make schema migration
SharmaineLim Jan 10, 2025
7154f5d
Add documentation about divisions
SharmaineLim Jan 10, 2025
bde48ca
Update comment on FourPhotoCollageBlock
SharmaineLim Jan 10, 2025
8a46015
Add division to ServiceAreaPage promote tab
SharmaineLim Jan 13, 2025
1cd3c0c
Add tests for the DivisionMixin
SharmaineLim Jan 13, 2025
14c7941
Use setUpTestData instead of setUp
SharmaineLim Jan 14, 2025
b5f3204
From get_division method to final_division cached property
SharmaineLim Jan 14, 2025
b5152a8
Filter out Root & Homepage, defer streamfields
SharmaineLim Jan 14, 2025
e0a3b4d
Add a page_description
SharmaineLim Jan 14, 2025
aca20db
Only allow ServiceAreaPages under a DivisionPage
SharmaineLim Jan 14, 2025
6ce7997
Merge branch 'integration/2024-evolution' into feature/twe-11-service…
SharmaineLim Jan 15, 2025
7a025d1
Update division story container to match block YAMLs
SharmaineLim Jan 15, 2025
4e291c6
Mention in docs: only 1 division allowed
SharmaineLim Jan 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/custom-features/divisions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Divisions
zerolab marked this conversation as resolved.
Show resolved Hide resolved

The site's target audience can be grouped into divisions; e.g. the charity division, the public sector division, and the Wagtail division. All content going forward can be associated to one of these divisions.

The idea is that if you're a charity organisation, you can find content that's specific and relevant for you because the relevant content will all be in one place.

This feature allows content to be associated to a specific `DivisionPage`, which allows us to display the same theme, logo and navigation for any content related to a division.

## Options

The available options are dependent on the `DivisionPage`s that have been created.

## Division configuration

The `tbx.core.utils.models.DivisionMixin` provides a mechanism for associating a specific division with a page. It offers the following functionality:

- `division` field: Adds a ForeignKey field to associate a specific division with a page.
- `get_division`: A method that determines the appropriate division to associate to a page. It first checks if the page has a `division` specified. If not, it traverses the page's ancestors to find the first page that either has a `division` specified or is a `DivisionPage`, defaulting to `None`.

---

???+ note

Please ensure that the Editors' guide is updated accordingly whenever any changes are made to this feature. A private link, for Torchbox employees only, can be found at https://intranet.torchbox.com/torchbox-com-project-docs.
5 changes: 3 additions & 2 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,11 @@ nav:
- front-end/incident-form.md
- 'Navigation': 'navigation.md'
- 'Custom features':
- 'Contact': 'custom-features/contact.md'
- 'Division': 'custom-features/divisions.md'
- 'Migration-friendly StreamFields': 'custom-features/migration-friendly-streamfields.md'
- 'Theme': 'custom-features/theme.md'
- 'Modes': 'custom-features/modes.md'
- 'Contact': 'custom-features/contact.md'
- 'Theme': 'custom-features/theme.md'
- 'Continuous integration': 'continuous-integration.md'
- 'Anonymised data': 'anonymised-data.md'
- 'Data import': 'data-import.md'
Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
83 changes: 83 additions & 0 deletions tbx/core/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -262,6 +263,88 @@ 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 and the service area 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
Expand Down
23 changes: 20 additions & 3 deletions tbx/core/factories.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
98 changes: 98 additions & 0 deletions tbx/core/tests/test_division_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from tbx.core.factories import HomePageFactory
from tbx.divisions.factories import DivisionPageFactory
from tbx.services.factories import ServiceAreaPageFactory
from wagtail.models import Site
from wagtail.test.utils import WagtailPageTestCase


class TestDivisionMixin(WagtailPageTestCase):
def setUp(self):
super().setUp()

# Set up the site & homepage.
site = Site.objects.get(is_default_site=True)
root = site.root_page.specific
self.home = HomePageFactory(parent=root)

site.root_page = self.home
site.save()
SharmaineLim marked this conversation as resolved.
Show resolved Hide resolved

# Set up a services "index" page.
self.services = ServiceAreaPageFactory(title="Services", parent=self.home)
self.service_1 = ServiceAreaPageFactory(title="Service 1", parent=self.services)
self.service_2 = ServiceAreaPageFactory(
title="Service 2", parent=self.service_1
)

# Set up a division page.
self.division_1 = DivisionPageFactory(parent=self.home)
self.division_2 = DivisionPageFactory(title="Public sector", parent=self.home)

def test_division_selected(self):
"""
For a page that has division selected,
get_division should return the selected page.
"""
self.service_1.division = self.division_1
self.service_1.save()

service_3 = ServiceAreaPageFactory(
division=self.division_2,
parent=self.service_2,
title="Service 3",
)

self.assertEqual(self.service_1.get_division(), self.division_1)
self.assertEqual(service_3.get_division(), self.division_2)

def test_division_selected_on_ancestor(self):
"""
For a page that does not have a division selected
but an ancestor page has a division selected,
get_division should return the ancestor's selected page.
"""
self.service_1.division = self.division_1
self.service_1.save()

service_3 = ServiceAreaPageFactory(
parent=self.service_2,
title="Service 3",
)

self.assertEqual(self.service_1.get_division(), self.division_1)
self.assertEqual(self.service_2.get_division(), self.division_1)
self.assertEqual(service_3.get_division(), self.division_1)

def test_division_as_ancestor(self):
"""
For a page that does not have a division selected
but an ancestor page is a DivisionPage,
get_division should return the ancestor DivisionPage.
"""
service_a = ServiceAreaPageFactory(
title="Service A",
parent=self.division_1,
)
service_b = ServiceAreaPageFactory(
title="Service A",
parent=service_a,
)

self.assertEqual(service_a.get_division(), self.division_1)
self.assertEqual(service_b.get_division(), self.division_1)

def test_no_division(self):
"""
For a page that does not have a division selected
and is not a descendant of a DivisionPage
nor has an ancestor with a selected division,
get_division should return None.
"""
service_3 = ServiceAreaPageFactory(
parent=self.service_2,
title="Service 3",
)

self.assertIsNone(self.service_1.get_division())
self.assertIsNone(self.service_2.get_division())
self.assertIsNone(service_3.get_division())
41 changes: 34 additions & 7 deletions tbx/core/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"]

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):
Expand Down
53 changes: 53 additions & 0 deletions tbx/core/utils/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,56 @@ def theme_class(self):

class Meta:
abstract = True


class DivisionMixin(models.Model):
"""
Provides a 'division' field to allow pages to be associated to a Division.
"""

division = models.ForeignKey(
"divisions.DivisionPage",
blank=True,
null=True,
on_delete=models.SET_NULL,
)

def get_division(self):
SharmaineLim marked this conversation as resolved.
Show resolved Hide resolved
zerolab marked this conversation as resolved.
Show resolved Hide resolved
"""
Returns a DivisionPage.

If a division field is set on the current page, use that.
If not, check the ancestors.

The closest ancestor that fulfills one of the following will be followed:
- the division field is populated, OR
- the ancestor page is a DivisionPage.
"""
from tbx.divisions.models import DivisionPage

if self.division:
return self.division

try:
return next(
getattr(p, "division", None) or p
for p in self.get_ancestors().specific().order_by("-depth")
SharmaineLim marked this conversation as resolved.
Show resolved Hide resolved
if isinstance(getattr(p, "division", None) or p, DivisionPage)
)
except StopIteration:
pass

promote_panels = [
FieldPanel(
"division",
help_text=_(
"The division will be applied to this page and its descendants. "
"If no division is selected, it will be derived from "
"this page's ancestors. "
"If one of the ancestors is a division page, that will be used."
),
),
]

class Meta:
abstract = True
Empty file added tbx/divisions/__init__.py
Empty file.
Loading
Loading