290 lines
9.0 KiB
Python
290 lines
9.0 KiB
Python
from typing import Any, Optional
|
|
from urllib.parse import urlsplit
|
|
|
|
from django.contrib.postgres.search import TrigramSimilarity
|
|
from django.db import models
|
|
from django.db.models.functions import Cast, Coalesce
|
|
from django.http import HttpRequest, HttpResponse, HttpResponsePermanentRedirect
|
|
from django.utils import timezone
|
|
from django.utils.functional import cached_property
|
|
from metadata_parser import MetadataParser
|
|
from modelcluster.fields import ParentalManyToManyField
|
|
from wagtail.admin.panels import FieldPanel
|
|
from wagtail.models import Page, PageQuerySet, Site
|
|
from wagtail.search import index
|
|
from wagtailautocomplete.edit_handlers import AutocompletePanel
|
|
|
|
from website.common.models import BaseContentPage, BaseListingPage, BasePage
|
|
from website.common.utils import (
|
|
TocEntry,
|
|
extend_query_params,
|
|
get_page_metadata,
|
|
get_url_mime_type,
|
|
)
|
|
from website.contrib.singleton_page.utils import SingletonPageCache
|
|
|
|
|
|
class BlogPostListPage(BaseListingPage):
|
|
max_count = 1
|
|
subpage_types = [
|
|
"blog.BlogPostPage",
|
|
"blog.BlogPostTagListPage",
|
|
"blog.BlogPostCollectionListPage",
|
|
"blog.BlogPostCollectionPage",
|
|
"blog.BlogPostCollectionPage",
|
|
"blog.ExternalBlogPostPage",
|
|
]
|
|
|
|
@cached_property
|
|
def show_table_of_contents(self) -> bool:
|
|
return False
|
|
|
|
def get_listing_pages(self) -> models.QuerySet:
|
|
return (
|
|
Page.objects.live()
|
|
.public()
|
|
.annotate(date=Coalesce("blogpostpage__date", "externalblogpostpage__date"))
|
|
.descendant_of(self)
|
|
.type(BlogPostPage, ExternalBlogPostPage)
|
|
.specific()
|
|
.order_by("-date", "title")
|
|
)
|
|
|
|
@cached_property
|
|
def tag_list_page_url(self) -> Optional[str]:
|
|
return SingletonPageCache.get_url(BlogPostTagListPage)
|
|
|
|
|
|
class BlogPostPage(BaseContentPage):
|
|
subpage_types: list[Any] = []
|
|
parent_page_types = [BlogPostListPage, "blog.BlogPostCollectionPage"]
|
|
|
|
tags = ParentalManyToManyField("blog.BlogPostTagPage", blank=True)
|
|
date = models.DateField(default=timezone.now)
|
|
|
|
promote_panels = BaseContentPage.promote_panels + [
|
|
FieldPanel("date"),
|
|
AutocompletePanel("tags"),
|
|
]
|
|
|
|
search_fields = BaseContentPage.search_fields + [
|
|
index.RelatedFields("tags", [index.SearchField("title", boost=1)])
|
|
]
|
|
|
|
@cached_property
|
|
def tag_list_page_url(self) -> Optional[str]:
|
|
return SingletonPageCache.get_url(BlogPostTagListPage)
|
|
|
|
@cached_property
|
|
def tags_list(self) -> models.QuerySet:
|
|
"""
|
|
Use this to get a page's tags.
|
|
"""
|
|
tags = self.tags.order_by("slug")
|
|
|
|
# In drafts, `django-modelcluster` doesn't support these filters
|
|
if isinstance(tags, PageQuerySet):
|
|
return tags.public().live()
|
|
|
|
return tags
|
|
|
|
@cached_property
|
|
def blog_post_list_page_url(self) -> Optional[str]:
|
|
return SingletonPageCache.get_url(BlogPostListPage)
|
|
|
|
def get_similar_posts(self) -> models.QuerySet:
|
|
listing_pages = BlogPostListPage.objects.get().get_listing_pages()
|
|
|
|
similar_posts = listing_pages.exclude(id=self.id).alias(
|
|
title_similarity=TrigramSimilarity("title", self.title),
|
|
)
|
|
|
|
page_tags = list(self.tags.public().live().values_list("id", flat=True))
|
|
# If this page has no tags, ignore it as part of similarity
|
|
divisor = len(page_tags) if page_tags else models.Value(1)
|
|
similar_posts = similar_posts.alias(
|
|
# NB: Cast to a float, because `COUNT` returns a `bigint`.
|
|
_blog_tag_similarity=Cast(
|
|
models.Count(
|
|
"blogpostpage__tags",
|
|
filter=models.Q(blogpostpage__tags__in=page_tags),
|
|
),
|
|
output_field=models.FloatField(),
|
|
)
|
|
/ divisor,
|
|
_external_tag_similarity=Cast(
|
|
models.Count(
|
|
"externalblogpostpage__tags",
|
|
filter=models.Q(externalblogpostpage__tags__in=page_tags),
|
|
),
|
|
output_field=models.FloatField(),
|
|
)
|
|
/ divisor,
|
|
tag_similarity=models.F("_blog_tag_similarity")
|
|
+ models.F("_external_tag_similarity"),
|
|
)
|
|
|
|
similar_posts = similar_posts.annotate(
|
|
similarity=(models.F("tag_similarity") * 2)
|
|
+ (models.F("title_similarity") * 10)
|
|
).order_by("-similarity")[:3]
|
|
|
|
return similar_posts
|
|
|
|
|
|
class BlogPostTagListPage(BaseListingPage):
|
|
max_count = 1
|
|
parent_page_types = [BlogPostListPage]
|
|
subpage_types = ["blog.BlogPostTagPage"]
|
|
|
|
@cached_property
|
|
def table_of_contents(self) -> list[TocEntry]:
|
|
return [
|
|
TocEntry(page.title, page.slug, 0, []) for page in self.get_listing_pages()
|
|
]
|
|
|
|
|
|
class BlogPostTagPage(BaseListingPage):
|
|
subpage_types: list[Any] = []
|
|
parent_page_types = [BlogPostTagListPage]
|
|
|
|
@cached_property
|
|
def html_title(self) -> str:
|
|
return f"Pages tagged with '{super().html_title}'"
|
|
|
|
def get_listing_pages(self) -> models.QuerySet:
|
|
blog_list_page = BlogPostListPage.objects.get()
|
|
listing_pages = blog_list_page.get_listing_pages()
|
|
|
|
return listing_pages.filter(
|
|
models.Q(blogpostpage__tags=self)
|
|
| models.Q(externalblogpostpage__tags=self)
|
|
).distinct()
|
|
|
|
|
|
class BlogPostCollectionListPage(BaseListingPage):
|
|
subpage_types: list[Any] = []
|
|
parent_page_types = [BlogPostListPage]
|
|
max_count = 1
|
|
|
|
@cached_property
|
|
def table_of_contents(self) -> list[TocEntry]:
|
|
return [
|
|
TocEntry(page.title, page.slug, 0, []) for page in self.get_listing_pages()
|
|
]
|
|
|
|
def get_listing_pages(self) -> models.QuerySet:
|
|
blog_list_page = BlogPostListPage.objects.get()
|
|
return BlogPostCollectionPage.objects.child_of(blog_list_page).live().public()
|
|
|
|
|
|
class BlogPostCollectionPage(BaseListingPage):
|
|
parent_page_types = [BlogPostListPage]
|
|
subpage_types = [BlogPostPage]
|
|
|
|
def get_listing_pages(self) -> models.QuerySet:
|
|
return (
|
|
BlogPostPage.objects.child_of(self)
|
|
.live()
|
|
.public()
|
|
.order_by("-date", "title")
|
|
)
|
|
|
|
|
|
class ExternalBlogPostPage(BaseContentPage):
|
|
subpage_types: list[Any] = []
|
|
parent_page_types = [BlogPostListPage]
|
|
preview_modes: list[Any] = []
|
|
|
|
is_external = True
|
|
|
|
# Some `BaseContentPage` fields aren't relevant
|
|
body = None
|
|
subtitle = None
|
|
hero_image = None
|
|
hero_unsplash_photo = None
|
|
|
|
external_url = models.URLField()
|
|
|
|
tags = ParentalManyToManyField("blog.BlogPostTagPage", blank=True)
|
|
date = models.DateField(default=timezone.now)
|
|
|
|
content_panels = BasePage.content_panels + [FieldPanel("external_url")]
|
|
|
|
promote_panels = BaseContentPage.promote_panels + [
|
|
FieldPanel("date"),
|
|
AutocompletePanel("tags"),
|
|
]
|
|
|
|
search_fields = BaseContentPage.search_fields + [
|
|
index.RelatedFields("tags", [index.SearchField("title", boost=1)]),
|
|
index.SearchField("external_url"),
|
|
]
|
|
|
|
@cached_property
|
|
def tag_list_page_url(self) -> Optional[str]:
|
|
return SingletonPageCache.get_url(BlogPostTagListPage)
|
|
|
|
@cached_property
|
|
def tags_list(self) -> models.QuerySet:
|
|
"""
|
|
Use this to get a page's tags.
|
|
"""
|
|
tags = self.tags.order_by("slug")
|
|
|
|
# In drafts, `django-modelcluster` doesn't support these filters
|
|
if isinstance(tags, PageQuerySet):
|
|
return tags.public().live()
|
|
|
|
return tags
|
|
|
|
@cached_property
|
|
def metadata(self) -> MetadataParser:
|
|
return get_page_metadata(self.external_url)
|
|
|
|
@cached_property
|
|
def _body_html(self) -> str:
|
|
try:
|
|
return self.metadata.get_metadatas("description")[0]
|
|
except (KeyError, IndexError, TypeError):
|
|
return ""
|
|
|
|
@cached_property
|
|
def plain_text(self) -> str:
|
|
# The metadata is already just text
|
|
return self._body_html
|
|
|
|
def hero_url(
|
|
self, image_size: str, wagtail_image_spec_extra: Optional[str] = None
|
|
) -> Optional[str]:
|
|
try:
|
|
return self.metadata.get_metadatas("image")[0]
|
|
except (KeyError, IndexError, TypeError):
|
|
return None
|
|
|
|
@cached_property
|
|
def hero_image_url(self) -> str:
|
|
return ""
|
|
|
|
@cached_property
|
|
def hero_image_alt(self) -> str:
|
|
return ""
|
|
|
|
def get_meta_image_mime(self) -> Optional[str]:
|
|
return get_url_mime_type(self.hero_url(""))
|
|
|
|
def get_url(
|
|
self, request: HttpRequest | None = None, current_site: Site | None = None
|
|
) -> str:
|
|
return self.get_full_url(request)
|
|
|
|
def get_full_url(self, request: HttpRequest | None = None) -> str:
|
|
full_url = urlsplit(super().get_full_url(request))
|
|
return extend_query_params(self.external_url, {"utm_source": full_url.netloc})
|
|
|
|
def serve(self, request: HttpRequest, *args: tuple, **kwargs: dict) -> HttpResponse:
|
|
"""
|
|
Send the user directly to the external page
|
|
"""
|
|
return HttpResponsePermanentRedirect(self.get_full_url(request))
|