From e19a2456e70769ea36a405268cace4a0a7cbe4c8 Mon Sep 17 00:00:00 2001 From: Jake Howard Date: Sat, 27 Aug 2022 12:21:13 +0100 Subject: [PATCH] Move listing functionality to generic base page --- website/blog/models.py | 148 +++++------------- .../blog/blog_post_collection_list_page.html | 10 +- .../blog/blog_post_collection_page.html | 10 +- .../templates/blog/blog_post_list_page.html | 6 +- .../blog/blog_post_tag_list_page.html | 10 +- .../templates/blog/blog_post_tag_page.html | 16 +- website/blog/views.py | 34 +--- website/common/models.py | 66 +++++++- .../common/templates/common/listing_page.html | 8 +- website/common/tests/test_pages.py | 9 +- website/common/views.py | 34 +++- website/home/models.py | 2 +- 12 files changed, 153 insertions(+), 200 deletions(-) diff --git a/website/blog/models.py b/website/blog/models.py index 44ef1ce..4069fd1 100644 --- a/website/blog/models.py +++ b/website/blog/models.py @@ -1,25 +1,18 @@ -from typing import Any +from typing import Any, Type -from django.core.paginator import EmptyPage -from django.core.paginator import Page as PaginatorPage -from django.core.paginator import Paginator from django.db import models from django.db.models.functions import TruncMonth -from django.http.request import HttpRequest -from django.http.response import Http404, HttpResponse, HttpResponseBadRequest from django.utils import timezone from django.utils.functional import cached_property from modelcluster.fields import ParentalManyToManyField from wagtail.admin.panels import FieldPanel -from wagtail.contrib.routable_page.models import RoutablePageMixin, route -from wagtail.query import PageQuerySet -from website.common.models import BaseContentPage -from website.common.serializers import PaginationSerializer +from website.common.models import BaseContentPage, BaseListingPage from website.common.utils import TocEntry +from website.common.views import ContentPageFeed -class BlogPostListPage(RoutablePageMixin, BaseContentPage): +class BlogPostListPage(BaseListingPage): max_count = 1 subpage_types = [ "blog.BlogPostPage", @@ -28,60 +21,36 @@ class BlogPostListPage(RoutablePageMixin, BaseContentPage): "blog.BlogPostCollectionPage", ] - @cached_property - def show_reading_time(self) -> bool: - return False - @cached_property def table_of_contents(self) -> list[TocEntry]: post_months = sorted( { dt.strftime("%Y-%m") - for dt in self.paginator_page.object_list.annotate( + for dt in self.get_paginator_page() + .object_list.annotate( post_month=TruncMonth("date", output_field=models.DateField()) - ).values_list("post_month", flat=True) + ) + .values_list("post_month", flat=True) } ) return [TocEntry(post_month, post_month, 0, []) for post_month in post_months] - def get_blog_posts(self) -> PageQuerySet: - return BlogPostPage.objects.descendant_of(self).live() - - @cached_property - def paginator_page(self) -> PaginatorPage: - pages = ( - self.get_blog_posts() + def get_listing_pages(self) -> models.QuerySet: + return ( + BlogPostPage.objects.descendant_of(self) + .live() .select_related("hero_image") .select_related("hero_unsplash_photo") .prefetch_related("tags") .order_by("-date", "title") ) - paginator = Paginator(pages, per_page=1) - 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["pages"] = self.paginator_page - return context - - @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: + @property + def feed_class(self) -> Type[ContentPageFeed]: from .views import BlogPostPageFeed - return BlogPostPageFeed( - self.get_blog_posts().order_by("-date"), self.get_url(), self.title - )(request) + return BlogPostPageFeed class BlogPostPage(BaseContentPage): @@ -97,66 +66,34 @@ class BlogPostPage(BaseContentPage): ] -class BlogPostTagListPage(BaseContentPage): +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_tags()] - - def get_tags(self) -> PageQuerySet: - return ( - self.get_children() - .specific() - .live() - .order_by("title") - .select_related("hero_image") - .select_related("hero_unsplash_photo") - ) - - def get_context(self, request: HttpRequest) -> dict: - context = super().get_context(request) - context["tags"] = self.get_children().specific().live().order_by("title") - return context + return [ + TocEntry(page.title, page.slug, 0, []) for page in self.get_listing_pages() + ] -class BlogPostTagPage(RoutablePageMixin, BaseContentPage): +class BlogPostTagPage(BaseListingPage): subpage_types: list[Any] = [] parent_page_types = [BlogPostTagListPage] - @cached_property - def table_of_contents(self) -> list[TocEntry]: - return [ - TocEntry(page.title, page.slug, 0, []) for page in self.get_blog_posts() - ] - - def get_blog_posts(self) -> PageQuerySet: + def get_listing_pages(self) -> models.QuerySet: blog_list_page = BlogPostListPage.objects.all().live().get() - return ( - blog_list_page.get_blog_posts() - .filter(tags=self) - .order_by("-date") - .select_related("hero_image") - .select_related("hero_unsplash_photo") - ) + return blog_list_page.get_listing_pages().filter(tags=self) - def get_context(self, request: HttpRequest) -> dict: - context = super().get_context(request) - context["pages"] = self.get_blog_posts() - return context - - @route(r"^feed/$") - def feed(self, request: HttpRequest) -> HttpResponse: + @property + def feed_class(self) -> Type[ContentPageFeed]: from .views import BlogPostPageFeed - return BlogPostPageFeed( - self.get_blog_posts().order_by("-date"), self.get_url(), self.title - )(request) + return BlogPostPageFeed -class BlogPostCollectionListPage(BaseContentPage): +class BlogPostCollectionListPage(BaseListingPage): subpage_types: list[Any] = [] parent_page_types = [BlogPostListPage] max_count = 1 @@ -164,38 +101,23 @@ class BlogPostCollectionListPage(BaseContentPage): @cached_property def table_of_contents(self) -> list[TocEntry]: return [ - TocEntry(page.title, page.slug, 0, []) for page in self.get_collections() + TocEntry(page.title, page.slug, 0, []) for page in self.get_listing_pages() ] - def get_collections(self) -> PageQuerySet: + def get_listing_pages(self) -> models.QuerySet: blog_list_page = BlogPostListPage.objects.all().live().get() return BlogPostCollectionPage.objects.child_of(blog_list_page).live() - def get_context(self, request: HttpRequest) -> dict: - context = super().get_context(request) - context["collections"] = self.get_collections() - return context - -class BlogPostCollectionPage(BaseContentPage): +class BlogPostCollectionPage(BaseListingPage): parent_page_types = [BlogPostListPage] subpage_types = [BlogPostPage] - @cached_property - def table_of_contents(self) -> list[TocEntry]: - return [ - TocEntry(page.title, page.slug, 0, []) for page in self.get_blog_posts() - ] + def get_listing_pages(self) -> models.QuerySet: + return super().get_listing_pages().order_by("-date") - def get_blog_posts(self) -> PageQuerySet: - return ( - BlogPostPage.objects.child_of(self) - .order_by("-date") - .select_related("hero_image") - .select_related("hero_unsplash_photo") - ) + @property + def feed_class(self) -> Type[ContentPageFeed]: + from .views import BlogPostPageFeed - def get_context(self, request: HttpRequest) -> dict: - context = super().get_context(request) - context["pages"] = self.get_blog_posts() - return context + return BlogPostPageFeed diff --git a/website/blog/templates/blog/blog_post_collection_list_page.html b/website/blog/templates/blog/blog_post_collection_list_page.html index 2ef36a3..434b95e 100644 --- a/website/blog/templates/blog/blog_post_collection_list_page.html +++ b/website/blog/templates/blog/blog_post_collection_list_page.html @@ -1,9 +1 @@ -{% extends "common/content_page.html" %} - -{% block post_content %} -
- {% for collection in collections %} - {% include "common/listing-item.html" with page=collection %} - {% endfor %} -
-{% endblock %} +{% extends "common/listing_page.html" %} diff --git a/website/blog/templates/blog/blog_post_collection_page.html b/website/blog/templates/blog/blog_post_collection_page.html index 06e1c0c..434b95e 100644 --- a/website/blog/templates/blog/blog_post_collection_page.html +++ b/website/blog/templates/blog/blog_post_collection_page.html @@ -1,9 +1 @@ -{% extends "common/content_page.html" %} - -{% block post_content %} -
- {% for page in pages %} - {% include "common/listing-item.html" %} - {% endfor %} -
-{% endblock %} +{% extends "common/listing_page.html" %} diff --git a/website/blog/templates/blog/blog_post_list_page.html b/website/blog/templates/blog/blog_post_list_page.html index 9987d84..3f31347 100644 --- a/website/blog/templates/blog/blog_post_list_page.html +++ b/website/blog/templates/blog/blog_post_list_page.html @@ -13,7 +13,7 @@ {% block post_content %}
- {% for page in pages %} + {% for page in listing_pages %} {% ifchanged %}
- {% if pages.has_other_pages %} + {% if listing_pages.has_other_pages %}

- {% include "common/pagination.html" %} + {% include "common/pagination.html" with pages=listing_pages %}
{% endif %} {% endblock %} diff --git a/website/blog/templates/blog/blog_post_tag_list_page.html b/website/blog/templates/blog/blog_post_tag_list_page.html index 531b0cc..434b95e 100644 --- a/website/blog/templates/blog/blog_post_tag_list_page.html +++ b/website/blog/templates/blog/blog_post_tag_list_page.html @@ -1,9 +1 @@ -{% extends "common/content_page.html" %} - -{% block post_content %} -
- {% for tag in tags %} - {% include "common/listing-item.html" with page=tag %} - {% endfor %} -
-{% endblock %} +{% extends "common/listing_page.html" %} diff --git a/website/blog/templates/blog/blog_post_tag_page.html b/website/blog/templates/blog/blog_post_tag_page.html index 7a35321..434b95e 100644 --- a/website/blog/templates/blog/blog_post_tag_page.html +++ b/website/blog/templates/blog/blog_post_tag_page.html @@ -1,15 +1 @@ -{% extends "common/content_page.html" %} - -{% load wagtailroutablepage_tags %} - -{% block extra_css %} - -{% endblock %} - -{% block post_content %} -
- {% for page in pages %} - {% include "common/listing-item.html" %} - {% endfor %} -
-{% endblock %} +{% extends "common/listing_page.html" %} diff --git a/website/blog/views.py b/website/blog/views.py index b99a3f6..bdbc07f 100644 --- a/website/blog/views.py +++ b/website/blog/views.py @@ -1,40 +1,10 @@ from datetime import datetime, time -from django.contrib.syndication.views import Feed -from django.http.request import HttpRequest -from django.http.response import HttpResponse -from wagtail.query import PageQuerySet +from website.common.views import ContentPageFeed from .models import BlogPostPage -class BlogPostPageFeed(Feed): - def __init__(self, posts: PageQuerySet, link: str, title: str): - self.posts = posts - self.link = link - self.title = title - super().__init__() - - def __call__( - self, request: HttpRequest, *args: list, **kwargs: dict - ) -> HttpResponse: - self.request = request - return super().__call__(request, *args, **kwargs) - - def items(self) -> PageQuerySet: - return self.posts - - def item_title(self, item: BlogPostPage) -> str: - return item.title - - def item_link(self, item: BlogPostPage) -> str: - return item.get_full_url(request=self.request) - - def item_description(self, item: BlogPostPage) -> str: - return item.summary - +class BlogPostPageFeed(ContentPageFeed): def item_pubdate(self, item: BlogPostPage) -> datetime: return datetime.combine(item.date, time()) - - def item_updateddate(self, item: BlogPostPage) -> datetime: - return item.last_published_at diff --git a/website/common/models.py b/website/common/models.py index a33d1e0..238de52 100644 --- a/website/common/models.py +++ b/website/common/models.py @@ -1,13 +1,19 @@ from datetime import timedelta -from typing import Any, Optional +from typing import Any, Optional, Type +from django.contrib.syndication.views import Feed from django.core.cache import cache +from django.core.paginator import EmptyPage +from django.core.paginator import Page as PaginatorPage +from django.core.paginator import Paginator from django.db import models from django.dispatch import receiver from django.http.request import HttpRequest +from django.http.response import Http404, HttpResponse, HttpResponseBadRequest from django.utils.functional import cached_property, classproperty from django.utils.text import slugify from wagtail.admin.panels import FieldPanel +from wagtail.contrib.routable_page.models import RoutablePageMixin, route from wagtail.fields import StreamField from wagtail.images import get_image_model_string from wagtail.images.views.serve import generate_image_url @@ -19,6 +25,7 @@ from wagtailmetadata.models import MetadataMixin from website.common.utils import count_words 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, extract_text, get_table_of_contents, truncate_string @@ -161,18 +168,67 @@ class ContentPage(BaseContentPage): subpage_types: list[Any] = [] -class ListingPage(BaseContentPage): - def get_context(self, request: HttpRequest) -> dict: - context = super().get_context(request) - context["child_pages"] = ( +class BaseListingPage(RoutablePageMixin, BaseContentPage): + PAGE_SIZE = 20 + + class Meta: + abstract = True + + def get_listing_pages(self) -> models.QuerySet: + return ( self.get_children() .live() .specific() .select_related("hero_image") .select_related("hero_unsplash_photo") + .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: + raise Http404 + + def get_context(self, request: HttpRequest) -> dict: + context = super().get_context(request) + context["listing_pages"] = self.get_paginator_page() return context + @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 + @register_snippet class ReferralLink(models.Model, index.Indexed): diff --git a/website/common/templates/common/listing_page.html b/website/common/templates/common/listing_page.html index 69e6f4b..6728f91 100644 --- a/website/common/templates/common/listing_page.html +++ b/website/common/templates/common/listing_page.html @@ -1,8 +1,14 @@ {% extends "common/content_page.html" %} +{% load wagtailroutablepage_tags %} + +{% block extra_css %} + +{% endblock %} + {% block post_content %}
- {% for page in child_pages %} + {% for page in listing_pages %} {% include "common/listing-item.html" %} {% endfor %}
diff --git a/website/common/tests/test_pages.py b/website/common/tests/test_pages.py index 6a65edc..9d7383e 100644 --- a/website/common/tests/test_pages.py +++ b/website/common/tests/test_pages.py @@ -54,8 +54,13 @@ class ListingPageTestCase(TestCase): def test_accessible(self) -> None: response = self.client.get(self.page.url) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.context["child_pages"]), 2) + self.assertEqual(len(response.context["listing_pages"]), 2) + + def test_feed_accessible(self) -> None: + response = self.client.get(self.page.url + self.page.reverse_subpage("feed")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/rss+xml; charset=utf-8") def test_queries(self) -> None: - with self.assertNumQueries(24): + with self.assertNumQueries(25): self.client.get(self.page.url) diff --git a/website/common/views.py b/website/common/views.py index c3cc4fe..16894d5 100644 --- a/website/common/views.py +++ b/website/common/views.py @@ -12,7 +12,7 @@ from wagtail.query import PageQuerySet from website.home.models import HomePage -from .models import BasePage +from .models import BaseContentPage, BasePage class Error404View(TemplateView): @@ -71,3 +71,35 @@ class AllPagesFeed(Feed): def item_updateddate(self, item: BasePage) -> datetime: return item.last_published_at + + +class ContentPageFeed(Feed): + def __init__(self, posts: PageQuerySet, link: str, title: str): + self.posts = posts + self.link = link + self.title = title + super().__init__() + + def __call__( + self, request: HttpRequest, *args: list, **kwargs: dict + ) -> HttpResponse: + self.request = request + return super().__call__(request, *args, **kwargs) + + def items(self) -> PageQuerySet: + return self.posts + + def item_title(self, item: BaseContentPage) -> str: + return item.title + + def item_link(self, item: BaseContentPage) -> str: + return item.get_full_url(request=self.request) + + def item_description(self, item: BaseContentPage) -> str: + return item.summary + + def item_pubdate(self, item: BaseContentPage) -> datetime: + return item.first_published_at + + def item_updateddate(self, item: BaseContentPage) -> datetime: + return item.last_published_at diff --git a/website/home/models.py b/website/home/models.py index e036c85..ea4568c 100644 --- a/website/home/models.py +++ b/website/home/models.py @@ -5,7 +5,6 @@ from wagtail.images import get_image_model_string from wagtail.images.models import Image from wagtailmetadata.models import WagtailImageMetadataMixin -from website.blog.models import BlogPostPage from website.common.models import BasePage @@ -38,6 +37,7 @@ class HomePage(BasePage, WagtailImageMetadataMixin): return "" def get_context(self, request: HttpRequest) -> dict: + from website.blog.models import BlogPostPage from website.search.models import SearchPage context = super().get_context(request)