from datetime import timedelta from typing import Any, Optional, Type from django.contrib.humanize.templatetags.humanize import NaturalTimeFormatter from django.contrib.syndication.views import Feed from django.core.paginator import EmptyPage, Paginator from django.core.paginator import Page as PaginatorPage from django.db import models from django.http.request import HttpRequest from django.http.response import Http404, HttpResponse, HttpResponseBadRequest from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.functional import cached_property, classproperty from django.utils.text import slugify from django.views.decorators.cache import cache_page from wagtail.admin.panels import FieldPanel, MultiFieldPanel from wagtail.contrib.routable_page.models import RoutablePageMixin, route from wagtail.contrib.settings.models import BaseGenericSetting, register_setting from wagtail.fields import RichTextField, StreamField from wagtail.images import get_image_model_string from wagtail.images.views.serve import generate_image_url from wagtail.models import Page, PageQuerySet from wagtail.search import index from wagtail.snippets.blocks import SnippetChooserBlock from wagtail.snippets.models import register_snippet from wagtailmetadata.models import MetadataMixin from website.contrib.unsplash.models import SIZES as UNSPLASH_SIZES from website.contrib.unsplash.widgets import UnsplashPhotoChooser from .serializers import PaginationSerializer from .streamfield import add_heading_anchors, get_blocks, get_content_html from .utils import ( TocEntry, count_words, extract_text, get_site_title, get_table_of_contents, truncate_string, ) class BasePage(Page): show_in_menus_default = True class Meta: abstract = True @classproperty def body_class(cls) -> str: # noqa: N805 return "page-" + slugify(cls.__name__) def get_parent_pages(self) -> PageQuerySet: """ Shim over the fact everything is in 1 tree """ return self.get_ancestors().exclude(depth__lte=2) @cached_property def html_title(self) -> str: return self.seo_title or self.title @cached_property def html_title_tag(self) -> str: return f"{self.html_title} :: {get_site_title()}" @cached_property def hero_title(self) -> str: return self.html_title class BaseContentPage(BasePage, MetadataMixin): subtitle = RichTextField(blank=True, editor="plain") hero_image = models.ForeignKey( get_image_model_string(), null=True, blank=True, on_delete=models.SET_NULL ) hero_unsplash_photo = models.ForeignKey( "unsplash.UnsplashPhoto", null=True, blank=True, on_delete=models.SET_NULL ) body = StreamField(get_blocks(), blank=True, use_json_field=True) content_panels = BasePage.content_panels + [ FieldPanel("subtitle"), MultiFieldPanel( [ FieldPanel("hero_image"), FieldPanel("hero_unsplash_photo", widget=UnsplashPhotoChooser), ], heading="Hero image", ), FieldPanel("body"), ] search_fields = BasePage.search_fields + [ index.SearchField("body"), index.SearchField("subtitle"), ] class Meta: abstract = True @cached_property def table_of_contents(self) -> list[TocEntry]: return get_table_of_contents(self.content_html) @cached_property def show_table_of_contents(self) -> bool: return len(self.table_of_contents) >= 3 @cached_property def reading_time(self) -> timedelta: """ https://help.medium.com/hc/en-us/articles/214991667-Read-time """ return timedelta(seconds=(self.word_count / 265) * 60) @cached_property def reading_time_display(self) -> str: return NaturalTimeFormatter.string_for( timezone.now() - self.reading_time ).removesuffix(" ago") @cached_property def show_reading_time(self) -> bool: """ Only show reading time if it's longer than 2 minutes """ return self.reading_time.total_seconds() >= 120 @cached_property def word_count(self) -> int: return count_words(self.plain_text) @cached_property def summary(self) -> str: summary = truncate_string(self.plain_text, 50) if summary and summary != self.plain_text and not summary.endswith("."): summary += "…" return summary @cached_property def body_html(self) -> str: return add_heading_anchors(self._body_html) @cached_property def _body_html(self) -> str: return str(self.body) @cached_property def content_html(self) -> str: return get_content_html(self._body_html) @cached_property def plain_text(self) -> str: return extract_text(self.content_html).strip() def hero_url( self, image_size: str, wagtail_image_spec_extra: Optional[str] = None ) -> Optional[str]: if self.hero_unsplash_photo_id is not None: return self.hero_unsplash_photo.get_image_urls()[image_size] elif self.hero_image_id is not None: image_width = UNSPLASH_SIZES[image_size] wagtail_image_spec = f"width-{image_width}" if wagtail_image_spec_extra: wagtail_image_spec += wagtail_image_spec_extra return generate_image_url(self.hero_image, wagtail_image_spec) return None @cached_property def hero_image_urls(self) -> dict: return { int(width * 1.5): self.hero_url(size) for size, width in UNSPLASH_SIZES.items() } def hero_image_url(self) -> Optional[str]: return self.hero_url("regular") @cached_property def list_image_url(self) -> Optional[str]: return self.hero_url("small") def get_meta_url(self) -> str: return self.full_url def get_meta_image_url(self, request: HttpRequest) -> Optional[str]: return self.hero_url("regular", "|format-png") def get_meta_title(self) -> str: return self.html_title def get_meta_description(self) -> str: return self.summary def get_object_title(self) -> str: return "" class ContentPage(BaseContentPage): subpage_types: list[Any] = [] class BaseListingPage(RoutablePageMixin, BaseContentPage): PAGE_SIZE = 20 subtitle = None content_panels = [ panel for panel in BaseContentPage.content_panels if getattr(panel, "field_name", None) != "subtitle" ] search_fields = [ panel for panel in BaseContentPage.search_fields if getattr(panel, "field_name", None) != "subtitle" ] class Meta: abstract = True def get_listing_pages(self) -> models.QuerySet: return self.get_children().live().public().specific().order_by("title") def get_paginator_page(self) -> PaginatorPage: paginator = Paginator(self.get_listing_pages(), per_page=self.PAGE_SIZE) try: return paginator.page(self.serializer.validated_data["page"]) except EmptyPage as e: raise Http404 from e def get_context(self, request: HttpRequest) -> dict: context = super().get_context(request) context["listing_pages"] = self.get_paginator_page() return context @cached_property def show_table_of_contents(self) -> bool: return False @cached_property def show_reading_time(self) -> bool: return False @property def feed_class(self) -> Type[Feed]: from .views import ContentPageFeed return ContentPageFeed @route(r"^$") def index_route(self, request: HttpRequest) -> HttpResponse: self.serializer = PaginationSerializer(data=request.GET) if not self.serializer.is_valid(): return HttpResponseBadRequest() return super().index_route(request) @route(r"^feed/$") @method_decorator(cache_page(60 * 30)) def feed(self, request: HttpRequest) -> HttpResponse: return self.feed_class( self.get_listing_pages(), self.get_full_url(request), self.html_title_tag, )(request) class ListingPage(BaseListingPage): pass @register_snippet class ReferralLink(models.Model, index.Indexed): url = models.URLField() name = models.CharField(max_length=64, unique=True) panels = [ FieldPanel("name"), FieldPanel("url"), ] search_fields = [index.AutocompleteField("name"), index.SearchField("url")] def __str__(self) -> str: return self.name @register_setting(icon="arrow-down") class FooterSetting(BaseGenericSetting): icons = StreamField( [("icon", SnippetChooserBlock("contact.OnlineAccount", icon="user"))], use_json_field=True, ) panels = [FieldPanel("icons")] class Meta: verbose_name = "Footer"