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

@@ -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-")