From 2c9404022135d5c39fa234f75dcea6dcae15eaf6 Mon Sep 17 00:00:00 2001 From: Mark <162816078+markashton480@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:07:27 +0000 Subject: [PATCH] feat: improve Wagtail admin editor experience for articles - Add published_date field to ArticlePage with auto-populate from first_published_at on first publish, plus data migration backfill - Surface go_live_at/expire_at scheduling fields in editor panels - Reorganise ArticlePage editor with TabbedInterface (Content, Metadata, Publishing, SEO tabs) - Add Articles PageListingViewSet to admin menu with custom columns (author, category, published date, status) and category/author filters - Add Articles summary dashboard panel showing drafts, scheduled, and recently published articles - Update all front-end queries and RSS feeds to use published_date - Add 10 unit tests and 4 E2E tests for new admin features Closes #39 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/blog/feeds.py | 8 +- .../migrations/0003_add_published_date.py | 18 ++ .../0004_backfill_published_date.py | 24 ++ apps/blog/models.py | 47 +++- apps/blog/tests/factories.py | 1 + apps/blog/tests/test_admin_experience.py | 232 ++++++++++++++++++ apps/blog/wagtail_hooks.py | 83 ++++++- .../management/commands/seed_e2e_content.py | 11 + e2e/test_admin_experience.py | 56 +++++ templates/blog/panels/articles_summary.html | 62 +++++ 10 files changed, 527 insertions(+), 15 deletions(-) create mode 100644 apps/blog/migrations/0003_add_published_date.py create mode 100644 apps/blog/migrations/0004_backfill_published_date.py create mode 100644 apps/blog/tests/test_admin_experience.py create mode 100644 e2e/test_admin_experience.py create mode 100644 templates/blog/panels/articles_summary.html diff --git a/apps/blog/feeds.py b/apps/blog/feeds.py index fce13c4..9280747 100644 --- a/apps/blog/feeds.py +++ b/apps/blog/feeds.py @@ -16,7 +16,7 @@ class AllArticlesFeed(Feed): return None def items(self): - return ArticlePage.objects.live().order_by("-first_published_at")[:20] + return ArticlePage.objects.live().order_by("-published_date")[:20] def item_title(self, item: ArticlePage): return item.title @@ -25,7 +25,7 @@ class AllArticlesFeed(Feed): return item.summary def item_pubdate(self, item: ArticlePage): - return item.first_published_at + return item.published_date or item.first_published_at def item_author_name(self, item: ArticlePage): return item.author.name @@ -47,7 +47,7 @@ class TagArticlesFeed(AllArticlesFeed): return f"No Hype AI — {obj.name}" def items(self, obj): - return ArticlePage.objects.live().filter(tags=obj).order_by("-first_published_at")[:20] + return ArticlePage.objects.live().filter(tags=obj).order_by("-published_date")[:20] class CategoryArticlesFeed(AllArticlesFeed): @@ -59,4 +59,4 @@ class CategoryArticlesFeed(AllArticlesFeed): return f"No Hype AI — {obj.name}" def items(self, obj): - return ArticlePage.objects.live().filter(category=obj).order_by("-first_published_at")[:20] + return ArticlePage.objects.live().filter(category=obj).order_by("-published_date")[:20] diff --git a/apps/blog/migrations/0003_add_published_date.py b/apps/blog/migrations/0003_add_published_date.py new file mode 100644 index 0000000..54b59ef --- /dev/null +++ b/apps/blog/migrations/0003_add_published_date.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-03-03 13:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0002_category_articlepage_category'), + ] + + operations = [ + migrations.AddField( + model_name='articlepage', + name='published_date', + field=models.DateTimeField(blank=True, help_text='Display date for this article. Auto-set on first publish if left blank.', null=True), + ), + ] diff --git a/apps/blog/migrations/0004_backfill_published_date.py b/apps/blog/migrations/0004_backfill_published_date.py new file mode 100644 index 0000000..626fdfe --- /dev/null +++ b/apps/blog/migrations/0004_backfill_published_date.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.11 on 2026-03-03 13:59 + +from django.db import migrations + + +def backfill_published_date(apps, schema_editor): + schema_editor.execute( + "UPDATE blog_articlepage SET published_date = p.first_published_at " + "FROM wagtailcore_page p " + "WHERE blog_articlepage.page_ptr_id = p.id " + "AND blog_articlepage.published_date IS NULL " + "AND p.first_published_at IS NOT NULL" + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0003_add_published_date'), + ] + + operations = [ + migrations.RunPython(backfill_published_date, migrations.RunPython.noop), + ] diff --git a/apps/blog/models.py b/apps/blog/models.py index 13a7f0a..c237d8e 100644 --- a/apps/blog/models.py +++ b/apps/blog/models.py @@ -11,7 +11,7 @@ from django.shortcuts import get_object_or_404 from modelcluster.contrib.taggit import ClusterTaggableManager from modelcluster.fields import ParentalKey from taggit.models import Tag, TaggedItemBase -from wagtail.admin.panels import FieldPanel, PageChooserPanel +from wagtail.admin.panels import FieldPanel, ObjectList, PageChooserPanel, TabbedInterface from wagtail.contrib.routable_page.models import RoutablePageMixin, route from wagtail.fields import RichTextField, StreamField from wagtail.models import Page @@ -38,7 +38,7 @@ class HomePage(Page): .public() .select_related("author", "category") .prefetch_related("tags__metadata") - .order_by("-first_published_at") + .order_by("-published_date") ) articles = list(articles_qs[:5]) ctx["featured_article"] = self.featured_article @@ -64,7 +64,7 @@ class ArticleIndexPage(RoutablePageMixin, Page): .live() .select_related("author", "category") .prefetch_related("tags__metadata") - .order_by("-first_published_at") + .order_by("-published_date") ) def get_category_url(self, category): @@ -191,21 +191,46 @@ class ArticlePage(SeoMixin, Page): tags = ClusterTaggableManager(through="blog.ArticleTag", blank=True) read_time_mins = models.PositiveIntegerField(editable=False, default=1) comments_enabled = models.BooleanField(default=True) + published_date = models.DateTimeField( + null=True, + blank=True, + help_text="Display date for this article. Auto-set on first publish if left blank.", + ) parent_page_types = ["blog.ArticleIndexPage"] subpage_types: list[str] = [] - content_panels = Page.content_panels + [ - FieldPanel("category"), - FieldPanel("author"), - FieldPanel("hero_image"), + content_panels = [ + FieldPanel("title"), FieldPanel("summary"), FieldPanel("body"), + ] + + metadata_panels = [ + FieldPanel("category"), + FieldPanel("author"), FieldPanel("tags"), + FieldPanel("hero_image"), FieldPanel("comments_enabled"), ] - promote_panels = Page.promote_panels + SeoMixin.seo_panels + publishing_panels = [ + FieldPanel("published_date"), + FieldPanel("go_live_at"), + FieldPanel("expire_at"), + ] + + edit_handler = TabbedInterface( + [ + ObjectList(content_panels, heading="Content"), + ObjectList(metadata_panels, heading="Metadata"), + ObjectList(publishing_panels, heading="Publishing"), + ObjectList( + Page.promote_panels + SeoMixin.seo_panels, + heading="SEO", + ), + ] + ) search_fields = Page.search_fields @@ -215,6 +240,8 @@ class ArticlePage(SeoMixin, Page): slug="general", defaults={"name": "General", "description": "General articles", "colour": "neutral"}, ) + if not self.published_date and self.first_published_at: + self.published_date = self.first_published_at self.read_time_mins = self._compute_read_time() return super().save(*args, **kwargs) @@ -239,14 +266,14 @@ class ArticlePage(SeoMixin, Page): .filter(tags__in=tag_ids) .exclude(pk=self.pk) .distinct() - .order_by("-first_published_at")[:count] + .order_by("-published_date")[:count] ) if len(related) < count: exclude_ids = [a.pk for a in related] + [self.pk] fallback = list( ArticlePage.objects.live() .exclude(pk__in=exclude_ids) - .order_by("-first_published_at")[: count - len(related)] + .order_by("-published_date")[: count - len(related)] ) return related + fallback return related diff --git a/apps/blog/tests/factories.py b/apps/blog/tests/factories.py index c3666d3..fcd8942 100644 --- a/apps/blog/tests/factories.py +++ b/apps/blog/tests/factories.py @@ -37,6 +37,7 @@ class ArticlePageFactory(wagtail_factories.PageFactory): summary = "Summary" body = [("rich_text", "
Hello world
")] first_published_at = factory.LazyFunction(timezone.now) + published_date = factory.LazyFunction(timezone.now) class LegalIndexPageFactory(wagtail_factories.PageFactory): diff --git a/apps/blog/tests/test_admin_experience.py b/apps/blog/tests/test_admin_experience.py new file mode 100644 index 0000000..db1150f --- /dev/null +++ b/apps/blog/tests/test_admin_experience.py @@ -0,0 +1,232 @@ +import pytest +from django.test import override_settings +from django.utils import timezone + +from apps.blog.models import ArticleIndexPage, ArticlePage, Category +from apps.blog.tests.factories import AuthorFactory + + +@pytest.mark.django_db +def test_published_date_auto_set_on_first_publish(home_page): + """published_date should be auto-populated from first_published_at on first publish.""" + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Auto Date", + slug="auto-date", + author=author, + summary="summary", + body=[("rich_text", "body
")], + ) + index.add_child(instance=article) + article.save_revision().publish() + article.refresh_from_db() + assert article.published_date is not None + assert article.published_date == article.first_published_at + + +@pytest.mark.django_db +def test_published_date_preserved_when_explicitly_set(home_page): + """An explicitly set published_date should not be overwritten on save.""" + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + custom_date = timezone.now() - timezone.timedelta(days=30) + article = ArticlePage( + title="Custom Date", + slug="custom-date", + author=author, + summary="summary", + body=[("rich_text", "body
")], + published_date=custom_date, + ) + index.add_child(instance=article) + article.save_revision().publish() + article.refresh_from_db() + assert article.published_date == custom_date + + +@pytest.mark.django_db +def test_homepage_orders_articles_by_published_date(home_page): + """HomePage context should list articles ordered by -published_date.""" + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + + older = ArticlePage( + title="Older", + slug="older", + author=author, + summary="s", + body=[("rich_text", "body
")], + published_date=timezone.now() - timezone.timedelta(days=10), + ) + index.add_child(instance=older) + older.save_revision().publish() + + newer = ArticlePage( + title="Newer", + slug="newer", + author=author, + summary="s", + body=[("rich_text", "body
")], + published_date=timezone.now(), + ) + index.add_child(instance=newer) + newer.save_revision().publish() + + ctx = home_page.get_context(type("Req", (), {"GET": {}})()) + titles = [a.title for a in ctx["latest_articles"]] + assert titles.index("Newer") < titles.index("Older") + + +@pytest.mark.django_db +def test_article_index_orders_by_published_date(home_page, rf): + """ArticleIndexPage.get_articles should order by -published_date.""" + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + + old = ArticlePage( + title="Old", + slug="old", + author=author, + summary="s", + body=[("rich_text", "b
")], + published_date=timezone.now() - timezone.timedelta(days=5), + ) + index.add_child(instance=old) + old.save_revision().publish() + + new = ArticlePage( + title="New", + slug="new", + author=author, + summary="s", + body=[("rich_text", "b
")], + published_date=timezone.now(), + ) + index.add_child(instance=new) + new.save_revision().publish() + + articles = list(index.get_articles()) + assert articles[0].title == "New" + assert articles[1].title == "Old" + + +@pytest.mark.django_db +def test_feed_uses_published_date(article_page): + """RSS feed item_pubdate should use published_date.""" + from apps.blog.feeds import AllArticlesFeed + + feed = AllArticlesFeed() + assert feed.item_pubdate(article_page) == article_page.published_date + + +@pytest.mark.django_db +@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"]) +def test_articles_listing_viewset_loads(client, django_user_model, home_page): + """The Articles PageListingViewSet index page should load.""" + admin = django_user_model.objects.create_superuser( + username="admin", email="admin@example.com", password="admin-pass" + ) + client.force_login(admin) + response = client.get("/cms/articles/") + assert response.status_code == 200 + + +@pytest.mark.django_db +@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"]) +def test_articles_listing_shows_articles(client, django_user_model, home_page): + """The Articles listing should show existing articles.""" + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Listed Article", + slug="listed-article", + author=author, + summary="summary", + body=[("rich_text", "body
")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + admin = django_user_model.objects.create_superuser( + username="admin", email="admin@example.com", password="admin-pass" + ) + client.force_login(admin) + response = client.get("/cms/articles/") + assert response.status_code == 200 + assert "Listed Article" in response.content.decode() + + +@pytest.mark.django_db +@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"]) +def test_dashboard_panel_renders(client, django_user_model, home_page): + """The Wagtail admin dashboard should include the articles summary panel.""" + admin = django_user_model.objects.create_superuser( + username="admin", email="admin@example.com", password="admin-pass" + ) + client.force_login(admin) + response = client.get("/cms/") + assert response.status_code == 200 + content = response.content.decode() + assert "Articles overview" in content + + +@pytest.mark.django_db +@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"]) +def test_dashboard_panel_shows_drafts(client, django_user_model, home_page): + """Dashboard panel should list draft articles.""" + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + draft = ArticlePage( + title="My Draft Post", + slug="draft-post", + author=author, + summary="summary", + body=[("rich_text", "body
")], + ) + index.add_child(instance=draft) + draft.save_revision() # save revision but don't publish + + admin = django_user_model.objects.create_superuser( + username="admin", email="admin@example.com", password="admin-pass" + ) + client.force_login(admin) + response = client.get("/cms/") + content = response.content.decode() + assert "My Draft Post" in content + + +@pytest.mark.django_db +@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"]) +def test_article_edit_page_has_tabbed_interface(client, django_user_model, home_page): + """ArticlePage editor should have tabbed panels (Content, Metadata, Publishing, SEO).""" + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Tabbed", + slug="tabbed", + author=author, + summary="summary", + body=[("rich_text", "body
")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + admin = django_user_model.objects.create_superuser( + username="admin", email="admin@example.com", password="admin-pass" + ) + client.force_login(admin) + response = client.get(f"/cms/pages/{article.pk}/edit/") + content = response.content.decode() + assert response.status_code == 200 + assert "Content" in content + assert "Metadata" in content + assert "Publishing" in content + assert "SEO" in content diff --git a/apps/blog/wagtail_hooks.py b/apps/blog/wagtail_hooks.py index 4568f40..59ffae3 100644 --- a/apps/blog/wagtail_hooks.py +++ b/apps/blog/wagtail_hooks.py @@ -1,7 +1,17 @@ +from django.utils.html import format_html + +import django_filters +from wagtail import hooks +from wagtail.admin.filters import WagtailFilterSet +from wagtail.admin.ui.components import Component +from wagtail.admin.ui.tables import Column, DateColumn +from wagtail.admin.ui.tables.pages import BulkActionsColumn, PageStatusColumn, PageTitleColumn +from wagtail.admin.viewsets.pages import PageListingViewSet from wagtail.snippets.models import register_snippet from wagtail.snippets.views.snippets import SnippetViewSet -from apps.blog.models import Category, TagMetadata +from apps.authors.models import Author +from apps.blog.models import ArticlePage, Category, TagMetadata class TagMetadataViewSet(SnippetViewSet): @@ -22,3 +32,74 @@ class CategoryViewSet(SnippetViewSet): register_snippet(CategoryViewSet) + + +# ── Articles page listing ──────────────────────────────────────────────────── + + +class ArticleFilterSet(WagtailFilterSet): + category = django_filters.ModelChoiceFilter( + queryset=Category.objects.all(), + empty_label="All categories", + ) + author = django_filters.ModelChoiceFilter( + queryset=Author.objects.all(), + empty_label="All authors", + ) + + class Meta: + model = ArticlePage + fields = [] + + +class ArticlePageListingViewSet(PageListingViewSet): + model = ArticlePage + icon = "doc-full" + menu_label = "Articles" + menu_order = 200 + add_to_admin_menu = True + name = "articles" + columns = [ + BulkActionsColumn("bulk_actions"), + PageTitleColumn("title", classname="title"), + Column("author", label="Author", sort_key="author__name"), + Column("category", label="Category"), + DateColumn("published_date", label="Published", sort_key="published_date"), + PageStatusColumn("status", sort_key="live"), + ] + filterset_class = ArticleFilterSet + + +@hooks.register("register_admin_viewset") +def register_article_listing(): + return ArticlePageListingViewSet("articles") + + +# ── Dashboard panel ────────────────────────────────────────────────────────── + + +class ArticlesSummaryPanel(Component): + name = "articles_summary" + template_name = "blog/panels/articles_summary.html" + order = 110 + + def get_context_data(self, parent_context): + context = super().get_context_data(parent_context) + context["drafts"] = ( + ArticlePage.objects.not_live() + .order_by("-latest_revision_created_at")[:5] + ) + context["scheduled"] = ( + ArticlePage.objects.filter(go_live_at__isnull=False, live=False) + .order_by("go_live_at")[:5] + ) + context["recent"] = ( + ArticlePage.objects.live() + .order_by("-published_date")[:5] + ) + return context + + +@hooks.register("construct_homepage_panels") +def add_articles_summary_panel(request, panels): + panels.append(ArticlesSummaryPanel()) diff --git a/apps/core/management/commands/seed_e2e_content.py b/apps/core/management/commands/seed_e2e_content.py index f14e9dd..c4a6f57 100644 --- a/apps/core/management/commands/seed_e2e_content.py +++ b/apps/core/management/commands/seed_e2e_content.py @@ -1,5 +1,6 @@ from __future__ import annotations +from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand from taggit.models import Tag from wagtail.models import Page, Site @@ -10,6 +11,8 @@ from apps.comments.models import Comment from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink from apps.legal.models import LegalIndexPage, LegalPage +User = get_user_model() + class Command(BaseCommand): help = "Seed deterministic content for E2E checks." @@ -181,4 +184,12 @@ class Command(BaseCommand): ] ) + # Admin user for E2E admin tests + if not User.objects.filter(username="e2e-admin").exists(): + User.objects.create_superuser( + username="e2e-admin", + email="e2e-admin@example.com", + password="e2e-admin-pass", + ) + self.stdout.write(self.style.SUCCESS("Seeded E2E content.")) diff --git a/e2e/test_admin_experience.py b/e2e/test_admin_experience.py new file mode 100644 index 0000000..d47f85a --- /dev/null +++ b/e2e/test_admin_experience.py @@ -0,0 +1,56 @@ +"""E2E tests for Wagtail admin editor experience improvements.""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import Page, expect + + +def admin_login(page: Page, base_url: str) -> None: + """Log in to the Wagtail admin using the seeded E2E admin user.""" + page.goto(f"{base_url}/cms/login/", wait_until="networkidle") + page.fill('input[name="username"]', "e2e-admin") + page.fill('input[name="password"]', "e2e-admin-pass") + page.click('button[type="submit"]') + page.wait_for_load_state("networkidle") + + +@pytest.mark.e2e +def test_articles_menu_item_visible(page: Page, base_url: str) -> None: + """The admin sidebar should contain an 'Articles' menu item.""" + admin_login(page, base_url) + sidebar = page.locator("[data-side-panel]").first + articles_link = sidebar.get_by_role("link", name="Articles") + expect(articles_link).to_be_visible() + + +@pytest.mark.e2e +def test_articles_listing_page_loads(page: Page, base_url: str) -> None: + """Clicking 'Articles' should load the articles listing with seeded articles.""" + admin_login(page, base_url) + page.goto(f"{base_url}/cms/articles/", wait_until="networkidle") + expect(page.get_by_role("heading").first).to_be_visible() + # Seeded articles should appear + expect(page.get_by_text("Nightly Playwright Journey")).to_be_visible() + + +@pytest.mark.e2e +def test_dashboard_has_articles_panel(page: Page, base_url: str) -> None: + """The admin dashboard should include the articles summary panel.""" + admin_login(page, base_url) + page.goto(f"{base_url}/cms/", wait_until="networkidle") + expect(page.get_by_text("Articles overview")).to_be_visible() + + +@pytest.mark.e2e +def test_article_editor_has_tabs(page: Page, base_url: str) -> None: + """The article editor should have Content, Metadata, Publishing, and SEO tabs.""" + admin_login(page, base_url) + page.goto(f"{base_url}/cms/articles/", wait_until="networkidle") + # Click the first article to edit it + page.get_by_text("Nightly Playwright Journey").first.click() + page.wait_for_load_state("networkidle") + expect(page.get_by_role("tab", name="Content")).to_be_visible() + expect(page.get_by_role("tab", name="Metadata")).to_be_visible() + expect(page.get_by_role("tab", name="Publishing")).to_be_visible() + expect(page.get_by_role("tab", name="SEO")).to_be_visible() diff --git a/templates/blog/panels/articles_summary.html b/templates/blog/panels/articles_summary.html new file mode 100644 index 0000000..3ce893f --- /dev/null +++ b/templates/blog/panels/articles_summary.html @@ -0,0 +1,62 @@ +{% load wagtailadmin_tags %} +| + {{ page.title }} + | +{{ page.latest_revision_created_at|timesince }} ago | +
| + {{ page.title }} + | +{{ page.go_live_at|date:"N j, Y H:i" }} | +
| + {{ page.title }} + | +{{ page.published_date|timesince }} ago | +
No articles yet. Create one.
+ {% endif %} +