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