Add category taxonomy and navigation integration
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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user