From a46b7998a732083ea4ee6878cd44168f97c60d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Mon, 3 Feb 2025 16:25:31 +0000 Subject: [PATCH 01/16] Add featured series, explore more, related articles/methodologies --- cms/topics/apps.py | 6 ++ cms/topics/blocks.py | 34 ++++++++ ...02_featured_series_explore_more_related.py | 84 +++++++++++++++++++ cms/topics/models.py | 49 ++++++++++- 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 cms/topics/apps.py create mode 100644 cms/topics/blocks.py create mode 100644 cms/topics/migrations/0002_featured_series_explore_more_related.py diff --git a/cms/topics/apps.py b/cms/topics/apps.py new file mode 100644 index 00000000..4f66e7c6 --- /dev/null +++ b/cms/topics/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TopicsConfig(AppConfig): + default_auto_field = "django.db.models.AutoField" + name = "cms.topics" diff --git a/cms/topics/blocks.py b/cms/topics/blocks.py new file mode 100644 index 00000000..7be0438b --- /dev/null +++ b/cms/topics/blocks.py @@ -0,0 +1,34 @@ +from django.utils.translation import gettext_lazy as _ +from wagtail.blocks import CharBlock, PageChooserBlock, StreamBlock, StructBlock, URLBlock +from wagtail.images.blocks import ImageChooserBlock + + +class ExploreMoreExternalLinkBlock(StructBlock): + url = URLBlock(label=_("External URL")) + title = CharBlock() + description = CharBlock() + thumbnail = ImageChooserBlock() + + class Meta: + icon = "link" + + +class ExploreMoreInternalLinkBlock(StructBlock): + page = PageChooserBlock() + title = CharBlock(required=False, help_text=_("Use to override the chosen page title.")) + description = CharBlock( + required=False, + help_text=_( + "Use to override the chosen page description. " + "By default, we will attempt to use the listing summary or the summary field." + ), + ) + thumbnail = ImageChooserBlock(required=False, help_text=_("Use to override the chosen page listing image.")) + + class Meta: + icon = "doc-empty-inverse" + + +class ExploreMoreStoryBlock(StreamBlock): + external_link = ExploreMoreExternalLinkBlock() + internal_link = ExploreMoreInternalLinkBlock() diff --git a/cms/topics/migrations/0002_featured_series_explore_more_related.py b/cms/topics/migrations/0002_featured_series_explore_more_related.py new file mode 100644 index 00000000..495523bf --- /dev/null +++ b/cms/topics/migrations/0002_featured_series_explore_more_related.py @@ -0,0 +1,84 @@ +# Generated by Django 5.1.4 on 2025-02-03 16:18 + +import django.db.models.deletion +import modelcluster.fields +from django.db import migrations, models + +import cms.core.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("articles", "0001_initial"), + ("topics", "0001_initial"), + ("wagtailcore", "0094_alter_page_locale"), + ] + + operations = [ + migrations.AddField( + model_name="topicpage", + name="explore_more", + field=cms.core.fields.StreamField(blank=True, block_lookup={}), + ), + migrations.AddField( + model_name="topicpage", + name="featured_series", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="featured_on_topic", + to="articles.articleseriespage", + ), + ), + migrations.CreateModel( + name="TopicPageRelatedArticle", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ("sort_order", models.IntegerField(blank=True, editable=False, null=True)), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="+", to="wagtailcore.page" + ), + ), + ( + "parent", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="related_articles", + to="topics.topicpage", + ), + ), + ], + options={ + "ordering": ["sort_order"], + "abstract": False, + }, + ), + migrations.CreateModel( + name="TopicPageRelatedMethodology", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ("sort_order", models.IntegerField(blank=True, editable=False, null=True)), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="+", to="wagtailcore.page" + ), + ), + ( + "parent", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="related_methodologies", + to="topics.topicpage", + ), + ), + ], + options={ + "ordering": ["sort_order"], + "abstract": False, + }, + ), + ] diff --git a/cms/topics/models.py b/cms/topics/models.py index 11d86d19..297d7236 100644 --- a/cms/topics/models.py +++ b/cms/topics/models.py @@ -1,16 +1,44 @@ from typing import TYPE_CHECKING, ClassVar from django.conf import settings +from django.db import models from django.utils.translation import gettext_lazy as _ -from wagtail.admin.panels import FieldPanel +from modelcluster.fields import ParentalKey +from wagtail.admin.panels import FieldPanel, InlinePanel, PageChooserPanel from wagtail.fields import RichTextField +from wagtail.models import Orderable +from wagtail.search import index +from cms.core.fields import StreamField from cms.core.models import BasePage +from cms.topics.blocks import ExploreMoreStoryBlock if TYPE_CHECKING: from wagtail.admin.panels import Panel +class TopicPageRelatedArticle(Orderable): + parent = ParentalKey("TopicPage", on_delete=models.CASCADE, related_name="related_articles") + page = models.ForeignKey( + "wagtailcore.Page", + on_delete=models.CASCADE, + related_name="+", + ) + + panels: ClassVar[list[FieldPanel]] = [PageChooserPanel("page", page_type=["articles.StatisticalArticlePage"])] + + +class TopicPageRelatedMethodology(Orderable): + parent = ParentalKey("TopicPage", on_delete=models.CASCADE, related_name="related_methodologies") + page = models.ForeignKey( + "wagtailcore.Page", + on_delete=models.CASCADE, + related_name="+", + ) + + panels: ClassVar[list[FieldPanel]] = [PageChooserPanel("page", page_type=["methodology.MethodologyPage"])] + + class TopicPage(BasePage): # type: ignore[django-manager-missing] """The Topic page model.""" @@ -20,5 +48,22 @@ class TopicPage(BasePage): # type: ignore[django-manager-missing] page_description = _("A specific topic page. e.g. 'Public sector finance' or 'Inflation and price indices'.") summary = RichTextField(features=settings.RICH_TEXT_BASIC) + featured_series = models.ForeignKey( + "articles.ArticleSeriesPage", + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="featured_on_topic", + ) + explore_more = StreamField(ExploreMoreStoryBlock(), blank=True) + + content_panels: ClassVar[list["Panel"]] = [ + *BasePage.content_panels, + FieldPanel("summary"), + FieldPanel("featured_series", heading=_("Featured")), + InlinePanel("related_articles", heading=_("Highlighted articles")), + InlinePanel("related_methodologies", heading=_("Highlighted methods and quality information")), + FieldPanel("explore_more", heading=_("Explore more")), + ] - content_panels: ClassVar[list["Panel"]] = [*BasePage.content_panels, FieldPanel("summary")] + search_fields: ClassVar[list[index.BaseField]] = [*BasePage.search_fields, index.SearchField("summary")] From 7886d7a264dad79ab11702bfccd6b17640b2107f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Mon, 3 Feb 2025 16:27:49 +0000 Subject: [PATCH 02/16] Exclude local settings from mypy --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 21dff07f..e8007e2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,6 +140,10 @@ ignore_errors = true module = "*.tests.*" ignore_errors = true +[[tool.mypy.overrides]] +module = "cms.settings.local" +ignore_errors = true + [tool.django-stubs] django_settings_module = "cms.settings.test" From 7bbd616275192dc263c723a7b85b1a99fac746dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Mon, 3 Feb 2025 17:24:50 +0000 Subject: [PATCH 03/16] Add toc, featured article and processed articles/methodologies --- cms/jinja2/templates/pages/topic_page.html | 51 +++++++++++++++- cms/topics/models.py | 70 ++++++++++++++++++++-- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/cms/jinja2/templates/pages/topic_page.html b/cms/jinja2/templates/pages/topic_page.html index 178f627c..a4c439a1 100644 --- a/cms/jinja2/templates/pages/topic_page.html +++ b/cms/jinja2/templates/pages/topic_page.html @@ -1,4 +1,5 @@ {% extends "templates/base_page.html" %} +{% from "components/table-of-contents/_macro.njk" import onsTableOfContents %} {% block header_area %}
@@ -21,9 +22,53 @@

+{% endblock %} + +{% block main %} +
+
+ {% with toc_title=_("Contents"), toc_aria_label=_("Sections in this page") %} + {# fmt:off #} + {{- + onsTableOfContents({ + "title": toc_title, + "ariaLabel": toc_aria_label, + "itemsList": table_of_contents + }) + }} + {# fmt:on #} + {% endwith %} +
+ +
+ {% if featured_item %} + + {% endif %} - {% if page.headline_figures %} - {% include_block page.headline_figures %} - {% endif %} + {% if page.processed_articles %} + + {% endif %} + {% if page.processed_methodologies %} + + {% endif %} + + {% if page.explore_more %} +
+

{{ _("Explore more") }}

+ + {{ page.explore_more }} +
+ {% endif %} +
+
{% endblock %} diff --git a/cms/topics/models.py b/cms/topics/models.py index 297d7236..da417edd 100644 --- a/cms/topics/models.py +++ b/cms/topics/models.py @@ -1,25 +1,30 @@ -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from django.conf import settings from django.db import models +from django.db.models import OuterRef, QuerySet, Subquery +from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from modelcluster.fields import ParentalKey from wagtail.admin.panels import FieldPanel, InlinePanel, PageChooserPanel from wagtail.fields import RichTextField -from wagtail.models import Orderable +from wagtail.models import Orderable, Page from wagtail.search import index +from cms.articles.models import ArticleSeriesPage, StatisticalArticlePage from cms.core.fields import StreamField from cms.core.models import BasePage +from cms.methodology.models import MethodologyPage from cms.topics.blocks import ExploreMoreStoryBlock if TYPE_CHECKING: + from django.http import HttpRequest from wagtail.admin.panels import Panel class TopicPageRelatedArticle(Orderable): parent = ParentalKey("TopicPage", on_delete=models.CASCADE, related_name="related_articles") - page = models.ForeignKey( + page = models.ForeignKey[Page]( "wagtailcore.Page", on_delete=models.CASCADE, related_name="+", @@ -30,7 +35,7 @@ class TopicPageRelatedArticle(Orderable): class TopicPageRelatedMethodology(Orderable): parent = ParentalKey("TopicPage", on_delete=models.CASCADE, related_name="related_methodologies") - page = models.ForeignKey( + page = models.ForeignKey[Page]( "wagtailcore.Page", on_delete=models.CASCADE, related_name="+", @@ -67,3 +72,60 @@ class TopicPage(BasePage): # type: ignore[django-manager-missing] ] search_fields: ClassVar[list[index.BaseField]] = [*BasePage.search_fields, index.SearchField("summary")] + + def get_context(self, request: "HttpRequest", *args: Any, **kwargs: Any) -> dict: + """Additional context for the template.""" + context: dict = super().get_context(request, *args, **kwargs) + context["table_of_contents"] = self.table_of_contents + context["featured_item"] = self.latest_article_in_featured_series + return context + + @cached_property + def latest_article_in_featured_series(self) -> StatisticalArticlePage | None: + """Returns the latest article in the featured series.""" + article: StatisticalArticlePage | None = StatisticalArticlePage.objects.none() + if self.featured_series: + article = ( + StatisticalArticlePage.objects.child_of(self.featured_series) + .live() + .public() + .order_by("-release_date") + .first() + ) + return article + + @cached_property + def processed_articles(self) -> QuerySet[ArticleSeriesPage]: + """Returns the latest articles in the series relevant for this topic.""" + newest_qs = ( + StatisticalArticlePage.objects.live() + .public() + .filter(path__startswith=OuterRef("path"), depth__gte=OuterRef("depth")) + ) + newest_qs = newest_qs.order_by("-release_date") + latest_by_series = ( + ArticleSeriesPage.objects.child_of(self) + .annotate(latest_child_page=Subquery(newest_qs.values("pk")[:1])) + .values_list("latest_child_page", flat=True) + ) + + return StatisticalArticlePage.objects.filter(pk__in=latest_by_series).order_by("-release_date")[:3] + + @cached_property + def processed_methodologies(self) -> QuerySet[MethodologyPage]: + pages: QuerySet[MethodologyPage] = MethodologyPage.objects.child_of(self).live().public()[:3] + return pages + + @cached_property + def table_of_contents(self) -> list[dict[str, str | object]]: + """Table of contents formatted to Design System specs.""" + items = [] + if self.latest_article_in_featured_series: + items += [{"url": "#featured", "text": _("Featured")}] + if self.processed_articles: # pylint: disable=not-an-iterable,useless-suppression + items += [{"url": "#related-articles", "text": _("Related articles")}] + if self.processed_methodologies: + items += [{"url": "#related-methods", "text": _("Methods and quality information")}] + if self.explore_more: + items += [{"url": "#explore-more", "text": _("Explore more")}] + return items From 34478188d2b6524aa5ae45bebd8f92326cac8d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Mon, 3 Feb 2025 17:25:28 +0000 Subject: [PATCH 04/16] Translate missed string in template --- .../templates/pages/release_calendar/release_calendar_page.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/jinja2/templates/pages/release_calendar/release_calendar_page.html b/cms/jinja2/templates/pages/release_calendar/release_calendar_page.html index 7f6b3dd7..f7e9fb20 100644 --- a/cms/jinja2/templates/pages/release_calendar/release_calendar_page.html +++ b/cms/jinja2/templates/pages/release_calendar/release_calendar_page.html @@ -67,7 +67,7 @@

-

Summary

+

{{ _("Summary") }}

{{ page.summary|richtext() }}
From eefa92170f898bcc70fa0e24fe34d1d697a2d576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Tue, 4 Feb 2025 16:21:53 +0000 Subject: [PATCH 05/16] Add component for featured article --- .../components/featured/featured-article.html | 26 ++++++++++++ .../sass/components/_featured-article.scss | 37 +++++++++++++++++ .../sass/components/_featured-chart.scss | 41 ------------------- cms/static_src/sass/main.scss | 2 +- 4 files changed, 64 insertions(+), 42 deletions(-) create mode 100644 cms/jinja2/templates/components/featured/featured-article.html create mode 100644 cms/static_src/sass/components/_featured-article.scss delete mode 100644 cms/static_src/sass/components/_featured-chart.scss diff --git a/cms/jinja2/templates/components/featured/featured-article.html b/cms/jinja2/templates/components/featured/featured-article.html new file mode 100644 index 00000000..94cfe4ab --- /dev/null +++ b/cms/jinja2/templates/components/featured/featured-article.html @@ -0,0 +1,26 @@ +
+

+ {{ article.display_title }} +

+ + + {% if article.listing_image %} +
+ {{ image(article.listing_image, "width-1000") }} +
+ {% endif %} + + {% if article.main_points_summary %} +
+ {{ article.main_points_summary|richtext }} +
+ {% endif %} +
diff --git a/cms/static_src/sass/components/_featured-article.scss b/cms/static_src/sass/components/_featured-article.scss new file mode 100644 index 00000000..0814f1f5 --- /dev/null +++ b/cms/static_src/sass/components/_featured-article.scss @@ -0,0 +1,37 @@ +@use 'config' as *; + +// Uses similar styles to the featured document block +// Does not use flex display as it messes up the iframe resize for pym +.featured-article { + background-color: var(--ons-color-banner-bg); + outline: 2px solid transparent; // for high contrast mode + outline-offset: -2px; + padding: rem-sizing(32); + margin-bottom: rem-sizing(64); + + &__metadata { + padding: 0; + margin-bottom: 2rem; + } + + &__attribute { + color: var(--ons-color-text-metadata); + display: inline-block; + margin: 0 1rem 0 0; + } + + &__description { + margin-bottom: 0; + max-width: 660px; + + p:last-of-type { + margin-bottom: 0; + } + } + + &__embed-container { + padding: 0 rem-sizing(32); + border-bottom: 1px solid var(--ons-color-grey-35); + margin: 0 (-(rem-sizing(32))) rem-sizing(32); + } +} diff --git a/cms/static_src/sass/components/_featured-chart.scss b/cms/static_src/sass/components/_featured-chart.scss deleted file mode 100644 index 6d3ce1cf..00000000 --- a/cms/static_src/sass/components/_featured-chart.scss +++ /dev/null @@ -1,41 +0,0 @@ -@use 'config' as *; - -/* Uses similar styles to the featured document block - Does not use flex display as it messes up the iframe resize for pym */ - -.featured-chart { - margin-bottom: rem-sizing(64); - - &__item { - background-color: var(--ons-color-banner-bg); - outline: 2px solid transparent; // for high contrast mode - outline-offset: -2px; - padding: rem-sizing(32); - } - - &__metadata { - padding: 0; - margin-bottom: rem-sizing(16); - } - - &__item-attribute { - color: var(--ons-color-text-metadata); - display: inline-block; - margin: 0 1rem 0 0; - } - - &__item-description { - margin-bottom: 0; - max-width: 660px; - - p:last-of-type { - margin-bottom: 0; - } - } - - &__embed-container { - padding-top: rem-sizing(32); - border-bottom: 1px solid var(--ons-color-grey-35); - margin-bottom: rem-sizing(32); - } -} diff --git a/cms/static_src/sass/main.scss b/cms/static_src/sass/main.scss index 5591bf0c..bcc9b68c 100644 --- a/cms/static_src/sass/main.scss +++ b/cms/static_src/sass/main.scss @@ -11,7 +11,7 @@ Don't add css code directly to this file, add it to the relevant component file @use 'components/document-list-block'; @use 'components/chart-embed'; @use 'components/contact-details'; -@use 'components/featured-chart'; +@use 'components/featured-article'; @use 'components/headline-figures'; @use 'components/navigation'; @use 'components/panel'; From 8695e99fddc31664c0990c6c355673e5d356e1ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Tue, 4 Feb 2025 16:25:53 +0000 Subject: [PATCH 06/16] Add a label/release date properties to relevant page models --- cms/articles/models.py | 5 +++++ cms/core/models/base.py | 6 ++++++ cms/methodology/models.py | 11 +++++++++++ cms/themes/models.py | 5 +++++ cms/topics/models.py | 5 +++++ 5 files changed, 32 insertions(+) diff --git a/cms/articles/models.py b/cms/articles/models.py index a1f33afc..3f1da8d4 100644 --- a/cms/articles/models.py +++ b/cms/articles/models.py @@ -23,6 +23,7 @@ from django.http import HttpRequest from django.http.response import HttpResponseRedirect from django.template.response import TemplateResponse + from django.utils.functional import Promise from wagtail.admin.panels import Panel @@ -196,6 +197,10 @@ def get_admin_display_title(self) -> str: """Changes the admin display title to include the parent title.""" return f"{self.get_parent().title}: {self.draft_title or self.title}" + @property + def label(self) -> "Promise": + return _("Article") + @property def display_title(self) -> str: """Returns the page display title. If the news headline is set, it takes precedence over the series+title.""" diff --git a/cms/core/models/base.py b/cms/core/models/base.py index dd3c361a..2763afef 100644 --- a/cms/core/models/base.py +++ b/cms/core/models/base.py @@ -3,6 +3,7 @@ from django.conf import settings from django.utils.decorators import method_decorator from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ from wagtail.models import Page from wagtail.query import PageQuerySet @@ -13,6 +14,7 @@ if TYPE_CHECKING: from django.db import models + from django.utils.functional import Promise from wagtail.admin.panels import FieldPanel from wagtail.contrib.settings.models import ( BaseGenericSetting as _WagtailBaseGenericSetting, @@ -58,6 +60,10 @@ class Meta: *SocialFieldsMixin.promote_panels, ] + @property + def label(self) -> "Promise": + return _("Page") + @cached_property def related_pages(self) -> PageQuerySet: """Return a `PageQuerySet` of items related to this page via the diff --git a/cms/methodology/models.py b/cms/methodology/models.py index 10309046..321803dc 100644 --- a/cms/methodology/models.py +++ b/cms/methodology/models.py @@ -18,6 +18,9 @@ from cms.core.query import order_by_pk_position if TYPE_CHECKING: + import datetime + + from django.utils.functional import Promise from wagtail.admin.panels import Panel from wagtail.query import PageQuerySet @@ -99,6 +102,14 @@ def get_context(self, request: HttpRequest, *args: Any, **kwargs: Any) -> dict: context["related_publications"] = self.get_formatted_related_publications_list(request=request) return context + @property + def label(self) -> "Promise": + return _("Methodology") + + @property + def release_date(self) -> "datetime.date": + return self.publication_date + @cached_property def related_publications(self) -> "PageQuerySet": """Return a `PageQuerySet` of the StatisticalArticlePage page model via the diff --git a/cms/themes/models.py b/cms/themes/models.py index a316cabc..5a58f1ba 100644 --- a/cms/themes/models.py +++ b/cms/themes/models.py @@ -8,6 +8,7 @@ from cms.core.models import BasePage if TYPE_CHECKING: + from django.utils.functional import Promise from wagtail.admin.panels import Panel @@ -22,3 +23,7 @@ class ThemePage(BasePage): # type: ignore[django-manager-missing] summary = RichTextField(features=settings.RICH_TEXT_BASIC) content_panels: ClassVar[list["Panel"]] = [*BasePage.content_panels, FieldPanel("summary")] + + @property + def label(self) -> "Promise": + return _("Theme") diff --git a/cms/topics/models.py b/cms/topics/models.py index da417edd..344dfd88 100644 --- a/cms/topics/models.py +++ b/cms/topics/models.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: from django.http import HttpRequest + from django.utils.functional import Promise from wagtail.admin.panels import Panel @@ -80,6 +81,10 @@ def get_context(self, request: "HttpRequest", *args: Any, **kwargs: Any) -> dict context["featured_item"] = self.latest_article_in_featured_series return context + @property + def label(self) -> "Promise": + return _("Topic") + @cached_property def latest_article_in_featured_series(self) -> StatisticalArticlePage | None: """Returns the latest article in the featured series.""" From 262505a49c889de167e627f41147017607720b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Tue, 4 Feb 2025 16:35:53 +0000 Subject: [PATCH 07/16] Update the topic template --- cms/core/utils.py | 45 ++++++++++++++++++++++ cms/jinja2/templates/pages/topic_page.html | 16 +++++--- cms/topics/models.py | 12 +++++- 3 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 cms/core/utils.py diff --git a/cms/core/utils.py b/cms/core/utils.py new file mode 100644 index 00000000..e9cc0f13 --- /dev/null +++ b/cms/core/utils.py @@ -0,0 +1,45 @@ +from typing import TYPE_CHECKING, Any, Optional, TypedDict + +from django.db.models import QuerySet +from django.utils.formats import date_format +from django.utils.translation import gettext_lazy as _ + +if TYPE_CHECKING: + from django.http import HttpRequest + from wagtail.models import Page + + +class DocumentListItem(TypedDict): + title: dict[str, str] + metadata: dict[str, Any] + description: str + + +def get_formatted_pages_list( + pages: list["Page"] | QuerySet["Page"], request: Optional["HttpRequest"] = None +) -> list[DocumentListItem]: + """Returns a formatted list of page data for the documentList DS macro. + + See the search results section in https://service-manual.ons.gov.uk/design-system/components/document-list. + """ + data = [] + for page in pages: + datum: DocumentListItem = { + "title": { + "text": page.title, + "url": page.get_url(request=request), + }, + "metadata": { + "object": {"text": getattr(page, "label", _("Page"))}, + }, + "description": getattr(page, "listing_summary", "") or getattr(page, "summary", ""), + } + if hasattr(page, "release_date"): + datum["metadata"]["date"] = { + "prefix": _("Released"), + "showPrefix": True, + "iso": date_format(page.release_date, "c"), + "short": date_format(page.release_date, "DATE_FORMAT"), + } + data.append(datum) + return data diff --git a/cms/jinja2/templates/pages/topic_page.html b/cms/jinja2/templates/pages/topic_page.html index a4c439a1..b1b2afa5 100644 --- a/cms/jinja2/templates/pages/topic_page.html +++ b/cms/jinja2/templates/pages/topic_page.html @@ -1,5 +1,6 @@ {% extends "templates/base_page.html" %} {% from "components/table-of-contents/_macro.njk" import onsTableOfContents %} +{% from "components/document-list/_macro.njk" import onsDocumentList %} {% block header_area %}
@@ -25,7 +26,7 @@

{% endblock %} {% block main %} -
+
{% with toc_title=_("Contents"), toc_aria_label=_("Sections in this page") %} {# fmt:off #} @@ -44,21 +45,24 @@

{% if featured_item %} {% endif %} - {% if page.processed_articles %} + {% if formatted_articles %} {% endif %} - {% if page.processed_methodologies %} + {% if formatted_methodologies %} {% endif %} diff --git a/cms/topics/models.py b/cms/topics/models.py index 344dfd88..0ffb1276 100644 --- a/cms/topics/models.py +++ b/cms/topics/models.py @@ -14,6 +14,7 @@ from cms.articles.models import ArticleSeriesPage, StatisticalArticlePage from cms.core.fields import StreamField from cms.core.models import BasePage +from cms.core.utils import get_formatted_pages_list from cms.methodology.models import MethodologyPage from cms.topics.blocks import ExploreMoreStoryBlock @@ -79,6 +80,8 @@ def get_context(self, request: "HttpRequest", *args: Any, **kwargs: Any) -> dict context: dict = super().get_context(request, *args, **kwargs) context["table_of_contents"] = self.table_of_contents context["featured_item"] = self.latest_article_in_featured_series + context["formatted_articles"] = get_formatted_pages_list(self.processed_articles, request=request) + context["formatted_methodologies"] = get_formatted_pages_list(self.processed_methodologies, request=request) return context @property @@ -101,7 +104,10 @@ def latest_article_in_featured_series(self) -> StatisticalArticlePage | None: @cached_property def processed_articles(self) -> QuerySet[ArticleSeriesPage]: - """Returns the latest articles in the series relevant for this topic.""" + """Returns the latest articles in the series relevant for this topic. + TODO: handle manually added articles + TODO: extend when Taxonomy is in. + """ newest_qs = ( StatisticalArticlePage.objects.live() .public() @@ -118,6 +124,10 @@ def processed_articles(self) -> QuerySet[ArticleSeriesPage]: @cached_property def processed_methodologies(self) -> QuerySet[MethodologyPage]: + """Returns the latest methodologies relevant for this topic. + TODO: handle manually added methodologies + TODO: extend when Taxonomy is in. + """ pages: QuerySet[MethodologyPage] = MethodologyPage.objects.child_of(self).live().public()[:3] return pages From c829ff59689f6d6e5425fee81b64d52d1c41273f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Wed, 5 Feb 2025 14:33:19 +0000 Subject: [PATCH 08/16] Wire in the Explore More block --- .../explore_more_stream_block.html | 2 + cms/jinja2/templates/pages/topic_page.html | 2 +- cms/topics/blocks.py | 57 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 cms/jinja2/templates/components/streamfield/explore_more_stream_block.html diff --git a/cms/jinja2/templates/components/streamfield/explore_more_stream_block.html b/cms/jinja2/templates/components/streamfield/explore_more_stream_block.html new file mode 100644 index 00000000..a56c9254 --- /dev/null +++ b/cms/jinja2/templates/components/streamfield/explore_more_stream_block.html @@ -0,0 +1,2 @@ +{% from "components/document-list/_macro.njk" import onsDocumentList %} +{{ onsDocumentList({"documents": formatted_items}) }} diff --git a/cms/jinja2/templates/pages/topic_page.html b/cms/jinja2/templates/pages/topic_page.html index b1b2afa5..ffee8d96 100644 --- a/cms/jinja2/templates/pages/topic_page.html +++ b/cms/jinja2/templates/pages/topic_page.html @@ -70,7 +70,7 @@

{{ _("Methods and quality information") }}

{{ _("Explore more") }}

- {{ page.explore_more }} + {% include_block page.explore_more %}
{% endif %}

diff --git a/cms/topics/blocks.py b/cms/topics/blocks.py index 7be0438b..886417d8 100644 --- a/cms/topics/blocks.py +++ b/cms/topics/blocks.py @@ -1,7 +1,13 @@ +from typing import TYPE_CHECKING + from django.utils.translation import gettext_lazy as _ from wagtail.blocks import CharBlock, PageChooserBlock, StreamBlock, StructBlock, URLBlock from wagtail.images.blocks import ImageChooserBlock +if TYPE_CHECKING: + from wagtail.blocks import StreamValue, StructValue + from wagtail.models import Page + class ExploreMoreExternalLinkBlock(StructBlock): url = URLBlock(label=_("External URL")) @@ -12,6 +18,21 @@ class ExploreMoreExternalLinkBlock(StructBlock): class Meta: icon = "link" + def get_formatted_value(self, value: "StructValue", context: dict | None = None) -> dict[str, str | dict]: # pylint: disable=unused-argument + """Returns the value formatted for the Design System onsDocumentList macro.""" + renditions = value["thumbnail"].get_renditions("fill-144x100", "fill-288x200") + return { + "thumbnail": { + "smallSrc": renditions["fill-144x100"].url, + "largeSrc": renditions["fill-288x200"].url, + }, + "title": { + "text": value["title"], + "url": value["url"], + }, + "description": value["description"], + } + class ExploreMoreInternalLinkBlock(StructBlock): page = PageChooserBlock() @@ -28,7 +49,43 @@ class ExploreMoreInternalLinkBlock(StructBlock): class Meta: icon = "doc-empty-inverse" + def get_formatted_value(self, value: "StructValue", context: dict | None = None) -> dict[str, str | dict]: + """Returns the value formatted for the Design System onsDocumentList macro.""" + page: Page = value["page"].specific_deferred + if not page.live: + return {} + + formatted_value = { + "title": { + "text": value["title"] or getattr(page, "display_title", page.title), + "url": page.get_url(request=context.get("request") if context else None), + }, + "description": value["description"] or getattr(page, "listing_summary", "") or getattr(page, "summary", ""), + } + image = value["thumbnail"] or page.listing_image + if image: + renditions = image.get_renditions("fill-144x100", "fill-288x200") + formatted_value["thumbnail"] = { + "smallSrc": renditions["fill-144x100"].url, + "largeSrc": renditions["fill-288x200"].url, + } + return formatted_value + class ExploreMoreStoryBlock(StreamBlock): external_link = ExploreMoreExternalLinkBlock() internal_link = ExploreMoreInternalLinkBlock() + + class Meta: + template = "templates/components/streamfield/explore_more_stream_block.html" + + def get_context(self, value: "StreamValue", parent_context: dict | None = None) -> dict: + context: dict = super().get_context(value, parent_context=parent_context) + + formatted_items = [] + for child in value: + if formatted_item := child.block.get_formatted_value(child.value, context=context): + formatted_items.append(formatted_item) + + context["formatted_items"] = formatted_items + return context From 0fc4423e5540795bbb3d3a496e1f22dc5b0b7f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Wed, 5 Feb 2025 15:26:20 +0000 Subject: [PATCH 09/16] Handle highlighted articles/methodologies --- cms/topics/models.py | 84 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/cms/topics/models.py b/cms/topics/models.py index 0ffb1276..54c3e185 100644 --- a/cms/topics/models.py +++ b/cms/topics/models.py @@ -2,7 +2,7 @@ from django.conf import settings from django.db import models -from django.db.models import OuterRef, QuerySet, Subquery +from django.db.models import OuterRef, Subquery from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from modelcluster.fields import ParentalKey @@ -14,6 +14,7 @@ from cms.articles.models import ArticleSeriesPage, StatisticalArticlePage from cms.core.fields import StreamField from cms.core.models import BasePage +from cms.core.query import order_by_pk_position from cms.core.utils import get_formatted_pages_list from cms.methodology.models import MethodologyPage from cms.topics.blocks import ExploreMoreStoryBlock @@ -24,6 +25,9 @@ from wagtail.admin.panels import Panel +MAX_ITEMS_PER_SECTION = 3 + + class TopicPageRelatedArticle(Orderable): parent = ParentalKey("TopicPage", on_delete=models.CASCADE, related_name="related_articles") page = models.ForeignKey[Page]( @@ -68,8 +72,24 @@ class TopicPage(BasePage): # type: ignore[django-manager-missing] *BasePage.content_panels, FieldPanel("summary"), FieldPanel("featured_series", heading=_("Featured")), - InlinePanel("related_articles", heading=_("Highlighted articles")), - InlinePanel("related_methodologies", heading=_("Highlighted methods and quality information")), + InlinePanel( + "related_articles", + heading=_("Highlighted articles"), + help_text=_( + f"Choose up to {MAX_ITEMS_PER_SECTION} articles to highlight. " + "The 'Related articles' section will be topped up automatically." + ), + max_num=MAX_ITEMS_PER_SECTION, + ), + InlinePanel( + "related_methodologies", + heading=_("Highlighted methods and quality information"), + help_text=_( + f"Choose up to {MAX_ITEMS_PER_SECTION} methodologies to highlight. " + "The 'Methods and quality information' section will be topped up automatically." + ), + max_num=MAX_ITEMS_PER_SECTION, + ), FieldPanel("explore_more", heading=_("Explore more")), ] @@ -103,11 +123,25 @@ def latest_article_in_featured_series(self) -> StatisticalArticlePage | None: return article @cached_property - def processed_articles(self) -> QuerySet[ArticleSeriesPage]: + def processed_articles(self) -> list[ArticleSeriesPage]: """Returns the latest articles in the series relevant for this topic. - TODO: handle manually added articles TODO: extend when Taxonomy is in. """ + # check if any statistical articles were highlighted. if so, fetch in the order they were added. + highlighted_page_pks = tuple(page_id for page_id in self.related_articles.values_list("page_id", flat=True)) + highlighted_pages = list( + order_by_pk_position( + StatisticalArticlePage.objects.live().public().defer_streamfields(), + pks=highlighted_page_pks, + exclude_non_matches=True, + ) + ) + + num_highlighted_pages = len(highlighted_pages) + if num_highlighted_pages > MAX_ITEMS_PER_SECTION - 1: + return highlighted_pages + + # supplement with the latest per series. newest_qs = ( StatisticalArticlePage.objects.live() .public() @@ -119,17 +153,47 @@ def processed_articles(self) -> QuerySet[ArticleSeriesPage]: .annotate(latest_child_page=Subquery(newest_qs.values("pk")[:1])) .values_list("latest_child_page", flat=True) ) + latest_articles = list( + StatisticalArticlePage.objects.filter(pk__in=latest_by_series) + .exclude(pk__in=highlighted_pages) + .live() + .public() + .defer_streamfields() + .order_by("-release_date")[: MAX_ITEMS_PER_SECTION - num_highlighted_pages] + ) - return StatisticalArticlePage.objects.filter(pk__in=latest_by_series).order_by("-release_date")[:3] + return highlighted_pages + latest_articles @cached_property - def processed_methodologies(self) -> QuerySet[MethodologyPage]: + def processed_methodologies(self) -> list[MethodologyPage]: """Returns the latest methodologies relevant for this topic. - TODO: handle manually added methodologies TODO: extend when Taxonomy is in. """ - pages: QuerySet[MethodologyPage] = MethodologyPage.objects.child_of(self).live().public()[:3] - return pages + # check if any methodologies were highlighted. if so, fetch in the order they were added. + highlighted_page_pks = tuple( + page_id for page_id in self.related_methodologies.values_list("page_id", flat=True) + ) + highlighted_pages = list( + order_by_pk_position( + MethodologyPage.objects.live().public().defer_streamfields(), + pks=highlighted_page_pks, + exclude_non_matches=True, + ) + ) + + num_highlighted_pages = len(highlighted_pages) + if num_highlighted_pages > MAX_ITEMS_PER_SECTION - 1: + return highlighted_pages + + # supplement the remaining slots. + pages = list( + MethodologyPage.objects.child_of(self) + .exclude(pk__in=highlighted_pages) + .live() + .public() + .order_by("-last_revised_date")[: MAX_ITEMS_PER_SECTION - num_highlighted_pages] + ) + return highlighted_pages + pages @cached_property def table_of_contents(self) -> list[dict[str, str | object]]: From e28dbe7d3acd30d5b916bd394e07951662a8ce4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Thu, 6 Feb 2025 09:05:45 +0000 Subject: [PATCH 10/16] Add tests --- cms/articles/tests/factories.py | 4 +- cms/articles/tests/test_models.py | 4 +- cms/core/tests/test_utils.py | 134 ++++++++++++++++++++++++++++ cms/core/utils.py | 6 +- cms/topics/blocks.py | 3 +- cms/topics/tests/__init__.py | 0 cms/topics/tests/factories.py | 20 ++++- cms/topics/tests/test_blocks.py | 106 ++++++++++++++++++++++ cms/topics/tests/test_models.py | 142 ++++++++++++++++++++++++++++++ 9 files changed, 409 insertions(+), 10 deletions(-) create mode 100644 cms/core/tests/test_utils.py create mode 100644 cms/topics/tests/__init__.py create mode 100644 cms/topics/tests/test_blocks.py create mode 100644 cms/topics/tests/test_models.py diff --git a/cms/articles/tests/factories.py b/cms/articles/tests/factories.py index c5fc788c..827e0761 100644 --- a/cms/articles/tests/factories.py +++ b/cms/articles/tests/factories.py @@ -18,7 +18,7 @@ class HeadlineFigureBlockFactory(wagtail_factories.StructBlockFactory): trend = wagtail_factories.CharBlockFactory() -class ArticleSeriesFactory(wagtail_factories.PageFactory): +class ArticleSeriesPageFactory(wagtail_factories.PageFactory): """Factory for ArticleSeriesPage.""" class Meta: @@ -36,7 +36,7 @@ class Meta: django_get_or_create: ClassVar[list[str]] = ["slug", "parent"] title = factory.Faker("sentence", nb_words=4) - parent = factory.SubFactory(ArticleSeriesFactory) + parent = factory.SubFactory(ArticleSeriesPageFactory) summary = factory.Faker("text", max_nb_chars=100) news_headline = factory.Faker("text", max_nb_chars=50) diff --git a/cms/articles/tests/test_models.py b/cms/articles/tests/test_models.py index 30525746..43c56335 100644 --- a/cms/articles/tests/test_models.py +++ b/cms/articles/tests/test_models.py @@ -7,7 +7,7 @@ from django.utils.formats import date_format from wagtail.test.utils import WagtailTestUtils -from cms.articles.tests.factories import ArticleSeriesFactory, StatisticalArticlePageFactory +from cms.articles.tests.factories import ArticleSeriesPageFactory, StatisticalArticlePageFactory from cms.core.tests.factories import ContactDetailsFactory @@ -16,7 +16,7 @@ class ArticleSeriesTestCase(WagtailTestUtils, TestCase): @classmethod def setUpTestData(cls): - cls.series = ArticleSeriesFactory() + cls.series = ArticleSeriesPageFactory() def test_index_redirect_404_with_no_subpages(self): """Test index path redirects to latest.""" diff --git a/cms/core/tests/test_utils.py b/cms/core/tests/test_utils.py new file mode 100644 index 00000000..399c69ca --- /dev/null +++ b/cms/core/tests/test_utils.py @@ -0,0 +1,134 @@ +from datetime import datetime + +from django.test import TestCase +from django.utils.formats import date_format + +from cms.core.models.base import BasePage +from cms.core.utils import get_formatted_pages_list + + +# DummyPage mimics the minimum attributes and methods of a Wagtail Page. +class DummyPage(BasePage): + def __init__(self, title, summary="", listing_summary="", url="https://ons.gov.uk", **kwargs): # pylint: disable=super-init-not-called + # this just set attributes manually. + self.title = title + self.summary = summary + self.listing_summary = listing_summary + self._url = url + self._release_date = kwargs.get("release_date") + + def get_url(self, request=None, current_site=None): + return self._url + + @property + def release_date(self): + return self._release_date + + class Meta: + abstract = True + + +class DummyPageWithNoReleaseDate(DummyPage): + @property + def label(self): + return "Dummy Page" + + class Meta: + abstract = True + + +class GetFormattedPagesListTests(TestCase): + def test_without_release_date_and_listing_summary(self): + # When no listing_summary and release_date, should use summary for description, + # and use the default label. + page = DummyPage(title="Test Page", summary="Test summary", listing_summary="") + result = get_formatted_pages_list([page]) + expected = { + "title": {"text": "Test Page", "url": "https://ons.gov.uk"}, + "metadata": {"object": {"text": "Page"}}, + "description": "Test summary", + } + self.assertEqual(len(result), 1) + self.assertDictEqual(result[0], expected) + + def test_with_listing_summary_overrides_summary(self): + # When listing_summary is provided, that should be used as description. + page = DummyPage(title="Test Page", summary="Test summary", listing_summary="Listing summary") + result = get_formatted_pages_list([page]) + expected = { + "title": {"text": "Test Page", "url": "https://ons.gov.uk"}, + "metadata": {"object": {"text": "Page"}}, + "description": "Listing summary", + } + self.assertEqual(len(result), 1) + self.assertDictEqual(result[0], expected) + + def test_with_custom_label(self): + # When a custom label is defined, it should be used in metadata. + page = DummyPageWithNoReleaseDate(title="Test Page", summary="Test summary", listing_summary="") + result = get_formatted_pages_list([page]) + expected = { + "title": {"text": "Test Page", "url": "https://ons.gov.uk"}, + "metadata": {"object": {"text": "Dummy Page"}}, + "description": "Test summary", + } + self.assertEqual(len(result), 1) + self.assertDictEqual(result[0], expected) + + def test_with_release_date(self): + # When release_date is provided, metadata should include date formatting. + test_date = datetime(2024, 1, 1, 12, 30) + page = DummyPage(title="Test Page", summary="Test summary", listing_summary="", release_date=test_date) + result = get_formatted_pages_list([page]) + + expected_iso = date_format(test_date, "c") + expected_short = date_format(test_date, "DATE_FORMAT") + + expected = { + "title": {"text": "Test Page", "url": "https://ons.gov.uk"}, + "metadata": { + "object": {"text": "Page"}, + "date": { + "prefix": "Released", + "showPrefix": True, + "iso": expected_iso, + "short": expected_short, + }, + }, + "description": "Test summary", + } + self.assertEqual(len(result), 1) + self.assertDictEqual(result[0], expected) + + def test_multiple_pages(self): + # Test processing multiple dummy pages + test_date = datetime(2024, 1, 1, 12, 30) + page1 = DummyPage(title="Page One", summary="Summary One", listing_summary="", release_date=test_date) + page2 = DummyPageWithNoReleaseDate(title="Page Two", summary="Summary Two", listing_summary="Listing Two") + pages = [page1, page2] + result = get_formatted_pages_list(pages) + + expected_iso = date_format(test_date, "c") + expected_short = date_format(test_date, "DATE_FORMAT") + + expected_page1 = { + "title": {"text": "Page One", "url": "https://ons.gov.uk"}, + "metadata": { + "object": {"text": "Page"}, + "date": { + "prefix": "Released", + "showPrefix": True, + "iso": expected_iso, + "short": expected_short, + }, + }, + "description": "Summary One", + } + expected_page2 = { + "title": {"text": "Page Two", "url": "https://ons.gov.uk"}, + "metadata": {"object": {"text": "Dummy Page"}}, + "description": "Listing Two", + } + self.assertEqual(len(result), 2) + self.assertDictEqual(result[0], expected_page1) + self.assertDictEqual(result[1], expected_page2) diff --git a/cms/core/utils.py b/cms/core/utils.py index e9cc0f13..88354b9e 100644 --- a/cms/core/utils.py +++ b/cms/core/utils.py @@ -34,12 +34,12 @@ def get_formatted_pages_list( }, "description": getattr(page, "listing_summary", "") or getattr(page, "summary", ""), } - if hasattr(page, "release_date"): + if release_date := page.release_date: datum["metadata"]["date"] = { "prefix": _("Released"), "showPrefix": True, - "iso": date_format(page.release_date, "c"), - "short": date_format(page.release_date, "DATE_FORMAT"), + "iso": date_format(release_date, "c"), + "short": date_format(release_date, "DATE_FORMAT"), } data.append(datum) return data diff --git a/cms/topics/blocks.py b/cms/topics/blocks.py index 886417d8..8dd488c9 100644 --- a/cms/topics/blocks.py +++ b/cms/topics/blocks.py @@ -62,8 +62,7 @@ def get_formatted_value(self, value: "StructValue", context: dict | None = None) }, "description": value["description"] or getattr(page, "listing_summary", "") or getattr(page, "summary", ""), } - image = value["thumbnail"] or page.listing_image - if image: + if image := (value["thumbnail"] or getattr(page, "listing_image", None)): renditions = image.get_renditions("fill-144x100", "fill-288x200") formatted_value["thumbnail"] = { "smallSrc": renditions["fill-144x100"].url, diff --git a/cms/topics/tests/__init__.py b/cms/topics/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cms/topics/tests/factories.py b/cms/topics/tests/factories.py index b03590ed..19b14e93 100644 --- a/cms/topics/tests/factories.py +++ b/cms/topics/tests/factories.py @@ -2,7 +2,7 @@ import wagtail_factories from cms.themes.tests.factories import ThemePageFactory -from cms.topics.models import TopicPage +from cms.topics.models import TopicPage, TopicPageRelatedArticle, TopicPageRelatedMethodology class TopicPageFactory(wagtail_factories.PageFactory): @@ -14,3 +14,21 @@ class Meta: title = factory.Faker("sentence", nb_words=4) summary = factory.Faker("text", max_nb_chars=100) parent = factory.SubFactory(ThemePageFactory) + + +class TopicPageRelatedArticleFactory(factory.django.DjangoModelFactory): + class Meta: + model = TopicPageRelatedArticle + + parent = factory.SubFactory(TopicPageFactory) + page = factory.SubFactory("cms.articles.tests.factories.StatisticalArticlePageFactory") + sort_order = factory.Sequence(lambda n: n) + + +class TopicPageRelatedMethodologyFactory(factory.django.DjangoModelFactory): + class Meta: + model = TopicPageRelatedMethodology + + parent = factory.SubFactory(TopicPageFactory) + page = factory.SubFactory("cms.methodology.tests.factories.MethodologyPageFactory") + sort_order = factory.Sequence(lambda n: n) diff --git a/cms/topics/tests/test_blocks.py b/cms/topics/tests/test_blocks.py new file mode 100644 index 00000000..ed8eaea4 --- /dev/null +++ b/cms/topics/tests/test_blocks.py @@ -0,0 +1,106 @@ +from django.test import TestCase +from wagtail.images.tests.utils import get_test_image_file + +from cms.home.models import HomePage +from cms.images.models import CustomImage +from cms.themes.tests.factories import ThemePageFactory +from cms.topics.blocks import ExploreMoreExternalLinkBlock, ExploreMoreInternalLinkBlock, ExploreMoreStoryBlock +from cms.topics.tests.factories import TopicPageFactory + + +class ExploreMoreBlocksTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.home_page = HomePage.objects.first() + cls.theme_page = ThemePageFactory(listing_summary="Theme summary") + cls.topic_page = TopicPageFactory(parent=cls.theme_page, live=False) + + cls.image = CustomImage.objects.create(title="Test Image", file=get_test_image_file()) + + def test_external_link_block__get_formatted_value(self): + block = ExploreMoreExternalLinkBlock() + value = block.to_python( + { + "url": "https://ons.gov.uk", + "title": "External Link", + "description": "Test description", + "thumbnail": self.image.pk, + } + ) + + formatted = block.get_formatted_value(value) + + self.assertEqual(formatted["title"]["text"], "External Link") + self.assertEqual(formatted["title"]["url"], "https://ons.gov.uk") + self.assertEqual(formatted["description"], "Test description") + self.assertIn("smallSrc", formatted["thumbnail"]) + self.assertIn("largeSrc", formatted["thumbnail"]) + + def test_internal_link_block__get_formatted_value_with_overrides(self): + block = ExploreMoreInternalLinkBlock() + value = block.to_python( + { + "page": self.home_page.pk, + "title": "Custom Title", + "description": "Custom Description", + "thumbnail": self.image.pk, + } + ) + + formatted = block.get_formatted_value(value) + + self.assertEqual(formatted["title"]["text"], "Custom Title") + self.assertEqual(formatted["description"], "Custom Description") + self.assertIn("smallSrc", formatted["thumbnail"]) + + def test_internal_link_block__get_formatted_value_without_overrides(self): + block = ExploreMoreInternalLinkBlock() + value = block.to_python({"page": self.home_page.pk, "title": "", "description": "", "thumbnail": None}) + + # Add page attributes that would normally exist + self.home_page.listing_summary = "Page listing summary" + self.home_page.listing_image = self.image + self.home_page.save() + + formatted = block.get_formatted_value(value) + + self.assertEqual(formatted["title"]["text"], self.home_page.title) + self.assertEqual(formatted["description"], "Page listing summary") + self.assertIn("smallSrc", formatted["thumbnail"]) + + def test_internal_link_block__get_formatted_value_with_unpublished_page_returns_empty(self): + block = ExploreMoreInternalLinkBlock() + value = block.to_python({"page": self.topic_page.pk}) + + self.assertEqual(block.get_formatted_value(value), {}) + + def test_explore_more_storyblock__get_context(self): + block = ExploreMoreStoryBlock() + + # Create stream value with valid and invalid items + stream_value = block.to_python( + [ + { + "type": "external_link", + "value": { + "url": "https://ons.gov.uk", + "title": "External", + "description": "Test", + "thumbnail": self.image.pk, + }, + }, + {"type": "internal_link", "value": {"page": self.theme_page.pk}}, + {"type": "internal_link", "value": {"page": self.topic_page.pk}}, + ] + ) + + context = block.get_context(stream_value) + formatted_items = context["formatted_items"] + + # Should only contain the valid external link + self.assertEqual(len(formatted_items), 2) + self.assertEqual(formatted_items[0]["title"]["text"], "External") + self.assertIn("thumbnail", formatted_items[0]) + + self.assertEqual(formatted_items[1]["title"]["text"], self.theme_page.title) + self.assertEqual(formatted_items[1]["description"], self.theme_page.listing_summary) diff --git a/cms/topics/tests/test_models.py b/cms/topics/tests/test_models.py new file mode 100644 index 00000000..29477d31 --- /dev/null +++ b/cms/topics/tests/test_models.py @@ -0,0 +1,142 @@ +from datetime import datetime + +from django.test import TestCase, override_settings +from django.utils.translation import gettext_lazy as _ +from wagtail.coreutils import get_dummy_request + +from cms.articles.tests.factories import ArticleSeriesPageFactory, StatisticalArticlePageFactory +from cms.home.models import HomePage +from cms.methodology.tests.factories import MethodologyPageFactory +from cms.topics.tests.factories import ( + TopicPageFactory, + TopicPageRelatedArticleFactory, + TopicPageRelatedMethodologyFactory, +) + + +class TopicPageTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.home_page = HomePage.objects.first() + cls.topic_page = TopicPageFactory(title="Test Topic") + + # Create relevant pages + cls.article_series = ArticleSeriesPageFactory(title="Article Series", parent=cls.topic_page) + cls.older_article = StatisticalArticlePageFactory( + title="Older Article", parent=cls.article_series, release_date=datetime(2024, 11, 1) + ) + cls.article = StatisticalArticlePageFactory( + title="Article", parent=cls.article_series, release_date=datetime(2024, 12, 1) + ) + + cls.topic_page.featured_series = cls.article_series + cls.topic_page.save() + + cls.methodology = MethodologyPageFactory(parent=cls.topic_page, publication_date=datetime(2024, 6, 1)) + cls.another_methodology = MethodologyPageFactory(parent=cls.topic_page, publication_date=datetime(2024, 11, 1)) + + def test_topic_label(self): + self.assertEqual(self.topic_page.label, "Topic") + + def test_latest_article_in_featured_series(self): + self.assertEqual(self.topic_page.latest_article_in_featured_series, self.article) + + another_article = StatisticalArticlePageFactory(parent=self.article_series, release_date=datetime(2025, 2, 1)) + del self.topic_page.latest_article_in_featured_series + self.assertEqual(self.topic_page.latest_article_in_featured_series, another_article) + + def test_processed_articles_combines_highlighted_and_latest_in_series(self): + # Create additional articles + article_in_other_series = StatisticalArticlePageFactory( + title="Article in other series", + parent=ArticleSeriesPageFactory(parent=self.topic_page), + release_date=datetime(2025, 2, 1), + ) + + TopicPageRelatedArticleFactory(parent=self.topic_page, page=self.older_article) + self.assertListEqual( + self.topic_page.processed_articles, [self.older_article, article_in_other_series, self.article] + ) + + def test_processed_articles_combines_highlighted_and_latest_in_series_but_not_if_same(self): + # Create additional articles + article_in_other_series = StatisticalArticlePageFactory( + title="Article in other series", + parent=ArticleSeriesPageFactory(parent=self.topic_page), + release_date=datetime(2025, 2, 1), + ) + + TopicPageRelatedArticleFactory(parent=self.topic_page, page=self.article) + self.assertListEqual(self.topic_page.processed_articles, [self.article, article_in_other_series]) + + def test_processed_articles_shows_only_highlighted_if_all_selected(self): + # Create additional articles + new_article = StatisticalArticlePageFactory(parent=self.article_series) + StatisticalArticlePageFactory( + title="Article in other series", + parent=ArticleSeriesPageFactory(parent=self.topic_page), + release_date=datetime(2025, 2, 1), + ) + + TopicPageRelatedArticleFactory(parent=self.topic_page, page=self.older_article) + TopicPageRelatedArticleFactory(parent=self.topic_page, page=new_article) + TopicPageRelatedArticleFactory(parent=self.topic_page, page=self.article) + self.assertListEqual(self.topic_page.processed_articles, [self.older_article, new_article, self.article]) + + def test_processed_methodologies_combines_highlighted_and_child_pages(self): + self.assertListEqual(self.topic_page.processed_methodologies, [self.another_methodology, self.methodology]) + + del self.topic_page.processed_methodologies + TopicPageRelatedMethodologyFactory(parent=self.topic_page, page=self.methodology) + + self.assertListEqual(self.topic_page.processed_methodologies, [self.methodology, self.another_methodology]) + + def test_processed_methodologies_shows_only_highlighted_if_all_selected(self): + new_methodology = MethodologyPageFactory(parent=self.topic_page, publication_date=datetime(2024, 2, 1)) + new_methodology2 = MethodologyPageFactory(parent=self.topic_page, publication_date=datetime(2023, 2, 1)) + + TopicPageRelatedMethodologyFactory(parent=self.topic_page, page=self.methodology) + TopicPageRelatedMethodologyFactory(parent=self.topic_page, page=new_methodology2) + TopicPageRelatedMethodologyFactory(parent=self.topic_page, page=new_methodology) + + self.assertListEqual( + self.topic_page.processed_methodologies, [self.methodology, new_methodology2, new_methodology] + ) + + def test_table_of_contents_includes_all_sections(self): + self.assertListEqual( + self.topic_page.table_of_contents, + [ + {"url": "#featured", "text": "Featured"}, + {"url": "#related-articles", "text": "Related articles"}, + {"url": "#related-methods", "text": "Methods and quality information"}, + ], + ) + + def test_table_of_contents_without_features(self): + self.topic_page.featured_series = None + + self.assertNotIn({"url": "#featured", "text": "Featured"}, self.topic_page.table_of_contents) + + def test_table_of_contents_includes_explore_more(self): + self.topic_page.explore_more = [("external_link", {"url": "https://example.com"})] + + toc = self.topic_page.table_of_contents + self.assertIn({"url": "#explore-more", "text": _("Explore more")}, toc) + + def test_get_context(self): + context = self.topic_page.get_context(get_dummy_request()) + + self.assertListEqual(context["table_of_contents"], self.topic_page.table_of_contents) + self.assertEqual(context["featured_item"], self.article) + self.assertIn("formatted_articles", context) + self.assertIn("formatted_methodologies", context) + self.assertEqual(len(context["formatted_articles"]), len(self.topic_page.processed_articles)) + self.assertEqual(len(context["formatted_methodologies"]), len(self.topic_page.processed_methodologies)) + + @override_settings(IS_EXTERNAL_ENV=True) + def test_render_in_external_env(self): + """Test that the index page renders in external environment.""" + response = self.client.get(self.topic_page.url) + + self.assertEqual(response.status_code, 200) From 5e129ab1eb62cb96210b1b780302ee940a126e0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Fri, 7 Feb 2025 09:14:46 +0000 Subject: [PATCH 11/16] Tweak the featured and highlight choosers to relevant pages only --- cms/topics/forms.py | 23 ++++ cms/topics/models.py | 27 +++- cms/topics/tests/test_viewsets.py | 217 ++++++++++++++++++++++++++++++ cms/topics/viewsets.py | 132 ++++++++++++++++++ cms/topics/wagtail_hooks.py | 31 +++++ 5 files changed, 426 insertions(+), 4 deletions(-) create mode 100644 cms/topics/forms.py create mode 100644 cms/topics/tests/test_viewsets.py create mode 100644 cms/topics/viewsets.py create mode 100644 cms/topics/wagtail_hooks.py diff --git a/cms/topics/forms.py b/cms/topics/forms.py new file mode 100644 index 00000000..8777b94f --- /dev/null +++ b/cms/topics/forms.py @@ -0,0 +1,23 @@ +from typing import Any + +from django import forms +from wagtail.admin.forms import WagtailAdminPageForm + + +class TopicPageAdminForm(WagtailAdminPageForm): + topic_page_id = forms.CharField(required=False, widget=forms.HiddenInput()) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + if self.instance.pk: + self.fields["topic_page_id"].initial = self.instance.pk + + def clean(self) -> dict[str, Any] | None: + cleaned_data: dict[str, Any] = super().clean() + + # remove topic_page_id before save + if "topic_page_id" in cleaned_data: + del cleaned_data["topic_page_id"] + + return cleaned_data diff --git a/cms/topics/models.py b/cms/topics/models.py index 54c3e185..ec3252a1 100644 --- a/cms/topics/models.py +++ b/cms/topics/models.py @@ -6,7 +6,7 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from modelcluster.fields import ParentalKey -from wagtail.admin.panels import FieldPanel, InlinePanel, PageChooserPanel +from wagtail.admin.panels import FieldPanel, InlinePanel from wagtail.fields import RichTextField from wagtail.models import Orderable, Page from wagtail.search import index @@ -18,6 +18,12 @@ from cms.core.utils import get_formatted_pages_list from cms.methodology.models import MethodologyPage from cms.topics.blocks import ExploreMoreStoryBlock +from cms.topics.forms import TopicPageAdminForm +from cms.topics.viewsets import ( + FeaturedSeriesPageChooserWidget, + HighlightedArticlePageChooserWidget, + HighlightedMethodologyPageChooserWidget, +) if TYPE_CHECKING: from django.http import HttpRequest @@ -36,7 +42,11 @@ class TopicPageRelatedArticle(Orderable): related_name="+", ) - panels: ClassVar[list[FieldPanel]] = [PageChooserPanel("page", page_type=["articles.StatisticalArticlePage"])] + panels: ClassVar[list[FieldPanel]] = [ + FieldPanel( + "page", widget=HighlightedArticlePageChooserWidget(linked_fields={"topic_page_id": "#id_topic_page_id"}) + ) + ] class TopicPageRelatedMethodology(Orderable): @@ -47,12 +57,17 @@ class TopicPageRelatedMethodology(Orderable): related_name="+", ) - panels: ClassVar[list[FieldPanel]] = [PageChooserPanel("page", page_type=["methodology.MethodologyPage"])] + panels: ClassVar[list[FieldPanel]] = [ + FieldPanel( + "page", widget=HighlightedMethodologyPageChooserWidget(linked_fields={"topic_page_id": "#id_topic_page_id"}) + ) + ] class TopicPage(BasePage): # type: ignore[django-manager-missing] """The Topic page model.""" + base_form_class = TopicPageAdminForm template = "templates/pages/topic_page.html" parent_page_types: ClassVar[list[str]] = ["themes.ThemePage"] subpage_types: ClassVar[list[str]] = ["articles.ArticleSeriesPage", "methodology.MethodologyPage"] @@ -71,7 +86,11 @@ class TopicPage(BasePage): # type: ignore[django-manager-missing] content_panels: ClassVar[list["Panel"]] = [ *BasePage.content_panels, FieldPanel("summary"), - FieldPanel("featured_series", heading=_("Featured")), + FieldPanel( + "featured_series", + heading=_("Featured"), + widget=FeaturedSeriesPageChooserWidget(linked_fields={"topic_page_id": "#id_topic_page_id"}), + ), InlinePanel( "related_articles", heading=_("Highlighted articles"), diff --git a/cms/topics/tests/test_viewsets.py b/cms/topics/tests/test_viewsets.py new file mode 100644 index 00000000..af76fa44 --- /dev/null +++ b/cms/topics/tests/test_viewsets.py @@ -0,0 +1,217 @@ +from datetime import datetime + +from django.test import TestCase +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from wagtail.test.utils import WagtailTestUtils + +from cms.articles.tests.factories import ArticleSeriesPageFactory, StatisticalArticlePageFactory +from cms.methodology.tests.factories import MethodologyPageFactory +from cms.topics.tests.factories import TopicPageFactory +from cms.topics.viewsets import ( + featured_series_page_chooser_viewset, + highlighted_article_page_chooser_viewset, + highlighted_methodology_page_chooser_viewset, +) + + +class FeaturedSeriesPageChooserViewSetTest(WagtailTestUtils, TestCase): + @classmethod + def setUpTestData(cls): + cls.superuser = cls.create_superuser(username="admin") + + # Create test pages + cls.topic_page = TopicPageFactory(title="PSF") + cls.series1 = ArticleSeriesPageFactory(parent=cls.topic_page, title="Series A") + cls.series2 = ArticleSeriesPageFactory(parent=cls.topic_page, title="Series B") + + cls.another_topic = TopicPageFactory(title="GDP") + cls.another_series = ArticleSeriesPageFactory(parent=cls.another_topic, title="Series Z") + + cls.chooser_url = featured_series_page_chooser_viewset.widget_class().get_chooser_modal_url() + cls.chooser_results_url = reverse(featured_series_page_chooser_viewset.get_url_name("choose_results")) + + cls.unused_topic = TopicPageFactory(title="CPI") + + def setUp(self): + self.client.force_login(self.superuser) + + def test_choose_view(self): + """Test the choose view loads with the expected content.""" + response = self.client.get(self.chooser_url) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "wagtailadmin/generic/chooser/chooser.html") + + for title in [self.series1.title, self.series2.title, self.another_series.title]: + self.assertContains(response, title) + + self.assertContains(response, self.topic_page.title) + self.assertContains(response, self.another_topic.title) + self.assertNotContains(response, self.unused_topic.title) + + # Check column headers + self.assertContains(response, "Topic") + self.assertContains(response, "Updated") + self.assertContains(response, "Status") + + def test_chooser_search(self): + """Test the AJAX results view.""" + response = self.client.get(f"{self.chooser_results_url}?q=Series A") + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "wagtailadmin/generic/chooser/results.html") + + self.assertContains(response, self.topic_page.title) + self.assertContains(response, self.series1.title) + self.assertNotContains(response, self.series2.title) + self.assertNotContains(response, self.another_series.title) + self.assertNotContains(response, self.another_topic.title) + + +class HighlightedPageChooserViewSetTest(WagtailTestUtils, TestCase): + @classmethod + def setUpTestData(cls): + cls.superuser = cls.create_superuser(username="admin") + + # Create test pages + cls.topic_page = TopicPageFactory() + cls.article_series = ArticleSeriesPageFactory(parent=cls.topic_page) + cls.article = StatisticalArticlePageFactory( + parent=cls.article_series, + title="Article 1", + release_date=datetime(2024, 1, 1), + ) + cls.second_article = StatisticalArticlePageFactory( + parent=cls.article_series, + title="Another Article", + release_date=datetime(2024, 2, 1), + ) + + cls.another_article = StatisticalArticlePageFactory(title="Article in another topic") + cls.methodology = MethodologyPageFactory( + parent=cls.topic_page, + title="Methodology 1", + publication_date=datetime(2024, 1, 1), + ) + cls.second_methodology = MethodologyPageFactory( + parent=cls.topic_page, + title="Another Methodology", + publication_date=datetime(2023, 1, 1), + ) + + cls.unused_methodology = MethodologyPageFactory( + title="Unused Method", + publication_date=datetime(2024, 1, 1), + ) + + cls.article_chooser_url = highlighted_article_page_chooser_viewset.widget_class().get_chooser_modal_url() + cls.article_chooser_results_url = reverse( + highlighted_article_page_chooser_viewset.get_url_name("choose_results") + ) + cls.methodology_chooser_url = ( + highlighted_methodology_page_chooser_viewset.widget_class().get_chooser_modal_url() + ) + cls.methodology_chooser_results_url = reverse( + highlighted_methodology_page_chooser_viewset.get_url_name("choose_results") + ) + + def setUp(self): + self.client.force_login(self.superuser) + + def test_article_choose_view_with_topic_filter(self): + """Test the article chooser view with topic_page_id filter.""" + response = self.client.get(f"{self.article_chooser_url}?topic_page_id={self.topic_page.id}") + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "wagtailadmin/generic/chooser/chooser.html") + + # Check articles are shown + self.assertListEqual( + response.context["items"].object_list, + [self.second_article, self.article], + ) + + # Check column headers + self.assertContains(response, "Release date") + self.assertContains(response, "Status") + + def test_article_choose_view_without_topic_filter(self): + """Test the article chooser view without topic_page_id returns no results.""" + response = self.client.get(self.article_chooser_url) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "wagtailadmin/generic/chooser/chooser.html") + self.assertNotContains(response, "Article 1") + self.assertNotContains(response, "Article 2") + + def test_article_results_view_with_topic_filter(self): + """Test the article AJAX results view with topic_page_id filter.""" + response = self.client.get(f"{self.article_chooser_results_url}?topic_page_id={self.topic_page.id}&q=Another") + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "wagtailadmin/generic/chooser/results.html") + + # Check articles are ordered by release_date descending + self.assertListEqual( + response.context["items"].object_list, + [self.second_article], + ) + + def test_methodology_choose_view_with_topic_filter(self): + """Test the methodology chooser view with topic_page_id filter.""" + response = self.client.get(f"{self.methodology_chooser_url}?topic_page_id={self.topic_page.id}") + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "wagtailadmin/generic/chooser/chooser.html") + + # Check methodology is shown + self.assertListEqual( + response.context["items"].object_list, + [self.methodology, self.second_methodology], + ) + + def test_methodology_results_view_with_topic_filter(self): + """Test the methodology AJAX results view with topic_page_id filter.""" + response = self.client.get( + f"{self.methodology_chooser_results_url}?topic_page_id={self.topic_page.id}&q=Another" + ) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "wagtailadmin/generic/chooser/results.html") + + self.assertListEqual( + response.context["items"].object_list, + [self.second_methodology], + ) + + +class ViewSetConfigurationTest(TestCase): + """Test the configuration of the viewsets.""" + + def test_featured_series_viewset_configuration(self): + self.assertFalse(featured_series_page_chooser_viewset.register_widget) + self.assertEqual(featured_series_page_chooser_viewset.model, ArticleSeriesPageFactory._meta.model) + self.assertEqual(featured_series_page_chooser_viewset.choose_one_text, _("Choose Article Series page")) + self.assertEqual( + featured_series_page_chooser_viewset.choose_another_text, _("Choose another Article Series page") + ) + self.assertEqual(featured_series_page_chooser_viewset.edit_item_text, _("Edit Article Series page")) + + def test_highlighted_article_viewset_configuration(self): + self.assertFalse(highlighted_article_page_chooser_viewset.register_widget) + self.assertEqual(highlighted_article_page_chooser_viewset.model, StatisticalArticlePageFactory._meta.model) + self.assertEqual(highlighted_article_page_chooser_viewset.choose_one_text, _("Choose Article page")) + self.assertEqual(highlighted_article_page_chooser_viewset.choose_another_text, _("Choose another Article page")) + self.assertEqual(highlighted_article_page_chooser_viewset.edit_item_text, _("Edit Article page")) + self.assertIn("topic_page_id", highlighted_article_page_chooser_viewset.preserve_url_parameters) + + def test_highlighted_methodology_viewset_configuration(self): + self.assertFalse(highlighted_methodology_page_chooser_viewset.register_widget) + self.assertEqual(highlighted_methodology_page_chooser_viewset.model, MethodologyPageFactory._meta.model) + self.assertEqual(highlighted_methodology_page_chooser_viewset.choose_one_text, _("Choose Methodology page")) + self.assertEqual( + highlighted_methodology_page_chooser_viewset.choose_another_text, _("Choose another Methodology page") + ) + self.assertEqual(highlighted_methodology_page_chooser_viewset.edit_item_text, _("Edit Methodology page")) + self.assertIn("topic_page_id", highlighted_methodology_page_chooser_viewset.preserve_url_parameters) diff --git a/cms/topics/viewsets.py b/cms/topics/viewsets.py new file mode 100644 index 00000000..2f1c1ba6 --- /dev/null +++ b/cms/topics/viewsets.py @@ -0,0 +1,132 @@ +from typing import TYPE_CHECKING, ClassVar + +from django.utils.translation import gettext_lazy as _ +from wagtail.admin.ui.tables import Column, DateColumn +from wagtail.admin.ui.tables.pages import PageStatusColumn +from wagtail.admin.views.generic.chooser import ChooseResultsView, ChooseView +from wagtail.admin.viewsets.chooser import ChooserViewSet +from wagtail.coreutils import resolve_model_string + +from cms.articles.models import ArticleSeriesPage, StatisticalArticlePage +from cms.methodology.models import MethodologyPage + +if TYPE_CHECKING: + from wagtail.models import Page + from wagtail.query import PageQuerySet + + +class FeaturedSeriesPageMixin: + model_class: ArticleSeriesPage + + def get_object_list(self) -> "PageQuerySet[ArticleSeriesPage]": + return ArticleSeriesPage.objects.all().order_by("path") + + @property + def columns(self) -> list["Column"]: + return [ + self.title_column, # type: ignore[attr-defined] + Column("parent", label=_("Topic"), accessor="get_parent"), + DateColumn( + "updated", + label=_("Updated"), + width="12%", + accessor="latest_revision_created_at", + ), + PageStatusColumn("status", label=_("Status"), width="12%"), + ] + + +class FeaturedSeriesPageChooseView(FeaturedSeriesPageMixin, ChooseView): ... + + +class FeaturedSeriesPageChooseResultsView(FeaturedSeriesPageMixin, ChooseResultsView): ... + + +class FeaturedSeriesPageChooserViewSet(ChooserViewSet): + model = ArticleSeriesPage + choose_view_class = FeaturedSeriesPageChooseView + choose_results_view_class = FeaturedSeriesPageChooseResultsView + register_widget = False + choose_one_text = _("Choose Article Series page") + choose_another_text = _("Choose another Article Series page") + edit_item_text = _("Edit Article Series page") + + +class HighlightedChildPageMixin: + def get_object_list(self) -> "PageQuerySet[StatisticalArticlePage | MethodologyPage]": + model_class: StatisticalArticlePage | MethodologyPage = self.model_class # type: ignore[attr-defined] + pages: PageQuerySet[Page] = model_class.objects.all().defer_streamfields() + if topic_page_id := self.request.GET.get("topic_page_id"): # type: ignore[attr-defined] + # using this rather than inline import to placate pyright complaining about cyclic imports + topic_page_model = resolve_model_string("topics.TopicPage") + try: + pages = pages.descendant_of(topic_page_model.objects.get(pk=topic_page_id)) + except topic_page_model.DoesNotExist: + pages = pages.none() + else: + # when adding new pages. + pages = pages.none() + + if model_class == StatisticalArticlePage: + pages = pages.order_by("-release_date") + + if model_class == MethodologyPage: + pages = pages.order_by("-publication_date") + return pages + + @property + def columns(self) -> list["Column"]: + title_column = self.title_column # type: ignore[attr-defined] + title_column.accessor = "get_admin_display_title" + return [ + title_column, + Column( + "release_date", + label=_("Release date"), + width="12%", + accessor="release_date", + ), + PageStatusColumn("status", label=_("Status"), width="12%"), + ] + + +class HighlightedPagePageChooseView(HighlightedChildPageMixin, ChooseView): ... + + +class HighlightedPagePageChooseResultsView(HighlightedChildPageMixin, ChooseResultsView): ... + + +class BaseHighlightedChildrenViewSet(ChooserViewSet): + choose_view_class = HighlightedPagePageChooseView + choose_results_view_class = HighlightedPagePageChooseResultsView + register_widget = False + preserve_url_parameters: ClassVar[list[str]] = ["multiple", "topic_page_id"] + icon = "doc-empty-inverse" + + +class HighlightedArticlePageChooserViewSet(BaseHighlightedChildrenViewSet): + model = StatisticalArticlePage + choose_one_text = _("Choose Article page") + choose_another_text = _("Choose another Article page") + edit_item_text = _("Edit Article page") + + +class HighlightedMethodologyPageChooserViewSet(BaseHighlightedChildrenViewSet): + model = MethodologyPage + choose_one_text = _("Choose Methodology page") + choose_another_text = _("Choose another Methodology page") + edit_item_text = _("Edit Methodology page") + + +featured_series_page_chooser_viewset = FeaturedSeriesPageChooserViewSet("topic_featured_series_page_chooser") +FeaturedSeriesPageChooserWidget = featured_series_page_chooser_viewset.widget_class + +highlighted_article_page_chooser_viewset = HighlightedArticlePageChooserViewSet( + "topic_highlighted_article_page_chooser" +) +HighlightedArticlePageChooserWidget = highlighted_article_page_chooser_viewset.widget_class + +highlighted_methodology_page_chooser_viewset = HighlightedMethodologyPageChooserViewSet( + "topic_highlighted_methodology_page_chooser" +) +HighlightedMethodologyPageChooserWidget = highlighted_methodology_page_chooser_viewset.widget_class diff --git a/cms/topics/wagtail_hooks.py b/cms/topics/wagtail_hooks.py new file mode 100644 index 00000000..c913292f --- /dev/null +++ b/cms/topics/wagtail_hooks.py @@ -0,0 +1,31 @@ +from typing import TYPE_CHECKING + +from wagtail import hooks + +from .viewsets import ( + featured_series_page_chooser_viewset, + highlighted_article_page_chooser_viewset, + highlighted_methodology_page_chooser_viewset, +) + +if TYPE_CHECKING: + from .viewsets import ( + FeaturedSeriesPageChooserViewSet, + HighlightedArticlePageChooserViewSet, + HighlightedMethodologyPageChooserViewSet, + ) + + +@hooks.register("register_admin_viewset") +def register_series_chooser_viewset() -> "FeaturedSeriesPageChooserViewSet": + return featured_series_page_chooser_viewset + + +@hooks.register("register_admin_viewset") +def register_highlighted_article_chooser_viewset() -> "HighlightedArticlePageChooserViewSet": + return highlighted_article_page_chooser_viewset + + +@hooks.register("register_admin_viewset") +def register_highlighted_methodology_chooser_viewset() -> "HighlightedMethodologyPageChooserViewSet": + return highlighted_methodology_page_chooser_viewset From c45deb1e9de490e57a70f4a9b7aa95b757e2b792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Fri, 7 Feb 2025 09:52:05 +0000 Subject: [PATCH 12/16] Tweak so related articles display the full title (with series prefix) --- cms/core/utils.py | 2 +- cms/topics/tests/test_models.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cms/core/utils.py b/cms/core/utils.py index 88354b9e..25f955c5 100644 --- a/cms/core/utils.py +++ b/cms/core/utils.py @@ -26,7 +26,7 @@ def get_formatted_pages_list( for page in pages: datum: DocumentListItem = { "title": { - "text": page.title, + "text": getattr(page, "display_title", page.title), "url": page.get_url(request=request), }, "metadata": { diff --git a/cms/topics/tests/test_models.py b/cms/topics/tests/test_models.py index 29477d31..0945f78b 100644 --- a/cms/topics/tests/test_models.py +++ b/cms/topics/tests/test_models.py @@ -140,3 +140,4 @@ def test_render_in_external_env(self): response = self.client.get(self.topic_page.url) self.assertEqual(response.status_code, 200) + self.assertContains(response, self.article.display_title) From f861cf9cf9274e3b4bb88e882dd908fbb3d977a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Mon, 10 Feb 2025 16:38:23 +0000 Subject: [PATCH 13/16] Add basic functional tests --- functional_tests/features/topic_page.feature | 21 ++++++++ functional_tests/steps/articles.py | 15 ++++++ functional_tests/steps/page_editor.py | 16 ++++++ functional_tests/steps/topic_page.py | 52 ++++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 functional_tests/features/topic_page.feature create mode 100644 functional_tests/steps/articles.py create mode 100644 functional_tests/steps/topic_page.py diff --git a/functional_tests/features/topic_page.feature b/functional_tests/features/topic_page.feature new file mode 100644 index 00000000..2f596e1e --- /dev/null +++ b/functional_tests/features/topic_page.feature @@ -0,0 +1,21 @@ +Feature: CMS users can draft, edit, and publish topic pages + + Scenario: A CMS user can feature an article series + Given a CMS user logs into the admin site + And a topic page exists + And the topic page has a statistical article in a series + When the user edits the topic page + And clicks the "Choose Article Series page" button + And the user selects the article series + And publishes the page + And visits the topic page + Then the topic page with the example content is displayed + And the user can see the topic page featured article + + Scenario: The feature series on a topic page displays the latest article + Given a topic page exists + And the user has created a statistical article in a series + And the user has featured the series + When the user creates a new statistical article in the series + And visits the topic page + Then the user can see the newly created article in featured spot diff --git a/functional_tests/steps/articles.py b/functional_tests/steps/articles.py new file mode 100644 index 00000000..b938b100 --- /dev/null +++ b/functional_tests/steps/articles.py @@ -0,0 +1,15 @@ +from behave import given, when # pylint: disable=no-name-in-module +from behave.runner import Context + +from cms.articles.tests.factories import ArticleSeriesPageFactory, StatisticalArticlePageFactory + + +@given("the user creates a new article series") +@given("the user has created a statistical article in a series") +def create_article_series(context: Context): + context.article_series = ArticleSeriesPageFactory() + + +@when("the user creates a new statistical article in the series") +def create_article_in_series(context: Context): + context.article = StatisticalArticlePageFactory(title="January 2025", parent=context.article_series) diff --git a/functional_tests/steps/page_editor.py b/functional_tests/steps/page_editor.py index 201981c2..b4e72ca4 100644 --- a/functional_tests/steps/page_editor.py +++ b/functional_tests/steps/page_editor.py @@ -1,8 +1,10 @@ from behave import when # pylint: disable=no-name-in-module from behave.runner import Context +from django.urls import reverse @when("the user clicks publish page") +@when("publishes the page") def user_clicks_publish_page(context: Context) -> None: context.page.get_by_role("button", name="More actions").click() context.page.get_by_role("button", name="Publish").click() @@ -11,3 +13,17 @@ def user_clicks_publish_page(context: Context) -> None: @when('the user clicks "View Live" on the publish confirmation banner') def user_clicks_view_live_on_publish_confirmation_banner(context: Context) -> None: context.page.get_by_role("link", name="View live").click() + + +@when('clicks the "{button_text}" button') +def clicks_the_given_button(context: Context, button_text: str): + context.page.get_by_role("button", name=button_text).click() + + +@when("the user edits the {page} page") +def the_user_edits_the_topic_page(context: Context, page: str) -> None: + the_page = page.lower().replace(" ", "_") + if not the_page.endswith("_page"): + the_page += "_page" + edit_url = reverse("wagtailadmin_pages:edit", args=[getattr(context, the_page).pk]) + context.page.goto(f"{context.base_url}{edit_url}") diff --git a/functional_tests/steps/topic_page.py b/functional_tests/steps/topic_page.py new file mode 100644 index 00000000..eb3d890b --- /dev/null +++ b/functional_tests/steps/topic_page.py @@ -0,0 +1,52 @@ +from behave import given, then, when # pylint: disable=no-name-in-module +from behave.runner import Context +from playwright.sync_api import expect + +from cms.articles.tests.factories import ArticleSeriesPageFactory, StatisticalArticlePageFactory +from cms.topics.tests.factories import TopicPageFactory + + +@given("a topic page exists") +def a_topic_page_exists(context: Context): + context.topic_page = TopicPageFactory(title="Public Sector Finance") + + +@given("the topic page has a statistical article in a series") +def the_topic_page_has_a_statistical_article_in_a_series(context: Context): + context.article_series = ArticleSeriesPageFactory(title="PSF") + context.first_article = StatisticalArticlePageFactory(parent=context.article_series) + + +@given("the user has featured the series") +def the_user_has_featured_the_series(context: Context): + context.topic_page.featured_series = context.article_series + context.topic_page.save_revision().publish() + + +@when("visits the topic page") +def visit_topic_page(context: Context): + context.page.goto(f"{context.base_url}{context.topic_page.url}") + + +@when("the user selects the article series") +def the_user_select_article_series(context: Context): + context.page.get_by_role("link", name="PSF", exact=True).click() + + +@then("the topic page with the example content is displayed") +def the_topic_page_with_example_content(context: Context): + expect(context.page.get_by_role("heading", name=context.topic_page.title)).to_be_visible() + + +@then("the user can see the topic page featured article") +def user_sees_featured_article(context: Context): + expect(context.page.get_by_role("heading", name="Featured")).to_be_visible() + expect(context.page.get_by_text(context.first_article.display_title)).to_be_visible() + expect(context.page.get_by_text(context.first_article.main_points_summary)).to_be_visible() + + +@then("the user can see the newly created article in featured spot") +def user_sees_newly_featured_article(context: Context): + expect(context.page.get_by_role("heading", name="Featured")).to_be_visible() + expect(context.page.get_by_text(context.article.display_title)).to_be_visible() + expect(context.page.get_by_text(context.article.main_points_summary)).to_be_visible() From 586f1c22b7356f7909e6e56c0e2bb255c5a87bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Tue, 11 Feb 2025 14:20:24 +0000 Subject: [PATCH 14/16] Make label a class var Old habits die hard --- cms/articles/models.py | 6 +----- cms/core/models/base.py | 8 +++----- cms/core/tests/test_utils.py | 4 +--- cms/methodology/models.py | 7 +------ cms/themes/models.py | 6 +----- cms/topics/models.py | 6 +----- 6 files changed, 8 insertions(+), 29 deletions(-) diff --git a/cms/articles/models.py b/cms/articles/models.py index 02b11373..f450334f 100644 --- a/cms/articles/models.py +++ b/cms/articles/models.py @@ -23,7 +23,6 @@ from django.http import HttpRequest from django.http.response import HttpResponseRedirect from django.template.response import TemplateResponse - from django.utils.functional import Promise from wagtail.admin.panels import Panel @@ -87,6 +86,7 @@ class StatisticalArticlePage(BundledPageMixin, BasePage): # type: ignore[django parent_page_types: ClassVar[list[str]] = ["ArticleSeriesPage"] subpage_types: ClassVar[list[str]] = [] template = "templates/pages/statistical_article_page.html" + label = _("Article") # Fields news_headline = models.CharField(max_length=255, blank=True) @@ -202,10 +202,6 @@ def get_admin_display_title(self) -> str: """Changes the admin display title to include the parent title.""" return f"{self.get_parent().title}: {self.draft_title or self.title}" - @property - def label(self) -> "Promise": - return _("Article") - @property def display_title(self) -> str: """Returns the page display title. If the news headline is set, it takes precedence over the series+title.""" diff --git a/cms/core/models/base.py b/cms/core/models/base.py index 2763afef..edda1384 100644 --- a/cms/core/models/base.py +++ b/cms/core/models/base.py @@ -14,7 +14,6 @@ if TYPE_CHECKING: from django.db import models - from django.utils.functional import Promise from wagtail.admin.panels import FieldPanel from wagtail.contrib.settings.models import ( BaseGenericSetting as _WagtailBaseGenericSetting, @@ -51,6 +50,9 @@ class BasePage(ListingFieldsMixin, SocialFieldsMixin, Page): # type: ignore[dja # Update in your specific Page class if the StreamField using them is different. content_field_name: str = "content" + # used a page type label in the front-end + label = _("Page") + class Meta: abstract = True @@ -60,10 +62,6 @@ class Meta: *SocialFieldsMixin.promote_panels, ] - @property - def label(self) -> "Promise": - return _("Page") - @cached_property def related_pages(self) -> PageQuerySet: """Return a `PageQuerySet` of items related to this page via the diff --git a/cms/core/tests/test_utils.py b/cms/core/tests/test_utils.py index 399c69ca..cd9d5efa 100644 --- a/cms/core/tests/test_utils.py +++ b/cms/core/tests/test_utils.py @@ -29,9 +29,7 @@ class Meta: class DummyPageWithNoReleaseDate(DummyPage): - @property - def label(self): - return "Dummy Page" + label = "Dummy Page" class Meta: abstract = True diff --git a/cms/methodology/models.py b/cms/methodology/models.py index 583c3ff8..0f307932 100644 --- a/cms/methodology/models.py +++ b/cms/methodology/models.py @@ -20,7 +20,6 @@ if TYPE_CHECKING: import datetime - from django.utils.functional import Promise from wagtail.admin.panels import Panel from wagtail.query import PageQuerySet @@ -40,8 +39,8 @@ class MethodologyRelatedPage(Orderable): class MethodologyPage(BasePage): # type: ignore[django-manager-missing] parent_page_types: ClassVar[list[str]] = ["topics.TopicPage"] - template = "templates/pages/methodology_page.html" + label = _("Methodology") summary = RichTextField(features=settings.RICH_TEXT_BASIC) publication_date = models.DateField() @@ -100,10 +99,6 @@ def get_context(self, request: HttpRequest, *args: Any, **kwargs: Any) -> dict: context["related_publications"] = self.get_formatted_related_publications_list(request=request) return context - @property - def label(self) -> "Promise": - return _("Methodology") - @property def release_date(self) -> "datetime.date": return self.publication_date diff --git a/cms/themes/models.py b/cms/themes/models.py index 5562729f..d7491afb 100644 --- a/cms/themes/models.py +++ b/cms/themes/models.py @@ -7,7 +7,6 @@ from cms.core.models import BasePage if TYPE_CHECKING: - from django.utils.functional import Promise from wagtail.admin.panels import Panel @@ -18,11 +17,8 @@ class ThemePage(BasePage): # type: ignore[django-manager-missing] parent_page_types: ClassVar[list[str]] = ["home.HomePage", "ThemePage"] subpage_types: ClassVar[list[str]] = ["ThemePage", "topics.TopicPage"] page_description = _("A theme page, such as 'Economy'.") + label = _("Theme") summary = RichTextField(features=settings.RICH_TEXT_BASIC) content_panels: ClassVar[list["Panel"]] = [*BasePage.content_panels, "summary"] - - @property - def label(self) -> "Promise": - return _("Theme") diff --git a/cms/topics/models.py b/cms/topics/models.py index aa655a78..4c6ba0ed 100644 --- a/cms/topics/models.py +++ b/cms/topics/models.py @@ -27,7 +27,6 @@ if TYPE_CHECKING: from django.http import HttpRequest - from django.utils.functional import Promise from wagtail.admin.panels import Panel @@ -72,6 +71,7 @@ class TopicPage(BasePage): # type: ignore[django-manager-missing] parent_page_types: ClassVar[list[str]] = ["themes.ThemePage"] subpage_types: ClassVar[list[str]] = ["articles.ArticleSeriesPage", "methodology.MethodologyPage"] page_description = _("A specific topic page. e.g. 'Public sector finance' or 'Inflation and price indices'.") + label = _("Topic") summary = RichTextField(features=settings.RICH_TEXT_BASIC) featured_series = models.ForeignKey( @@ -123,10 +123,6 @@ def get_context(self, request: "HttpRequest", *args: Any, **kwargs: Any) -> dict context["formatted_methodologies"] = get_formatted_pages_list(self.processed_methodologies, request=request) return context - @property - def label(self) -> "Promise": - return _("Topic") - @cached_property def latest_article_in_featured_series(self) -> StatisticalArticlePage | None: """Returns the latest article in the featured series.""" From 708da0fd4944a9b4802b09a78dc3ad65f286f991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Thu, 13 Feb 2025 09:23:06 +0000 Subject: [PATCH 15/16] Tweak choose view mixins names for clarity --- cms/topics/viewsets.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cms/topics/viewsets.py b/cms/topics/viewsets.py index 2f1c1ba6..042bd2d0 100644 --- a/cms/topics/viewsets.py +++ b/cms/topics/viewsets.py @@ -15,7 +15,7 @@ from wagtail.query import PageQuerySet -class FeaturedSeriesPageMixin: +class FeaturedSeriesPageChooseViewMixin: model_class: ArticleSeriesPage def get_object_list(self) -> "PageQuerySet[ArticleSeriesPage]": @@ -36,10 +36,10 @@ def columns(self) -> list["Column"]: ] -class FeaturedSeriesPageChooseView(FeaturedSeriesPageMixin, ChooseView): ... +class FeaturedSeriesPageChooseView(FeaturedSeriesPageChooseViewMixin, ChooseView): ... -class FeaturedSeriesPageChooseResultsView(FeaturedSeriesPageMixin, ChooseResultsView): ... +class FeaturedSeriesPageChooseResultsView(FeaturedSeriesPageChooseViewMixin, ChooseResultsView): ... class FeaturedSeriesPageChooserViewSet(ChooserViewSet): @@ -52,8 +52,8 @@ class FeaturedSeriesPageChooserViewSet(ChooserViewSet): edit_item_text = _("Edit Article Series page") -class HighlightedChildPageMixin: - def get_object_list(self) -> "PageQuerySet[StatisticalArticlePage | MethodologyPage]": +class HighlightedChildPageChooseViewMixin: + def get_object_list(self) -> "PageQuerySet[Page]": model_class: StatisticalArticlePage | MethodologyPage = self.model_class # type: ignore[attr-defined] pages: PageQuerySet[Page] = model_class.objects.all().defer_streamfields() if topic_page_id := self.request.GET.get("topic_page_id"): # type: ignore[attr-defined] @@ -90,10 +90,10 @@ def columns(self) -> list["Column"]: ] -class HighlightedPagePageChooseView(HighlightedChildPageMixin, ChooseView): ... +class HighlightedPagePageChooseView(HighlightedChildPageChooseViewMixin, ChooseView): ... -class HighlightedPagePageChooseResultsView(HighlightedChildPageMixin, ChooseResultsView): ... +class HighlightedPagePageChooseResultsView(HighlightedChildPageChooseViewMixin, ChooseResultsView): ... class BaseHighlightedChildrenViewSet(ChooserViewSet): From 069f8cd7341ffb15bc848cb90c057162a78eaefb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dan=20Braghi=C8=99?= Date: Thu, 13 Feb 2025 11:36:32 +0000 Subject: [PATCH 16/16] Tidy up based on code review --- cms/topics/models.py | 8 ++++---- cms/topics/tests/test_viewsets.py | 22 +++++++++------------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/cms/topics/models.py b/cms/topics/models.py index 4c6ba0ed..1c771ae7 100644 --- a/cms/topics/models.py +++ b/cms/topics/models.py @@ -126,16 +126,16 @@ def get_context(self, request: "HttpRequest", *args: Any, **kwargs: Any) -> dict @cached_property def latest_article_in_featured_series(self) -> StatisticalArticlePage | None: """Returns the latest article in the featured series.""" - article: StatisticalArticlePage | None = StatisticalArticlePage.objects.none() if self.featured_series: - article = ( + article: StatisticalArticlePage = ( StatisticalArticlePage.objects.child_of(self.featured_series) .live() .public() .order_by("-release_date") .first() ) - return article + return article + return None @cached_property def processed_articles(self) -> list[ArticleSeriesPage]: @@ -143,7 +143,7 @@ def processed_articles(self) -> list[ArticleSeriesPage]: TODO: extend when Taxonomy is in. """ # check if any statistical articles were highlighted. if so, fetch in the order they were added. - highlighted_page_pks = tuple(page_id for page_id in self.related_articles.values_list("page_id", flat=True)) + highlighted_page_pks = list(self.related_articles.values_list("page_id", flat=True)) highlighted_pages = list( order_by_pk_position( StatisticalArticlePage.objects.live().public().defer_streamfields(), diff --git a/cms/topics/tests/test_viewsets.py b/cms/topics/tests/test_viewsets.py index af76fa44..a74a960b 100644 --- a/cms/topics/tests/test_viewsets.py +++ b/cms/topics/tests/test_viewsets.py @@ -68,6 +68,15 @@ def test_chooser_search(self): self.assertNotContains(response, self.another_series.title) self.assertNotContains(response, self.another_topic.title) + def test_featured_series_viewset_configuration(self): + self.assertFalse(featured_series_page_chooser_viewset.register_widget) + self.assertEqual(featured_series_page_chooser_viewset.model, ArticleSeriesPageFactory._meta.model) + self.assertEqual(featured_series_page_chooser_viewset.choose_one_text, _("Choose Article Series page")) + self.assertEqual( + featured_series_page_chooser_viewset.choose_another_text, _("Choose another Article Series page") + ) + self.assertEqual(featured_series_page_chooser_viewset.edit_item_text, _("Edit Article Series page")) + class HighlightedPageChooserViewSetTest(WagtailTestUtils, TestCase): @classmethod @@ -185,19 +194,6 @@ def test_methodology_results_view_with_topic_filter(self): [self.second_methodology], ) - -class ViewSetConfigurationTest(TestCase): - """Test the configuration of the viewsets.""" - - def test_featured_series_viewset_configuration(self): - self.assertFalse(featured_series_page_chooser_viewset.register_widget) - self.assertEqual(featured_series_page_chooser_viewset.model, ArticleSeriesPageFactory._meta.model) - self.assertEqual(featured_series_page_chooser_viewset.choose_one_text, _("Choose Article Series page")) - self.assertEqual( - featured_series_page_chooser_viewset.choose_another_text, _("Choose another Article Series page") - ) - self.assertEqual(featured_series_page_chooser_viewset.edit_item_text, _("Edit Article Series page")) - def test_highlighted_article_viewset_configuration(self): self.assertFalse(highlighted_article_page_chooser_viewset.register_widget) self.assertEqual(highlighted_article_page_chooser_viewset.model, StatisticalArticlePageFactory._meta.model)