Scaffold containerized Django/Wagtail app with core features
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user