Move listing functionality to generic base page

This commit is contained in:
Jake Howard 2022-08-27 12:21:13 +01:00
parent 655d3a484e
commit e19a2456e7
Signed by: jake
GPG Key ID: 57AFB45680EDD477
12 changed files with 153 additions and 200 deletions

View File

@ -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

View File

@ -1,9 +1 @@
{% extends "common/content_page.html" %}
{% block post_content %}
<section class="container">
{% for collection in collections %}
{% include "common/listing-item.html" with page=collection %}
{% endfor %}
</section>
{% endblock %}
{% extends "common/listing_page.html" %}

View File

@ -1,9 +1 @@
{% extends "common/content_page.html" %}
{% block post_content %}
<section class="container">
{% for page in pages %}
{% include "common/listing-item.html" %}
{% endfor %}
</section>
{% endblock %}
{% extends "common/listing_page.html" %}

View File

@ -13,7 +13,7 @@
{% block post_content %}
<section class="container">
{% for page in pages %}
{% for page in listing_pages %}
{% ifchanged %}
<time datetime="{{ page.date|date:'Y-m' }}" title='{{ page.date|date:"F Y" }}'>
<h3 id="{{ page.date|date:'Y-m' }}" class="date-header">{{ page.date|date:"Y-m" }}</h3>
@ -24,10 +24,10 @@
{% endfor %}
</section>
{% if pages.has_other_pages %}
{% if listing_pages.has_other_pages %}
<section class="container">
<hr class="my-5" />
{% include "common/pagination.html" %}
{% include "common/pagination.html" with pages=listing_pages %}
</section>
{% endif %}
{% endblock %}

View File

@ -1,9 +1 @@
{% extends "common/content_page.html" %}
{% block post_content %}
<section class="container">
{% for tag in tags %}
{% include "common/listing-item.html" with page=tag %}
{% endfor %}
</section>
{% endblock %}
{% extends "common/listing_page.html" %}

View File

@ -1,15 +1 @@
{% extends "common/content_page.html" %}
{% load wagtailroutablepage_tags %}
{% block extra_css %}
<link rel="alternate" type="application/rss+xml" href="{% routablepageurl page 'feed' %}">
{% endblock %}
{% block post_content %}
<section class="container">
{% for page in pages %}
{% include "common/listing-item.html" %}
{% endfor %}
</section>
{% endblock %}
{% extends "common/listing_page.html" %}

View File

@ -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

View File

@ -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):

View File

@ -1,8 +1,14 @@
{% extends "common/content_page.html" %}
{% load wagtailroutablepage_tags %}
{% block extra_css %}
<link rel="alternate" type="application/rss+xml" href="{% routablepageurl page 'feed' %}">
{% endblock %}
{% block post_content %}
<section class="container">
{% for page in child_pages %}
{% for page in listing_pages %}
{% include "common/listing-item.html" %}
{% endfor %}
</section>

View File

@ -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)

View File

@ -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

View File

@ -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)