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, Prefetch from django.shortcuts import get_object_or_404 from modelcluster.contrib.taggit import ClusterTaggableManager from modelcluster.fields import ParentalKey from taggit.models import Tag, TaggedItemBase from wagtail.admin.panels import FieldPanel, PageChooserPanel from wagtail.contrib.routable_page.models import RoutablePageMixin, route 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_qs = ( ArticlePage.objects.live() .public() .select_related("author", "category") .prefetch_related("tags__metadata") .order_by("-first_published_at") ) articles = list(articles_qs[:5]) ctx["featured_article"] = self.featured_article ctx["latest_articles"] = articles ctx["more_articles"] = articles[:3] ctx["available_tags"] = ( Tag.objects.filter( id__in=ArticlePage.objects.live().public().values_list("tags__id", flat=True) ).distinct().order_by("name") ) ctx["available_categories"] = ( Category.objects.filter(show_in_nav=True, articles__live=True).distinct().order_by("sort_order", "name") ) return ctx class ArticleIndexPage(RoutablePageMixin, 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", "category") .prefetch_related("tags__metadata") .order_by("-first_published_at") ) def get_category_url(self, category): return f"{self.url}category/{category.slug}/" def get_listing_context(self, request, active_category=None): tag_slug = request.GET.get("tag") articles = self.get_articles() all_articles = articles available_categories = ( Category.objects.filter(articles__in=all_articles).distinct().order_by("sort_order", "name") ) category_links = [{"category": category, "url": self.get_category_url(category)} for category in available_categories] if active_category: articles = articles.filter(category=active_category) available_tags = ( Tag.objects.filter(id__in=articles.values_list("tags__id", flat=True)).distinct().order_by("name") ) 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) return { "articles": page_obj, "paginator": paginator, "active_tag": tag_slug, "available_tags": available_tags, "available_categories": available_categories, "category_links": category_links, "active_category": active_category, "active_category_url": self.get_category_url(active_category) if active_category else "", } @route(r"^category/(?P[-\w]+)/$") def category_listing(self, request, category_slug): category = get_object_or_404(Category.objects.filter(articles__in=self.get_articles()).distinct(), slug=category_slug) return self.render(request, context_overrides=self.get_listing_context(request, active_category=category)) def get_context(self, request, *args, **kwargs): ctx = super().get_context(request, *args, **kwargs) ctx.update(self.get_listing_context(request)) return ctx class ArticleTag(TaggedItemBase): content_object = ParentalKey("blog.ArticlePage", related_name="tagged_items", on_delete=CASCADE) class Category(models.Model): COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")] name = models.CharField(max_length=100, unique=True) slug = models.SlugField(unique=True) description = models.TextField(blank=True) hero_image = models.ForeignKey( "wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+" ) colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default="neutral") sort_order = models.IntegerField(default=0) show_in_nav = models.BooleanField(default=True) panels = [ FieldPanel("name"), FieldPanel("slug"), FieldPanel("description"), FieldPanel("hero_image"), FieldPanel("colour"), FieldPanel("sort_order"), FieldPanel("show_in_nav"), ] class Meta: ordering = ["sort_order", "name"] def __str__(self): return self.name 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-800 dark:bg-zinc-100", "text": "text-white dark:text-black", "border": "border-zinc-600/20 dark:border-zinc-400/20", } def get_css_classes(self) -> dict[str, str]: mapping = { "cyan": { "bg": "bg-brand-cyan/10", "text": "text-brand-cyan", "border": "border-brand-cyan/20", }, "pink": { "bg": "bg-brand-pink/10", "text": "text-brand-pink", "border": "border-brand-pink/20", }, "neutral": self.get_fallback_css(), } return mapping.get(self.colour, self.get_fallback_css()) class ArticlePage(SeoMixin, Page): category = models.ForeignKey("blog.Category", on_delete=PROTECT, related_name="articles") 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("category"), 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: if not self.category_id: self.category, _ = Category.objects.get_or_create( slug="general", defaults={"name": "General", "description": "General articles", "colour": "neutral"}, ) 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() from apps.comments.models import Comment approved_replies = Comment.objects.filter(is_approved=True).select_related("parent") ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related( Prefetch("replies", queryset=approved_replies) ) 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"), ]