28 Commits

Author SHA1 Message Date
92c1ee425d Merge pull request 'Fix admin messages never auto-dismissing (root cause)' (#66) from fix/admin-messages-auto-dismiss-v3 into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / deploy (push) Has been skipped
CI / nightly-e2e (push) Failing after 3m57s
Reviewed-on: #66
2026-03-19 10:59:41 +00:00
ff587d9e1b Fix server-rendered admin messages never auto-dismissing
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 1m25s
CI / pr-e2e (pull_request) Successful in 1m24s
Root cause: Wagtail's w-messages Stimulus controller only auto-clears
messages added dynamically via JavaScript (the add() method).  Server-
rendered messages — the <li> elements produced by Django's messages
framework after a redirect — have no connect() lifecycle handler and
sit in the DOM indefinitely.

PR #64 added data-w-messages-auto-clear-value="8000" which correctly
handles dynamic messages, but server-rendered ones were unaffected.
PR #64 also added {% ifchanged %} for de-duplication, which doesn't
address persistence.

Fix: mark server-rendered <li> elements with data-server-rendered and
add an inline script that removes them after 8 seconds (matching the
auto-clear timeout for dynamic messages).  Also remove the ineffective
{% ifchanged %} de-duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:55:30 +00:00
0fab9ac0bf Merge pull request 'Auto slug/summary/SEO and deterministic tag colours' (#65) from feature/auto-slug-summary-and-tag-colours into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / deploy (push) Has been skipped
CI / nightly-e2e (push) Failing after 1m23s
Reviewed-on: #65
Reviewed-by: codex_a <codex_a@linteldigital.com>
2026-03-19 01:04:37 +00:00
607d8eaf85 Fix eager evaluation in get_css_classes and auto-slug test
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 1m22s
CI / pr-e2e (pull_request) Successful in 1m21s
- Replace dict.get() with eager default in TagMetadata.get_css_classes()
  with explicit if/else to avoid unnecessary MD5 hash + DB access
- Fix test_article_save_auto_generates_slug_from_title to actually test
  auto-generation by passing slug="" instead of the expected result

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:55:16 +00:00
0dc997d2cf 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>
2026-03-19 00:35:39 +00:00
0e35fb0ad3 Merge pull request 'Fix admin message auto-dismiss and Category plural label' (#64) from fix/issue-62-messages-and-categories into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 29s
2026-03-19 00:15:50 +00:00
6ab6c3c0bf Fix admin message auto-dismiss and Category plural label
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 1m47s
CI / pr-e2e (pull_request) Successful in 1m46s
Admin messages now auto-clear after 8 seconds via the w-messages
Stimulus controller's autoClear value, preventing message pile-up.
Category model gains verbose_name_plural so Wagtail shows "Categories"
instead of "Categorys" in the snippets menu.

Closes #62

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:13:00 +00:00
e75dda84ef Merge pull request 'Migrate deploy workflow to shared deploy/OpenBao model' (#60) from fix/ci-openbao-deploy-model into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 3m2s
Reviewed-on: #60
2026-03-18 23:08:55 +00:00
Mark
b0e009d606 Migrate deploy workflow to OpenBao SSH CA
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 3m2s
CI / pr-e2e (pull_request) Successful in 3m6s
2026-03-17 16:56:45 +00:00
3848cb6d23 Merge pull request 'Guard admin messages from leaking or duplicating' (#59) from fix/issue-57-message-followup into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / deploy (push) Has been skipped
CI / nightly-e2e (push) Failing after 1m19s
Reviewed-on: #59
2026-03-15 18:53:41 +00:00
d0a90ce8ff Update generated Tailwind stylesheet
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m22s
CI / ci (pull_request) Successful in 1m49s
2026-03-15 18:19:58 +00:00
9d7821b94d Fix mypy typing for admin message guard
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m26s
CI / ci (pull_request) Failing after 1m50s
2026-03-15 17:39:53 +00:00
8e43409895 Merge remote-tracking branch 'origin/main' into fix/issue-57-message-followup
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 30s
CI / pr-e2e (pull_request) Successful in 1m29s
2026-03-15 17:28:38 +00:00
9b3992f250 Guard admin messages from leaking to frontend 2026-03-15 17:28:33 +00:00
fbc9a1ff0a Merge pull request 'Fix Wagtail article publish regressions' (#58) from fix/issue-57-admin-publish-regressions into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 1m41s
Reviewed-on: #58
2026-03-15 17:09:44 +00:00
1a0617fbd0 Fix Wagtail article publish regressions
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 1m17s
CI / pr-e2e (pull_request) Successful in 1m46s
2026-03-15 16:53:49 +00:00
15ef35e249 Merge pull request 'feat(health): add /health/ endpoint for OpsLog monitoring' (#56) from feature/health-endpoint-opslog into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / deploy (push) Has been skipped
CI / nightly-e2e (push) Failing after 1m25s
Reviewed-on: #56
Reviewed-by: mark <mark@linteldigital.com>
2026-03-06 17:42:10 +00:00
Codex_B
a450e7409f fix: address health endpoint review feedback
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 1m33s
CI / pr-e2e (pull_request) Successful in 1m42s
2026-03-06 16:08:52 +00:00
Codex_B
10e39b8331 feat: add health monitoring endpoint
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m38s
CI / ci (pull_request) Successful in 1m46s
2026-03-06 15:46:08 +00:00
59cc1c41a9 Merge pull request 'fix(editor): remove SEO panel duplication and auto-default draft metadata' (#54) from fix/article-editor-defaults into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / deploy (push) Has been skipped
CI / nightly-e2e (push) Failing after 1m38s
Reviewed-on: #54
2026-03-04 22:47:21 +00:00
codex_a
2c2cb5446f test(core): keep blank-summary integrity check by bypassing auto-summary save hook
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m29s
CI / ci (pull_request) Successful in 1m40s
2026-03-04 22:42:30 +00:00
codex_a
521075cf04 fix(editor): auto-default article metadata and de-duplicate SEO panels
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 1m33s
CI / pr-e2e (pull_request) Successful in 1m35s
2026-03-04 22:32:14 +00:00
93d3e4703b Merge pull request 'fix(preview): stop frame-policy conflicts and enforce canonical host' (#52) from fix/wagtail-preview-frame-policy into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #52
2026-03-04 21:07:11 +00:00
e09e6a21f0 Merge branch 'main' into fix/wagtail-preview-frame-policy
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 1m38s
CI / pr-e2e (pull_request) Successful in 1m35s
2026-03-04 20:59:21 +00:00
codex_a
4ea1e66cdf fix(preview): align frame policy and canonical host for Wagtail preview
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 1m32s
CI / pr-e2e (pull_request) Successful in 1m34s
2026-03-04 20:51:23 +00:00
c2ad0e67c3 Merge pull request 'Restore exact original comment/reply button styles' (#51) from fix/restore-exact-comment-buttons into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #51
2026-03-04 13:09:40 +00:00
Mark
96a3971781 Restore exact original comment/reply button styling
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m29s
CI / ci (pull_request) Successful in 1m39s
- use exact pre-makeover main comment button classes/spacing/icon sizing
- use exact pre-makeover reply button classes/text casing
- rebuild Tailwind CSS
2026-03-04 13:05:18 +00:00
989d0fc20d Merge pull request 'Follow-up: fix reply alignment and Turnstile render on reply open' (#50) from fix/comments-followup-reply-turnstile into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #50
2026-03-04 12:35:43 +00:00
35 changed files with 1461 additions and 89 deletions

View File

@@ -215,12 +215,34 @@ jobs:
deploy: deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: deploy runs-on:
- ubuntu-latest
- agent-workspace
env:
BAO_TOKEN_FILE: /run/openbao-agent-ci_runner/token
steps: steps:
- name: Configure SSH via OpenBao CA
shell: bash
run: |
set -euo pipefail
: "${OPENBAO_ADDR:?OPENBAO_ADDR must be set by the runner environment}"
mkdir -p ~/.ssh && chmod 700 ~/.ssh
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -q
BAO_TOKEN="$(<"$BAO_TOKEN_FILE")"
SIGNED_KEY=$(curl -fsS \
-H "X-Vault-Token: $BAO_TOKEN" \
-H "X-Vault-Request: true" \
-X POST \
-d "{\"public_key\": \"$(cat ~/.ssh/id_ed25519.pub)\", \"valid_principals\": \"${{ vars.DEPLOY_USER }}\"}" \
"${OPENBAO_ADDR}/v1/ssh/sign/${{ vars.DEPLOY_SSH_ROLE }}" \
| jq -r '.data.signed_key')
[ -n "$SIGNED_KEY" ] && [ "$SIGNED_KEY" != "null" ] \
|| { echo "ERROR: failed to sign SSH key via OpenBao CA" >&2; exit 1; }
printf '%s\n' "$SIGNED_KEY" > ~/.ssh/id_ed25519-cert.pub
unset BAO_TOKEN SIGNED_KEY
- name: Add deploy host to known_hosts
run: ssh-keyscan -H "${{ vars.DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy to lintel-prod-01 - name: Deploy to lintel-prod-01
uses: appleboy/ssh-action@v1 run: ssh "${{ vars.DEPLOY_USER }}@${{ vars.DEPLOY_HOST }}" "bash /srv/sum/nohype/app/deploy/deploy.sh"
with:
host: ${{ secrets.PROD_SSH_HOST }}
username: deploy
key: ${{ secrets.PROD_SSH_KEY }}
script: bash /srv/sum/nohype/app/deploy/deploy.sh

View File

@@ -50,4 +50,9 @@ RUN pip install --upgrade pip && pip install -r requirements/base.txt
COPY . /app COPY . /app
ARG GIT_SHA=unknown
ARG BUILD_ID=unknown
ENV GIT_SHA=${GIT_SHA} \
BUILD_ID=${BUILD_ID}
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.12 on 2026-03-19 00:10
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('blog', '0004_backfill_published_date'),
]
operations = [
migrations.AlterModelOptions(
name='category',
options={'ordering': ['sort_order', 'name'], 'verbose_name_plural': 'categories'},
),
]

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
@@ -8,9 +9,12 @@ from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import models from django.db import models
from django.db.models import CASCADE, PROTECT, SET_NULL, Prefetch from django.db.models import CASCADE, PROTECT, SET_NULL, Prefetch
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.html import strip_tags
from django.utils.text import slugify
from modelcluster.contrib.taggit import ClusterTaggableManager from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey from modelcluster.fields import ParentalKey
from taggit.models import Tag, TaggedItemBase from taggit.models import Tag, TaggedItemBase
from wagtail.admin.forms.pages import WagtailAdminPageForm
from wagtail.admin.panels import FieldPanel, ObjectList, PageChooserPanel, TabbedInterface from wagtail.admin.panels import FieldPanel, ObjectList, PageChooserPanel, TabbedInterface
from wagtail.contrib.routable_page.models import RoutablePageMixin, route from wagtail.contrib.routable_page.models import RoutablePageMixin, route
from wagtail.fields import RichTextField, StreamField from wagtail.fields import RichTextField, StreamField
@@ -18,9 +22,29 @@ from wagtail.models import Page
from wagtail.search import index from wagtail.search import index
from wagtailseo.models import SeoMixin from wagtailseo.models import SeoMixin
from apps.authors.models import Author
from apps.blog.blocks import ARTICLE_BODY_BLOCKS from apps.blog.blocks import ARTICLE_BODY_BLOCKS
def _generate_summary_from_stream(body: Any, *, max_chars: int = 220) -> str:
parts: list[str] = []
if body is None:
return ""
for block in body:
if getattr(block, "block_type", None) == "code":
continue
value = getattr(block, "value", block)
text = value.source if hasattr(value, "source") else str(value)
clean_text = strip_tags(text)
if clean_text:
parts.append(clean_text)
summary = re.sub(r"\s+", " ", " ".join(parts)).strip()
if len(summary) <= max_chars:
return summary
truncated = summary[:max_chars].rsplit(" ", 1)[0].strip()
return truncated or summary[:max_chars].strip()
class HomePage(Page): class HomePage(Page):
featured_article = models.ForeignKey( featured_article = models.ForeignKey(
"blog.ArticlePage", null=True, blank=True, on_delete=SET_NULL, related_name="+" "blog.ArticlePage", null=True, blank=True, on_delete=SET_NULL, related_name="+"
@@ -145,25 +169,93 @@ class Category(models.Model):
class Meta: class Meta:
ordering = ["sort_order", "name"] ordering = ["sort_order", "name"]
verbose_name_plural = "categories"
def __str__(self): def __str__(self):
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": {
@@ -176,9 +268,114 @@ 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:
return css
return get_auto_tag_colour_css(self.tag.name)
class ArticlePageAdminForm(WagtailAdminPageForm):
SUMMARY_MAX_CHARS = 220
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for name in ("slug", "author", "category", "summary"):
if name in self.fields:
self.fields[name].required = False
default_author = self._get_default_author(create=False)
if default_author and not self.initial.get("author"):
self.initial["author"] = default_author.pk
default_category = self._get_default_category(create=False)
if default_category and not self.initial.get("category"):
self.initial["category"] = default_category.pk
def clean(self):
cleaned_data = getattr(self, "cleaned_data", {})
self._apply_defaults(cleaned_data)
self.cleaned_data = cleaned_data
cleaned_data = super().clean()
self._apply_defaults(cleaned_data)
if not cleaned_data.get("slug"):
self.add_error("slug", "Slug is required.")
if not cleaned_data.get("author"):
self.add_error("author", "Author is required.")
if not cleaned_data.get("category"):
self.add_error("category", "Category is required.")
if not cleaned_data.get("summary"):
self.add_error("summary", "Summary is required.")
return cleaned_data
def _apply_defaults(self, cleaned_data: dict[str, Any]) -> dict[str, Any]:
title = (cleaned_data.get("title") or "").strip()
if not cleaned_data.get("slug") and title:
cleaned_data["slug"] = self._build_unique_page_slug(title)
if not cleaned_data.get("author"):
cleaned_data["author"] = self._get_default_author(create=True)
if not cleaned_data.get("category"):
cleaned_data["category"] = self._get_default_category(create=True)
if not cleaned_data.get("summary"):
cleaned_data["summary"] = _generate_summary_from_stream(
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
def _get_default_author(self, *, create: bool) -> Author | None:
user = self.for_user
if not user or not user.is_authenticated:
return None
existing = Author.objects.filter(user=user).first()
if existing or not create:
return existing
base_name = (user.get_full_name() or user.get_username() or f"user-{user.pk}").strip()
base_slug = slugify(base_name) or f"user-{user.pk}"
slug = base_slug
suffix = 2
while Author.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{suffix}"
suffix += 1
return Author.objects.create(user=user, name=base_name, slug=slug)
def _get_default_category(self, *, create: bool):
existing = Category.objects.filter(slug="general").first()
if existing or not create:
return existing
category, _ = Category.objects.get_or_create(
slug="general",
defaults={"name": "General", "description": "General articles", "colour": "neutral"},
)
return category
def _build_unique_page_slug(self, title: str) -> str:
base_slug = slugify(title) or "article"
parent_page = self.parent_page
if parent_page is None and self.instance.pk:
parent_page = self.instance.get_parent()
if parent_page is None:
return base_slug
sibling_pages = parent_page.get_children().exclude(pk=self.instance.pk)
slug = base_slug
suffix = 2
while sibling_pages.filter(slug=slug).exists():
slug = f"{base_slug}-{suffix}"
suffix += 1
return slug
class ArticlePage(SeoMixin, Page): class ArticlePage(SeoMixin, Page):
@@ -200,6 +397,7 @@ class ArticlePage(SeoMixin, Page):
parent_page_types = ["blog.ArticleIndexPage"] parent_page_types = ["blog.ArticleIndexPage"]
subpage_types: list[str] = [] subpage_types: list[str] = []
base_form_class = ArticlePageAdminForm
content_panels = [ content_panels = [
FieldPanel("title"), FieldPanel("title"),
@@ -226,10 +424,7 @@ class ArticlePage(SeoMixin, Page):
ObjectList(content_panels, heading="Content"), ObjectList(content_panels, heading="Content"),
ObjectList(metadata_panels, heading="Metadata"), ObjectList(metadata_panels, heading="Metadata"),
ObjectList(publishing_panels, heading="Publishing"), ObjectList(publishing_panels, heading="Publishing"),
ObjectList( ObjectList(SeoMixin.seo_panels, heading="SEO"),
Page.promote_panels + SeoMixin.seo_panels,
heading="SEO",
),
] ]
) )
@@ -257,16 +452,46 @@ 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",
defaults={"name": "General", "description": "General articles", "colour": "neutral"}, defaults={"name": "General", "description": "General articles", "colour": "neutral"},
) )
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: 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():
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:
if not self.pk:
return True
previous = type(self).objects.only("body").filter(pk=self.pk).first()
if previous is None:
return True
return previous.body_text != self.body_text
def _compute_read_time(self) -> int: def _compute_read_time(self) -> int:
words = [] words = []
for block in self.body: for block in self.body:

View File

@@ -1,10 +1,14 @@
from datetime import timedelta from datetime import timedelta
from types import SimpleNamespace
import pytest import pytest
from django.contrib import messages
from django.contrib.messages.storage.fallback import FallbackStorage
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import override_settings from django.test import override_settings
from django.utils import timezone from django.utils import timezone
from apps.blog.models import ArticleIndexPage, ArticlePage from apps.blog.models import ArticleIndexPage, ArticlePage, ArticlePageAdminForm, Category
from apps.blog.tests.factories import AuthorFactory from apps.blog.tests.factories import AuthorFactory
@@ -273,3 +277,219 @@ def test_article_search_fields_include_summary():
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name") f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
] ]
assert "summary" in field_names assert "summary" in field_names
@pytest.mark.django_db
def test_article_admin_form_relaxes_initial_required_fields(article_index, django_user_model):
"""Slug/author/category/summary should not block initial draft validation."""
user = django_user_model.objects.create_user(
username="writer",
email="writer@example.com",
password="writer-pass",
)
form_class = ArticlePage.get_edit_handler().get_form_class()
form = form_class(parent_page=article_index, for_user=user)
assert form.fields["slug"].required is False
assert form.fields["author"].required is False
assert form.fields["category"].required is False
assert form.fields["summary"].required is False
@pytest.mark.django_db
def test_article_admin_form_clean_applies_defaults(article_index, django_user_model, monkeypatch):
"""Form clean should populate defaults before parent validation runs."""
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="code", value=SimpleNamespace(raw_code="print('ignore')")),
SimpleNamespace(block_type="rich_text", value=SimpleNamespace(source="<p>Hello world body text.</p>")),
]
form.cleaned_data = {
"title": "Auto Defaults Title",
"slug": "",
"author": None,
"category": None,
"summary": "",
"body": body,
}
observed = {}
def fake_super_clean(_self):
observed["slug_before_parent_clean"] = _self.cleaned_data.get("slug")
return _self.cleaned_data
mro = form.__class__.__mro__
super_form_class = mro[mro.index(ArticlePageAdminForm) + 1]
monkeypatch.setattr(super_form_class, "clean", fake_super_clean)
cleaned = form.clean()
assert observed["slug_before_parent_clean"] == "auto-defaults-title"
assert cleaned["slug"] == "auto-defaults-title"
assert cleaned["author"] is not None
assert cleaned["author"].user_id == user.id
assert cleaned["category"] is not None
assert cleaned["category"].slug == "general"
assert cleaned["summary"] == "Hello world body text."
@pytest.mark.django_db
def test_article_seo_tab_fields_not_duplicated():
"""SEO tab should include each promote/SEO field only once."""
handler = ArticlePage.get_edit_handler()
seo_tab = next(panel for panel in handler.children if panel.heading == "SEO")
def flatten_field_names(panel):
names = []
for child in panel.children:
if hasattr(child, "field_name"):
names.append(child.field_name)
else:
names.extend(flatten_field_names(child))
return names
field_names = flatten_field_names(seo_tab)
assert field_names.count("slug") == 1
assert field_names.count("seo_title") == 1
assert field_names.count("search_description") == 1
assert field_names.count("show_in_menus") == 1
@pytest.mark.django_db
def test_article_save_autogenerates_summary_when_missing(article_index):
"""Model save fallback should generate summary from prose blocks."""
category = Category.objects.create(name="Guides", slug="guides")
author = AuthorFactory()
article = ArticlePage(
title="Summary Auto",
slug="summary-auto",
author=author,
category=category,
summary="",
body=[
("code", {"language": "python", "filename": "", "raw_code": "print('skip')"}),
("rich_text", "<p>This should become the summary text.</p>"),
],
)
article_index.add_child(instance=article)
article.save()
assert article.summary == "This should become the summary text."
@pytest.mark.django_db
def test_category_verbose_name_plural():
"""Category Meta should define verbose_name_plural as 'categories'."""
assert Category._meta.verbose_name_plural == "categories"
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_snippet_category_listing_shows_categories(client, django_user_model):
"""Categories created in the database should appear in the Snippets listing."""
Category.objects.create(name="Reviews", slug="reviews")
Category.objects.create(name="Tutorials", slug="tutorials")
admin = django_user_model.objects.create_superuser(
username="admin-cat", email="admin-cat@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/snippets/blog/category/")
content = response.content.decode()
assert response.status_code == 200
assert "Reviews" 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
def test_article_page_omits_admin_messages_on_frontend(article_page, rf):
"""Frontend templates should not render admin session messages."""
request = rf.get(article_page.url)
SessionMiddleware(lambda req: None).process_request(request)
request.session.save()
setattr(request, "_messages", FallbackStorage(request))
messages.success(request, "Page 'Test' has been published.")
response = article_page.serve(request)
response.render()
content = response.content.decode()
assert "Page 'Test' has been published." not in content
assert 'aria-label="Messages"' not in content

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
@@ -59,6 +67,32 @@ def test_article_default_category_is_assigned(home_page):
assert article.category.slug == "general" assert article.category.slug == "general"
@pytest.mark.django_db
def test_article_read_time_is_not_recomputed_when_body_text_is_unchanged(home_page, monkeypatch):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Stable read time",
slug="stable-read-time",
author=author,
summary="s",
body=[("rich_text", "<p>body words</p>")],
)
index.add_child(instance=article)
article.save()
def fail_compute():
raise AssertionError("read time should not be recomputed when body text is unchanged")
monkeypatch.setattr(article, "_compute_read_time", fail_compute)
article.title = "Retitled"
article.save()
article.refresh_from_db()
assert article.read_time_mins == 1
@pytest.mark.django_db @pytest.mark.django_db
def test_category_ordering(): def test_category_ordering():
Category.objects.get_or_create(name="General", slug="general") Category.objects.get_or_create(name="General", slug="general")
@@ -66,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="",
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

@@ -149,7 +149,7 @@ def test_non_htmx_post_still_redirects(client, _article):
"""Non-HTMX POST continues to redirect (progressive enhancement).""" """Non-HTMX POST continues to redirect (progressive enhancement)."""
resp = _post_comment(client, _article) resp = _post_comment(client, _article)
assert resp.status_code == 302 assert resp.status_code == 302
assert resp["Location"].endswith("?commented=1") assert resp["Location"].endswith("?commented=pending")
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -1,3 +1,5 @@
from unittest.mock import patch
import pytest import pytest
from django.core.cache import cache from django.core.cache import cache
from django.test import override_settings from django.test import override_settings
@@ -28,10 +30,64 @@ def test_comment_post_flow(client, home_page):
}, },
) )
assert resp.status_code == 302 assert resp.status_code == 302
assert resp["Location"].endswith("?commented=1") assert resp["Location"].endswith("?commented=pending")
assert Comment.objects.count() == 1 assert Comment.objects.count() == 1
@pytest.mark.django_db
def test_comment_post_redirect_banner_renders_on_article_page(client, home_page):
cache.clear()
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "<p>body</p>")])
index.add_child(instance=article)
article.save_revision().publish()
resp = client.post(
"/comments/post/",
{
"article_id": article.id,
"author_name": "Test",
"author_email": "test@example.com",
"body": "Hello",
"honeypot": "",
},
follow=True,
)
assert resp.status_code == 200
assert b"Your comment has been posted and is awaiting moderation." in resp.content
@pytest.mark.django_db
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
def test_comment_post_redirect_banner_renders_approved_state(client, home_page):
cache.clear()
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "<p>body</p>")])
index.add_child(instance=article)
article.save_revision().publish()
with patch("apps.comments.views._verify_turnstile", return_value=True):
resp = client.post(
"/comments/post/",
{
"article_id": article.id,
"author_name": "Test",
"author_email": "test@example.com",
"body": "Hello",
"honeypot": "",
"cf-turnstile-response": "tok",
},
follow=True,
)
assert resp.status_code == 200
assert b"Comment posted!" in resp.content
@pytest.mark.django_db @pytest.mark.django_db
def test_comment_post_rejected_when_comments_disabled(client, home_page): def test_comment_post_rejected_when_comments_disabled(client, home_page):
cache.clear() cache.clear()

View File

@@ -4,7 +4,6 @@ import logging
import requests as http_requests import requests as http_requests
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import IntegrityError from django.db import IntegrityError
@@ -41,6 +40,11 @@ def _add_vary_header(response):
return response return response
def _comment_redirect(article: ArticlePage, *, approved: bool):
state = "approved" if approved else "pending"
return redirect(f"{article.url}?commented={state}")
def _verify_turnstile(token: str, ip: str) -> bool: def _verify_turnstile(token: str, ip: str) -> bool:
secret = getattr(settings, "TURNSTILE_SECRET_KEY", "") secret = getattr(settings, "TURNSTILE_SECRET_KEY", "")
if not secret: if not secret:
@@ -201,7 +205,7 @@ class CommentCreateView(View):
return _add_vary_header( return _add_vary_header(
render(request, "comments/_comment_success.html", {"message": "Comment posted!"}) render(request, "comments/_comment_success.html", {"message": "Comment posted!"})
) )
return redirect(f"{article.url}?commented=1") return _comment_redirect(article, approved=True)
# Turnstile verification # Turnstile verification
turnstile_ok = False turnstile_ok = False
@@ -230,11 +234,7 @@ class CommentCreateView(View):
if _is_htmx(request): if _is_htmx(request):
return self._render_htmx_success(request, article, comment) return self._render_htmx_success(request, article, comment)
messages.success( return _comment_redirect(article, approved=comment.is_approved)
request,
"Comment posted!" if comment.is_approved else "Your comment is awaiting moderation",
)
return redirect(f"{article.url}?commented=1")
if _is_htmx(request): if _is_htmx(request):
return self._render_htmx_error(request, article, form) return self._render_htmx_error(request, article, form)

View File

@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import secrets import secrets
from typing import Any, cast
from django.contrib.messages import get_messages
from .consent import ConsentService from .consent import ConsentService
@@ -40,3 +43,25 @@ class SecurityHeadersMiddleware:
) )
response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
return response return response
class AdminMessageGuardMiddleware:
ADMIN_PREFIXES = ("/cms/", "/django-admin/")
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# The public site has no legitimate use of Django's shared flash queue.
# Drain any stale admin messages before frontend rendering can see them.
if not request.path.startswith(self.ADMIN_PREFIXES):
storage = cast(Any, get_messages(request))
list(storage)
storage._queued_messages = []
storage._loaded_data = []
for sub_storage in getattr(storage, "storages", []):
sub_storage._queued_messages = []
sub_storage._loaded_data = []
sub_storage.used = True
storage.used = True
return self.get_response(request)

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

@@ -25,6 +25,8 @@ def test_check_content_integrity_fails_for_blank_summary(home_page):
) )
index.add_child(instance=article) index.add_child(instance=article)
article.save_revision().publish() article.save_revision().publish()
# Simulate legacy/bad data by bypassing model save() auto-summary fallback.
ArticlePage.objects.filter(pk=article.pk).update(summary=" ")
with pytest.raises(CommandError, match="empty summary"): with pytest.raises(CommandError, match="empty summary"):
call_command("check_content_integrity") call_command("check_content_integrity")

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
import pytest
from django.contrib import messages
from django.contrib.auth.models import AnonymousUser
from django.contrib.messages import get_messages
from django.contrib.messages.storage.fallback import FallbackStorage
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpResponse
from django.shortcuts import render
from django.test import RequestFactory, override_settings
from django.urls import include, path
from apps.core.middleware import AdminMessageGuardMiddleware
def admin_message_test_view(request):
messages.success(request, "Page 'Test page' has been updated.")
messages.success(request, "Page 'Test page' has been published.")
return render(request, "wagtailadmin/base.html", {})
urlpatterns = [
path("cms/__tests__/admin-messages/", admin_message_test_view),
path("", include("config.urls")),
]
def _build_request(rf: RequestFactory, path: str):
request = rf.get(path)
SessionMiddleware(lambda req: None).process_request(request)
request.session.save()
request.user = AnonymousUser()
setattr(request, "_messages", FallbackStorage(request))
return request
@pytest.mark.django_db
def test_admin_message_guard_clears_stale_messages_on_frontend(rf):
request = _build_request(rf, "/articles/test/")
messages.success(request, "Page 'Test page' has been updated.")
response = AdminMessageGuardMiddleware(lambda req: HttpResponse("ok"))(request)
assert response.status_code == 200
assert list(get_messages(request)) == []
@pytest.mark.django_db
def test_admin_message_guard_preserves_admin_messages(rf):
request = _build_request(rf, "/cms/pages/1/edit/")
messages.success(request, "Page 'Test page' has been updated.")
response = AdminMessageGuardMiddleware(lambda req: HttpResponse("ok"))(request)
remaining = list(get_messages(request))
assert response.status_code == 200
assert len(remaining) == 1
assert remaining[0].message == "Page 'Test page' has been updated."
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="apps.core.tests.test_message_handling")
def test_admin_messages_have_auto_clear(client, django_user_model):
"""The messages container must set auto-clear so messages dismiss themselves."""
admin = django_user_model.objects.create_superuser(
username="admin-autoclear",
email="admin-autoclear@example.com",
password="admin-pass",
)
client.force_login(admin)
response = client.get("/cms/__tests__/admin-messages/")
content = response.content.decode()
assert response.status_code == 200
assert "data-w-messages-auto-clear-value" in content
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="apps.core.tests.test_message_handling")
def test_server_rendered_messages_have_auto_dismiss_script(client, django_user_model):
"""Server-rendered messages must include an inline script that removes them
after a timeout, because the w-messages Stimulus controller only auto-clears
messages added via JavaScript — not ones already in the HTML."""
admin = django_user_model.objects.create_superuser(
username="admin-dismiss",
email="admin-dismiss@example.com",
password="admin-pass",
)
client.force_login(admin)
response = client.get("/cms/__tests__/admin-messages/")
content = response.content.decode()
assert response.status_code == 200
# Messages are rendered with the data-server-rendered marker
assert "data-server-rendered" in content
# The auto-dismiss script targets those markers
assert "querySelectorAll" in content
assert "[data-server-rendered]" in content
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="apps.core.tests.test_message_handling")
def test_admin_messages_render_all_messages(client, django_user_model):
"""All messages should be rendered (no de-duplication filtering)."""
admin = django_user_model.objects.create_superuser(
username="admin-render",
email="admin-render@example.com",
password="admin-pass",
)
client.force_login(admin)
response = client.get("/cms/__tests__/admin-messages/")
content = response.content.decode()
assert response.status_code == 200
assert "has been updated." in content
assert "has been published." in content

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

1
apps/health/__init__.py Normal file
View File

@@ -0,0 +1 @@

6
apps/health/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class HealthConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.health"

80
apps/health/checks.py Normal file
View File

@@ -0,0 +1,80 @@
from __future__ import annotations
import importlib
import os
import time
import uuid
from pathlib import Path
from django.core.cache import cache
from django.db import connection
BACKUP_MAX_AGE_SECONDS = 48 * 60 * 60
def check_db() -> dict[str, float | str]:
started = time.perf_counter()
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
except Exception as exc:
return {"status": "fail", "detail": str(exc)}
return {"status": "ok", "latency_ms": (time.perf_counter() - started) * 1000}
def check_cache() -> dict[str, float | str]:
cache_key = f"health:{uuid.uuid4().hex}"
probe_value = uuid.uuid4().hex
started = time.perf_counter()
try:
cache.set(cache_key, probe_value, timeout=5)
cached_value = cache.get(cache_key)
if cached_value != probe_value:
return {"status": "fail", "detail": "Cache probe returned unexpected value"}
cache.delete(cache_key)
except Exception as exc:
return {"status": "fail", "detail": str(exc)}
return {"status": "ok", "latency_ms": (time.perf_counter() - started) * 1000}
def check_celery() -> dict[str, str]:
broker_url = os.environ.get("CELERY_BROKER_URL")
if not broker_url:
return {"status": "ok", "detail": "Celery not configured: CELERY_BROKER_URL is unset"}
try:
kombu = importlib.import_module("kombu")
except ImportError:
return {"status": "ok", "detail": "Celery broker check skipped: kombu is not installed"}
try:
with kombu.Connection(broker_url, connect_timeout=3) as broker_connection:
broker_connection.ensure_connection(max_retries=1)
except Exception as exc:
return {"status": "fail", "detail": str(exc)}
return {"status": "ok"}
def check_backup() -> dict[str, str]:
backup_status_file = os.environ.get("BACKUP_STATUS_FILE")
if not backup_status_file:
return {"status": "fail", "detail": "Backup monitoring not configured: BACKUP_STATUS_FILE is unset"}
try:
raw_timestamp = Path(backup_status_file).read_text(encoding="utf-8").strip()
except FileNotFoundError:
return {"status": "fail", "detail": f"Backup status file not found: {backup_status_file}"}
except OSError as exc:
return {"status": "fail", "detail": str(exc)}
try:
last_backup_at = float(raw_timestamp)
except ValueError:
return {"status": "fail", "detail": "Invalid backup status file"}
age_seconds = time.time() - last_backup_at
if age_seconds > BACKUP_MAX_AGE_SECONDS:
age_hours = age_seconds / 3600
return {"status": "fail", "detail": f"Last backup is {age_hours:.1f} hours old (> 48 h)"}
return {"status": "ok"}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,205 @@
from __future__ import annotations
import importlib
import time
from types import SimpleNamespace
import pytest
from django.db.utils import OperationalError
from apps.health import checks
class SuccessfulCursor:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, query):
self.query = query
class FailingCursor:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def execute(self, query):
raise OperationalError("database unavailable")
class FakeCache:
def __init__(self, value_to_return=None):
self.value_to_return = value_to_return
self.stored = {}
def set(self, key, value, timeout=None):
self.stored[key] = value
def get(self, key):
if self.value_to_return is not None:
return self.value_to_return
return self.stored.get(key)
def delete(self, key):
self.stored.pop(key, None)
@pytest.mark.django_db
def test_db_ok(monkeypatch):
monkeypatch.setattr(checks.connection, "cursor", lambda: SuccessfulCursor())
result = checks.check_db()
assert result["status"] == "ok"
assert "latency_ms" in result
@pytest.mark.django_db
def test_db_fail(monkeypatch):
monkeypatch.setattr(checks.connection, "cursor", lambda: FailingCursor())
result = checks.check_db()
assert result == {"status": "fail", "detail": "database unavailable"}
@pytest.mark.django_db
def test_cache_ok(monkeypatch):
monkeypatch.setattr(checks, "cache", FakeCache())
result = checks.check_cache()
assert result["status"] == "ok"
assert "latency_ms" in result
@pytest.mark.django_db
def test_cache_fail(monkeypatch):
monkeypatch.setattr(checks, "cache", FakeCache(value_to_return="wrong-value"))
result = checks.check_cache()
assert result == {"status": "fail", "detail": "Cache probe returned unexpected value"}
def test_celery_no_broker(monkeypatch):
monkeypatch.delenv("CELERY_BROKER_URL", raising=False)
result = checks.check_celery()
assert result["status"] == "ok"
assert "CELERY_BROKER_URL is unset" in result["detail"]
def test_celery_no_kombu(monkeypatch):
monkeypatch.setenv("CELERY_BROKER_URL", "redis://broker")
def raise_import_error(name):
raise ImportError(name)
monkeypatch.setattr(importlib, "import_module", raise_import_error)
result = checks.check_celery()
assert result["status"] == "ok"
assert "kombu is not installed" in result["detail"]
def test_celery_ok(monkeypatch):
monkeypatch.setenv("CELERY_BROKER_URL", "redis://broker")
class FakeBrokerConnection:
def __init__(self, url, connect_timeout):
self.url = url
self.connect_timeout = connect_timeout
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def ensure_connection(self, max_retries):
self.max_retries = max_retries
monkeypatch.setattr(importlib, "import_module", lambda name: SimpleNamespace(Connection=FakeBrokerConnection))
result = checks.check_celery()
assert result == {"status": "ok"}
def test_celery_fail(monkeypatch):
monkeypatch.setenv("CELERY_BROKER_URL", "redis://broker")
class BrokenBrokerConnection:
def __init__(self, url, connect_timeout):
self.url = url
self.connect_timeout = connect_timeout
def __enter__(self):
raise OSError("broker down")
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(importlib, "import_module", lambda name: SimpleNamespace(Connection=BrokenBrokerConnection))
result = checks.check_celery()
assert result == {"status": "fail", "detail": "broker down"}
def test_backup_no_env(monkeypatch):
monkeypatch.delenv("BACKUP_STATUS_FILE", raising=False)
result = checks.check_backup()
assert result["status"] == "fail"
assert "BACKUP_STATUS_FILE is unset" in result["detail"]
def test_backup_missing_file(monkeypatch, tmp_path):
status_file = tmp_path / "missing-backup-status"
monkeypatch.setenv("BACKUP_STATUS_FILE", str(status_file))
result = checks.check_backup()
assert result == {"status": "fail", "detail": f"Backup status file not found: {status_file}"}
def test_backup_fresh(monkeypatch, tmp_path):
status_file = tmp_path / "backup-status"
status_file.write_text(str(time.time() - 60), encoding="utf-8")
monkeypatch.setenv("BACKUP_STATUS_FILE", str(status_file))
result = checks.check_backup()
assert result == {"status": "ok"}
def test_backup_stale(monkeypatch, tmp_path):
status_file = tmp_path / "backup-status"
stale_timestamp = time.time() - (checks.BACKUP_MAX_AGE_SECONDS + 1)
status_file.write_text(str(stale_timestamp), encoding="utf-8")
monkeypatch.setenv("BACKUP_STATUS_FILE", str(status_file))
result = checks.check_backup()
assert result["status"] == "fail"
assert "Last backup is" in result["detail"]
def test_backup_invalid(monkeypatch, tmp_path):
status_file = tmp_path / "backup-status"
status_file.write_text("not-a-timestamp", encoding="utf-8")
monkeypatch.setenv("BACKUP_STATUS_FILE", str(status_file))
result = checks.check_backup()
assert result == {"status": "fail", "detail": "Invalid backup status file"}

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import re
import pytest
def _mock_checks(monkeypatch, **overrides):
payloads = {
"db": {"status": "ok", "latency_ms": 1.0},
"cache": {"status": "ok", "latency_ms": 1.0},
"celery": {"status": "ok"},
"backup": {"status": "ok"},
}
payloads.update(overrides)
monkeypatch.setattr("apps.health.views.check_db", lambda: payloads["db"])
monkeypatch.setattr("apps.health.views.check_cache", lambda: payloads["cache"])
monkeypatch.setattr("apps.health.views.check_celery", lambda: payloads["celery"])
monkeypatch.setattr("apps.health.views.check_backup", lambda: payloads["backup"])
@pytest.mark.django_db
def test_healthy(client, monkeypatch):
_mock_checks(monkeypatch)
response = client.get("/health/")
assert response.status_code == 200
assert response.json()["status"] == "ok"
@pytest.mark.django_db
def test_degraded_celery(client, monkeypatch):
_mock_checks(monkeypatch, celery={"status": "fail", "detail": "broker down"})
response = client.get("/health/")
assert response.status_code == 200
assert response.json()["status"] == "degraded"
@pytest.mark.django_db
def test_degraded_backup(client, monkeypatch):
_mock_checks(monkeypatch, backup={"status": "fail", "detail": "backup missing"})
response = client.get("/health/")
assert response.status_code == 200
assert response.json()["status"] == "degraded"
@pytest.mark.django_db
def test_unhealthy_db(client, monkeypatch):
_mock_checks(monkeypatch, db={"status": "fail", "detail": "db down"})
response = client.get("/health/")
assert response.status_code == 503
assert response.json()["status"] == "unhealthy"
@pytest.mark.django_db
def test_unhealthy_cache(client, monkeypatch):
_mock_checks(monkeypatch, cache={"status": "fail", "detail": "cache down"})
response = client.get("/health/")
assert response.status_code == 503
assert response.json()["status"] == "unhealthy"
@pytest.mark.django_db
def test_response_shape(client, monkeypatch):
_mock_checks(monkeypatch)
payload = client.get("/health/").json()
assert set(payload) == {"status", "version", "checks", "timestamp"}
assert set(payload["version"]) == {"git_sha", "build"}
assert set(payload["checks"]) == {"db", "cache", "celery", "backup"}
assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", payload["timestamp"])
@pytest.mark.django_db
def test_version_fields(client, monkeypatch):
_mock_checks(monkeypatch)
monkeypatch.setenv("GIT_SHA", "59cc1c4")
monkeypatch.setenv("BUILD_ID", "build-20260306-59cc1c4")
payload = client.get("/health/").json()
assert payload["version"]["git_sha"] == "59cc1c4"
assert payload["version"]["build"] == "build-20260306-59cc1c4"
@pytest.mark.django_db
def test_no_cache_headers(client, monkeypatch):
_mock_checks(monkeypatch)
response = client.get("/health/")
assert "no-cache" in response["Cache-Control"]

7
apps/health/urls.py Normal file
View File

@@ -0,0 +1,7 @@
from django.urls import path
from apps.health.views import health_view
urlpatterns = [
path("", health_view, name="health"),
]

42
apps/health/views.py Normal file
View File

@@ -0,0 +1,42 @@
from __future__ import annotations
import os
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import cast
from django.http import JsonResponse
from django.views.decorators.cache import never_cache
from apps.health.checks import check_backup, check_cache, check_celery, check_db
CRITICAL_CHECKS = {"db", "cache"}
@never_cache
def health_view(request):
checks: dict[str, Mapping[str, object]] = {
"db": check_db(),
"cache": check_cache(),
"celery": check_celery(),
"backup": check_backup(),
}
if any(cast(str, checks[name]["status"]) == "fail" for name in CRITICAL_CHECKS):
overall_status = "unhealthy"
elif any(cast(str, check["status"]) == "fail" for check in checks.values()):
overall_status = "degraded"
else:
overall_status = "ok"
payload = {
"status": overall_status,
"version": {
"git_sha": os.environ.get("GIT_SHA", "unknown"),
"build": os.environ.get("BUILD_ID", "unknown"),
},
"checks": checks,
"timestamp": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
response_status = 503 if overall_status == "unhealthy" else 200
return JsonResponse(payload, status=response_status)

View File

@@ -49,6 +49,7 @@ INSTALLED_APPS = [
"tailwind", "tailwind",
"theme", "theme",
"django_htmx", "django_htmx",
"apps.health",
"apps.core", "apps.core",
"apps.blog", "apps.blog",
"apps.authors", "apps.authors",
@@ -66,6 +67,7 @@ MIDDLEWARE = [
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"apps.core.middleware.AdminMessageGuardMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware", "django_htmx.middleware.HtmxMiddleware",
"wagtail.contrib.redirects.middleware.RedirectMiddleware", "wagtail.contrib.redirects.middleware.RedirectMiddleware",

View File

@@ -15,6 +15,7 @@ urlpatterns = [
path("cms/", include("wagtail.admin.urls")), path("cms/", include("wagtail.admin.urls")),
path("documents/", include("wagtail.documents.urls")), path("documents/", include("wagtail.documents.urls")),
path("comments/", include("apps.comments.urls")), path("comments/", include("apps.comments.urls")),
path("health/", include("apps.health.urls")),
path("newsletter/", include("apps.newsletter.urls")), path("newsletter/", include("apps.newsletter.urls")),
path("consent/", consent_view, name="consent"), path("consent/", consent_view, name="consent"),
path("robots.txt", robots_txt, name="robots_txt"), path("robots.txt", robots_txt, name="robots_txt"),

View File

@@ -1,9 +1,12 @@
nohypeai.net, www.nohypeai.net { www.nohypeai.net {
redir https://nohypeai.net{uri} permanent
}
nohypeai.net {
encode gzip zstd encode gzip zstd
header { header {
X-Content-Type-Options nosniff X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy "geolocation=(), microphone=(), camera=()" Permissions-Policy "geolocation=(), microphone=(), camera=()"
X-Forwarded-Proto https X-Forwarded-Proto https

View File

@@ -11,6 +11,10 @@ cd "${SITE_DIR}"
echo "==> Pulling latest code" echo "==> Pulling latest code"
git -C "${APP_DIR}" pull origin main git -C "${APP_DIR}" pull origin main
GIT_SHA=$(git -C "${APP_DIR}" rev-parse --short HEAD)
BUILD_ID="build-$(date +%Y%m%d)-${GIT_SHA}"
export GIT_SHA BUILD_ID
echo "==> Updating compose file" echo "==> Updating compose file"
cp "${APP_DIR}/docker-compose.prod.yml" "${SITE_DIR}/docker-compose.prod.yml" cp "${APP_DIR}/docker-compose.prod.yml" "${SITE_DIR}/docker-compose.prod.yml"
@@ -22,7 +26,7 @@ docker compose -f "${SITE_DIR}/docker-compose.prod.yml" up -d --no-deps --build
echo "==> Waiting for health check" echo "==> Waiting for health check"
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -fsS -H "Host: nohypeai.net" http://localhost:8001/ >/dev/null 2>&1; then if curl -fsS -H "Host: nohypeai.net" http://localhost:8001/health/ >/dev/null 2>&1; then
echo "==> Site is up" echo "==> Site is up"
exit 0 exit 0
fi fi

View File

@@ -7,11 +7,16 @@ python manage.py migrate --noinput
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
python manage.py update_index python manage.py update_index
# Set Wagtail site hostname from first entry in ALLOWED_HOSTS # Set Wagtail site hostname from WAGTAILADMIN_BASE_URL when available.
# This keeps preview/page URLs on the same origin as the admin host.
python manage.py shell -c " python manage.py shell -c "
from wagtail.models import Site from wagtail.models import Site
import os import os
hostname = os.environ.get('ALLOWED_HOSTS', 'localhost').split(',')[0].strip() from urllib.parse import urlparse
admin_base = os.environ.get('WAGTAILADMIN_BASE_URL', '').strip()
parsed = urlparse(admin_base) if admin_base else None
hostname = parsed.hostname if parsed and parsed.hostname else os.environ.get('ALLOWED_HOSTS', 'localhost').split(',')[0].strip()
Site.objects.update(hostname=hostname, port=443, site_name='No Hype AI') Site.objects.update(hostname=hostname, port=443, site_name='No Hype AI')
" "

View File

@@ -1,12 +1,18 @@
services: services:
web: web:
build: app build:
context: app
args:
GIT_SHA: ${GIT_SHA:-unknown}
BUILD_ID: ${BUILD_ID:-unknown}
working_dir: /app working_dir: /app
command: /app/deploy/entrypoint.prod.sh command: /app/deploy/entrypoint.prod.sh
env_file: .env env_file: .env
environment: environment:
BACKUP_STATUS_FILE: /srv/sum/nohype/backup_status
DJANGO_SETTINGS_MODULE: config.settings.production DJANGO_SETTINGS_MODULE: config.settings.production
volumes: volumes:
- /srv/sum/nohype:/srv/sum/nohype:ro
- /srv/sum/nohype/static:/app/staticfiles - /srv/sum/nohype/static:/app/staticfiles
- /srv/sum/nohype/media:/app/media - /srv/sum/nohype/media:/app/media
ports: ports:

View File

@@ -26,13 +26,6 @@
<div class="fixed inset-0 bg-grid-pattern pointer-events-none z-[-1]"></div> <div class="fixed inset-0 bg-grid-pattern pointer-events-none z-[-1]"></div>
{% include 'components/nav.html' %} {% include 'components/nav.html' %}
{% include 'components/cookie_banner.html' %} {% include 'components/cookie_banner.html' %}
{% if messages %}
<section aria-label="Messages" class="max-w-7xl mx-auto px-6 py-2">
{% for message in messages %}
<p class="font-mono text-sm py-2 px-4 bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20 mb-2">{{ message }}</p>
{% endfor %}
</section>
{% endif %}
<main class="flex-grow w-full max-w-7xl mx-auto px-6 py-8">{% block content %}{% endblock %}</main> <main class="flex-grow w-full max-w-7xl mx-auto px-6 py-8">{% block content %}{% endblock %}</main>
{% include 'components/footer.html' %} {% include 'components/footer.html' %}
</body> </body>

View File

@@ -145,6 +145,15 @@
<p class="mt-2 mb-6 font-mono text-xs uppercase tracking-wider text-zinc-500"> <p class="mt-2 mb-6 font-mono text-xs uppercase tracking-wider text-zinc-500">
{{ approved_comments|length }} public comment{{ approved_comments|length|pluralize }} {{ approved_comments|length }} public comment{{ approved_comments|length|pluralize }}
</p> </p>
{% if request.GET.commented %}
<div class="mb-6 rounded-md border border-brand-cyan/20 bg-brand-cyan/10 px-4 py-3 font-mono text-sm text-brand-cyan">
{% if request.GET.commented == "approved" %}
Comment posted!
{% else %}
Your comment has been posted and is awaiting moderation.
{% endif %}
</div>
{% endif %}
{% include "comments/_comment_list.html" %} {% include "comments/_comment_list.html" %}
<div id="comments-empty-state" class="mb-8 rounded-md border border-zinc-200 bg-zinc-50 p-4 text-center dark:border-zinc-800 dark:bg-zinc-900/40 {% if approved_comments %}hidden{% endif %}"> <div id="comments-empty-state" class="mb-8 rounded-md border border-zinc-200 bg-zinc-50 p-4 text-center dark:border-zinc-800 dark:bg-zinc-900/40 {% if approved_comments %}hidden{% endif %}">

View File

@@ -82,13 +82,13 @@
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="auto"></div> <div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="auto"></div>
{% endif %} {% endif %}
<div class="pt-1"> <div class="pt-4">
<button <button
type="submit" type="submit"
class="group relative inline-flex items-center gap-3 rounded-md bg-brand-pink px-7 py-3 font-display text-sm font-bold uppercase tracking-widest text-white transition-all hover:-translate-y-0.5 hover:bg-brand-pink/90 focus:outline-none focus:ring-2 focus:ring-brand-pink/40 active:translate-y-0" class="group relative inline-flex items-center gap-3 px-8 py-4 bg-brand-pink text-white font-display font-bold uppercase tracking-widest text-sm hover:-translate-y-1 transition-all active:translate-y-0"
> >
<span>Post comment</span> <span>Post comment</span>
<svg class="h-4 w-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg> </svg>
</button> </button>

View File

@@ -68,9 +68,9 @@
<button <button
type="submit" type="submit"
data-testid="post-reply-btn" data-testid="post-reply-btn"
class="inline-flex items-center rounded-md bg-brand-pink px-5 py-2.5 font-display text-sm font-bold uppercase tracking-wider text-white shadow-solid-dark transition-all hover:-translate-y-0.5 hover:bg-brand-pink/90 hover:shadow-solid-dark/80 focus:outline-none focus:ring-2 focus:ring-brand-pink/40 active:translate-y-0" class="px-6 py-2 bg-brand-pink text-white font-display font-bold text-sm shadow-solid-dark hover:-translate-y-0.5 hover:shadow-solid-dark/80 transition-all active:translate-y-0"
> >
Post reply Post Reply
</button> </button>
</div> </div>
</form> </form>

View File

@@ -0,0 +1,67 @@
{% extends "wagtailadmin/admin_base.html" %}
{% load wagtailadmin_tags wagtailcore_tags i18n %}
{% block furniture %}
<template data-wagtail-sidebar-branding-logo>{% block branding_logo %}{% endblock %}</template>
{% sidebar_props %}
<aside id="wagtail-sidebar" class="sidebar-loading" data-wagtail-sidebar aria-label="{% trans 'Sidebar' %}"></aside>
{% keyboard_shortcuts_dialog %}
<main class="content-wrapper w-overflow-x-hidden" id="main">
<div class="content">
{# Always show messages div so it can be appended to by JS #}
<div class="messages" role="status" data-controller="w-messages" data-action="w-messages:add@document->w-messages#add" data-w-messages-added-class="new" data-w-messages-show-class="appear" data-w-messages-show-delay-value="100" data-w-messages-auto-clear-value="8000">
<ul data-w-messages-target="container">
{% if messages %}
{% for message in messages %}
{% message_level_tag message as level_tag %}
<li class="{% message_tags message %}" data-server-rendered>
{% if level_tag == "error" %}
{% icon name="warning" classname="messages-icon" %}
{% elif message.extra_tags == "lock" %}
{% icon name="lock" classname="messages-icon" %}
{% elif message.extra_tags == "unlock" %}
{% icon name="lock-open" classname="messages-icon" %}
{% else %}
{% icon name=level_tag classname="messages-icon" %}
{% endif %}
{{ message }}
</li>
{% endfor %}
{% endif %}
</ul>
<template data-w-messages-target="template" data-type="success">
<li class="success">{% icon name="success" classname="messages-icon" %}<span></span></li>
</template>
<template data-w-messages-target="template" data-type="error">
<li class="error">{% icon name="warning" classname="messages-icon" %}<span></span></li>
</template>
<template data-w-messages-target="template" data-type="warning">
<li class="warning">{% icon name="warning" classname="messages-icon" %}<span></span></li>
</template>
</div>
{% comment %}
Wagtail's w-messages Stimulus controller only auto-clears messages
added dynamically via JavaScript (the add() method). Server-rendered
messages — the <li> elements above — have no connect() handler and
sit in the DOM forever. This script schedules their removal so they
auto-dismiss after the same timeout used for dynamic messages.
{% endcomment %}
<script>
(function () {
var items = document.querySelectorAll('[data-server-rendered]');
if (!items.length) return;
setTimeout(function () {
items.forEach(function (el) { el.remove(); });
var ul = document.querySelector('[data-w-messages-target="container"]');
if (ul && !ul.children.length) {
document.body.classList.remove('has-messages');
}
}, 8000);
})();
</script>
{% block content %}{% endblock %}
</div>
</main>
{% endblock %}

File diff suppressed because one or more lines are too long