Scaffold containerized Django/Wagtail app with core features
This commit is contained in:
0
apps/__init__.py
Normal file
0
apps/__init__.py
Normal file
0
apps/authors/__init__.py
Normal file
0
apps/authors/__init__.py
Normal file
6
apps/authors/apps.py
Normal file
6
apps/authors/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthorsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.authors"
|
||||
34
apps/authors/migrations/0001_initial.py
Normal file
34
apps/authors/migrations/0001_initial.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/authors/migrations/__init__.py
Normal file
0
apps/authors/migrations/__init__.py
Normal file
44
apps/authors/models.py
Normal file
44
apps/authors/models.py
Normal 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
|
||||
15
apps/authors/wagtail_hooks.py
Normal file
15
apps/authors/wagtail_hooks.py
Normal 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
0
apps/blog/__init__.py
Normal file
6
apps/blog/apps.py
Normal file
6
apps/blog/apps.py
Normal 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
81
apps/blog/blocks.py
Normal 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
41
apps/blog/feeds.py
Normal 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]
|
||||
98
apps/blog/migrations/0001_initial.py
Normal file
98
apps/blog/migrations/0001_initial.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
apps/blog/migrations/__init__.py
Normal file
0
apps/blog/migrations/__init__.py
Normal file
191
apps/blog/models.py
Normal file
191
apps/blog/models.py
Normal 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"),
|
||||
]
|
||||
13
apps/blog/wagtail_hooks.py
Normal file
13
apps/blog/wagtail_hooks.py
Normal 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)
|
||||
0
apps/comments/__init__.py
Normal file
0
apps/comments/__init__.py
Normal file
6
apps/comments/apps.py
Normal file
6
apps/comments/apps.py
Normal 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
19
apps/comments/forms.py
Normal 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
|
||||
30
apps/comments/migrations/0001_initial.py
Normal file
30
apps/comments/migrations/0001_initial.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
apps/comments/migrations/__init__.py
Normal file
0
apps/comments/migrations/__init__.py
Normal file
25
apps/comments/models.py
Normal file
25
apps/comments/models.py
Normal 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
7
apps/comments/urls.py
Normal 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
42
apps/comments/views.py
Normal 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)
|
||||
20
apps/comments/wagtail_hooks.py
Normal file
20
apps/comments/wagtail_hooks.py
Normal 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
0
apps/core/__init__.py
Normal file
6
apps/core/apps.py
Normal file
6
apps/core/apps.py
Normal 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
59
apps/core/consent.py
Normal 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,
|
||||
)
|
||||
9
apps/core/context_processors.py
Normal file
9
apps/core/context_processors.py
Normal 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
12
apps/core/middleware.py
Normal 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)
|
||||
29
apps/core/migrations/0001_initial.py
Normal file
29
apps/core/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/core/migrations/__init__.py
Normal file
0
apps/core/migrations/__init__.py
Normal file
21
apps/core/models.py
Normal file
21
apps/core/models.py
Normal 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="+",
|
||||
)
|
||||
0
apps/core/templatetags/__init__.py
Normal file
0
apps/core/templatetags/__init__.py
Normal file
30
apps/core/templatetags/core_tags.py
Normal file
30
apps/core/templatetags/core_tags.py
Normal 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']}")
|
||||
37
apps/core/templatetags/seo_tags.py
Normal file
37
apps/core/templatetags/seo_tags.py
Normal 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
33
apps/core/views.py
Normal 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
0
apps/legal/__init__.py
Normal file
6
apps/legal/apps.py
Normal file
6
apps/legal/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LegalConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.legal"
|
||||
40
apps/legal/migrations/0001_initial.py
Normal file
40
apps/legal/migrations/0001_initial.py
Normal 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',),
|
||||
),
|
||||
]
|
||||
0
apps/legal/migrations/__init__.py
Normal file
0
apps/legal/migrations/__init__.py
Normal file
25
apps/legal/models.py
Normal file
25
apps/legal/models.py
Normal 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 = []
|
||||
0
apps/newsletter/__init__.py
Normal file
0
apps/newsletter/__init__.py
Normal file
6
apps/newsletter/apps.py
Normal file
6
apps/newsletter/apps.py
Normal 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
7
apps/newsletter/forms.py
Normal 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)
|
||||
24
apps/newsletter/migrations/0001_initial.py
Normal file
24
apps/newsletter/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
apps/newsletter/migrations/__init__.py
Normal file
0
apps/newsletter/migrations/__init__.py
Normal file
11
apps/newsletter/models.py
Normal file
11
apps/newsletter/models.py
Normal 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
|
||||
23
apps/newsletter/services.py
Normal file
23
apps/newsletter/services.py
Normal 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
8
apps/newsletter/urls.py
Normal 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
51
apps/newsletter/views.py
Normal 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")
|
||||
Reference in New Issue
Block a user