feat: improve Wagtail admin editor experience for articles #40
@@ -16,7 +16,7 @@ class AllArticlesFeed(Feed):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def items(self):
|
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):
|
def item_title(self, item: ArticlePage):
|
||||||
return item.title
|
return item.title
|
||||||
@@ -25,7 +25,7 @@ class AllArticlesFeed(Feed):
|
|||||||
return item.summary
|
return item.summary
|
||||||
|
|
||||||
def item_pubdate(self, item: ArticlePage):
|
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):
|
def item_author_name(self, item: ArticlePage):
|
||||||
return item.author.name
|
return item.author.name
|
||||||
@@ -47,7 +47,7 @@ class TagArticlesFeed(AllArticlesFeed):
|
|||||||
return f"No Hype AI — {obj.name}"
|
return f"No Hype AI — {obj.name}"
|
||||||
|
|
||||||
def items(self, obj):
|
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):
|
class CategoryArticlesFeed(AllArticlesFeed):
|
||||||
@@ -59,4 +59,4 @@ class CategoryArticlesFeed(AllArticlesFeed):
|
|||||||
return f"No Hype AI — {obj.name}"
|
return f"No Hype AI — {obj.name}"
|
||||||
|
|
||||||
def items(self, obj):
|
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.contrib.taggit import ClusterTaggableManager
|
||||||
from modelcluster.fields import ParentalKey
|
from modelcluster.fields import ParentalKey
|
||||||
from taggit.models import Tag, TaggedItemBase
|
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.contrib.routable_page.models import RoutablePageMixin, route
|
||||||
from wagtail.fields import RichTextField, StreamField
|
from wagtail.fields import RichTextField, StreamField
|
||||||
from wagtail.models import Page
|
from wagtail.models import Page
|
||||||
@@ -38,7 +38,7 @@ class HomePage(Page):
|
|||||||
.public()
|
.public()
|
||||||
.select_related("author", "category")
|
.select_related("author", "category")
|
||||||
.prefetch_related("tags__metadata")
|
.prefetch_related("tags__metadata")
|
||||||
.order_by("-first_published_at")
|
.order_by("-published_date")
|
||||||
)
|
)
|
||||||
articles = list(articles_qs[:5])
|
articles = list(articles_qs[:5])
|
||||||
ctx["featured_article"] = self.featured_article
|
ctx["featured_article"] = self.featured_article
|
||||||
@@ -64,7 +64,7 @@ class ArticleIndexPage(RoutablePageMixin, Page):
|
|||||||
.live()
|
.live()
|
||||||
.select_related("author", "category")
|
.select_related("author", "category")
|
||||||
.prefetch_related("tags__metadata")
|
.prefetch_related("tags__metadata")
|
||||||
.order_by("-first_published_at")
|
.order_by("-published_date")
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_category_url(self, category):
|
def get_category_url(self, category):
|
||||||
@@ -191,21 +191,46 @@ class ArticlePage(SeoMixin, Page):
|
|||||||
tags = ClusterTaggableManager(through="blog.ArticleTag", blank=True)
|
tags = ClusterTaggableManager(through="blog.ArticleTag", blank=True)
|
||||||
read_time_mins = models.PositiveIntegerField(editable=False, default=1)
|
read_time_mins = models.PositiveIntegerField(editable=False, default=1)
|
||||||
comments_enabled = models.BooleanField(default=True)
|
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"]
|
parent_page_types = ["blog.ArticleIndexPage"]
|
||||||
subpage_types: list[str] = []
|
subpage_types: list[str] = []
|
||||||
|
|
||||||
content_panels = Page.content_panels + [
|
content_panels = [
|
||||||
FieldPanel("category"),
|
FieldPanel("title"),
|
||||||
FieldPanel("author"),
|
|
||||||
FieldPanel("hero_image"),
|
|
||||||
FieldPanel("summary"),
|
FieldPanel("summary"),
|
||||||
FieldPanel("body"),
|
FieldPanel("body"),
|
||||||
|
]
|
||||||
|
|
||||||
|
metadata_panels = [
|
||||||
|
FieldPanel("category"),
|
||||||
|
FieldPanel("author"),
|
||||||
FieldPanel("tags"),
|
FieldPanel("tags"),
|
||||||
|
FieldPanel("hero_image"),
|
||||||
FieldPanel("comments_enabled"),
|
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
|
search_fields = Page.search_fields
|
||||||
|
|
||||||
@@ -215,6 +240,8 @@ class ArticlePage(SeoMixin, Page):
|
|||||||
slug="general",
|
slug="general",
|
||||||
defaults={"name": "General", "description": "General articles", "colour": "neutral"},
|
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()
|
self.read_time_mins = self._compute_read_time()
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
@@ -239,14 +266,14 @@ class ArticlePage(SeoMixin, Page):
|
|||||||
.filter(tags__in=tag_ids)
|
.filter(tags__in=tag_ids)
|
||||||
.exclude(pk=self.pk)
|
.exclude(pk=self.pk)
|
||||||
.distinct()
|
.distinct()
|
||||||
.order_by("-first_published_at")[:count]
|
.order_by("-published_date")[:count]
|
||||||
)
|
)
|
||||||
if len(related) < count:
|
if len(related) < count:
|
||||||
exclude_ids = [a.pk for a in related] + [self.pk]
|
exclude_ids = [a.pk for a in related] + [self.pk]
|
||||||
fallback = list(
|
fallback = list(
|
||||||
ArticlePage.objects.live()
|
ArticlePage.objects.live()
|
||||||
.exclude(pk__in=exclude_ids)
|
.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 + fallback
|
||||||
return related
|
return related
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class ArticlePageFactory(wagtail_factories.PageFactory):
|
|||||||
summary = "Summary"
|
summary = "Summary"
|
||||||
body = [("rich_text", "<p>Hello world</p>")]
|
body = [("rich_text", "<p>Hello world</p>")]
|
||||||
first_published_at = factory.LazyFunction(timezone.now)
|
first_published_at = factory.LazyFunction(timezone.now)
|
||||||
|
published_date = factory.LazyFunction(timezone.now)
|
||||||
|
|
||||||
|
|
||||||
class LegalIndexPageFactory(wagtail_factories.PageFactory):
|
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.models import register_snippet
|
||||||
from wagtail.snippets.views.snippets import SnippetViewSet
|
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):
|
class TagMetadataViewSet(SnippetViewSet):
|
||||||
@@ -22,3 +32,74 @@ class CategoryViewSet(SnippetViewSet):
|
|||||||
|
|
||||||
|
|
||||||
register_snippet(CategoryViewSet)
|
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())
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
from wagtail.models import Page, Site
|
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.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink
|
||||||
from apps.legal.models import LegalIndexPage, LegalPage
|
from apps.legal.models import LegalIndexPage, LegalPage
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Seed deterministic content for E2E checks."
|
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."))
|
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))
|
||||||
|
|||||||
56
e2e/test_admin_experience.py
Normal file
56
e2e/test_admin_experience.py
Normal file
@@ -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()
|
||||||
62
templates/blog/panels/articles_summary.html
Normal file
62
templates/blog/panels/articles_summary.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{% load wagtailadmin_tags %}
|
||||||
|
<section class="nice-padding">
|
||||||
|
<h2 class="visuallyhidden">Articles overview</h2>
|
||||||
|
|
||||||
|
{% if drafts %}
|
||||||
|
<div class="w-mb-4">
|
||||||
|
<h3><svg class="icon icon-doc-empty" aria-hidden="true"><use href="#icon-doc-empty"></use></svg> Drafts</h3>
|
||||||
|
<table class="listing">
|
||||||
|
<tbody>
|
||||||
|
{% for page in drafts %}
|
||||||
|
<tr>
|
||||||
|
<td class="title">
|
||||||
|
<a href="{% url 'wagtailadmin_pages:edit' page.pk %}">{{ page.title }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ page.latest_revision_created_at|timesince }} ago</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if scheduled %}
|
||||||
|
<div class="w-mb-4">
|
||||||
|
<h3><svg class="icon icon-time" aria-hidden="true"><use href="#icon-time"></use></svg> Scheduled</h3>
|
||||||
|
<table class="listing">
|
||||||
|
<tbody>
|
||||||
|
{% for page in scheduled %}
|
||||||
|
<tr>
|
||||||
|
<td class="title">
|
||||||
|
<a href="{% url 'wagtailadmin_pages:edit' page.pk %}">{{ page.title }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ page.go_live_at|date:"N j, Y H:i" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if recent %}
|
||||||
|
<div class="w-mb-4">
|
||||||
|
<h3><svg class="icon icon-doc-full" aria-hidden="true"><use href="#icon-doc-full"></use></svg> Recently published</h3>
|
||||||
|
<table class="listing">
|
||||||
|
<tbody>
|
||||||
|
{% for page in recent %}
|
||||||
|
<tr>
|
||||||
|
<td class="title">
|
||||||
|
<a href="{% url 'wagtailadmin_pages:edit' page.pk %}">{{ page.title }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ page.published_date|timesince }} ago</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not drafts and not scheduled and not recent %}
|
||||||
|
<p>No articles yet. <a href="{% url 'articles:choose_parent' %}">Create one</a>.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
Reference in New Issue
Block a user