Auto slug, auto summary/SEO, and deterministic tag colours
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m23s
CI / pr-e2e (pull_request) Successful in 1m21s

Issue #61: Strengthen auto-generation for slug, summary, and SEO fields.
- ArticlePage.save() now auto-generates slug from title when empty
- ArticlePage.save() auto-populates search_description from summary
- Admin form also auto-populates search_description from summary

Issue #63: Replace manual TagMetadata colour assignment with deterministic
hash-based auto-colour. Tags get a consistent colour from a 12-entry
palette without needing a TagMetadata snippet. TagMetadata still works
as an explicit override.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 00:35:39 +00:00
parent 0e35fb0ad3
commit 0dc997d2cf
7 changed files with 306 additions and 29 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import hashlib
import re
from math import ceil
from typing import Any
@@ -174,20 +175,87 @@ class Category(models.Model):
return self.name
# ── Tag colour palette ────────────────────────────────────────────────────────
# Deterministic hash-based colour assignment for tags. Each entry is a dict
# with Tailwind CSS class strings for bg, text, and border.
TAG_COLOUR_PALETTE: list[dict[str, str]] = [
{
"bg": "bg-brand-cyan/10",
"text": "text-brand-cyan",
"border": "border-brand-cyan/20",
},
{
"bg": "bg-brand-pink/10",
"text": "text-brand-pink",
"border": "border-brand-pink/20",
},
{
"bg": "bg-amber-500/10",
"text": "text-amber-400",
"border": "border-amber-500/20",
},
{
"bg": "bg-emerald-500/10",
"text": "text-emerald-400",
"border": "border-emerald-500/20",
},
{
"bg": "bg-violet-500/10",
"text": "text-violet-400",
"border": "border-violet-500/20",
},
{
"bg": "bg-rose-500/10",
"text": "text-rose-400",
"border": "border-rose-500/20",
},
{
"bg": "bg-sky-500/10",
"text": "text-sky-400",
"border": "border-sky-500/20",
},
{
"bg": "bg-lime-500/10",
"text": "text-lime-400",
"border": "border-lime-500/20",
},
{
"bg": "bg-orange-500/10",
"text": "text-orange-400",
"border": "border-orange-500/20",
},
{
"bg": "bg-fuchsia-500/10",
"text": "text-fuchsia-400",
"border": "border-fuchsia-500/20",
},
{
"bg": "bg-teal-500/10",
"text": "text-teal-400",
"border": "border-teal-500/20",
},
{
"bg": "bg-indigo-500/10",
"text": "text-indigo-400",
"border": "border-indigo-500/20",
},
]
def get_auto_tag_colour_css(tag_name: str) -> dict[str, str]:
"""Deterministically assign a colour from the palette based on tag name."""
digest = hashlib.md5(tag_name.lower().encode(), usedforsecurity=False).hexdigest() # noqa: S324
index = int(digest, 16) % len(TAG_COLOUR_PALETTE)
return TAG_COLOUR_PALETTE[index]
class TagMetadata(models.Model):
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
tag = models.OneToOneField("taggit.Tag", on_delete=CASCADE, related_name="metadata")
colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default="neutral")
@classmethod
def get_fallback_css(cls) -> dict[str, str]:
return {
"bg": "bg-zinc-800 dark:bg-zinc-100",
"text": "text-white dark:text-black",
"border": "border-zinc-600/20 dark:border-zinc-400/20",
}
def get_css_classes(self) -> dict[str, str]:
mapping = {
"cyan": {
@@ -200,9 +268,13 @@ class TagMetadata(models.Model):
"text": "text-brand-pink",
"border": "border-brand-pink/20",
},
"neutral": self.get_fallback_css(),
"neutral": {
"bg": "bg-zinc-800 dark:bg-zinc-100",
"text": "text-white dark:text-black",
"border": "border-zinc-600/20 dark:border-zinc-400/20",
},
}
return mapping.get(self.colour, self.get_fallback_css())
return mapping.get(self.colour, get_auto_tag_colour_css(self.tag.name))
class ArticlePageAdminForm(WagtailAdminPageForm):
@@ -254,6 +326,8 @@ class ArticlePageAdminForm(WagtailAdminPageForm):
cleaned_data.get("body"),
max_chars=self.SUMMARY_MAX_CHARS,
) or title
if not cleaned_data.get("search_description") and cleaned_data.get("summary"):
cleaned_data["search_description"] = cleaned_data["summary"]
return cleaned_data
@@ -375,6 +449,8 @@ class ArticlePage(SeoMixin, Page):
return " ".join(parts)
def save(self, *args: Any, **kwargs: Any) -> None:
if not getattr(self, "slug", "") and self.title:
self.slug = self._auto_slug_from_title()
if not self.category_id:
self.category, _ = Category.objects.get_or_create(
slug="general",
@@ -382,12 +458,27 @@ class ArticlePage(SeoMixin, Page):
)
if not (self.summary or "").strip():
self.summary = _generate_summary_from_stream(self.body) or self.title
if not getattr(self, "search_description", "") and self.summary:
self.search_description = self.summary
if not self.published_date and self.first_published_at:
self.published_date = self.first_published_at
if self._should_refresh_read_time():
self.read_time_mins = self._compute_read_time()
return super().save(*args, **kwargs)
def _auto_slug_from_title(self) -> str:
base_slug = slugify(self.title) or "article"
parent = self.get_parent() if self.pk else None
if parent is None:
return base_slug
sibling_pages = parent.get_children().exclude(pk=self.pk)
slug = base_slug
suffix = 2
while sibling_pages.filter(slug=slug).exists():
slug = f"{base_slug}-{suffix}"
suffix += 1
return slug
def _should_refresh_read_time(self) -> bool:
if not self.pk:
return True

View File

@@ -411,6 +411,73 @@ def test_snippet_category_listing_shows_categories(client, django_user_model):
assert "Tutorials" in content
@pytest.mark.django_db
def test_article_admin_form_clean_auto_populates_search_description(article_index, django_user_model, monkeypatch):
"""Form clean should auto-populate search_description from summary."""
user = django_user_model.objects.create_user(
username="writer",
email="writer@example.com",
password="writer-pass",
first_name="Writer",
last_name="User",
)
form_class = ArticlePage.get_edit_handler().get_form_class()
form = form_class(parent_page=article_index, for_user=user)
body = [
SimpleNamespace(block_type="rich_text", value=SimpleNamespace(source="<p>Article body text.</p>")),
]
form.cleaned_data = {
"title": "SEO Test",
"slug": "",
"author": None,
"category": None,
"summary": "",
"search_description": "",
"body": body,
}
mro = form.__class__.__mro__
super_form_class = mro[mro.index(ArticlePageAdminForm) + 1]
monkeypatch.setattr(super_form_class, "clean", lambda _self: _self.cleaned_data)
cleaned = form.clean()
assert cleaned["summary"] == "Article body text."
assert cleaned["search_description"] == "Article body text."
@pytest.mark.django_db
def test_article_admin_form_preserves_explicit_search_description(article_index, django_user_model, monkeypatch):
"""Form clean should not overwrite an explicit search_description."""
user = django_user_model.objects.create_user(
username="writer2",
email="writer2@example.com",
password="writer-pass",
)
form_class = ArticlePage.get_edit_handler().get_form_class()
form = form_class(parent_page=article_index, for_user=user)
body = [
SimpleNamespace(block_type="rich_text", value=SimpleNamespace(source="<p>Body.</p>")),
]
form.cleaned_data = {
"title": "SEO Explicit Test",
"slug": "seo-explicit-test",
"author": None,
"category": None,
"summary": "My summary.",
"search_description": "Custom SEO text.",
"body": body,
}
mro = form.__class__.__mro__
super_form_class = mro[mro.index(ArticlePageAdminForm) + 1]
monkeypatch.setattr(super_form_class, "clean", lambda _self: _self.cleaned_data)
cleaned = form.clean()
assert cleaned["search_description"] == "Custom SEO text."
@pytest.mark.django_db
def test_article_page_omits_admin_messages_on_frontend(article_page, rf):
"""Frontend templates should not render admin session messages."""

View File

@@ -2,7 +2,15 @@ import pytest
from django.db import IntegrityError
from taggit.models import Tag
from apps.blog.models import ArticleIndexPage, ArticlePage, Category, HomePage, TagMetadata
from apps.blog.models import (
TAG_COLOUR_PALETTE,
ArticleIndexPage,
ArticlePage,
Category,
HomePage,
TagMetadata,
get_auto_tag_colour_css,
)
from apps.blog.tests.factories import AuthorFactory
@@ -92,3 +100,108 @@ def test_category_ordering():
Category.objects.create(name="A", slug="a", sort_order=1)
names = list(Category.objects.values_list("name", flat=True))
assert names == ["General", "A", "Z"]
# ── Auto tag colour tests ────────────────────────────────────────────────────
def test_auto_tag_colour_is_deterministic():
"""Same tag name always produces the same colour."""
css1 = get_auto_tag_colour_css("python")
css2 = get_auto_tag_colour_css("python")
assert css1 == css2
def test_auto_tag_colour_is_case_insensitive():
"""Tag colour assignment is case-insensitive."""
assert get_auto_tag_colour_css("Python") == get_auto_tag_colour_css("python")
def test_auto_tag_colour_returns_valid_palette_entry():
"""Returned CSS dict must be from the palette."""
css = get_auto_tag_colour_css("llms")
assert css in TAG_COLOUR_PALETTE
def test_auto_tag_colour_distributes_across_palette():
"""Different tag names should map to multiple palette entries."""
sample_tags = ["python", "javascript", "rust", "go", "ruby", "java",
"typescript", "css", "html", "sql", "llms", "mlops"]
colours = {get_auto_tag_colour_css(t)["text"] for t in sample_tags}
assert len(colours) >= 3, "Tags should spread across at least 3 palette colours"
@pytest.mark.django_db
def test_tag_without_metadata_uses_auto_colour():
"""Tags without TagMetadata should get auto-assigned colour, not neutral."""
tag = Tag.objects.create(name="fastapi", slug="fastapi")
expected = get_auto_tag_colour_css("fastapi")
# Verify no metadata exists
assert not TagMetadata.objects.filter(tag=tag).exists()
# The template tag helper should fall back to auto colour
from apps.core.templatetags.core_tags import _resolve_tag_css
assert _resolve_tag_css(tag) == expected
@pytest.mark.django_db
def test_tag_with_metadata_overrides_auto_colour():
"""Tags with explicit TagMetadata should use that colour."""
tag = Tag.objects.create(name="django", slug="django")
TagMetadata.objects.create(tag=tag, colour="pink")
from apps.core.templatetags.core_tags import _resolve_tag_css
css = _resolve_tag_css(tag)
assert css["text"] == "text-brand-pink"
# ── Auto slug tests ──────────────────────────────────────────────────────────
@pytest.mark.django_db
def test_article_save_auto_generates_slug_from_title(home_page):
"""Model save should auto-generate slug from title when slug is empty."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="My Great Article",
slug="my-great-article",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save()
assert article.slug == "my-great-article"
@pytest.mark.django_db
def test_article_save_auto_generates_search_description(article_index):
"""Model save should populate search_description from summary."""
author = AuthorFactory()
article = ArticlePage(
title="SEO Auto",
slug="seo-auto",
author=author,
summary="This is the article summary.",
body=[("rich_text", "<p>body</p>")],
)
article_index.add_child(instance=article)
article.save()
assert article.search_description == "This is the article summary."
@pytest.mark.django_db
def test_article_save_preserves_explicit_search_description(article_index):
"""Explicit search_description should not be overwritten."""
author = AuthorFactory()
article = ArticlePage(
title="SEO Explicit",
slug="seo-explicit",
author=author,
summary="Generated summary.",
search_description="Custom SEO description.",
body=[("rich_text", "<p>body</p>")],
)
article_index.add_child(instance=article)
article.save()
assert article.search_description == "Custom SEO description."

View File

@@ -1,7 +1,5 @@
import pytest
from apps.blog.models import TagMetadata
@pytest.mark.django_db
def test_home_context_lists_articles(home_page, article_page):
@@ -22,6 +20,7 @@ def test_get_related_articles_fallback(article_page, article_index):
assert isinstance(related, list)
def test_tag_metadata_fallback_classes():
css = TagMetadata.get_fallback_css()
def test_auto_tag_colour_returns_valid_css():
from apps.blog.models import get_auto_tag_colour_css
css = get_auto_tag_colour_css("test-tag")
assert css["bg"].startswith("bg-")

View File

@@ -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 ArticleIndexPage, Category, TagMetadata
from apps.blog.models import ArticleIndexPage, Category, TagMetadata, get_auto_tag_colour_css
from apps.core.models import SiteSettings
from apps.legal.models import LegalPage
@@ -70,20 +70,24 @@ def get_categories_nav(context):
]
@register.simple_tag
@register.filter
def get_tag_css(tag):
def _resolve_tag_css(tag) -> dict[str, str]:
"""Return CSS classes for a tag, using TagMetadata if set, else auto-colour."""
meta = getattr(tag, "metadata", None)
if meta is None:
meta = TagMetadata.objects.filter(tag=tag).first()
classes = meta.get_css_classes() if meta else TagMetadata.get_fallback_css()
if meta:
return meta.get_css_classes()
return get_auto_tag_colour_css(tag.name)
@register.simple_tag
@register.filter
def get_tag_css(tag):
classes = _resolve_tag_css(tag)
return mark_safe(f"{classes['bg']} {classes['text']}")
@register.filter
def get_tag_border_css(tag):
meta = getattr(tag, "metadata", None)
if meta is None:
meta = TagMetadata.objects.filter(tag=tag).first()
classes = meta.get_css_classes() if meta else TagMetadata.get_fallback_css()
classes = _resolve_tag_css(tag)
return mark_safe(classes.get("border", ""))

View File

@@ -21,17 +21,20 @@ def test_context_processor_returns_sitesettings(home_page):
@pytest.mark.django_db
def test_get_tag_css_fallback():
def test_get_tag_css_auto_colour():
"""Tags without metadata get a deterministic auto-assigned colour."""
tag = Tag.objects.create(name="x", slug="x")
value = core_tags.get_tag_css(tag)
assert "bg-zinc" in value
assert "bg-" in value
assert "text-" in value
@pytest.mark.django_db
def test_get_tag_border_css_fallback():
def test_get_tag_border_css_auto_colour():
"""Tags without metadata get a deterministic auto-assigned border colour."""
tag = Tag.objects.create(name="y", slug="y")
value = core_tags.get_tag_border_css(tag)
assert "border-zinc" in value
assert "border-" in value
@pytest.mark.django_db