1. Reply HTMX target: server sends HX-Retarget/HX-Reswap headers to insert replies inside parent comment's .replies-container div 2. Empty thread swap target: always render #comments-list container even when no approved comments exist 3. Reaction hydration: add _annotate_reaction_counts() helper that hydrates reaction_counts and user_reacted on comments in get_context(), comment_poll(), and single-comment responses 4. HTMX error swap: return 200 instead of 422 for form errors since HTMX 2 doesn't swap 4xx responses by default 5. Vary header: use patch_vary_headers() instead of direct assignment to avoid overwriting existing Vary directives Also fixes _get_session_key() to handle missing session attribute (e.g. from RequestFactory in performance tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
338 lines
12 KiB
Python
338 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from math import ceil
|
|
from typing import Any
|
|
|
|
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
|
from django.db import models
|
|
from django.db.models import CASCADE, PROTECT, SET_NULL, Prefetch
|
|
from django.shortcuts import get_object_or_404
|
|
from modelcluster.contrib.taggit import ClusterTaggableManager
|
|
from modelcluster.fields import ParentalKey
|
|
from taggit.models import Tag, TaggedItemBase
|
|
from wagtail.admin.panels import FieldPanel, ObjectList, PageChooserPanel, TabbedInterface
|
|
from wagtail.contrib.routable_page.models import RoutablePageMixin, route
|
|
from wagtail.fields import RichTextField, StreamField
|
|
from wagtail.models import Page
|
|
from wagtail.search import index
|
|
from wagtailseo.models import SeoMixin
|
|
|
|
from apps.blog.blocks import ARTICLE_BODY_BLOCKS
|
|
|
|
|
|
class HomePage(Page):
|
|
featured_article = models.ForeignKey(
|
|
"blog.ArticlePage", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
|
)
|
|
|
|
subpage_types = ["blog.ArticleIndexPage", "legal.LegalIndexPage", "blog.AboutPage"]
|
|
|
|
content_panels = Page.content_panels + [
|
|
PageChooserPanel("featured_article", "blog.ArticlePage"),
|
|
]
|
|
|
|
def get_context(self, request, *args, **kwargs):
|
|
ctx = super().get_context(request, *args, **kwargs)
|
|
articles_qs = (
|
|
ArticlePage.objects.live()
|
|
.public()
|
|
.select_related("author", "category")
|
|
.prefetch_related("tags__metadata")
|
|
.order_by("-published_date")
|
|
)
|
|
articles = list(articles_qs[:5])
|
|
ctx["featured_article"] = self.featured_article
|
|
ctx["latest_articles"] = articles
|
|
ctx["more_articles"] = articles[:3]
|
|
ctx["available_tags"] = (
|
|
Tag.objects.filter(
|
|
id__in=ArticlePage.objects.live().public().values_list("tags__id", flat=True)
|
|
).distinct().order_by("name")
|
|
)
|
|
ctx["available_categories"] = Category.objects.filter(show_in_nav=True).order_by("sort_order", "name")
|
|
return ctx
|
|
|
|
|
|
class ArticleIndexPage(RoutablePageMixin, Page):
|
|
parent_page_types = ["blog.HomePage"]
|
|
subpage_types = ["blog.ArticlePage"]
|
|
ARTICLES_PER_PAGE = 12
|
|
|
|
def get_articles(self):
|
|
return (
|
|
ArticlePage.objects.child_of(self)
|
|
.live()
|
|
.select_related("author", "category")
|
|
.prefetch_related("tags__metadata")
|
|
.order_by("-published_date")
|
|
)
|
|
|
|
def get_category_url(self, category):
|
|
return f"{self.url}category/{category.slug}/"
|
|
|
|
def get_listing_context(self, request, active_category=None):
|
|
tag_slug = request.GET.get("tag")
|
|
articles = self.get_articles()
|
|
available_categories = Category.objects.order_by("sort_order", "name")
|
|
category_links = [
|
|
{"category": category, "url": self.get_category_url(category)}
|
|
for category in available_categories
|
|
]
|
|
if active_category:
|
|
articles = articles.filter(category=active_category)
|
|
available_tags = (
|
|
Tag.objects.filter(id__in=articles.values_list("tags__id", flat=True)).distinct().order_by("name")
|
|
)
|
|
if tag_slug:
|
|
articles = articles.filter(tags__slug=tag_slug)
|
|
paginator = Paginator(articles, self.ARTICLES_PER_PAGE)
|
|
page_num = request.GET.get("page")
|
|
try:
|
|
page_obj = paginator.page(page_num)
|
|
except PageNotAnInteger:
|
|
page_obj = paginator.page(1)
|
|
except EmptyPage:
|
|
page_obj = paginator.page(paginator.num_pages)
|
|
return {
|
|
"articles": page_obj,
|
|
"paginator": paginator,
|
|
"active_tag": tag_slug,
|
|
"available_tags": available_tags,
|
|
"available_categories": available_categories,
|
|
"category_links": category_links,
|
|
"active_category": active_category,
|
|
"active_category_url": self.get_category_url(active_category) if active_category else "",
|
|
}
|
|
|
|
@route(r"^category/(?P<category_slug>[-\w]+)/$")
|
|
def category_listing(self, request, category_slug):
|
|
category = get_object_or_404(Category, slug=category_slug)
|
|
return self.render(request, context_overrides=self.get_listing_context(request, active_category=category))
|
|
|
|
def get_context(self, request, *args, **kwargs):
|
|
ctx = super().get_context(request, *args, **kwargs)
|
|
ctx.update(self.get_listing_context(request))
|
|
return ctx
|
|
|
|
|
|
class ArticleTag(TaggedItemBase):
|
|
content_object = ParentalKey("blog.ArticlePage", related_name="tagged_items", on_delete=CASCADE)
|
|
|
|
|
|
class Category(models.Model):
|
|
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
|
|
|
|
name = models.CharField(max_length=100, unique=True)
|
|
slug = models.SlugField(unique=True)
|
|
description = models.TextField(blank=True)
|
|
hero_image = models.ForeignKey(
|
|
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
|
)
|
|
colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default="neutral")
|
|
sort_order = models.IntegerField(default=0)
|
|
show_in_nav = models.BooleanField(default=True)
|
|
|
|
panels = [
|
|
FieldPanel("name"),
|
|
FieldPanel("slug"),
|
|
FieldPanel("description"),
|
|
FieldPanel("hero_image"),
|
|
FieldPanel("colour"),
|
|
FieldPanel("sort_order"),
|
|
FieldPanel("show_in_nav"),
|
|
]
|
|
|
|
class Meta:
|
|
ordering = ["sort_order", "name"]
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class TagMetadata(models.Model):
|
|
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
|
|
|
|
tag = models.OneToOneField("taggit.Tag", on_delete=CASCADE, related_name="metadata")
|
|
colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default="neutral")
|
|
|
|
@classmethod
|
|
def get_fallback_css(cls) -> dict[str, str]:
|
|
return {
|
|
"bg": "bg-zinc-800 dark:bg-zinc-100",
|
|
"text": "text-white dark:text-black",
|
|
"border": "border-zinc-600/20 dark:border-zinc-400/20",
|
|
}
|
|
|
|
def get_css_classes(self) -> dict[str, str]:
|
|
mapping = {
|
|
"cyan": {
|
|
"bg": "bg-brand-cyan/10",
|
|
"text": "text-brand-cyan",
|
|
"border": "border-brand-cyan/20",
|
|
},
|
|
"pink": {
|
|
"bg": "bg-brand-pink/10",
|
|
"text": "text-brand-pink",
|
|
"border": "border-brand-pink/20",
|
|
},
|
|
"neutral": self.get_fallback_css(),
|
|
}
|
|
return mapping.get(self.colour, self.get_fallback_css())
|
|
|
|
|
|
class ArticlePage(SeoMixin, Page):
|
|
category = models.ForeignKey("blog.Category", on_delete=PROTECT, related_name="+")
|
|
author = models.ForeignKey("authors.Author", on_delete=PROTECT)
|
|
hero_image = models.ForeignKey(
|
|
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
|
)
|
|
summary = models.TextField()
|
|
body = StreamField(ARTICLE_BODY_BLOCKS, use_json_field=True)
|
|
tags = ClusterTaggableManager(through="blog.ArticleTag", blank=True)
|
|
read_time_mins = models.PositiveIntegerField(editable=False, default=1)
|
|
comments_enabled = models.BooleanField(default=True)
|
|
published_date = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Display date for this article. Auto-set on first publish if left blank.",
|
|
)
|
|
|
|
parent_page_types = ["blog.ArticleIndexPage"]
|
|
subpage_types: list[str] = []
|
|
|
|
content_panels = [
|
|
FieldPanel("title"),
|
|
FieldPanel("summary"),
|
|
FieldPanel("body"),
|
|
]
|
|
|
|
metadata_panels = [
|
|
FieldPanel("category"),
|
|
FieldPanel("author"),
|
|
FieldPanel("tags"),
|
|
FieldPanel("hero_image"),
|
|
FieldPanel("comments_enabled"),
|
|
]
|
|
|
|
publishing_panels = [
|
|
FieldPanel("published_date"),
|
|
FieldPanel("go_live_at"),
|
|
FieldPanel("expire_at"),
|
|
]
|
|
|
|
edit_handler = TabbedInterface(
|
|
[
|
|
ObjectList(content_panels, heading="Content"),
|
|
ObjectList(metadata_panels, heading="Metadata"),
|
|
ObjectList(publishing_panels, heading="Publishing"),
|
|
ObjectList(
|
|
Page.promote_panels + SeoMixin.seo_panels,
|
|
heading="SEO",
|
|
),
|
|
]
|
|
)
|
|
|
|
search_fields = Page.search_fields + [
|
|
index.SearchField("summary"),
|
|
index.SearchField("body_text", es_extra={"analyzer": "english"}),
|
|
index.AutocompleteField("title"),
|
|
index.RelatedFields("tags", [
|
|
index.SearchField("name"),
|
|
]),
|
|
index.FilterField("category"),
|
|
index.FilterField("published_date"),
|
|
]
|
|
|
|
@property
|
|
def body_text(self) -> str:
|
|
"""Extract prose text from body StreamField, excluding code blocks."""
|
|
parts: list[str] = []
|
|
for block in self.body:
|
|
if block.block_type == "code":
|
|
continue
|
|
value = block.value
|
|
text = value.source if hasattr(value, "source") else str(value)
|
|
parts.append(text)
|
|
return " ".join(parts)
|
|
|
|
def save(self, *args: Any, **kwargs: Any) -> None:
|
|
if not self.category_id:
|
|
self.category, _ = Category.objects.get_or_create(
|
|
slug="general",
|
|
defaults={"name": "General", "description": "General articles", "colour": "neutral"},
|
|
)
|
|
if not self.published_date and self.first_published_at:
|
|
self.published_date = self.first_published_at
|
|
self.read_time_mins = self._compute_read_time()
|
|
return super().save(*args, **kwargs)
|
|
|
|
def _compute_read_time(self) -> int:
|
|
words = []
|
|
for block in self.body:
|
|
if block.block_type == "code":
|
|
continue
|
|
value = block.value
|
|
text = value.source if hasattr(value, "source") else str(value)
|
|
words.extend(re.findall(r"\w+", text))
|
|
return max(1, ceil(len(words) / 200))
|
|
|
|
def get_tags_with_metadata(self):
|
|
tags = self.tags.all()
|
|
return [(tag, getattr(tag, "metadata", None)) for tag in tags]
|
|
|
|
def get_related_articles(self, count: int = 3):
|
|
tag_ids = self.tags.values_list("id", flat=True)
|
|
related = list(
|
|
ArticlePage.objects.live()
|
|
.filter(tags__in=tag_ids)
|
|
.exclude(pk=self.pk)
|
|
.distinct()
|
|
.order_by("-published_date")[:count]
|
|
)
|
|
if len(related) < count:
|
|
exclude_ids = [a.pk for a in related] + [self.pk]
|
|
fallback = list(
|
|
ArticlePage.objects.live()
|
|
.exclude(pk__in=exclude_ids)
|
|
.order_by("-published_date")[: count - len(related)]
|
|
)
|
|
return related + fallback
|
|
return related
|
|
|
|
def get_context(self, request, *args, **kwargs):
|
|
ctx = super().get_context(request, *args, **kwargs)
|
|
ctx["related_articles"] = self.get_related_articles()
|
|
from django.conf import settings
|
|
|
|
from apps.comments.models import Comment
|
|
from apps.comments.views import _annotate_reaction_counts, _get_session_key
|
|
|
|
approved_replies = Comment.objects.filter(is_approved=True).select_related("parent")
|
|
comments = list(
|
|
self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related(
|
|
Prefetch("replies", queryset=approved_replies)
|
|
)
|
|
)
|
|
_annotate_reaction_counts(comments, _get_session_key(request))
|
|
ctx["approved_comments"] = comments
|
|
ctx["turnstile_site_key"] = getattr(settings, "TURNSTILE_SITE_KEY", "")
|
|
return ctx
|
|
|
|
|
|
class AboutPage(Page):
|
|
mission_statement = models.TextField()
|
|
body = RichTextField(blank=True)
|
|
featured_author = models.ForeignKey(
|
|
"authors.Author", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
|
)
|
|
|
|
parent_page_types = ["blog.HomePage"]
|
|
subpage_types: list[str] = []
|
|
|
|
content_panels = Page.content_panels + [
|
|
FieldPanel("mission_statement"),
|
|
FieldPanel("body"),
|
|
FieldPanel("featured_author"),
|
|
]
|