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] 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