Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions cms/dashboard/management/commands/build_cms_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
},
]
60 changes: 60 additions & 0 deletions cms/snippets/managers/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
`<SimpleMenuQuerySet [<SimpleMenu>]>`
"""
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)
72 changes: 72 additions & 0 deletions cms/snippets/migrations/0014_simplemenu.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
],
),
]
2 changes: 1 addition & 1 deletion cms/snippets/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion cms/snippets/models/menu_builder/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from .menu import Menu
from .menu import Menu, SimpleMenu
4 changes: 4 additions & 0 deletions cms/snippets/models/menu_builder/help_texts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
29 changes: 28 additions & 1 deletion cms/snippets/models/menu_builder/menu.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
36 changes: 36 additions & 0 deletions cms/snippets/models/menu_builder/menu_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 7 additions & 2 deletions cms/snippets/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
InternalButtonSerializer,
)
from .global_banner import (
GlobalBannerSerializer,
GlobalBannerResponseSerializer,
GlobalBannerSerializer,
)
from .menu import (
MenuResponseSerializer,
MenuSerializer,
SimpleMenuResponseSerializer,
SimpleMenuSerializer,
)
from .menu import MenuSerializer, MenuResponseSerializer
31 changes: 30 additions & 1 deletion cms/snippets/serializers/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion cms/snippets/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
from .global_banner import GlobalBannerView
from .menu import MenuView
from .menu import MenuView, SimpleMenuView
25 changes: 25 additions & 0 deletions cms/snippets/views/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from cms.snippets.serializers import (
MenuResponseSerializer,
MenuSerializer,
SimpleMenuResponseSerializer,
SimpleMenuSerializer,
)


Expand All @@ -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)
Loading
Loading