Add category taxonomy and navigation integration
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 14s
CI / pr-e2e (pull_request) Failing after 1m19s

Implements Issue #35 with category snippets, article category routing, category-aware templates, and category RSS feeds with tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mark
2026-03-03 11:20:07 +00:00
parent 49baf6a37d
commit e2f71a801c
13 changed files with 404 additions and 18 deletions

View File

@@ -7,10 +7,12 @@ from typing import Any
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import models
from django.db.models import CASCADE, PROTECT, SET_NULL, Prefetch
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.contrib.routable_page.models import RoutablePageMixin, route
from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page
from wagtailseo.models import SeoMixin
@@ -34,7 +36,7 @@ class HomePage(Page):
articles_qs = (
ArticlePage.objects.live()
.public()
.select_related("author")
.select_related("author", "category")
.prefetch_related("tags__metadata")
.order_by("-first_published_at")
)
@@ -47,10 +49,13 @@ class HomePage(Page):
id__in=ArticlePage.objects.live().public().values_list("tags__id", flat=True)
).distinct().order_by("name")
)
ctx["available_categories"] = (
Category.objects.filter(show_in_nav=True, articles__live=True).distinct().order_by("sort_order", "name")
)
return ctx
class ArticleIndexPage(Page):
class ArticleIndexPage(RoutablePageMixin, Page):
parent_page_types = ["blog.HomePage"]
subpage_types = ["blog.ArticlePage"]
ARTICLES_PER_PAGE = 12
@@ -59,15 +64,24 @@ class ArticleIndexPage(Page):
return (
ArticlePage.objects.child_of(self)
.live()
.select_related("author")
.select_related("author", "category")
.prefetch_related("tags__metadata")
.order_by("-first_published_at")
)
def get_context(self, request, *args, **kwargs):
ctx = super().get_context(request, *args, **kwargs)
def get_category_url(self, category):
return f"{self.url}category/{category.slug}/"
def get_listing_context(self, request, active_category=None):
tag_slug = request.GET.get("tag")
articles = self.get_articles()
all_articles = articles
available_categories = (
Category.objects.filter(articles__in=all_articles).distinct().order_by("sort_order", "name")
)
category_links = [{"category": category, "url": self.get_category_url(category)} for category in available_categories]
if active_category:
articles = articles.filter(category=active_category)
available_tags = (
Tag.objects.filter(id__in=articles.values_list("tags__id", flat=True)).distinct().order_by("name")
)
@@ -81,10 +95,25 @@ class ArticleIndexPage(Page):
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
ctx["articles"] = page_obj
ctx["paginator"] = paginator
ctx["active_tag"] = tag_slug
ctx["available_tags"] = available_tags
return {
"articles": page_obj,
"paginator": paginator,
"active_tag": tag_slug,
"available_tags": available_tags,
"available_categories": available_categories,
"category_links": category_links,
"active_category": active_category,
"active_category_url": self.get_category_url(active_category) if active_category else "",
}
@route(r"^category/(?P<category_slug>[-\w]+)/$")
def category_listing(self, request, category_slug):
category = get_object_or_404(Category.objects.filter(articles__in=self.get_articles()).distinct(), slug=category_slug)
return self.render(request, context_overrides=self.get_listing_context(request, active_category=category))
def get_context(self, request, *args, **kwargs):
ctx = super().get_context(request, *args, **kwargs)
ctx.update(self.get_listing_context(request))
return ctx
@@ -92,6 +121,36 @@ class ArticleTag(TaggedItemBase):
content_object = ParentalKey("blog.ArticlePage", related_name="tagged_items", on_delete=CASCADE)
class Category(models.Model):
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
hero_image = models.ForeignKey(
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
)
colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default="neutral")
sort_order = models.IntegerField(default=0)
show_in_nav = models.BooleanField(default=True)
panels = [
FieldPanel("name"),
FieldPanel("slug"),
FieldPanel("description"),
FieldPanel("hero_image"),
FieldPanel("colour"),
FieldPanel("sort_order"),
FieldPanel("show_in_nav"),
]
class Meta:
ordering = ["sort_order", "name"]
def __str__(self):
return self.name
class TagMetadata(models.Model):
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
@@ -124,6 +183,7 @@ class TagMetadata(models.Model):
class ArticlePage(SeoMixin, Page):
category = models.ForeignKey("blog.Category", on_delete=PROTECT, related_name="articles")
author = models.ForeignKey("authors.Author", on_delete=PROTECT)
hero_image = models.ForeignKey(
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
@@ -138,6 +198,7 @@ class ArticlePage(SeoMixin, Page):
subpage_types: list[str] = []
content_panels = Page.content_panels + [
FieldPanel("category"),
FieldPanel("author"),
FieldPanel("hero_image"),
FieldPanel("summary"),
@@ -151,6 +212,11 @@ class ArticlePage(SeoMixin, Page):
search_fields = Page.search_fields
def save(self, *args: Any, **kwargs: Any) -> None:
if not self.category_id:
self.category, _ = Category.objects.get_or_create(
slug="general",
defaults={"name": "General", "description": "General articles", "colour": "neutral"},
)
self.read_time_mins = self._compute_read_time()
return super().save(*args, **kwargs)