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)