Scaffold containerized Django/Wagtail app with core features

This commit is contained in:
Codex_B
2026-02-28 11:52:59 +00:00
parent 62323abd62
commit b5f0f40c4c
84 changed files with 1647 additions and 0 deletions

0
apps/__init__.py Normal file
View File

0
apps/authors/__init__.py Normal file
View File

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

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

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.11 on 2026-02-28 11:42
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailimages', '0027_image_description'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Author',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(unique=True)),
('bio', models.TextField(blank=True)),
('twitter_url', models.URLField(blank=True)),
('github_url', models.URLField(blank=True)),
('avatar', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Author',
},
),
]

View File

44
apps/authors/models.py Normal file
View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.db import models
from django.db.models import SET_NULL
from wagtail.admin.panels import FieldPanel
User = get_user_model()
class Author(models.Model):
user = models.OneToOneField(User, null=True, blank=True, on_delete=SET_NULL)
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
bio = models.TextField(blank=True)
avatar = models.ForeignKey(
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
)
twitter_url = models.URLField(blank=True)
github_url = models.URLField(blank=True)
panels = [
FieldPanel("user"),
FieldPanel("name"),
FieldPanel("slug"),
FieldPanel("bio"),
FieldPanel("avatar"),
FieldPanel("twitter_url"),
FieldPanel("github_url"),
]
class Meta:
verbose_name = "Author"
def __str__(self) -> str:
return self.name
def get_social_links(self) -> dict[str, str]:
links: dict[str, str] = {}
if self.twitter_url:
links["twitter"] = self.twitter_url
if self.github_url:
links["github"] = self.github_url
return links

View File

@@ -0,0 +1,15 @@
from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import SnippetViewSet
from apps.authors.models import Author
class AuthorViewSet(SnippetViewSet):
model = Author
icon = "user"
list_display = ["name", "slug"]
search_fields = ["name"]
add_to_admin_menu = True
register_snippet(AuthorViewSet)

0
apps/blog/__init__.py Normal file
View File

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

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

81
apps/blog/blocks.py Normal file
View File

@@ -0,0 +1,81 @@
from wagtail import blocks
from wagtail.embeds.blocks import EmbedBlock
from wagtail.images.blocks import ImageChooserBlock
class CodeBlock(blocks.StructBlock):
LANGUAGE_CHOICES = [
("python", "Python"),
("javascript", "JavaScript"),
("typescript", "TypeScript"),
("tsx", "TSX"),
("bash", "Bash"),
("json", "JSON"),
("css", "CSS"),
("html", "HTML"),
("plaintext", "Plain Text"),
]
language = blocks.ChoiceBlock(choices=LANGUAGE_CHOICES, default="python")
filename = blocks.CharBlock(required=False)
raw_code = blocks.TextBlock()
class Meta:
icon = "code"
template = "blog/blocks/code_block.html"
def get_language_label(self, value):
choices = dict(self.LANGUAGE_CHOICES)
lang = str(value.get("language", "")) if isinstance(value, dict) else ""
return choices.get(lang, "Plain Text")
class CalloutBlock(blocks.StructBlock):
ICON_CHOICES = [
("info", "Info"),
("warning", "Warning"),
("trophy", "Trophy / Conclusion"),
("tip", "Tip"),
]
icon = blocks.ChoiceBlock(choices=ICON_CHOICES, default="info")
heading = blocks.CharBlock()
body = blocks.RichTextBlock(features=["bold", "italic", "link"])
class Meta:
icon = "pick"
template = "blog/blocks/callout_block.html"
class PullQuoteBlock(blocks.StructBlock):
quote = blocks.TextBlock()
attribution = blocks.CharBlock(required=False)
class Meta:
icon = "openquote"
template = "blog/blocks/pull_quote_block.html"
class ImageBlock(blocks.StructBlock):
image = ImageChooserBlock()
caption = blocks.CharBlock(required=False)
alt = blocks.CharBlock(required=True)
class Meta:
icon = "image"
template = "blog/blocks/image_block.html"
ARTICLE_BODY_BLOCKS = [
(
"rich_text",
blocks.RichTextBlock(
features=["h2", "h3", "h4", "bold", "italic", "link", "ol", "ul", "hr", "blockquote", "code"]
),
),
("code", CodeBlock()),
("callout", CalloutBlock()),
("image", ImageBlock()),
("embed", EmbedBlock()),
("pull_quote", PullQuoteBlock()),
]

41
apps/blog/feeds.py Normal file
View File

@@ -0,0 +1,41 @@
from django.conf import settings
from django.contrib.syndication.views import Feed
from django.shortcuts import get_object_or_404
from taggit.models import Tag
from apps.blog.models import ArticlePage
class AllArticlesFeed(Feed):
title = "No Hype AI"
link = "/articles/"
description = "Honest AI coding tool reviews for developers."
def items(self):
return ArticlePage.objects.live().order_by("-first_published_at")[:20]
def item_title(self, item: ArticlePage):
return item.title
def item_description(self, item: ArticlePage):
return item.summary
def item_pubdate(self, item: ArticlePage):
return item.first_published_at
def item_author_name(self, item: ArticlePage):
return item.author.name
def item_link(self, item: ArticlePage):
return f"{settings.WAGTAILADMIN_BASE_URL}{item.url}"
class TagArticlesFeed(AllArticlesFeed):
def get_object(self, request, tag_slug: str):
return get_object_or_404(Tag, slug=tag_slug)
def title(self, obj):
return f"No Hype AI — {obj.name}"
def items(self, obj):
return ArticlePage.objects.live().filter(tags=obj).order_by("-first_published_at")[:20]

View File

@@ -0,0 +1,98 @@
# Generated by Django 5.2.11 on 2026-02-28 11:42
import django.db.models.deletion
import modelcluster.contrib.taggit
import modelcluster.fields
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('authors', '0001_initial'),
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
('wagtailcore', '0094_alter_page_locale'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='ArticleIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='AboutPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('mission_statement', models.TextField()),
('body', wagtail.fields.RichTextField(blank=True)),
('featured_author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='authors.author')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='ArticlePage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('canonical_url', models.URLField(blank=True, help_text="Leave blank to use the page's URL.", max_length=255, verbose_name='Canonical URL')),
('summary', models.TextField()),
('body', wagtail.fields.StreamField([('rich_text', 0), ('code', 4), ('callout', 8), ('image', 11), ('embed', 12), ('pull_quote', 13)], block_lookup={0: ('wagtail.blocks.RichTextBlock', (), {'features': ['h2', 'h3', 'h4', 'bold', 'italic', 'link', 'ol', 'ul', 'hr', 'blockquote', 'code']}), 1: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('python', 'Python'), ('javascript', 'JavaScript'), ('typescript', 'TypeScript'), ('tsx', 'TSX'), ('bash', 'Bash'), ('json', 'JSON'), ('css', 'CSS'), ('html', 'HTML'), ('plaintext', 'Plain Text')]}), 2: ('wagtail.blocks.CharBlock', (), {'required': False}), 3: ('wagtail.blocks.TextBlock', (), {}), 4: ('wagtail.blocks.StructBlock', [[('language', 1), ('filename', 2), ('raw_code', 3)]], {}), 5: ('wagtail.blocks.ChoiceBlock', [], {'choices': [('info', 'Info'), ('warning', 'Warning'), ('trophy', 'Trophy / Conclusion'), ('tip', 'Tip')]}), 6: ('wagtail.blocks.CharBlock', (), {}), 7: ('wagtail.blocks.RichTextBlock', (), {'features': ['bold', 'italic', 'link']}), 8: ('wagtail.blocks.StructBlock', [[('icon', 5), ('heading', 6), ('body', 7)]], {}), 9: ('wagtail.images.blocks.ImageChooserBlock', (), {}), 10: ('wagtail.blocks.CharBlock', (), {'required': True}), 11: ('wagtail.blocks.StructBlock', [[('image', 9), ('caption', 2), ('alt', 10)]], {}), 12: ('wagtail.embeds.blocks.EmbedBlock', (), {}), 13: ('wagtail.blocks.StructBlock', [[('quote', 3), ('attribution', 2)]], {})})),
('read_time_mins', models.PositiveIntegerField(default=1, editable=False)),
('comments_enabled', models.BooleanField(default=True)),
('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='authors.author')),
('hero_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
('og_image', models.ForeignKey(blank=True, help_text='Shown when linking to this page on social media. If blank, may show an image from the page, or the default from Settings > SEO.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image', verbose_name='Preview image')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page', models.Model),
),
migrations.CreateModel(
name='ArticleTag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content_object', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='tagged_items', to='blog.articlepage')),
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='taggit.tag')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='articlepage',
name='tags',
field=modelcluster.contrib.taggit.ClusterTaggableManager(blank=True, help_text='A comma-separated list of tags.', through='blog.ArticleTag', to='taggit.Tag', verbose_name='Tags'),
),
migrations.CreateModel(
name='HomePage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('featured_article', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='blog.articlepage')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='TagMetadata',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('colour', models.CharField(choices=[('cyan', 'Cyan'), ('pink', 'Pink'), ('neutral', 'Neutral')], default='neutral', max_length=20)),
('tag', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='metadata', to='taggit.tag')),
],
),
]

View File

191
apps/blog/models.py Normal file
View File

@@ -0,0 +1,191 @@
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
from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase
from wagtail.admin.panels import FieldPanel, PageChooserPanel
from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page
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 = (
ArticlePage.objects.live()
.public()
.select_related("author")
.prefetch_related("tags__metadata")
.order_by("-first_published_at")
)
ctx["featured_article"] = self.featured_article
ctx["latest_articles"] = articles[:5]
ctx["more_articles"] = articles[:3]
return ctx
class ArticleIndexPage(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")
.prefetch_related("tags__metadata")
.order_by("-first_published_at")
)
def get_context(self, request, *args, **kwargs):
ctx = super().get_context(request, *args, **kwargs)
tag_slug = request.GET.get("tag")
articles = self.get_articles()
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)
ctx["articles"] = page_obj
ctx["paginator"] = paginator
ctx["active_tag"] = tag_slug
return ctx
class ArticleTag(TaggedItemBase):
content_object = ParentalKey("blog.ArticlePage", related_name="tagged_items", on_delete=CASCADE)
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-100", "text": "text-zinc-800"}
def get_css_classes(self) -> dict[str, str]:
mapping = {
"cyan": {"bg": "bg-cyan-100", "text": "text-cyan-900"},
"pink": {"bg": "bg-pink-100", "text": "text-pink-900"},
"neutral": self.get_fallback_css(),
}
return mapping.get(self.colour, self.get_fallback_css())
class ArticlePage(SeoMixin, Page):
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)
parent_page_types = ["blog.ArticleIndexPage"]
subpage_types: list[str] = []
content_panels = Page.content_panels + [
FieldPanel("author"),
FieldPanel("hero_image"),
FieldPanel("summary"),
FieldPanel("body"),
FieldPanel("tags"),
FieldPanel("comments_enabled"),
]
promote_panels = Page.promote_panels + SeoMixin.seo_panels
search_fields = Page.search_fields
def save(self, *args: Any, **kwargs: Any) -> None:
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("-first_published_at")[: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("-first_published_at")[: 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()
ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).select_related(
"parent"
)
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"),
]

View File

@@ -0,0 +1,13 @@
from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import SnippetViewSet
from apps.blog.models import TagMetadata
class TagMetadataViewSet(SnippetViewSet):
model = TagMetadata
icon = "tag"
list_display = ["tag", "colour"]
register_snippet(TagMetadataViewSet)

View File

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

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

19
apps/comments/forms.py Normal file
View File

@@ -0,0 +1,19 @@
from django import forms
from apps.comments.models import Comment
class CommentForm(forms.ModelForm):
honeypot = forms.CharField(required=False)
article_id = forms.IntegerField(widget=forms.HiddenInput)
parent_id = forms.IntegerField(required=False, widget=forms.HiddenInput)
class Meta:
model = Comment
fields = ["author_name", "author_email", "body"]
def clean_body(self):
body = self.cleaned_data["body"]
if not body.strip():
raise forms.ValidationError("Comment body is required.")
return body

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.2.11 on 2026-02-28 11:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('blog', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('author_name', models.CharField(max_length=100)),
('author_email', models.EmailField(max_length=254)),
('body', models.TextField(max_length=2000)),
('is_approved', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='blog.articlepage')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='comments.comment')),
],
),
]

View File

25
apps/comments/models.py Normal file
View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from django.core.exceptions import ValidationError
from django.db import models
class Comment(models.Model):
article = models.ForeignKey("blog.ArticlePage", on_delete=models.CASCADE, related_name="comments")
parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE, related_name="replies")
author_name = models.CharField(max_length=100)
author_email = models.EmailField()
body = models.TextField(max_length=2000)
is_approved = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
def clean(self) -> None:
if self.parent and self.parent.parent_id is not None:
raise ValidationError("Replies cannot be nested beyond one level.")
def get_absolute_url(self):
return f"{self.article.url}#comment-{self.pk}"
def __str__(self) -> str:
return f"Comment by {self.author_name}"

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

@@ -0,0 +1,7 @@
from django.urls import path
from apps.comments.views import CommentCreateView
urlpatterns = [
path("post/", CommentCreateView.as_view(), name="comment_post"),
]

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

@@ -0,0 +1,42 @@
from __future__ import annotations
from django.contrib import messages
from django.core.cache import cache
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from apps.blog.models import ArticlePage
from apps.comments.forms import CommentForm
from apps.comments.models import Comment
class CommentCreateView(View):
def post(self, request):
ip = (request.META.get("HTTP_X_FORWARDED_FOR") or request.META.get("REMOTE_ADDR", "")).split(",")[0].strip()
key = f"comment-rate:{ip}"
count = cache.get(key, 0)
if count >= 3:
return HttpResponse(status=429)
cache.set(key, count + 1, timeout=60)
form = CommentForm(request.POST)
article = get_object_or_404(ArticlePage, pk=request.POST.get("article_id"))
if not article.comments_enabled:
return HttpResponse(status=404)
if form.is_valid():
if form.cleaned_data.get("honeypot"):
return redirect(f"{article.url}?commented=1")
comment = form.save(commit=False)
comment.article = article
parent_id = form.cleaned_data.get("parent_id")
if parent_id:
comment.parent = Comment.objects.filter(pk=parent_id, article=article).first()
comment.ip_address = ip or None
comment.save()
messages.success(request, "Your comment is awaiting moderation")
return redirect(f"{article.url}?commented=1")
messages.error(request, "Please correct the form errors")
return redirect(article.url)

View File

@@ -0,0 +1,20 @@
from wagtail.admin.ui.tables import BooleanColumn
from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import SnippetViewSet
from apps.comments.models import Comment
class CommentViewSet(SnippetViewSet):
model = Comment
icon = "comment"
list_display = ["author_name", "article", BooleanColumn("is_approved"), "created_at"]
list_filter = ["is_approved"]
search_fields = ["author_name", "body"]
add_to_admin_menu = True
def get_queryset(self, request):
return super().get_queryset(request).select_related("article", "parent")
register_snippet(CommentViewSet)

0
apps/core/__init__.py Normal file
View File

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

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

59
apps/core/consent.py Normal file
View File

@@ -0,0 +1,59 @@
from __future__ import annotations
import time
from dataclasses import dataclass
from urllib.parse import parse_qs, urlencode
from django.conf import settings
CONSENT_COOKIE_NAME = "nhAiConsent"
@dataclass
class ConsentState:
analytics: bool = False
advertising: bool = False
policy_version: int = 0
timestamp: int = 0
@property
def requires_prompt(self) -> bool:
return self.policy_version != settings.CONSENT_POLICY_VERSION
class ConsentService:
@staticmethod
def get_consent(request) -> ConsentState:
raw = request.COOKIES.get(CONSENT_COOKIE_NAME, "")
if not raw:
return ConsentState()
try:
data = {k: v[0] for k, v in parse_qs(raw).items()}
return ConsentState(
analytics=data.get("a", "0") == "1",
advertising=data.get("d", "0") == "1",
policy_version=int(data.get("v", "0")),
timestamp=int(data.get("ts", "0")),
)
except (ValueError, AttributeError):
return ConsentState()
@staticmethod
def set_consent(response, *, analytics: bool, advertising: bool) -> None:
payload = urlencode(
{
"a": int(analytics),
"d": int(advertising),
"v": settings.CONSENT_POLICY_VERSION,
"ts": int(time.time()),
}
)
response.set_cookie(
CONSENT_COOKIE_NAME,
payload,
max_age=60 * 60 * 24 * 365,
httponly=False,
samesite="Lax",
secure=not settings.DEBUG,
)

View File

@@ -0,0 +1,9 @@
from wagtail.models import Site
from apps.core.models import SiteSettings
def site_settings(request):
site = Site.find_for_request(request)
settings_obj = SiteSettings.for_site(site) if site else None
return {"site_settings": settings_obj}

12
apps/core/middleware.py Normal file
View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from .consent import ConsentService
class ConsentMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request.consent = ConsentService.get_consent(request)
return self.get_response(request)

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.11 on 2026-02-28 11:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0094_alter_page_locale'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='SiteSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('default_og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
('privacy_policy_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page')),
('site', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.site')),
],
options={
'abstract': False,
},
),
]

View File

21
apps/core/models.py Normal file
View File

@@ -0,0 +1,21 @@
from django.db import models
from django.db.models import SET_NULL
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
@register_setting
class SiteSettings(BaseSiteSetting):
default_og_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=SET_NULL,
related_name="+",
)
privacy_policy_page = models.ForeignKey(
"wagtailcore.Page",
null=True,
blank=True,
on_delete=SET_NULL,
related_name="+",
)

View File

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from django import template
from django.utils.safestring import mark_safe
from wagtail.models import Site
from apps.blog.models import TagMetadata
from apps.legal.models import LegalPage
register = template.Library()
@register.simple_tag(takes_context=True)
def get_legal_pages(context):
request = context.get("request")
site = Site.find_for_request(request) if request else None
pages = LegalPage.objects.live().filter(show_in_footer=True)
if site:
pages = pages.in_site(site)
return pages
@register.simple_tag
@register.filter
def get_tag_css(tag):
meta = getattr(tag, "metadata", None)
if meta is None:
meta = TagMetadata.objects.filter(tag=tag).first()
classes = meta.get_css_classes() if meta else TagMetadata.get_fallback_css()
return mark_safe(f"{classes['bg']} {classes['text']}")

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import json
from django import template
from django.utils.safestring import mark_safe
from wagtail.images.models import Image
from apps.core.models import SiteSettings
register = template.Library()
@register.simple_tag(takes_context=True)
def article_json_ld(context, article):
request = context["request"]
site_settings = SiteSettings.for_request(request)
image = article.hero_image or site_settings.default_og_image
image_url = ""
if isinstance(image, Image):
rendition = image.get_rendition("fill-1200x630")
image_url = request.build_absolute_uri(rendition.url)
data = {
"@context": "https://schema.org",
"@type": "Article",
"headline": article.title,
"author": {"@type": "Person", "name": article.author.name},
"datePublished": article.first_published_at.isoformat() if article.first_published_at else "",
"dateModified": article.last_published_at.isoformat() if article.last_published_at else "",
"description": article.search_description or article.summary,
"url": article.get_full_url(request),
"image": image_url,
}
return mark_safe(
'<script type="application/ld+json">' + json.dumps(data, ensure_ascii=True) + "</script>"
)

33
apps/core/views.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
from django.shortcuts import redirect, render
from apps.core.consent import ConsentService
def consent_view(request: HttpRequest) -> HttpResponse:
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
analytics = False
advertising = False
if request.POST.get("accept_all"):
analytics = True
advertising = True
elif request.POST.get("reject_all"):
analytics = False
advertising = False
else:
analytics = request.POST.get("analytics") in {"true", "1", "on"}
advertising = request.POST.get("advertising") in {"true", "1", "on"}
target = request.META.get("HTTP_REFERER", "/")
response = redirect(target)
ConsentService.set_consent(response, analytics=analytics, advertising=advertising)
return response
def robots_txt(request: HttpRequest) -> HttpResponse:
return render(request, "core/robots.txt", content_type="text/plain")

0
apps/legal/__init__.py Normal file
View File

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

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

View File

@@ -0,0 +1,40 @@
# Generated by Django 5.2.11 on 2026-02-28 11:42
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0094_alter_page_locale'),
]
operations = [
migrations.CreateModel(
name='LegalIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='LegalPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('body', wagtail.fields.RichTextField()),
('last_updated', models.DateField()),
('show_in_footer', models.BooleanField(default=True)),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
]

View File

25
apps/legal/models.py Normal file
View File

@@ -0,0 +1,25 @@
from django.db import models
from wagtail.fields import RichTextField
from wagtail.models import Page
class LegalIndexPage(Page):
parent_page_types = ["blog.HomePage"]
subpage_types = ["legal.LegalPage"]
def serve(self, request):
from django.shortcuts import redirect
return redirect("/")
def get_sitemap_urls(self, request=None):
return []
class LegalPage(Page):
body = RichTextField()
last_updated = models.DateField()
show_in_footer = models.BooleanField(default=True)
parent_page_types = ["legal.LegalIndexPage"]
subpage_types = []

View File

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

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

7
apps/newsletter/forms.py Normal file
View File

@@ -0,0 +1,7 @@
from django import forms
class SubscriptionForm(forms.Form):
email = forms.EmailField()
source = forms.CharField(required=False)
honeypot = forms.CharField(required=False)

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.11 on 2026-02-28 11:42
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='NewsletterSubscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254, unique=True)),
('confirmed', models.BooleanField(default=False)),
('source', models.CharField(default='unknown', max_length=100)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

11
apps/newsletter/models.py Normal file
View File

@@ -0,0 +1,11 @@
from django.db import models
class NewsletterSubscription(models.Model):
email = models.EmailField(unique=True)
confirmed = models.BooleanField(default=False)
source = models.CharField(max_length=100, default="unknown")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
return self.email

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
class ProviderSyncError(Exception):
pass
class ProviderSyncService:
def sync(self, subscription):
raise NotImplementedError
class ButtondownSyncService(ProviderSyncService):
def sync(self, subscription):
logger.info("Synced subscription %s", subscription.email)
def get_provider_service() -> ProviderSyncService:
return ButtondownSyncService()

8
apps/newsletter/urls.py Normal file
View File

@@ -0,0 +1,8 @@
from django.urls import path
from apps.newsletter.views import ConfirmView, SubscribeView
urlpatterns = [
path("subscribe/", SubscribeView.as_view(), name="newsletter_subscribe"),
path("confirm/<str:token>/", ConfirmView.as_view(), name="newsletter_confirm"),
]

51
apps/newsletter/views.py Normal file
View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from django.core import signing
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from apps.newsletter.forms import SubscriptionForm
from apps.newsletter.models import NewsletterSubscription
from apps.newsletter.services import ProviderSyncError, get_provider_service
CONFIRMATION_TOKEN_MAX_AGE_SECONDS = 60 * 60 * 24 * 2
class SubscribeView(View):
def post(self, request):
form = SubscriptionForm(request.POST)
if not form.is_valid():
return JsonResponse({"status": "error", "field": "email"}, status=400)
if form.cleaned_data.get("honeypot"):
return JsonResponse({"status": "ok"})
email = form.cleaned_data["email"]
source = form.cleaned_data.get("source") or "unknown"
NewsletterSubscription.objects.get_or_create(email=email, defaults={"source": source})
return JsonResponse({"status": "ok"})
class ConfirmView(View):
def get(self, request, token: str):
try:
email = signing.loads(
token,
salt="newsletter-confirm",
max_age=CONFIRMATION_TOKEN_MAX_AGE_SECONDS,
)
except signing.BadSignature as exc:
raise Http404 from exc
subscription = get_object_or_404(NewsletterSubscription, email=email)
subscription.confirmed = True
subscription.save(update_fields=["confirmed"])
service = get_provider_service()
try:
service.sync(subscription)
except ProviderSyncError:
pass
return redirect("/")
def confirmation_token(email: str) -> str:
return signing.dumps(email, salt="newsletter-confirm")