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"), ]