Merge pull request 'feat: improve Wagtail admin editor experience for articles' (#40) from feature/improve-editor-experience into main
Reviewed-on: #40 Reviewed-by: codex_a <codex_a@linteldigital.com>
This commit was merged in pull request #40.
This commit is contained in:
@@ -118,6 +118,7 @@ jobs:
|
|||||||
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
||||||
-e NEWSLETTER_PROVIDER=buttondown \
|
-e NEWSLETTER_PROVIDER=buttondown \
|
||||||
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||||
|
-e E2E_MODE=1 \
|
||||||
"$CI_IMAGE" \
|
"$CI_IMAGE" \
|
||||||
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
||||||
for i in $(seq 1 40); do
|
for i in $(seq 1 40); do
|
||||||
@@ -188,6 +189,7 @@ jobs:
|
|||||||
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
||||||
-e NEWSLETTER_PROVIDER=buttondown \
|
-e NEWSLETTER_PROVIDER=buttondown \
|
||||||
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||||
|
-e E2E_MODE=1 \
|
||||||
"$CI_IMAGE" \
|
"$CI_IMAGE" \
|
||||||
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
||||||
for i in $(seq 1 40); do
|
for i in $(seq 1 40); do
|
||||||
|
|||||||
@@ -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,10 +11,11 @@ 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
|
||||||
|
from wagtail.search import index
|
||||||
from wagtailseo.models import SeoMixin
|
from wagtailseo.models import SeoMixin
|
||||||
|
|
||||||
from apps.blog.blocks import ARTICLE_BODY_BLOCKS
|
from apps.blog.blocks import ARTICLE_BODY_BLOCKS
|
||||||
@@ -38,7 +39,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 +65,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,23 +192,50 @@ 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"),
|
||||||
|
]
|
||||||
|
|
||||||
search_fields = Page.search_fields
|
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 + [
|
||||||
|
index.SearchField("summary"),
|
||||||
|
]
|
||||||
|
|
||||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||||
if not self.category_id:
|
if not self.category_id:
|
||||||
@@ -215,6 +243,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 +269,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):
|
||||||
|
|||||||
275
apps/blog/tests/test_admin_experience.py
Normal file
275
apps/blog/tests/test_admin_experience.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||||
|
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() - 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() - 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() - 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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
||||||
|
def test_articles_listing_has_status_filter(client, django_user_model, home_page):
|
||||||
|
"""The Articles listing should accept status filter parameter."""
|
||||||
|
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/?status=live")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
||||||
|
def test_articles_listing_has_tag_filter(client, django_user_model, home_page):
|
||||||
|
"""The Articles listing should accept tag filter parameter."""
|
||||||
|
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/?tag=1")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_listing_default_ordering():
|
||||||
|
"""ArticlePageListingViewSet should default to -published_date ordering."""
|
||||||
|
from apps.blog.wagtail_hooks import ArticlePageListingViewSet
|
||||||
|
|
||||||
|
assert ArticlePageListingViewSet.default_ordering == "-published_date"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_search_fields_include_summary():
|
||||||
|
"""ArticlePage.search_fields should index the summary field."""
|
||||||
|
field_names = [
|
||||||
|
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
|
||||||
|
]
|
||||||
|
assert "summary" in field_names
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
|
import django_filters
|
||||||
|
from taggit.models import Tag
|
||||||
|
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
|
||||||
|
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
("live", "Published"),
|
||||||
|
("draft", "Draft"),
|
||||||
|
("scheduled", "Scheduled"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TagMetadataViewSet(SnippetViewSet):
|
class TagMetadataViewSet(SnippetViewSet):
|
||||||
@@ -22,3 +37,95 @@ class CategoryViewSet(SnippetViewSet):
|
|||||||
|
|
||||||
|
|
||||||
register_snippet(CategoryViewSet)
|
register_snippet(CategoryViewSet)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Articles page listing ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class StatusFilter(django_filters.ChoiceFilter):
|
||||||
|
def filter(self, qs, value): # noqa: A003
|
||||||
|
if value == "live":
|
||||||
|
return qs.filter(live=True)
|
||||||
|
if value == "draft":
|
||||||
|
return qs.filter(live=False, go_live_at__isnull=True)
|
||||||
|
if value == "scheduled":
|
||||||
|
return qs.filter(live=False, go_live_at__isnull=False)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
status = StatusFilter(
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
empty_label="All statuses",
|
||||||
|
)
|
||||||
|
tag = django_filters.ModelChoiceFilter(
|
||||||
|
field_name="tags",
|
||||||
|
queryset=Tag.objects.all(),
|
||||||
|
empty_label="All tags",
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
default_ordering = "-published_date"
|
||||||
|
|
||||||
|
|
||||||
|
@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,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
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 +13,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."
|
||||||
@@ -17,6 +22,8 @@ class Command(BaseCommand):
|
|||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
root = Page.get_first_root_node()
|
root = Page.get_first_root_node()
|
||||||
|
|
||||||
home = HomePage.objects.child_of(root).first()
|
home = HomePage.objects.child_of(root).first()
|
||||||
@@ -40,6 +47,9 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Primary article — comments enabled, used by nightly journey test
|
# Primary article — comments enabled, used by nightly journey test
|
||||||
|
# published_date is set explicitly to ensure deterministic ordering
|
||||||
|
# (most recent first) so this article appears at the top of listings.
|
||||||
|
now = timezone.now()
|
||||||
article = ArticlePage.objects.child_of(article_index).filter(slug="nightly-playwright-journey").first()
|
article = ArticlePage.objects.child_of(article_index).filter(slug="nightly-playwright-journey").first()
|
||||||
if article is None:
|
if article is None:
|
||||||
article = ArticlePage(
|
article = ArticlePage(
|
||||||
@@ -49,9 +59,12 @@ class Command(BaseCommand):
|
|||||||
summary="Seeded article for nightly browser journey.",
|
summary="Seeded article for nightly browser journey.",
|
||||||
body=[("rich_text", "<p>Seeded article body for nightly browser checks.</p>")],
|
body=[("rich_text", "<p>Seeded article body for nightly browser checks.</p>")],
|
||||||
comments_enabled=True,
|
comments_enabled=True,
|
||||||
|
published_date=now,
|
||||||
)
|
)
|
||||||
article_index.add_child(instance=article)
|
article_index.add_child(instance=article)
|
||||||
article.save_revision().publish()
|
article.save_revision().publish()
|
||||||
|
# Ensure deterministic ordering — primary article always newest
|
||||||
|
ArticlePage.objects.filter(pk=article.pk).update(published_date=now)
|
||||||
|
|
||||||
# Seed one approved top-level comment on the primary article for reply E2E tests
|
# Seed one approved top-level comment on the primary article for reply E2E tests
|
||||||
if not Comment.objects.filter(article=article, author_name="E2E Approved Commenter").exists():
|
if not Comment.objects.filter(article=article, author_name="E2E Approved Commenter").exists():
|
||||||
@@ -75,9 +88,13 @@ class Command(BaseCommand):
|
|||||||
summary="An article with tags for E2E filter tests.",
|
summary="An article with tags for E2E filter tests.",
|
||||||
body=[("rich_text", "<p>This article is tagged with AI Tools.</p>")],
|
body=[("rich_text", "<p>This article is tagged with AI Tools.</p>")],
|
||||||
comments_enabled=True,
|
comments_enabled=True,
|
||||||
|
published_date=now - datetime.timedelta(hours=1),
|
||||||
)
|
)
|
||||||
article_index.add_child(instance=tagged_article)
|
article_index.add_child(instance=tagged_article)
|
||||||
tagged_article.save_revision().publish()
|
tagged_article.save_revision().publish()
|
||||||
|
ArticlePage.objects.filter(pk=tagged_article.pk).update(
|
||||||
|
published_date=now - datetime.timedelta(hours=1)
|
||||||
|
)
|
||||||
tagged_article.tags.add(tag)
|
tagged_article.tags.add(tag)
|
||||||
tagged_article.save()
|
tagged_article.save()
|
||||||
|
|
||||||
@@ -91,6 +108,7 @@ class Command(BaseCommand):
|
|||||||
summary="An article with comments disabled.",
|
summary="An article with comments disabled.",
|
||||||
body=[("rich_text", "<p>Comments are disabled on this one.</p>")],
|
body=[("rich_text", "<p>Comments are disabled on this one.</p>")],
|
||||||
comments_enabled=False,
|
comments_enabled=False,
|
||||||
|
published_date=now - datetime.timedelta(hours=2),
|
||||||
)
|
)
|
||||||
article_index.add_child(instance=no_comments_article)
|
article_index.add_child(instance=no_comments_article)
|
||||||
# Explicitly persist False after add_child (which internally calls save())
|
# Explicitly persist False after add_child (which internally calls save())
|
||||||
@@ -98,6 +116,9 @@ class Command(BaseCommand):
|
|||||||
ArticlePage.objects.filter(pk=no_comments_article.pk).update(comments_enabled=False)
|
ArticlePage.objects.filter(pk=no_comments_article.pk).update(comments_enabled=False)
|
||||||
no_comments_article.comments_enabled = False
|
no_comments_article.comments_enabled = False
|
||||||
no_comments_article.save_revision().publish()
|
no_comments_article.save_revision().publish()
|
||||||
|
ArticlePage.objects.filter(pk=no_comments_article.pk).update(
|
||||||
|
published_date=now - datetime.timedelta(hours=2)
|
||||||
|
)
|
||||||
|
|
||||||
# About page
|
# About page
|
||||||
if not AboutPage.objects.child_of(home).filter(slug="about").exists():
|
if not AboutPage.objects.child_of(home).filter(slug="about").exists():
|
||||||
@@ -181,4 +202,12 @@ class Command(BaseCommand):
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Admin user for E2E admin tests — only when E2E_MODE is set
|
||||||
|
if os.environ.get("E2E_MODE") and 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."))
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def test_nightly_playwright_journey() -> None:
|
|||||||
|
|
||||||
article_url = article_href if article_href.startswith("http") else f"{base_url}{article_href}"
|
article_url = article_href if article_href.startswith("http") else f"{base_url}{article_href}"
|
||||||
page.goto(article_url, wait_until="networkidle")
|
page.goto(article_url, wait_until="networkidle")
|
||||||
expect(page.get_by_role("heading", name="Comments")).to_be_visible()
|
expect(page.get_by_role("heading", name="Comments", exact=True)).to_be_visible()
|
||||||
expect(page.get_by_role("button", name="Post comment")).to_be_visible()
|
expect(page.get_by_role("button", name="Post comment")).to_be_visible()
|
||||||
|
|
||||||
page.goto(f"{base_url}/feed/", wait_until="networkidle")
|
page.goto(f"{base_url}/feed/", wait_until="networkidle")
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ services:
|
|||||||
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
|
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
|
||||||
DEFAULT_FROM_EMAIL: hello@nohypeai.com
|
DEFAULT_FROM_EMAIL: hello@nohypeai.com
|
||||||
NEWSLETTER_PROVIDER: buttondown
|
NEWSLETTER_PROVIDER: buttondown
|
||||||
|
E2E_MODE: "1"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
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("#wagtail-sidebar")
|
||||||
|
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 title link to edit it
|
||||||
|
page.get_by_role("link", name="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>
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user