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
|
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,13 @@ 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())
|
return mapping.get(self.colour, get_auto_tag_colour_css(self.tag.name))
|
||||||
|
|
||||||
|
|
||||||
class ArticlePageAdminForm(WagtailAdminPageForm):
|
class ArticlePageAdminForm(WagtailAdminPageForm):
|
||||||
@@ -254,6 +326,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 +449,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 +458,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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
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."
|
||||||
|
|||||||
@@ -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-")
|
||||||
|
|||||||
@@ -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", ""))
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user