From e2f71a801c94da136f76e5e6bf5423b4c866e71d Mon Sep 17 00:00:00 2001 From: Mark <162816078+markashton480@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:20:07 +0000 Subject: [PATCH 1/5] 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> --- apps/blog/feeds.py | 14 ++- .../0002_category_articlepage_category.py | 86 +++++++++++++++++++ apps/blog/models.py | 84 ++++++++++++++++-- apps/blog/tests/test_feeds_more.py | 31 +++++++ apps/blog/tests/test_models.py | 28 +++++- apps/blog/tests/test_views.py | 74 +++++++++++++++- apps/blog/wagtail_hooks.py | 13 ++- apps/core/templatetags/core_tags.py | 26 +++++- apps/core/tests/test_tags.py | 24 ++++++ config/urls.py | 3 +- templates/blog/article_index_page.html | 22 ++++- templates/blog/home_page.html | 10 +++ templates/components/nav.html | 7 ++ 13 files changed, 404 insertions(+), 18 deletions(-) create mode 100644 apps/blog/migrations/0002_category_articlepage_category.py diff --git a/apps/blog/feeds.py b/apps/blog/feeds.py index efd8842..fce13c4 100644 --- a/apps/blog/feeds.py +++ b/apps/blog/feeds.py @@ -3,7 +3,7 @@ from django.contrib.syndication.views import Feed from django.shortcuts import get_object_or_404 from taggit.models import Tag -from apps.blog.models import ArticlePage +from apps.blog.models import ArticlePage, Category class AllArticlesFeed(Feed): @@ -48,3 +48,15 @@ class TagArticlesFeed(AllArticlesFeed): def items(self, obj): return ArticlePage.objects.live().filter(tags=obj).order_by("-first_published_at")[:20] + + +class CategoryArticlesFeed(AllArticlesFeed): + def get_object(self, request, category_slug: str): + self.request = request + return get_object_or_404(Category, slug=category_slug) + + def title(self, obj): + return f"No Hype AI — {obj.name}" + + def items(self, obj): + return ArticlePage.objects.live().filter(category=obj).order_by("-first_published_at")[:20] diff --git a/apps/blog/migrations/0002_category_articlepage_category.py b/apps/blog/migrations/0002_category_articlepage_category.py new file mode 100644 index 0000000..75cd5ef --- /dev/null +++ b/apps/blog/migrations/0002_category_articlepage_category.py @@ -0,0 +1,86 @@ +# Generated by Django 5.2.11 on 2026-03-03 + +import django.db.models.deletion +from django.db import migrations, models + + +def create_default_category(apps, schema_editor): + Category = apps.get_model("blog", "Category") + Category.objects.get_or_create( + slug="general", + defaults={ + "name": "General", + "description": "General articles", + "colour": "neutral", + "sort_order": 0, + "show_in_nav": True, + }, + ) + + +def assign_default_category_to_articles(apps, schema_editor): + Category = apps.get_model("blog", "Category") + ArticlePage = apps.get_model("blog", "ArticlePage") + default_category = Category.objects.get(slug="general") + ArticlePage.objects.filter(category__isnull=True).update(category=default_category) + + +class Migration(migrations.Migration): + dependencies = [ + ("blog", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="Category", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=100, unique=True)), + ("slug", models.SlugField(unique=True)), + ("description", models.TextField(blank=True)), + ( + "hero_image", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailimages.image", + ), + ), + ( + "colour", + models.CharField( + choices=[("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")], + default="neutral", + max_length=20, + ), + ), + ("sort_order", models.IntegerField(default=0)), + ("show_in_nav", models.BooleanField(default=True)), + ], + options={"ordering": ["sort_order", "name"]}, + ), + migrations.AddField( + model_name="articlepage", + name="category", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="articles", + to="blog.category", + ), + ), + migrations.RunPython(create_default_category, migrations.RunPython.noop), + migrations.RunPython(assign_default_category_to_articles, migrations.RunPython.noop), + migrations.AlterField( + model_name="articlepage", + name="category", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="articles", + to="blog.category", + ), + ), + ] diff --git a/apps/blog/models.py b/apps/blog/models.py index adee7ab..e3a3212 100644 --- a/apps/blog/models.py +++ b/apps/blog/models.py @@ -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[-\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) diff --git a/apps/blog/tests/test_feeds_more.py b/apps/blog/tests/test_feeds_more.py index 68da99d..b462414 100644 --- a/apps/blog/tests/test_feeds_more.py +++ b/apps/blog/tests/test_feeds_more.py @@ -1,6 +1,8 @@ import pytest from apps.blog.feeds import AllArticlesFeed +from apps.blog.models import ArticleIndexPage, ArticlePage, Category +from apps.blog.tests.factories import AuthorFactory @pytest.mark.django_db @@ -16,3 +18,32 @@ def test_all_feed_methods(article_page): def test_tag_feed_not_found(client): resp = client.get("/feed/tag/does-not-exist/") assert resp.status_code == 404 + + +@pytest.mark.django_db +def test_category_feed_endpoint(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + category = Category.objects.create(name="Reviews", slug="reviews") + author = AuthorFactory() + article = ArticlePage( + title="Feed Review", + slug="feed-review", + author=author, + summary="summary", + body=[("rich_text", "

Body

")], + category=category, + ) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.get("/feed/category/reviews/") + assert resp.status_code == 200 + assert resp["Content-Type"].startswith("application/rss+xml") + assert "Feed Review" in resp.content.decode() + + +@pytest.mark.django_db +def test_category_feed_not_found(client): + resp = client.get("/feed/category/does-not-exist/") + assert resp.status_code == 404 diff --git a/apps/blog/tests/test_models.py b/apps/blog/tests/test_models.py index 1d24356..c2a7808 100644 --- a/apps/blog/tests/test_models.py +++ b/apps/blog/tests/test_models.py @@ -2,7 +2,7 @@ import pytest from django.db import IntegrityError from taggit.models import Tag -from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage, TagMetadata +from apps.blog.models import ArticleIndexPage, ArticlePage, Category, HomePage, TagMetadata from apps.blog.tests.factories import AuthorFactory @@ -40,3 +40,29 @@ def test_tag_metadata_css_and_uniqueness(): assert meta.get_css_classes()["bg"] == "bg-brand-cyan/10" with pytest.raises(IntegrityError): TagMetadata.objects.create(tag=tag, colour="pink") + + +@pytest.mark.django_db +def test_article_default_category_is_assigned(home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Categorised", + slug="categorised", + author=author, + summary="s", + body=[("rich_text", "

body

")], + ) + index.add_child(instance=article) + article.save() + assert article.category.slug == "general" + + +@pytest.mark.django_db +def test_category_ordering(): + Category.objects.get_or_create(name="General", slug="general") + Category.objects.create(name="Z", slug="z", sort_order=2) + Category.objects.create(name="A", slug="a", sort_order=1) + names = list(Category.objects.values_list("name", flat=True)) + assert names == ["General", "A", "Z"] diff --git a/apps/blog/tests/test_views.py b/apps/blog/tests/test_views.py index e8e9982..3b8f538 100644 --- a/apps/blog/tests/test_views.py +++ b/apps/blog/tests/test_views.py @@ -1,7 +1,7 @@ import pytest from taggit.models import Tag -from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.models import ArticleIndexPage, ArticlePage, Category from apps.blog.tests.factories import AuthorFactory from apps.comments.models import Comment @@ -161,3 +161,75 @@ def test_article_index_renders_tag_filter_controls(client, home_page): html = resp.content.decode() assert resp.status_code == 200 assert "/articles/?tag=tag-one" in html + + +@pytest.mark.django_db +def test_article_index_category_route_filters_articles(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + reviews = Category.objects.create(name="Reviews", slug="reviews") + tutorials = Category.objects.create(name="Tutorials", slug="tutorials") + review_article = ArticlePage( + title="Review A", + slug="review-a", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + category=reviews, + ) + tutorial_article = ArticlePage( + title="Tutorial A", + slug="tutorial-a", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + category=tutorials, + ) + index.add_child(instance=review_article) + review_article.save_revision().publish() + index.add_child(instance=tutorial_article) + tutorial_article.save_revision().publish() + + resp = client.get("/articles/category/reviews/") + html = resp.content.decode() + assert resp.status_code == 200 + assert "Review A" in html + assert "Tutorial A" not in html + + +@pytest.mark.django_db +def test_article_index_category_route_supports_tag_filter(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + reviews = Category.objects.create(name="Reviews", slug="reviews") + keep = ArticlePage( + title="Keep Me", + slug="keep-me", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + category=reviews, + ) + drop = ArticlePage( + title="Drop Me", + slug="drop-me", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + category=reviews, + ) + index.add_child(instance=keep) + keep.save_revision().publish() + index.add_child(instance=drop) + drop.save_revision().publish() + target_tag = Tag.objects.create(name="Python", slug="python") + keep.tags.add(target_tag) + keep.save_revision().publish() + + resp = client.get("/articles/category/reviews/?tag=python") + html = resp.content.decode() + assert resp.status_code == 200 + assert "Keep Me" in html + assert "Drop Me" not in html diff --git a/apps/blog/wagtail_hooks.py b/apps/blog/wagtail_hooks.py index 0dddec4..4568f40 100644 --- a/apps/blog/wagtail_hooks.py +++ b/apps/blog/wagtail_hooks.py @@ -1,7 +1,7 @@ from wagtail.snippets.models import register_snippet from wagtail.snippets.views.snippets import SnippetViewSet -from apps.blog.models import TagMetadata +from apps.blog.models import Category, TagMetadata class TagMetadataViewSet(SnippetViewSet): @@ -11,3 +11,14 @@ class TagMetadataViewSet(SnippetViewSet): register_snippet(TagMetadataViewSet) + + +class CategoryViewSet(SnippetViewSet): + model = Category + icon = "folder-open-inverse" + list_display = ["name", "slug", "show_in_nav", "sort_order"] + list_filter = ["show_in_nav"] + ordering = ["sort_order", "name"] + + +register_snippet(CategoryViewSet) diff --git a/apps/core/templatetags/core_tags.py b/apps/core/templatetags/core_tags.py index e6db4c7..ae290b9 100644 --- a/apps/core/templatetags/core_tags.py +++ b/apps/core/templatetags/core_tags.py @@ -4,7 +4,7 @@ from django import template from django.utils.safestring import mark_safe from wagtail.models import Site -from apps.blog.models import TagMetadata +from apps.blog.models import ArticleIndexPage, TagMetadata from apps.core.models import SiteSettings from apps.legal.models import LegalPage @@ -46,6 +46,30 @@ def get_social_links(context): return list(settings.social_links.all()) +@register.simple_tag(takes_context=True) +def get_categories_nav(context): + request = context.get("request") + if not request: + return [] + site = Site.find_for_request(request) if request else None + index_qs = ArticleIndexPage.objects.live().public() + if site: + index_qs = index_qs.in_site(site) + index_page = index_qs.first() + if not index_page: + return [] + categories = index_page.get_listing_context(request, active_category=None)["available_categories"].filter(show_in_nav=True) + return [ + { + "name": category.name, + "slug": category.slug, + "url": index_page.get_category_url(category), + "article_count": index_page.get_articles().filter(category=category).count(), + } + for category in categories + ] + + @register.simple_tag @register.filter def get_tag_css(tag): diff --git a/apps/core/tests/test_tags.py b/apps/core/tests/test_tags.py index 5ccabb5..cdf758d 100644 --- a/apps/core/tests/test_tags.py +++ b/apps/core/tests/test_tags.py @@ -1,5 +1,7 @@ import pytest +from apps.blog.models import ArticleIndexPage, ArticlePage, Category +from apps.blog.tests.factories import AuthorFactory from apps.legal.models import LegalIndexPage, LegalPage @@ -13,3 +15,25 @@ def test_get_legal_pages_tag(client, home_page): resp = client.get("/") assert resp.status_code == 200 + + +@pytest.mark.django_db +def test_categories_nav_tag_renders_category_link(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + category = Category.objects.create(name="Reviews", slug="reviews", show_in_nav=True) + author = AuthorFactory() + article = ArticlePage( + title="R1", + slug="r1", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + category=category, + ) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.get("/") + assert resp.status_code == 200 + assert "/articles/category/reviews/" in resp.content.decode() diff --git a/config/urls.py b/config/urls.py index 90e0544..8ae28e5 100644 --- a/config/urls.py +++ b/config/urls.py @@ -6,7 +6,7 @@ from django.views.generic import RedirectView from wagtail import urls as wagtail_urls from wagtail.contrib.sitemaps.views import sitemap -from apps.blog.feeds import AllArticlesFeed, TagArticlesFeed +from apps.blog.feeds import AllArticlesFeed, CategoryArticlesFeed, TagArticlesFeed from apps.core.views import consent_view, robots_txt urlpatterns = [ @@ -18,6 +18,7 @@ urlpatterns = [ path("consent/", consent_view, name="consent"), path("robots.txt", robots_txt, name="robots_txt"), path("feed/", AllArticlesFeed(), name="rss_feed"), + path("feed/category//", CategoryArticlesFeed(), name="rss_feed_by_category"), path("feed/tag//", TagArticlesFeed(), name="rss_feed_by_tag"), path("sitemap.xml", sitemap), path("admin/", RedirectView.as_view(url="/cms/", permanent=False)), diff --git a/templates/blog/article_index_page.html b/templates/blog/article_index_page.html index 691dbcc..931eafb 100644 --- a/templates/blog/article_index_page.html +++ b/templates/blog/article_index_page.html @@ -13,13 +13,29 @@
-

{{ page.title }}

+ {% if active_category %} + + {% endif %} +

{% if active_category %}{{ active_category.name }}{% else %}{{ page.title }}{% endif %}

+ {% if active_category.description %} +

{{ active_category.description }}

+ {% endif %} + + +
+ All Categories + {% for category_link in category_links %} + {{ category_link.category.name }} + {% endfor %} +
- All + All Tags {% for tag in available_tags %} - {{ tag.name }} + {{ tag.name }} {% endfor %}
diff --git a/templates/blog/home_page.html b/templates/blog/home_page.html index d71b079..1973092 100644 --- a/templates/blog/home_page.html +++ b/templates/blog/home_page.html @@ -140,6 +140,16 @@ {% endif %} {% if available_tags %} + {% if available_categories %} +
+

Browse Categories

+
+ {% for category in available_categories %} + {{ category.name }} + {% endfor %} +
+
+ {% endif %}

Explore Topics

diff --git a/templates/components/nav.html b/templates/components/nav.html index a410f54..cfa0aa4 100644 --- a/templates/components/nav.html +++ b/templates/components/nav.html @@ -1,5 +1,6 @@ {% load static core_tags %} {% get_nav_items "header" as header_items %} +{% get_categories_nav as category_nav_items %}