Auto slug/summary/SEO and deterministic tag colours #65

Merged
mark merged 2 commits from feature/auto-slug-summary-and-tag-colours into main 2026-03-19 01:04:38 +00:00
7 changed files with 309 additions and 29 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import re import re
from math import ceil from math import ceil
from typing import Any from typing import Any
@@ -174,20 +175,87 @@ class Category(models.Model):
return self.name 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): class TagMetadata(models.Model):
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")] COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
tag = models.OneToOneField("taggit.Tag", on_delete=CASCADE, related_name="metadata") tag = models.OneToOneField("taggit.Tag", on_delete=CASCADE, related_name="metadata")
colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default="neutral") 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]: def get_css_classes(self) -> dict[str, str]:
mapping = { mapping = {
"cyan": { "cyan": {
@@ -200,9 +268,16 @@ class TagMetadata(models.Model):
"text": "text-brand-pink", "text": "text-brand-pink",
"border": "border-brand-pink/20", "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()) css = mapping.get(self.colour)
if css is not None:
mark marked this conversation as resolved Outdated

Performance bug — eager default evaluation. Python evaluates get_auto_tag_colour_css(self.tag.name) before calling dict.get(), so it runs on every call even when self.colour matches a valid key. This means an MD5 hash is computed and potentially an extra DB query is fired (to load self.tag) — then the result is silently discarded.

Fix:

css = mapping.get(self.colour)
if css is not None:
    return css
return get_auto_tag_colour_css(self.tag.name)
**Performance bug — eager default evaluation.** Python evaluates `get_auto_tag_colour_css(self.tag.name)` *before* calling `dict.get()`, so it runs on every call even when `self.colour` matches a valid key. This means an MD5 hash is computed and potentially an extra DB query is fired (to load `self.tag`) — then the result is silently discarded. Fix: ```python css = mapping.get(self.colour) if css is not None: return css return get_auto_tag_colour_css(self.tag.name) ```
return css
return get_auto_tag_colour_css(self.tag.name)
class ArticlePageAdminForm(WagtailAdminPageForm): class ArticlePageAdminForm(WagtailAdminPageForm):
@@ -254,6 +329,8 @@ class ArticlePageAdminForm(WagtailAdminPageForm):
cleaned_data.get("body"), cleaned_data.get("body"),
max_chars=self.SUMMARY_MAX_CHARS, max_chars=self.SUMMARY_MAX_CHARS,
) or title ) or title
if not cleaned_data.get("search_description") and cleaned_data.get("summary"):
cleaned_data["search_description"] = cleaned_data["summary"]
return cleaned_data return cleaned_data
@@ -375,6 +452,8 @@ class ArticlePage(SeoMixin, Page):
return " ".join(parts) return " ".join(parts)
def save(self, *args: Any, **kwargs: Any) -> None: 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: if not self.category_id:
self.category, _ = Category.objects.get_or_create( self.category, _ = Category.objects.get_or_create(
slug="general", slug="general",
@@ -382,12 +461,27 @@ class ArticlePage(SeoMixin, Page):
) )
if not (self.summary or "").strip(): if not (self.summary or "").strip():
self.summary = _generate_summary_from_stream(self.body) or self.title 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: if not self.published_date and self.first_published_at:
self.published_date = self.first_published_at self.published_date = self.first_published_at
if self._should_refresh_read_time(): if self._should_refresh_read_time():
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)
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: def _should_refresh_read_time(self) -> bool:
if not self.pk: if not self.pk:
return True return True

View File

@@ -411,6 +411,73 @@ def test_snippet_category_listing_shows_categories(client, django_user_model):
assert "Tutorials" in content 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 @pytest.mark.django_db
def test_article_page_omits_admin_messages_on_frontend(article_page, rf): def test_article_page_omits_admin_messages_on_frontend(article_page, rf):
"""Frontend templates should not render admin session messages.""" """Frontend templates should not render admin session messages."""

View File

@@ -2,7 +2,15 @@ import pytest
from django.db import IntegrityError from django.db import IntegrityError
from taggit.models import Tag 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 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) Category.objects.create(name="A", slug="a", sort_order=1)
names = list(Category.objects.values_list("name", flat=True)) names = list(Category.objects.values_list("name", flat=True))
assert names == ["General", "A", "Z"] 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")
mark marked this conversation as resolved
Review

This test doesn't exercise auto-generation. slug="my-great-article" is truthy, so the save() condition not getattr(self, "slug", "") is False and the auto-slug path is never entered. This test verifies "preserve explicit slug", not "auto-generate slug from title".

To test actual auto-generation, pass slug="" and assert the result:

article = ArticlePage(
    title="My Great Article",
    slug="",
    ...
)
index.add_child(instance=article)
article.refresh_from_db()
assert article.slug == "my-great-article"
**This test doesn't exercise auto-generation.** `slug="my-great-article"` is truthy, so the `save()` condition `not getattr(self, "slug", "")` is `False` and the auto-slug path is never entered. This test verifies "preserve explicit slug", not "auto-generate slug from title". To test actual auto-generation, pass `slug=""` and assert the result: ```python article = ArticlePage( title="My Great Article", slug="", ... ) index.add_child(instance=article) article.refresh_from_db() assert article.slug == "my-great-article" ```
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="",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.refresh_from_db()
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 import pytest
from apps.blog.models import TagMetadata
@pytest.mark.django_db @pytest.mark.django_db
def test_home_context_lists_articles(home_page, article_page): 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) assert isinstance(related, list)
def test_tag_metadata_fallback_classes(): def test_auto_tag_colour_returns_valid_css():
css = TagMetadata.get_fallback_css() from apps.blog.models import get_auto_tag_colour_css
css = get_auto_tag_colour_css("test-tag")
assert css["bg"].startswith("bg-") assert css["bg"].startswith("bg-")

View File

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

View File

@@ -21,17 +21,20 @@ def test_context_processor_returns_sitesettings(home_page):
@pytest.mark.django_db @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") tag = Tag.objects.create(name="x", slug="x")
value = core_tags.get_tag_css(tag) value = core_tags.get_tag_css(tag)
assert "bg-zinc" in value assert "bg-" in value
assert "text-" in value
@pytest.mark.django_db @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") tag = Tag.objects.create(name="y", slug="y")
value = core_tags.get_tag_border_css(tag) value = core_tags.get_tag_border_css(tag)
assert "border-zinc" in value assert "border-" in value
@pytest.mark.django_db @pytest.mark.django_db

File diff suppressed because one or more lines are too long