website/website/common/models.py

326 lines
9.5 KiB
Python
Raw Normal View History

2022-08-19 17:31:40 +01:00
from datetime import timedelta
from typing import Any, Optional, Type
2023-10-06 21:19:01 +01:00
from urllib.parse import urlencode
2022-06-19 13:23:41 +01:00
from django.contrib.humanize.templatetags.humanize import NaturalTimeFormatter
from django.contrib.syndication.views import Feed
2023-07-15 15:10:05 +01:00
from django.core.paginator import EmptyPage, Paginator
from django.core.paginator import Page as PaginatorPage
2022-06-14 20:57:43 +01:00
from django.db import models
2022-06-19 16:35:56 +01:00
from django.http.request import HttpRequest
from django.http.response import Http404, HttpResponse, HttpResponseBadRequest
from django.utils import timezone
2022-09-04 17:34:04 +01:00
from django.utils.decorators import method_decorator
2022-06-19 20:13:19 +01:00
from django.utils.functional import cached_property, classproperty
from django.utils.text import slugify
2022-09-04 17:34:04 +01:00
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
2022-09-03 17:00:09 +01:00
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
2022-07-26 08:41:40 +01:00
from wagtail.search import index
from wagtail.snippets.blocks import SnippetChooserBlock
2022-07-14 21:41:43 +01:00
from wagtail.snippets.models import register_snippet
2022-08-19 16:56:20 +01:00
from wagtailmetadata.models import MetadataMixin
2023-06-14 09:13:49 +01:00
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
2022-08-28 14:52:27 +01:00
from .utils import (
TocEntry,
count_words,
extract_text,
get_site_title,
2022-08-28 14:52:27 +01:00
get_table_of_contents,
2023-09-04 21:39:14 +01:00
get_url_mime_type,
2022-08-28 14:52:27 +01:00
truncate_string,
)
2022-06-19 20:13:19 +01:00
class BasePage(Page):
2022-06-17 14:03:43 +01:00
show_in_menus_default = True
class Meta:
abstract = True
2022-06-10 15:54:31 +01:00
@classproperty
2023-07-15 15:10:05 +01:00
def body_class(cls) -> str: # noqa: N805
return "page-" + slugify(cls.__name__)
2022-06-14 20:57:43 +01:00
def get_parent_pages(self) -> PageQuerySet:
2022-06-19 16:56:47 +01:00
"""
Shim over the fact everything is in 1 tree
"""
return self.get_ancestors().exclude(depth__lte=2)
2022-06-19 16:56:47 +01:00
2022-08-27 13:12:45 +01:00
@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()}"
2022-08-27 13:12:45 +01:00
@cached_property
def hero_title(self) -> str:
return self.html_title
2022-06-14 20:57:43 +01:00
2022-08-19 16:56:20 +01:00
class BaseContentPage(BasePage, MetadataMixin):
subtitle = RichTextField(blank=True, editor="plain")
hero_image = models.ForeignKey(
2022-06-19 11:36:15 +01:00
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
)
2022-06-26 18:37:04 +01:00
body = StreamField(get_blocks(), blank=True, use_json_field=True)
2022-06-14 20:57:43 +01:00
content_panels = BasePage.content_panels + [
FieldPanel("subtitle"),
MultiFieldPanel(
[
FieldPanel("hero_image"),
FieldPanel("hero_unsplash_photo", widget=UnsplashPhotoChooser),
],
heading="Hero image",
),
2022-06-26 18:37:04 +01:00
FieldPanel("body"),
]
2022-06-19 13:23:41 +01:00
search_fields = BasePage.search_fields + [
index.SearchField("body"),
index.SearchField("subtitle"),
]
2022-06-19 13:23:41 +01:00
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
2022-08-19 17:31:40 +01:00
def reading_time(self) -> timedelta:
2022-06-26 19:25:30 +01:00
"""
https://help.medium.com/hc/en-us/articles/214991667-Read-time
"""
2022-08-19 17:31:40 +01:00
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")
2022-08-19 17:31:40 +01:00
@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)
2022-06-26 19:52:20 +01:00
@cached_property
def summary(self) -> str:
2023-07-15 17:43:04 +01:00
summary = truncate_string(self.plain_text, 50)
if summary and summary != self.plain_text and not summary.endswith("."):
summary += ""
return summary
2022-06-26 19:52:20 +01:00
2022-07-01 09:25:57 +01:00
@cached_property
2022-07-03 22:00:52 +01:00
def body_html(self) -> str:
2022-08-16 20:50:34 +01:00
return add_heading_anchors(self._body_html)
@cached_property
def _body_html(self) -> str:
return str(self.body)
2022-07-01 09:25:57 +01:00
@cached_property
def content_html(self) -> str:
2022-08-16 20:50:34 +01:00
return get_content_html(self._body_html)
@cached_property
def plain_text(self) -> str:
2023-07-15 17:43:04 +01:00
return extract_text(self.content_html).strip()
2023-06-14 09:13:49 +01:00
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:
2023-06-14 09:13:49 +01:00
return self.hero_unsplash_photo.get_image_urls()[image_size]
elif self.hero_image_id is not None:
2023-06-14 09:13:49 +01:00
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
2023-06-14 09:13:49 +01:00
def hero_image_urls(self) -> dict:
return {
int(width * 1.5): self.hero_url(size)
for size, width in UNSPLASH_SIZES.items()
}
2023-06-14 09:13:49 +01:00
def hero_image_url(self) -> Optional[str]:
return self.hero_url("regular")
2022-08-19 13:57:25 +01:00
@cached_property
def list_image_url(self) -> Optional[str]:
2023-06-14 09:13:49 +01:00
return self.hero_url("small")
2022-08-19 13:57:25 +01:00
2022-08-19 16:56:20 +01:00
def get_meta_url(self) -> str:
return self.full_url
def get_meta_image_url(self, request: HttpRequest) -> Optional[str]:
2023-06-14 09:13:49 +01:00
return self.hero_url("regular", "|format-png")
2022-08-19 16:56:20 +01:00
2023-09-04 21:39:14 +01:00
def get_meta_image_mime(self) -> Optional[str]:
if self.hero_unsplash_photo_id is not None:
return get_url_mime_type(self.hero_url("regular"))
elif self.hero_image_id is not None:
# We force these to PNG in `get_meta_image_url`
return "image/png"
return None
2022-08-19 16:56:20 +01:00
def get_meta_title(self) -> str:
2022-08-27 13:12:45 +01:00
return self.html_title
2022-08-19 16:56:20 +01:00
def get_meta_description(self) -> str:
return self.summary or self.get_meta_title()
2022-08-19 16:56:20 +01:00
def get_object_title(self) -> str:
return ""
2022-06-19 13:23:41 +01:00
class ContentPage(BaseContentPage):
2022-06-19 13:23:41 +01:00
subpage_types: list[Any] = []
class BaseListingPage(RoutablePageMixin, BaseContentPage):
2023-10-06 21:23:32 +01:00
PAGE_SIZE = 30
2022-08-27 19:21:51 +01:00
subtitle = None
content_panels = [
panel
for panel in BaseContentPage.content_panels
if getattr(panel, "field_name", None) != "subtitle"
2022-08-27 19:21:51 +01:00
]
search_fields = [
panel
for panel in BaseContentPage.search_fields
if getattr(panel, "field_name", None) != "subtitle"
2022-08-27 19:21:51 +01:00
]
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"])
2023-07-15 15:10:05 +01:00
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()
2022-06-19 16:35:56 +01:00
return context
2022-07-14 21:41:43 +01:00
@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)
2023-10-06 21:19:01 +01:00
def get_meta_url(self) -> str:
query_data = self.serializer.validated_data.copy()
if query_data["page"] == 1:
del query_data["page"]
2023-10-22 15:26:28 +01:00
url = super().get_meta_url()
if not query_data:
return url
return url + "?" + urlencode(query_data)
2023-10-06 21:19:01 +01:00
@route(r"^feed/$")
2022-09-04 17:34:04 +01:00
@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
2022-07-14 21:41:43 +01:00
@register_snippet
2022-07-26 08:41:40 +01:00
class ReferralLink(models.Model, index.Indexed):
2022-07-14 21:41:43 +01:00
url = models.URLField()
name = models.CharField(max_length=64, unique=True)
panels = [
FieldPanel("name"),
FieldPanel("url"),
]
2022-07-26 08:41:40 +01:00
search_fields = [index.AutocompleteField("name"), index.SearchField("url")]
2022-07-14 21:41:43 +01:00
def __str__(self) -> str:
return self.name
@register_setting(icon="arrow-down")
class FooterSetting(BaseGenericSetting):
icons = StreamField(
2023-04-16 15:04:51 +01:00
[("icon", SnippetChooserBlock("contact.OnlineAccount", icon="user"))],
use_json_field=True,
)
panels = [FieldPanel("icons")]
class Meta:
verbose_name = "Footer"