Skip to content

Commit

Permalink
Release calendar pages (#16)
Browse files Browse the repository at this point in the history
* Add the Contact Details snippet
   https://jira.ons.gov.uk/browse/CMS-80
* Add a basic table block
* Add the release calendar app with models
* Programmatically create the release calendar index
* Add the basic templates
* Add a base form class for easier validation
* Add icons to the various panels
* Add validation logic
* Update templates
* Add tests
* Expand core tests
* Expand coverage configuration
  adds a few extra files to the ignore.
  and explicitly filters the RemovedInDjango60Warning

* Tidy up based on code review
  * Prevent release calendar page deletion through the UI
  * Move the changes to the release date below contact details
    e.g. https://www.ons.gov.uk/releases/disabilitypaygapsintheuk2014to2023
* Fail if under 90% test coverage
  • Loading branch information
zerolab authored Nov 8, 2024
1 parent 87d5cc1 commit fae6ce0
Show file tree
Hide file tree
Showing 52 changed files with 2,143 additions and 182 deletions.
2 changes: 1 addition & 1 deletion .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ trim_trailing_whitespace = true
# Possible values - true, false
insert_final_newline = true

[*.{js,html,json,yaml,yml}]
[*.{js,json,yaml,yml}]
indent_size = 2

[*.{json,yaml,yml}]
Expand Down
6 changes: 4 additions & 2 deletions .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ ignore=CVS
# ignore-list. The regex matches against paths and can be in Posix or Windows
# format. Because '\\' represents the directory delimiter on Windows systems,
# it can't be used as an escape character.
ignore-paths=.*/migrations,.venv,venv,.mypy_cache,node_modules
ignore-paths=.*/migrations,.venv,venv,.mypy_cache,node_modules,cms/settings/formats

# Files or directories matching the regular expression patterns are skipped.
# The regex matches against base names, not paths. The default value ignores
Expand Down Expand Up @@ -434,12 +434,14 @@ disable=raw-checker-failed,
duplicate-code,
wrong-import-order,
missing-module-docstring,
too-many-ancestors
too-many-ancestors,
fixme

# note:
# - wrong-import-order: covered by ruff
# - missing-module-docstring: because they should be self-explanatory
# - too-many-ancestors: because of our and Wagtail's use of mixins
# - fixme: because we want to leave TODO notes for future features.

# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ test: ## Run the tests and check coverage.
poetry run coverage erase
poetry run coverage run ./manage.py test --parallel --settings=cms.settings.test
poetry run coverage combine
poetry run coverage report
poetry run coverage report --fail-under=90

.PHONY: mypy
mypy: ## Run mypy.
Expand Down
8 changes: 5 additions & 3 deletions cms/core/blocks/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
from .embeddable import DocumentBlock, DocumentsBlock, ImageBlock, ONSEmbedBlock
from .markup import HeadingBlock, QuoteBlock
from .markup import BasicTableBlock, HeadingBlock, QuoteBlock
from .panels import PanelBlock
from .related import RelatedContentBlock, RelatedLinksBlock
from .related import LinkBlock, RelatedContentBlock, RelatedLinksBlock

__all__ = [
"BasicTableBlock",
"DocumentBlock",
"DocumentsBlock",
"HeadingBlock",
"ImageBlock",
"LinkBlock",
"ONSEmbedBlock",
"PanelBlock",
"QuoteBlock",
"RelatedContentBlock",
"RelatedLinksBlock",
"DocumentsBlock",
]
9 changes: 6 additions & 3 deletions cms/core/blocks/embeddable.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.template.defaultfilters import filesizeformat
from django.utils.translation import gettext as _
from wagtail import blocks
from wagtail.blocks import StructBlockValidationError
from wagtail.documents.blocks import DocumentChooserBlock
Expand All @@ -24,7 +25,7 @@ class Meta: # pylint: disable=missing-class-docstring,too-few-public-methods


class DocumentBlockStructValue(blocks.StructValue):
"""Bespoke StructValue to convert a struct block value to DS macro macros data."""
"""Bespoke StructValue to convert a struct block value to DS macro data."""

def as_macro_data(self) -> dict[str, str | bool | dict]:
"""Return the value as a macro data dict."""
Expand Down Expand Up @@ -83,10 +84,12 @@ def clean(self, value: "StructValue") -> "StructValue":
errors = {}

if not value["url"].startswith(settings.ONS_EMBED_PREFIX):
errors["url"] = ValidationError(f"The URL must start with {settings.ONS_EMBED_PREFIX}")
errors["url"] = ValidationError(
_("The URL must start with %(prefix)s") % {"prefix": settings.ONS_EMBED_PREFIX}
)

if errors:
raise StructBlockValidationError(errors)
raise StructBlockValidationError(block_errors=errors)

return super().clean(value)

Expand Down
68 changes: 67 additions & 1 deletion cms/core/blocks/markup.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from typing import Any
from typing import TYPE_CHECKING, Any, Union

from django.core.exceptions import ValidationError
from django.utils.text import slugify
from django.utils.translation import gettext as _
from wagtail import blocks
from wagtail.contrib.table_block.blocks import TableBlock as WagtailTableBlock

if TYPE_CHECKING:
from django.utils.safestring import SafeString


class HeadingBlock(blocks.CharBlock):
Expand Down Expand Up @@ -39,3 +45,63 @@ class QuoteBlock(blocks.StructBlock):
class Meta: # pylint: disable=missing-class-docstring,too-few-public-methods
icon = "openquote"
template = "templates/components/streamfield/quote_block.html"


class BasicTableBlock(WagtailTableBlock):
"""Provides a basic table block with data processed for Design System components."""

class Meta: # pylint: disable=missing-class-docstring,too-few-public-methods
icon = "table"
template = "templates/components/streamfield/table_block.html"
label = "Basic table"

def _get_header(self, value: dict) -> list[dict[str, str]]:
"""Prepares the table header for the Design System."""
table_header = []
if value.get("data", "") and len(value["data"]) > 0 and value.get("first_row_is_table_header", False):
for cell in value["data"][0]:
table_header.append({"value": cell or ""})
return table_header

def _get_rows(self, value: dict) -> list[dict[str, list[dict[str, str]]]]:
"""Prepares the table data rows for the Design System."""
trs = []
has_header = value.get("data", "") and len(value["data"]) > 0 and value.get("first_row_is_table_header", False)
data = value["data"][1:] if has_header else value.get("data", [])

for row in data:
tds = [{"value": cell} for cell in row]
trs.append({"tds": tds})

return trs

def clean(self, value: dict) -> dict:
"""Validate that a header was chosen, and the cells are not empty."""
if not value or not value.get("table_header_choice"):
raise ValidationError(_("Select an option for Table headers"))

data = value.get("data", [])
all_cells_empty = all(not cell for row in data for cell in row)
if all_cells_empty:
raise ValidationError(_("The table cannot be empty"))

cleaned_value: dict = super().clean(value)
return cleaned_value

def get_context(self, value: dict, parent_context: dict | None = None) -> dict:
"""Insert the DS-ready options in the template context."""
context: dict = super().get_context(value, parent_context=parent_context)

return {
"options": {
"caption": value.get("table_caption"),
"ths": self._get_header(value),
"trs": self._get_rows(value),
},
**context,
}

def render(self, value: dict, context: dict | None = None) -> Union[str, "SafeString"]:
"""The Wagtail core TableBlock has a very custom `render` method. We don't want that."""
rendered: str | SafeString = super(blocks.FieldBlock, self).render(value, context)
return rendered
42 changes: 25 additions & 17 deletions cms/core/blocks/related.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
)

if TYPE_CHECKING:
from wagtail.blocks import Block
from wagtail.blocks.list_block import ListValue


Expand All @@ -28,22 +27,26 @@ 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"):
return {"url": external_url, "text": title, "description": desc}
value = {"url": external_url, "text": title}
if has_description:
value["description"] = desc

if (page := self.get("page")) and page.live:
return {
"url": page.url,
"text": title or page,
"description": desc or getattr(page.specific_deferred, "summary", ""),
}
return None
value = {"url": page.url, "text": title or page.title}
if has_description:
value["description"] = desc or getattr(page.specific_deferred, "summary", "")

return value

class RelatedContentBlock(StructBlock):
"""Related content block with page or link validation."""

class LinkBlock(StructBlock):
"""Related link block with page or link validation."""

page = PageChooserBlock(required=False)
external_url = URLBlock(required=False, label="or External Link")
Expand All @@ -52,7 +55,6 @@ class RelatedContentBlock(StructBlock):
"When choosing a page, you can leave it blank to use the page's own title",
required=False,
)
description = CharBlock(required=False)

class Meta: # pylint: disable=missing-class-docstring,too-few-public-methods
icon = "link"
Expand All @@ -68,30 +70,36 @@ def clean(self, value: LinkBlockStructValue) -> LinkBlockStructValue:

# Require exactly one link
if not page and not external_url:
error = ValidationError("Either Page or External Link is required.", code="invalid")
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"))
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")
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")])
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."""

description = CharBlock(required=False)


class RelatedLinksBlock(ListBlock):
"""Defines a list of links block."""

def __init__(self, child_block: "Block", search_index: bool = True, **kwargs: Any) -> None:
super().__init__(child_block, search_index=search_index, **kwargs)
def __init__(self, search_index: bool = True, **kwargs: Any) -> None:
super().__init__(RelatedContentBlock, search_index=search_index, **kwargs)

self.heading = _("Related links")
self.slug = slugify(self.heading)
Expand Down
3 changes: 1 addition & 2 deletions cms/core/blocks/stream_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
HeadingBlock,
ONSEmbedBlock,
PanelBlock,
RelatedContentBlock,
RelatedLinksBlock,
)

Expand All @@ -24,7 +23,7 @@ class CoreStoryBlock(StreamBlock):
embed = EmbedBlock()
image = ImageChooserBlock()
documents = DocumentsBlock()
related_links = RelatedLinksBlock(RelatedContentBlock())
related_links = RelatedLinksBlock()
equation = MathBlock(group="DataVis", icon="decimal")
ons_embed = ONSEmbedBlock(group="DataVis", label="ONS General Embed")

Expand Down
26 changes: 26 additions & 0 deletions cms/core/migrations/0002_contactdetails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 5.1.2 on 2024-10-28 16:51

import wagtail.search.index
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0001_initial"),
]

operations = [
migrations.CreateModel(
name="ContactDetails",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)),
("name", models.CharField(max_length=255)),
("email", models.EmailField(max_length=254)),
("phone", models.CharField(blank=True, max_length=255)),
],
options={
"verbose_name_plural": "contact details",
},
bases=(wagtail.search.index.Indexed, models.Model),
),
]
1 change: 1 addition & 0 deletions cms/core/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
from .inlines import * # noqa: F403
from .mixins import * # noqa: F403
from .settings import * # noqa: F403
from .snippets import * # noqa: F403
12 changes: 5 additions & 7 deletions cms/core/models/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@
if TYPE_CHECKING:
from django.core.paginator import Page
from django.http import HttpRequest
from wagtail.admin.panels import Panel

__all__ = [
"ListingFieldsMixin",
"SocialFieldsMixin",
]
__all__ = ["ListingFieldsMixin", "SocialFieldsMixin", "SubpageMixin"]


class ListingFieldsMixin(models.Model):
Expand Down Expand Up @@ -43,7 +41,7 @@ class ListingFieldsMixin(models.Model):
class Meta:
abstract = True

promote_panels: ClassVar[list[FieldPanel]] = [
promote_panels: ClassVar[list["Panel"]] = [
MultiFieldPanel(
heading="Listing information",
children=[
Expand All @@ -70,7 +68,7 @@ class SocialFieldsMixin(models.Model):
class Meta:
abstract = True

promote_panels: ClassVar[list[FieldPanel]] = [
promote_panels: ClassVar[list["Panel"]] = [
MultiFieldPanel(
heading="Social networks",
children=[
Expand All @@ -96,7 +94,7 @@ def get_paginator_page(self, request: "HttpRequest") -> "Page":
raise Http404 from e

def get_context(self, request: "HttpRequest", *args: Any, **kwargs: Any) -> dict:
"""Add paginage subpages to the template context."""
"""Add paginated subpages to the template context."""
context: dict = super().get_context(request, *args, **kwargs) # type: ignore[misc]
context["subpages"] = self.get_paginator_page(request)
return context
35 changes: 35 additions & 0 deletions cms/core/models/snippets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from typing import ClassVar

from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.search import index
from wagtail.snippets.models import register_snippet


@register_snippet
class ContactDetails(index.Indexed, models.Model):
"""A model for contact details."""

name = models.CharField(max_length=255)
email = models.EmailField()
phone = models.CharField(max_length=255, blank=True)

panels: ClassVar[list[FieldPanel]] = [
FieldPanel("name"),
FieldPanel("email"),
FieldPanel("phone"),
]

search_fields: ClassVar[list[index.SearchField | index.AutocompleteField]] = [
*index.Indexed.search_fields,
index.SearchField("name"),
index.AutocompleteField("name"),
index.SearchField("email"),
index.SearchField("phone"),
]

class Meta:
verbose_name_plural = "contact details"

def __str__(self) -> str:
return str(self.name)
Loading

0 comments on commit fae6ce0

Please sign in to comment.