2022-08-19 17:31:40 +01:00
|
|
|
from datetime import timedelta
|
2022-08-27 12:21:13 +01:00
|
|
|
from typing import Any, Optional, Type
|
2022-06-19 13:23:41 +01:00
|
|
|
|
2022-08-27 12:21:13 +01:00
|
|
|
from django.contrib.syndication.views import Feed
|
2022-08-24 23:55:25 +01:00
|
|
|
from django.core.cache import cache
|
2022-08-27 12:21:13 +01:00
|
|
|
from django.core.paginator import EmptyPage
|
|
|
|
from django.core.paginator import Page as PaginatorPage
|
|
|
|
from django.core.paginator import Paginator
|
2022-06-14 20:57:43 +01:00
|
|
|
from django.db import models
|
2022-08-24 23:55:25 +01:00
|
|
|
from django.dispatch import receiver
|
2022-06-19 16:35:56 +01:00
|
|
|
from django.http.request import HttpRequest
|
2022-08-27 12:21:13 +01:00
|
|
|
from django.http.response import Http404, HttpResponse, HttpResponseBadRequest
|
2022-06-19 20:13:19 +01:00
|
|
|
from django.utils.functional import cached_property, classproperty
|
2022-07-25 19:30:47 +01:00
|
|
|
from django.utils.text import slugify
|
2022-06-14 20:57:43 +01:00
|
|
|
from wagtail.admin.panels import FieldPanel
|
2022-08-27 12:21:13 +01:00
|
|
|
from wagtail.contrib.routable_page.models import RoutablePageMixin, route
|
2022-06-26 18:37:04 +01:00
|
|
|
from wagtail.fields import StreamField
|
2022-06-15 09:27:20 +01:00
|
|
|
from wagtail.images import get_image_model_string
|
2022-07-12 22:45:50 +01:00
|
|
|
from wagtail.images.views.serve import generate_image_url
|
2022-07-16 10:29:47 +01:00
|
|
|
from wagtail.models import Page, PageQuerySet
|
2022-07-26 08:41:40 +01:00
|
|
|
from wagtail.search import index
|
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
|
2022-06-10 15:48:07 +01:00
|
|
|
|
2022-07-03 23:10:57 +01:00
|
|
|
from website.common.utils import count_words
|
2022-07-12 15:14:27 +01:00
|
|
|
from website.contrib.unsplash.widgets import UnsplashPhotoChooser
|
2022-07-03 23:10:57 +01:00
|
|
|
|
2022-08-27 12:21:13 +01:00
|
|
|
from .serializers import PaginationSerializer
|
2022-07-03 23:10:57 +01:00
|
|
|
from .streamfield import add_heading_anchors, get_blocks, get_content_html
|
|
|
|
from .utils import TocEntry, extract_text, get_table_of_contents, truncate_string
|
2022-06-19 20:13:19 +01:00
|
|
|
|
2022-06-10 15:48:07 +01:00
|
|
|
|
|
|
|
class BasePage(Page):
|
2022-06-17 14:03:43 +01:00
|
|
|
show_in_menus_default = True
|
|
|
|
|
2022-06-10 15:48:07 +01:00
|
|
|
class Meta:
|
|
|
|
abstract = True
|
2022-06-10 15:54:31 +01:00
|
|
|
|
2022-06-14 22:30:39 +01:00
|
|
|
@classproperty
|
2022-06-12 15:17:28 +01:00
|
|
|
def body_class(cls) -> str:
|
2022-07-25 19:30:47 +01:00
|
|
|
return "page-" + slugify(cls.__name__)
|
2022-06-14 20:57:43 +01:00
|
|
|
|
2022-07-16 10:29:47 +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
|
|
|
|
"""
|
2022-07-16 10:29:47 +01:00
|
|
|
return self.get_ancestors().exclude(depth__lte=2)
|
2022-06-19 16:56:47 +01:00
|
|
|
|
2022-06-14 20:57:43 +01:00
|
|
|
|
2022-08-19 16:56:20 +01:00
|
|
|
class BaseContentPage(BasePage, MetadataMixin):
|
2022-06-14 20:57:43 +01:00
|
|
|
subtitle = models.CharField(max_length=255, blank=True)
|
2022-06-15 09:27:20 +01:00
|
|
|
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
|
2022-06-15 09:27:20 +01:00
|
|
|
)
|
2022-07-12 15:14:27 +01:00
|
|
|
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
|
|
|
|
2022-08-16 21:32:46 +01:00
|
|
|
content_panels = BasePage.content_panels + [
|
2022-06-15 09:27:20 +01:00
|
|
|
FieldPanel("subtitle"),
|
|
|
|
FieldPanel("hero_image"),
|
2022-07-12 15:14:27 +01:00
|
|
|
FieldPanel("hero_unsplash_photo", widget=UnsplashPhotoChooser),
|
2022-06-26 18:37:04 +01:00
|
|
|
FieldPanel("body"),
|
2022-06-15 09:27:20 +01:00
|
|
|
]
|
2022-06-19 13:23:41 +01:00
|
|
|
|
2022-08-16 21:32:46 +01:00
|
|
|
search_fields = BasePage.search_fields + [
|
2022-07-28 23:06:11 +01:00
|
|
|
index.SearchField("body"),
|
|
|
|
index.SearchField("subtitle"),
|
|
|
|
]
|
|
|
|
|
2022-06-19 13:23:41 +01:00
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
2022-06-19 21:16:03 +01:00
|
|
|
@cached_property
|
|
|
|
def table_of_contents(self) -> list[TocEntry]:
|
2022-07-03 23:10:57 +01:00
|
|
|
return get_table_of_contents(self.content_html)
|
2022-06-19 21:16:03 +01:00
|
|
|
|
|
|
|
@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 show_reading_time(self) -> bool:
|
|
|
|
"""
|
|
|
|
Only show reading time if it's longer than 2 minutes
|
|
|
|
"""
|
|
|
|
return self.reading_time.total_seconds() >= 120
|
2022-06-19 21:16:03 +01:00
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def word_count(self) -> int:
|
2022-07-03 23:10:57 +01:00
|
|
|
return count_words(self.plain_text)
|
2022-06-19 21:16:03 +01:00
|
|
|
|
2022-06-26 19:52:20 +01:00
|
|
|
@cached_property
|
|
|
|
def summary(self) -> str:
|
2022-07-03 23:10:57 +01:00
|
|
|
return truncate_string(self.plain_text, 50)
|
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)
|
|
|
|
|
2022-08-24 23:55:25 +01:00
|
|
|
@cached_property
|
|
|
|
def body_html_cache_key(self) -> str:
|
|
|
|
return f"body_html_{self.id}"
|
|
|
|
|
2022-08-16 20:50:34 +01:00
|
|
|
@cached_property
|
|
|
|
def _body_html(self) -> str:
|
2022-08-24 23:55:25 +01:00
|
|
|
body_html = cache.get(self.body_html_cache_key)
|
|
|
|
|
|
|
|
if body_html is None:
|
|
|
|
body_html = str(self.body)
|
|
|
|
|
|
|
|
# Cache for 1 day
|
|
|
|
cache.set(self.body_html_cache_key, body_html, 86400)
|
|
|
|
|
|
|
|
return body_html
|
2022-07-01 09:25:57 +01:00
|
|
|
|
2022-07-03 23:10: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)
|
2022-07-03 23:10:57 +01:00
|
|
|
|
|
|
|
@cached_property
|
|
|
|
def plain_text(self) -> str:
|
|
|
|
return extract_text(self.content_html)
|
|
|
|
|
2022-07-12 22:45:50 +01:00
|
|
|
@cached_property
|
|
|
|
def hero_image_url(self) -> Optional[str]:
|
|
|
|
if self.hero_unsplash_photo_id is not None:
|
2022-08-19 13:57:25 +01:00
|
|
|
return self.hero_unsplash_photo.get_image_urls()["regular"]
|
2022-07-12 22:45:50 +01:00
|
|
|
elif self.hero_image_id is not None:
|
|
|
|
return generate_image_url(self.hero_image, "width-1200")
|
|
|
|
return None
|
|
|
|
|
2022-08-19 13:57:25 +01:00
|
|
|
@cached_property
|
|
|
|
def list_image_url(self) -> Optional[str]:
|
|
|
|
if self.hero_unsplash_photo_id is not None:
|
|
|
|
return self.hero_unsplash_photo.get_image_urls()["small"]
|
|
|
|
elif self.hero_image_id is not None:
|
|
|
|
return generate_image_url(self.hero_image, "width-400")
|
|
|
|
return None
|
|
|
|
|
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]:
|
|
|
|
return self.hero_image_url
|
|
|
|
|
|
|
|
def get_meta_title(self) -> str:
|
|
|
|
return self.seo_title or self.title
|
|
|
|
|
|
|
|
def get_meta_description(self) -> str:
|
|
|
|
return self.summary
|
|
|
|
|
|
|
|
def get_object_title(self) -> str:
|
|
|
|
return ""
|
|
|
|
|
2022-06-19 13:23:41 +01:00
|
|
|
|
2022-08-24 23:55:25 +01:00
|
|
|
@receiver(models.signals.post_save)
|
|
|
|
def clear_body_html_cache(sender: Any, instance: models.Model, **kwargs: dict) -> None:
|
|
|
|
if isinstance(instance, BaseContentPage):
|
|
|
|
cache.delete(instance.body_html_cache_key)
|
|
|
|
|
|
|
|
|
2022-08-16 21:32:46 +01:00
|
|
|
class ContentPage(BaseContentPage):
|
2022-06-19 13:23:41 +01:00
|
|
|
subpage_types: list[Any] = []
|
|
|
|
|
|
|
|
|
2022-08-27 12:21:13 +01:00
|
|
|
class BaseListingPage(RoutablePageMixin, BaseContentPage):
|
|
|
|
PAGE_SIZE = 20
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
abstract = True
|
|
|
|
|
|
|
|
def get_listing_pages(self) -> models.QuerySet:
|
|
|
|
return (
|
2022-08-19 13:57:25 +01:00
|
|
|
self.get_children()
|
|
|
|
.live()
|
|
|
|
.specific()
|
|
|
|
.select_related("hero_image")
|
|
|
|
.select_related("hero_unsplash_photo")
|
2022-08-27 12:21:13 +01:00
|
|
|
.order_by("title")
|
2022-06-19 16:35:56 +01:00
|
|
|
)
|
2022-08-27 12:21:13 +01:00
|
|
|
|
|
|
|
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:
|
|
|
|
raise Http404
|
|
|
|
|
|
|
|
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
|
|
|
|
2022-08-27 12:21:13 +01:00
|
|
|
@cached_property
|
|
|
|
def table_of_contents(self) -> list[TocEntry]:
|
|
|
|
return []
|
|
|
|
|
|
|
|
@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/$")
|
|
|
|
def feed(self, request: HttpRequest) -> HttpResponse:
|
|
|
|
return self.feed_class(
|
|
|
|
self.get_listing_pages(),
|
|
|
|
self.get_full_url(request),
|
|
|
|
self.title,
|
|
|
|
)(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
|