Table of Contents
What we're solving
In Wagtail, pages don't have a typical, direct relationship like you'd expect in Django. Instead of using foreign keys to link them, each page has a `path` and a `depth` attribute, which Wagtail uses to determine the page's relative positions.
Because of this, you can't do a typical Django filtering query like:
# Doesn't work on standard Wagtail Page models
BlogPag.objects.filter(parent_page=some_page)
Instead, you have to use a queryset method provided by Wagtail:
BlogPage.objects.child_of(some_page)
While that might not seem like a big deal at first glance, it can introduce frustrating limitations when you require complex querying between page models. For example, let's say you wanted to retrieve all index pages for your blog posts with a certain tag. With a typical Django foreign key relationship, this is as simple as:
# Doesn't work on standard Wagtail Page models
BlogIndexPage.object.filter(child_pages__tags__slug="python")
Before we begin
I wouldn't recommend implementing these relationships for all pages across your whole site unless you know what you're doing. I also wouldn't recommend doing this unless you have need for complex querying between parent/child pages. If you're just looking to use this as a more familiar alternative to Wagtail's build-in queryset methods, then you're going out of your way to work against the framework instead of with it.
Adding a foreign key relationship between Wagtail pages
We're going to add a foreign key relationship between pages and then use hooks to keep the relationships up-to-date. We'll also need to write a custom migration to populate the new field for existing pages. But firstly, creating the relationship:
# blog/models.py
# Where 'BlogPage' is the child page and 'BlogIndexPage' is the parent.
class BlogPage(Page):
parent_page = models.ForeignKey(
"Blog.BlogIndexPage",
on_delete=models.SET_NULL,
related_name="child_pages",
editable=False,
null=True,
# db_index=True, # Optional, but if you're going to query based on this field a lot, it'll help performance
)
You can call the attributes whatever you like, but to keep things generalised for this example, I've gone with parent_page and child_pages.
You'll notice that on_delete is SET_NULL. This is required because using CASCADE or PROTECT causes issues with Wagtail's page systems. SET_NULL is fine to use because if the parent page is deleted, Wagtail will cascade this to the child pages anyway.
Once you've done this, you'll need to make migrations.
python run ./manage.py makemigrations
If you already have existing pages in your database, the make migrations command will ask you for a default. Instead of providing a default, I'd recommend either writing the migration yourself or making the field nullable first, creating migrations, then removing the null support. This will allow the migrations to be generated, and then you can move onto the next step, where we populate existing records.
Creating a migration to populate existing records
The following migration is an example of what you'll need to populate the new relationships for existing pages. You'll see that we first create the field, then we run a function to populate existing records:
import django.db.models.deletion
from django.db import migrations, models
def populate_field(apps, schema_editor):
BlogIndexPage = apps.get_model("blog", "BlogIndexPage")
BlogPage = apps.get_model("blog", "BlogPage")
for blog in BlogPage.objects.all():
index = BlogIndexPage.objects.parent_of(blog).first()
if index is None:
raise Exception(
f"Could not find parent BlogIndexPage for BlogPage id={blog.id}"
)
blog.parent_page = index
blog.save(update_fields=["parent_page"])
class Migration(migrations.Migration):
dependencies = [
("blog", "xxxx_previous_migrations"),
]
operations = [
migrations.AddField(
model_name="blogpage",
name="parent_page",
field=models.ForeignKey(
editable=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="child_pages",
to="blog.blogindexpage",
),
),
]
If it's a brand new table in the database without any records, you can simplify this to just be the AddField step.
Keeping the new relationship up-to-date
From here, we just need to add a new method to the BlogPage to update the parent_page field. Then, using some Wagtail hooks, we can update this field whenever a BlogPage or a BlogIndexPage is modified.
It's important to note that I'm using a task to update the page parent. Doing this ensures that Wagtail's other page tree index jobs run first before we try to update the page parent. The overall process then is that the BlogPage model has a method for updating its parent, we have the task that can be enqueued to run that method for any given BlogPage or BlogIndexPage (to update all children of that index), and we use Wagtail's hooks to enqueue the tasks whenever page modifications are made.
# blog/models.py
class BlogPage(Page):
# .... Rest of the model
def update_parent_page_field(self, save: bool = True):
"""Updates the parent_page field to match the parent page within Wagtail's page tree."""
parent = self.get_parent().specific
if not parent:
raise ValidationError("Parent page not found.")
self.parent_page = parent
if save:
self.save(update_fields=["parent_page"])
# blog/services.py
from wagtail.models import Page
from .models import BlogIndexPage, BlogPage
@task()
def update_page_relationships(page_id: int) -> None:
"""Task for updating the page parent relationships when a page is modified.
This is handled as a background task as it allows Wagtail's publish/move operations to complete first & faster.
"""
if type(page_id) is not int:
logger.error(f"Invalid page ID type: {type(page_id)}. Expected int.")
return
page = Page.objects.filter(id=page_id).first()
if not page:
logger.error(f"Page with ID {page_id} not found.")
return
page = page.specific
if isinstance(page, BlogIndexPage):
index = page
child_pages = BlogPage.objects.child_of(index).all()
related_pages = BlogPage.objects.filter(parent_page=index).all()
all_child_pages = (child_pages | related_pages).distinct()
for related_page in related_pages.iterator(100):
try:
page.update_page_parent_field()
logger.debug(
f"Updated parent page field for BlogPage {related_page.id} related to BlogPageIndex {index.id}"
)
except Exception as e:
logger.error(
f"Error updating parent_page field for BlogPage {related_page.id}: {e}"
)
elif isinstance(page, BlogPage):
page.update_page_parent_field()
logger.debug(
f"Updated parent page field for BlogPage {page.id} related to BlogIndexPage {page.parent_page.id}"
)
# blog/wagtail_hooks.py
from wagtail import hooks
from .services import update_page_relationships
@hooks.register("after_delete_page")
@hooks.register("after_publish_page")
@hooks.register("after_move_page")
def update_parent_pages(request, page, *args, **kwargs) -> None:
"""Updates BlogIndexPages and BlogPages when they're modified."""
page = page.specific
if isinstance(page, (BlogIndexPage, BlogPage)):
update_page_relationships.enqueue(page.id)
@hooks.register("after_bulk_action")
def bulk_update_parent_pages(
request, action_type, pages, *args, **kwargs
) -> None:
"""Updates BlogIndexPage and BlogPages after a bulk action."""
if action_type != "move":
return
for page in pages:
page = page.specific
if isinstance(page, (BlogIndexPage, BlogPage)):
update_page_relationships.enqueue(page.id)