Compare commits
28 Commits
fix/commen
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 92c1ee425d | |||
| ff587d9e1b | |||
| 0fab9ac0bf | |||
| 607d8eaf85 | |||
| 0dc997d2cf | |||
| 0e35fb0ad3 | |||
| 6ab6c3c0bf | |||
| e75dda84ef | |||
|
|
b0e009d606 | ||
| 3848cb6d23 | |||
| d0a90ce8ff | |||
| 9d7821b94d | |||
| 8e43409895 | |||
| 9b3992f250 | |||
| fbc9a1ff0a | |||
| 1a0617fbd0 | |||
| 15ef35e249 | |||
|
|
a450e7409f
|
||
|
|
10e39b8331
|
||
| 59cc1c41a9 | |||
|
|
2c2cb5446f
|
||
|
|
521075cf04
|
||
| 93d3e4703b | |||
| e09e6a21f0 | |||
|
|
4ea1e66cdf
|
||
| c2ad0e67c3 | |||
|
|
96a3971781 | ||
| 989d0fc20d |
@@ -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
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
self.read_time_mins = self._compute_read_time()
|
if self._should_refresh_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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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-")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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", ""))
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
120
apps/core/tests/test_message_handling.py
Normal file
120
apps/core/tests/test_message_handling.py
Normal 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
|
||||||
@@ -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
1
apps/health/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
6
apps/health/apps.py
Normal file
6
apps/health/apps.py
Normal 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
80
apps/health/checks.py
Normal 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"}
|
||||||
1
apps/health/tests/__init__.py
Normal file
1
apps/health/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
205
apps/health/tests/test_checks.py
Normal file
205
apps/health/tests/test_checks.py
Normal 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"}
|
||||||
103
apps/health/tests/test_views.py
Normal file
103
apps/health/tests/test_views.py
Normal 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
7
apps/health/urls.py
Normal 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
42
apps/health/views.py
Normal 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)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
nohypeai.net, www.nohypeai.net {
|
www.nohypeai.net {
|
||||||
encode gzip zstd
|
redir https://nohypeai.net{uri} permanent
|
||||||
|
}
|
||||||
header {
|
|
||||||
X-Content-Type-Options nosniff
|
nohypeai.net {
|
||||||
X-Frame-Options DENY
|
encode gzip zstd
|
||||||
Referrer-Policy strict-origin-when-cross-origin
|
|
||||||
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
header {
|
||||||
X-Forwarded-Proto https
|
X-Content-Type-Options nosniff
|
||||||
}
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||||
handle_path /static/* {
|
X-Forwarded-Proto https
|
||||||
root * /srv/sum/nohype/static
|
}
|
||||||
file_server
|
|
||||||
}
|
handle_path /static/* {
|
||||||
|
root * /srv/sum/nohype/static
|
||||||
handle_path /media/* {
|
file_server
|
||||||
root * /srv/sum/nohype/media
|
}
|
||||||
file_server
|
|
||||||
}
|
handle_path /media/* {
|
||||||
|
root * /srv/sum/nohype/media
|
||||||
reverse_proxy localhost:8001
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy localhost:8001
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
"
|
"
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}">
|
||||||
|
|||||||
67
templates/wagtailadmin/base.html
Normal file
67
templates/wagtailadmin/base.html
Normal 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
Reference in New Issue
Block a user