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/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)