Wagtail Page URLs with extra parts

Wagtail organises its pages in a tree. So the page URLs are based on the given page position and slug. Let’s imagine that you run a podcast aggregation site, with podcast pages and podcast episode pages:

/
├─ about/
├─ the-podcast/  <-- PodcastPage
│  ├─ hello-world/ <-- PodcastEpisodePage
...

The path for the “Hello world” episode is https://example.com/the-podcast/hello-world.

Your page models may look like:

from wagtail.models import Page


class PodcastPage(Page):
    subpage_types = ["PodcastEpisodePage"]


class PodcastEpisodePage(Page):
    parent_page_types = ["PodcastPage"]

Now, you are asked to change the URL structure so that the episodes are prefixed with /editions/. So https://example.com/the-podcast/hello-world would become https://example.com/the-podcast/editions/hello-world. In a traditional Wagtail way, you could create a placeholder page model PodcastEditionsPage that sits under PodcastPage and the episode pages get created under it:

class PodcastPage(Page):
    subpage_types = ["PodcastEditionsPage"]


class PodcastEditionsPage(Page):
    parent_page_types = ["PodcastEditionsPage"]
    subpage_types = ["PodcastEpisodePage"]
    max_count_per_parent = 1


class PodcastEpisodePage(Page):
    parent_page_types = ["PodcastEditionsPage"]

For whatever reasons, the site editors do not like this setup (e.g. it is too much work to start a new podcast section).

To solve this, we can make use of RoutablePageMixin which can help with serving content under defined paths. So https://example.com/the-podcast/editions/ would be a sub-url for the podcast page (https://example.com/the-podcast/). But the episode pages would still use the old format. Looking through the Wagtail documentation Page.get_url_parts() looks interesting. In short, it is used internally to generate the page URL:

from typing import TYPE_CHECKING, Optional

from wagtail.models import Page
from wagtail.contrib.routable_page.models import RoutablePageMixin, path

if TYPE_CHECKING:
    from django.http import HttpRequest
    from django.template.response import TemplateResponse


class PodcastPage(RoutablePageMixin, Page):
    subpage_types = ["PodcastEpisodePage"]

    @path("edition/<str:slug>/")
    def edition(self, request: "HttpRequest", slug: str) -> "TemplateResponse":

        if not (edition := PodcastEpisodePage.objects.live().child_of(self).filter(slug=slug).first()):
            raise Http404

        response: "TemplateResponse" = edition.serve(request)
        return response


class PodcastEpisodePage(Page):
    parent_page_types = ["PodcastPage"]

    def get_url_parts(self, request: Optional["HttpRequest"] = None):
        site_id, root_url, page_path = super().get_url_parts(request=request)

        # inject the "edition" slug before the page slug in the path
        # works in conjunction with PodcastPage.edition()
        split = page_path.strip("/").split("/")
        split.insert(-1, "edition")
        page_path = "/".join(["", *split, ""])

        return (site_id, root_url, page_path)

This takes care of the requirement for episode page paths to be prefixed /editions/ without the intermediate page type. But the simple path is still available and if we want to prevent that, this can be extended to:

from typing import TYPE_CHECKING, Optional

from django.shortcuts import redirect
from wagtail.models import Page
from wagtail.contrib.routable_page.models import RoutablePageMixin, path

if TYPE_CHECKING:
    from django.http import HttpRequest
    from django.template.response import TemplateResponse


class PodcastPage(RoutablePageMixin, Page):
    subpage_types = ["PodcastEpisodePage"]

    @path("edition/<str:slug>/")
    def edition(self, request: "HttpRequest", slug: str) -> "TemplateResponse":

        if not (edition := PodcastEpisodePage.objects.live().child_of(self).filter(slug=slug).first()):
            raise Http404

        response: "TemplateResponse" = edition.serve(request, serve_as_edition=True)
        return response


class PodcastEpisodePage(Page):
    parent_page_types = ["PodcastPage"]

    def get_url_parts(self, request: Optional["HttpRequest"] = None):
        site_id, root_url, page_path = super().get_url_parts(request=request)

        # inject the "edition" slug before the page slug in the path
        # works in conjunction with PodcastPage.edition()
        split = page_path.strip("/").split("/")
        split.insert(-1, "edition")
        page_path = "/".join(["", *split, ""])

        return (site_id, root_url, page_path)

    def serve(self, request: "HttpRequest", *args, **kwargs) -> "HttpResponse":
        if not kwargs.get("serve_as_edition"):
            # if for some reason we're getting the non-editioned path
            # redirect to the path with the /edition/ slug
            page_url = self.get_url(request=request)
            if page_url != request.path:
                return redirect(page_url)


        return super().serve(request, *args, **kwargs)

And that should do the trick.

May your site handle odd path structures!