Auto slug, auto summary/SEO, and deterministic tag colours
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user