Wagtail Custom Admin: Views, Menu Items, and ModelAdmin
Wagtail's admin is extensible at every level — you can register non-page models as Snippets,
add custom menu items that link to your own views, inject dashboard panels, intercept page
lifecycle events with hooks, and replace the generic list/edit views with custom ones using
SnippetViewSet. This post works through each extension point with production-ready code.
1. Snippets — Non-Page Models in the Admin
Snippets are regular Django models that you want editors to manage through Wagtail's admin without them being full pages in the page tree. Common uses: authors, testimonials, navigation items, global settings, product categories.
# testimonials/models.py
from django.db import models
from wagtail.admin.panels import FieldPanel
from wagtail.fields import RichTextField
from wagtail.snippets.models import register_snippet
@register_snippet
class Testimonial(models.Model):
author = models.CharField(max_length=100)
role = models.CharField(max_length=100, blank=True)
company = models.CharField(max_length=100, blank=True)
quote = RichTextField(features=['bold', 'italic'])
rating = models.PositiveSmallIntegerField(default=5, choices=[(i, i) for i in range(1, 6)])
panels = [
FieldPanel('author'),
FieldPanel('role'),
FieldPanel('company'),
FieldPanel('quote'),
FieldPanel('rating'),
]
def __str__(self):
return f'{self.author} — {self.company}'
class Meta:
ordering = ['author']
The @register_snippet decorator adds the model to Wagtail's Snippets section.
Editors can create, edit, delete, and search testimonials. Use a
SnippetChooserBlock in StreamField or a SnippetChooserPanel on a
page to reference them.
2. SnippetViewSet — Custom List & Edit Views
SnippetViewSet (introduced in Wagtail 5.0, replacing the deprecated
ModelAdmin) lets you customise how a snippet appears in the admin — column
layout, search fields, filters, ordering, and custom actions:
# testimonials/views.py
from wagtail.snippets.views.snippets import SnippetViewSet, SnippetViewSetGroup
from wagtail.snippets.models import register_snippet
from .models import Testimonial, TestimonialCategory
class TestimonialViewSet(SnippetViewSet):
model = Testimonial
menu_label = 'Testimonials'
icon = 'openquote'
menu_order = 200
add_to_admin_menu = True
list_display = ['author', 'company', 'rating', 'updated_at']
list_filter = ['rating']
search_fields = ['author', 'company', 'quote']
ordering = ['-rating', 'author']
# Show a custom column with a star-rating display
list_display = ['author', 'company', 'star_rating']
def star_rating(self, obj):
return '★' * obj.rating + '☆' * (5 - obj.rating)
star_rating.short_description = 'Rating'
# Register via viewset instead of @register_snippet
register_snippet(Testimonial, viewset=TestimonialViewSet)
3. Custom Menu Items
Add items to Wagtail's left sidebar using the register_admin_menu_item hook:
from wagtail import hooks
from wagtail.admin.menu import MenuItem
@hooks.register('register_admin_menu_item')
def register_analytics_menu():
return MenuItem(
label = 'Analytics',
url = '/cms/analytics/',
icon_name = 'chart-bar',
order = 500, # lower number = higher position
)
@hooks.register('register_admin_menu_item')
def register_export_menu():
return MenuItem(
label = 'Export Content',
url = '/cms/export/',
icon_name = 'download-alt',
order = 600,
classnames= 'icon',
)
For grouped sub-menus, use SubmenuMenuItem with a list of child MenuItems.
Icon names come from Wagtail's built-in icon set — browse them at
/cms/styleguide/ in your running Wagtail instance.
4. Custom Admin Views
Register custom URLs inside the Wagtail admin URL namespace using register_admin_urls:
# myapp/wagtail_hooks.py
from django.urls import path
from wagtail import hooks
from . import admin_views
@hooks.register('register_admin_urls')
def register_admin_urls():
return [
path('analytics/', admin_views.analytics_view, name='analytics'),
path('export/', admin_views.export_view, name='export'),
path('export/run/', admin_views.run_export_view, name='export-run'),
]
# myapp/admin_views.py
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.views.generic import TemplateView
from wagtail.admin.auth import user_has_any_page_permission
from wagtail.admin.ui.tables import Column, DateColumn, Table
@login_required
def analytics_view(request):
from wagtail.admin.views.generic import WagtailAdminTemplateMixin
# Use Wagtail's template mixin for consistent chrome (nav, messages, breadcrumbs)
from wagtail.admin import messages
from django.shortcuts import render
# Your data here
stats = {
'total_pages': BlogPage.objects.live().count(),
'drafts': BlogPage.objects.filter(live=False).count(),
'published_today': BlogPage.objects.live().filter(
first_published_at__date=date.today()
).count(),
}
return render(request, 'admin/analytics.html', {'stats': stats})
Templates for custom admin views should extend wagtailadmin/base.html to
inherit the sidebar, header, and message system:
{# templates/admin/analytics.html #}
{% extends "wagtailadmin/base.html" %}
{% load i18n %}
{% block titletag %}Analytics{% endblock %}
{% block content %}
{% include "wagtailadmin/shared/header.html" with title="Analytics" icon="chart-bar" %}
<div class="nice-padding">
<div class="stats-row">
<div class="stat"><strong>{{ stats.total_pages }}</strong> live pages</div>
<div class="stat"><strong>{{ stats.drafts }}</strong> drafts</div>
<div class="stat"><strong>{{ stats.published_today }}</strong> published today</div>
</div>
</div>
{% endblock %}
5. Hooks — Lifecycle Events
Wagtail's hooks system lets you inject behaviour at defined points in page and admin lifecycles without monkeypatching or subclassing:
from wagtail import hooks
from django.http import HttpResponse
import requests
@hooks.register('before_publish_page')
def validate_seo_before_publish(request, page):
"""Block publication if SEO title or search description is missing."""
if not getattr(page, 'seo_title', None):
# Returning an HttpResponse aborts publication and shows the response to the editor
return HttpResponse(
'SEO title is required before publishing. '
'Fill in the Promote tab and try again.',
status=412
)
@hooks.register('after_publish_page')
def notify_slack_on_publish(request, page):
"""Post to Slack when a page is published."""
webhook = getattr(settings, 'SLACK_WEBHOOK_URL', None)
if not webhook:
return
try:
requests.post(webhook, json={
'text': f'Published: *{page.title}* — {page.full_url}'
}, timeout=3)
except requests.RequestException:
pass
@hooks.register('construct_explorer_page_queryset')
def order_pages_by_date(parent_page, pages, request):
"""Sort pages in the explorer by date descending (for blog post parents only)."""
from blog.models import BlogIndexPage
if isinstance(parent_page.specific, BlogIndexPage):
return pages.order_by('-first_published_at')
return pages
Key hooks to know:
before_publish_page— return a response to block, or None to allowafter_publish_page— post-publish side effects (notifications, cache busting)after_delete_page— cleanup on deleteconstruct_explorer_page_queryset— modify the page list in the explorerconstruct_page_listing_buttons— add action buttons to page rowsregister_rich_text_features— add custom rich text toolbar buttons
6. Admin Panels & Edit Handlers
Panels control how fields appear in the Wagtail admin edit form. The most useful ones:
from wagtail.admin.panels import (
FieldPanel, # Standard field widget
MultiFieldPanel, # Group fields with a collapsible heading
FieldRowPanel, # Fields side by side in a row
InlinePanel, # Related model inline (uses Orderable)
ObjectList, # Tab content wrapper
TabbedInterface, # Multiple tabs in the edit form
HelpPanel, # Read-only help text block
)
class ArticlePage(Page):
content_panels = Page.content_panels + [
MultiFieldPanel([
FieldRowPanel([
FieldPanel('author'),
FieldPanel('date'),
]),
FieldPanel('category'),
], heading='Article metadata', classname='collapsible'),
FieldPanel('intro'),
FieldPanel('body'),
InlinePanel('related_links', label='Related links', max_num=5),
]
promote_panels = Page.promote_panels + [
HelpPanel('Fill in SEO title and search description below.'),
]
settings_panels = Page.settings_panels + [
FieldPanel('show_in_menus'),
]
# Tabbed interface replacing the default three-column layout
edit_handler = TabbedInterface([
ObjectList(content_panels, heading='Content'),
ObjectList(promote_panels, heading='SEO'),
ObjectList(settings_panels, heading='Settings'),
])
7. Dashboard Panels
Add custom panels to the Wagtail admin dashboard homepage using
register_home_panel:
from wagtail import hooks
from wagtail.admin.ui.components import Component
class PublishingStatsPanel(Component):
name = 'publishing_stats'
template_name = 'admin/dashboard/publishing_stats.html'
order = 50 # position on dashboard (lower = higher)
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
context.update({
'live_pages': Page.objects.live().count(),
'total_pages': Page.objects.count(),
'recent': BlogPage.objects.live().order_by('-first_published_at')[:5],
})
return context
@hooks.register('construct_homepage_panels')
def add_publishing_stats_panel(request, panels):
panels.append(PublishingStatsPanel())
8. Permissions
Wagtail's permission system integrates with Django's groups. Assign editors to groups with page-level permissions through the admin. For custom admin views, check permissions manually:
from wagtail.admin.auth import user_passes_test
def is_analytics_user(user):
return user.has_perm('analytics.view_report') or user.is_superuser
@user_passes_test(is_analytics_user)
def analytics_view(request):
# ...
pass
For snippet-level permissions, use Django's standard model permissions:
app.add_testimonial, app.change_testimonial,
app.delete_testimonial. Assign these to groups in the Django admin
(/django-admin/auth/group/) and Wagtail's snippet views will enforce them
automatically.