1 Commits

Author SHA1 Message Date
Mark
393a574500 Restore exact original comment/reply button styling
- 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 12:48:31 +00:00
33 changed files with 84 additions and 1456 deletions

View File

@@ -215,34 +215,12 @@ 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: runs-on: deploy
- 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
run: ssh "${{ vars.DEPLOY_USER }}@${{ vars.DEPLOY_HOST }}" "bash /srv/sum/nohype/app/deploy/deploy.sh" uses: appleboy/ssh-action@v1
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,9 +50,4 @@ 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

@@ -1,17 +0,0 @@
# 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,6 +1,5 @@
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
@@ -9,12 +8,9 @@ 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
@@ -22,29 +18,9 @@ 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="+"
@@ -169,93 +145,25 @@ 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": {
@@ -268,114 +176,9 @@ class TagMetadata(models.Model):
"text": "text-brand-pink", "text": "text-brand-pink",
"border": "border-brand-pink/20", "border": "border-brand-pink/20",
}, },
"neutral": { "neutral": self.get_fallback_css(),
"bg": "bg-zinc-800 dark:bg-zinc-100",
"text": "text-white dark:text-black",
"border": "border-zinc-600/20 dark:border-zinc-400/20",
},
} }
css = mapping.get(self.colour) return mapping.get(self.colour, self.get_fallback_css())
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):
@@ -397,7 +200,6 @@ 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"),
@@ -424,7 +226,10 @@ 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(SeoMixin.seo_panels, heading="SEO"), ObjectList(
Page.promote_panels + SeoMixin.seo_panels,
heading="SEO",
),
] ]
) )
@@ -452,46 +257,16 @@ 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,14 +1,10 @@
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, ArticlePageAdminForm, Category from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory from apps.blog.tests.factories import AuthorFactory
@@ -277,219 +273,3 @@ 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,15 +2,7 @@ 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 ( from apps.blog.models import ArticleIndexPage, ArticlePage, Category, HomePage, TagMetadata
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
@@ -67,32 +59,6 @@ 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")
@@ -100,108 +66,3 @@ 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,5 +1,7 @@
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):
@@ -20,7 +22,6 @@ def test_get_related_articles_fallback(article_page, article_index):
assert isinstance(related, list) assert isinstance(related, list)
def test_auto_tag_colour_returns_valid_css(): def test_tag_metadata_fallback_classes():
from apps.blog.models import get_auto_tag_colour_css css = TagMetadata.get_fallback_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=pending") assert resp["Location"].endswith("?commented=1")
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -1,5 +1,3 @@
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
@@ -30,64 +28,10 @@ def test_comment_post_flow(client, home_page):
}, },
) )
assert resp.status_code == 302 assert resp.status_code == 302
assert resp["Location"].endswith("?commented=pending") assert resp["Location"].endswith("?commented=1")
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,6 +4,7 @@ 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
@@ -40,11 +41,6 @@ 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:
@@ -205,7 +201,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 _comment_redirect(article, approved=True) return redirect(f"{article.url}?commented=1")
# Turnstile verification # Turnstile verification
turnstile_ok = False turnstile_ok = False
@@ -234,7 +230,11 @@ 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)
return _comment_redirect(article, approved=comment.is_approved) messages.success(
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,9 +1,6 @@
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
@@ -43,25 +40,3 @@ 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, get_auto_tag_colour_css from apps.blog.models import ArticleIndexPage, Category, TagMetadata
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,24 +70,20 @@ def get_categories_nav(context):
] ]
def _resolve_tag_css(tag) -> dict[str, str]:
"""Return CSS classes for a tag, using TagMetadata if set, else auto-colour."""
meta = getattr(tag, "metadata", None)
if meta is None:
meta = TagMetadata.objects.filter(tag=tag).first()
if meta:
return meta.get_css_classes()
return get_auto_tag_colour_css(tag.name)
@register.simple_tag @register.simple_tag
@register.filter @register.filter
def get_tag_css(tag): def get_tag_css(tag):
classes = _resolve_tag_css(tag) meta = getattr(tag, "metadata", None)
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(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):
classes = _resolve_tag_css(tag) meta = getattr(tag, "metadata", None)
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,8 +25,6 @@ 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

@@ -1,120 +0,0 @@
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,20 +21,17 @@ def test_context_processor_returns_sitesettings(home_page):
@pytest.mark.django_db @pytest.mark.django_db
def test_get_tag_css_auto_colour(): def test_get_tag_css_fallback():
"""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-" in value assert "bg-zinc" in value
assert "text-" in value
@pytest.mark.django_db @pytest.mark.django_db
def test_get_tag_border_css_auto_colour(): def test_get_tag_border_css_fallback():
"""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-" in value assert "border-zinc" in value
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -1 +0,0 @@

View File

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

View File

@@ -1,80 +0,0 @@
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

@@ -1 +0,0 @@

View File

@@ -1,205 +0,0 @@
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

@@ -1,103 +0,0 @@
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"]

View File

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

View File

@@ -1,42 +0,0 @@
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,7 +49,6 @@ INSTALLED_APPS = [
"tailwind", "tailwind",
"theme", "theme",
"django_htmx", "django_htmx",
"apps.health",
"apps.core", "apps.core",
"apps.blog", "apps.blog",
"apps.authors", "apps.authors",
@@ -67,7 +66,6 @@ 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,7 +15,6 @@ 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,12 +1,9 @@
www.nohypeai.net { 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,10 +11,6 @@ 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"
@@ -26,7 +22,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/health/ >/dev/null 2>&1; then if curl -fsS -H "Host: nohypeai.net" http://localhost:8001/ >/dev/null 2>&1; then
echo "==> Site is up" echo "==> Site is up"
exit 0 exit 0
fi fi

View File

@@ -7,16 +7,11 @@ 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 WAGTAILADMIN_BASE_URL when available. # Set Wagtail site hostname from first entry in ALLOWED_HOSTS
# 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
from urllib.parse import urlparse hostname = os.environ.get('ALLOWED_HOSTS', 'localhost').split(',')[0].strip()
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,18 +1,12 @@
services: services:
web: web:
build: build: app
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,6 +26,13 @@
<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,15 +145,6 @@
<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

@@ -1,67 +0,0 @@
{% 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