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>
This commit is contained in:
@@ -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]
|
||||
|
||||
18
apps/blog/migrations/0003_add_published_date.py
Normal file
18
apps/blog/migrations/0003_add_published_date.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
24
apps/blog/migrations/0004_backfill_published_date.py
Normal file
24
apps/blog/migrations/0004_backfill_published_date.py
Normal file
@@ -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),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,7 @@ class ArticlePageFactory(wagtail_factories.PageFactory):
|
||||
summary = "Summary"
|
||||
body = [("rich_text", "<p>Hello world</p>")]
|
||||
first_published_at = factory.LazyFunction(timezone.now)
|
||||
published_date = factory.LazyFunction(timezone.now)
|
||||
|
||||
|
||||
class LegalIndexPageFactory(wagtail_factories.PageFactory):
|
||||
|
||||
232
apps/blog/tests/test_admin_experience.py
Normal file
232
apps/blog/tests/test_admin_experience.py
Normal file
@@ -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", "<p>body</p>")],
|
||||
)
|
||||
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", "<p>body</p>")],
|
||||
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", "<p>body</p>")],
|
||||
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", "<p>body</p>")],
|
||||
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", "<p>b</p>")],
|
||||
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", "<p>b</p>")],
|
||||
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", "<p>body</p>")],
|
||||
)
|
||||
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", "<p>body</p>")],
|
||||
)
|
||||
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", "<p>body</p>")],
|
||||
)
|
||||
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
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user