Custom Wagtail Page Models Development
Page Models are the heart of Wagtail. Each page type is a Django model inheriting from Page. Type defines fields, admin editor, template, and routes.
Basic Page Model Structure
from django.db import models
from wagtail.models import Page, Orderable
from wagtail.fields import RichTextField, StreamField
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel, TabbedInterface, ObjectList
from wagtail.search import index
from modelcluster.fields import ParentalKey
class ServicePage(Page):
# Fields
intro = models.CharField(max_length=500, blank=True)
body = RichTextField(blank=True)
icon = models.ForeignKey(
'wagtailimages.Image',
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='+',
)
price_from = models.DecimalField(max_digits=10, decimal_places=0, null=True, blank=True)
# Search indexing
search_fields = Page.search_fields + [
index.SearchField('intro'),
index.SearchField('body'),
index.FilterField('price_from'),
]
# Editor interface — with tabs
content_panels = Page.content_panels + [
FieldPanel('intro'),
FieldPanel('icon'),
FieldPanel('body'),
InlinePanel('features', label='Features'),
]
promote_panels = [
MultiFieldPanel([
FieldPanel('slug'),
FieldPanel('seo_title'),
FieldPanel('search_description'),
], heading='SEO'),
]
pricing_panels = [
FieldPanel('price_from'),
InlinePanel('pricing_tiers', label='Pricing Tiers'),
]
edit_handler = TabbedInterface([
ObjectList(content_panels, heading='Content'),
ObjectList(pricing_panels, heading='Pricing'),
ObjectList(promote_panels, heading='SEO'),
])
# Hierarchy constraints
parent_page_types = ['services.ServicesIndexPage']
subpage_types = []
class Meta:
verbose_name = 'Service Page'
class ServiceFeature(Orderable):
page = ParentalKey(ServicePage, on_delete=models.CASCADE, related_name='features')
title = models.CharField(max_length=255)
text = models.TextField(blank=True)
icon = models.CharField(max_length=50, blank=True) # icon name
panels = [
FieldPanel('title'),
FieldPanel('text'),
FieldPanel('icon'),
]
RoutablePage — Multiple URLs Per Page
from wagtail.contrib.routable_page.models import RoutablePage, path, re_path
class BlogIndexPage(RoutablePage, Page):
intro = RichTextField(blank=True)
content_panels = Page.content_panels + [FieldPanel('intro')]
subpage_types = ['blog.BlogPost']
@path('')
def index_view(self, request):
posts = BlogPost.objects.live().child_of(self).order_by('-first_published_at')
from django.core.paginator import Paginator
paginator = Paginator(posts, 12)
return self.render(request, context_overrides={
'posts': paginator.get_page(request.GET.get('page')),
})
@path('category/<str:category_slug>/')
def category_view(self, request, category_slug):
category = get_object_or_404(BlogCategory, slug=category_slug)
posts = BlogPost.objects.live().child_of(self).filter(categories=category)
return self.render(request, context_overrides={
'posts': posts,
'category': category,
}, template='blog/blog_category.html')
@path('tag/<str:tag>/')
def tag_view(self, request, tag):
posts = BlogPost.objects.live().child_of(self).filter(tags__slug=tag)
return self.render(request, context_overrides={
'posts': posts,
'tag': tag,
})
@re_path(r'^archive/(\d{4})/$')
def year_archive(self, request, year):
posts = BlogPost.objects.live().child_of(self).filter(date__year=year)
return self.render(request, context_overrides={
'posts': posts,
'year': year,
})
Page with Custom Images
# core/models.py
from wagtail.images.models import AbstractImage, AbstractRendition, Image
class CustomImage(AbstractImage):
alt_text = models.CharField(max_length=255, blank=True)
credit = models.CharField(max_length=255, blank=True)
admin_form_fields = Image.admin_form_fields + [
'alt_text',
'credit',
]
@property
def default_alt_text(self):
return self.alt_text or self.title
class CustomRendition(AbstractRendition):
image = models.ForeignKey(CustomImage, on_delete=models.CASCADE, related_name='renditions')
class Meta:
unique_together = [('image', 'filter_spec', 'focal_point_key')]
get_context — Passing Data to Template
def get_context(self, request):
context = super().get_context(request)
# Additional data
context['related_posts'] = BlogPost.objects.live().exclude(
pk=self.pk
).filter(
categories__in=self.categories.all()
).distinct().order_by('-first_published_at')[:3]
# Comment form
context['comment_form'] = CommentForm()
# Data from GET parameters
context['current_tag'] = request.GET.get('tag')
return context
Revisions and Drafts
Page Models automatically support revisions and workflow. To enable, only RevisionMixin is needed (already in base Page). Configure stored revisions count:
WAGTAIL_WORKFLOW_MAX_LOCK_EXPIRY_DAYS = 14
# In the model
class BlogPost(Page):
MAX_NUM_REVIEWERS = 3
Developing 5–7 Page Models with RoutablePage and InlinePanel — 3–6 days.







