Wagtail Python Admin

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)
WAGTAIL ADMIN EXTENSION POINTS Snippets @register_snippet SnippetViewSet Menu Items MenuItem register_admin_menu_item Custom Views register_admin_urls AdminViewMixin Hooks before/after_publish construct_* Dashboard register_home_panel DashboardPanel All extension points use @hooks.register() — no subclassing of core admin classes needed
Wagtail's five admin extension points. All use the hooks system, keeping your app code decoupled from Wagtail internals.

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 allow
  • after_publish_page — post-publish side effects (notifications, cache busting)
  • after_delete_page — cleanup on delete
  • construct_explorer_page_queryset — modify the page list in the explorer
  • construct_page_listing_buttons — add action buttons to page rows
  • register_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.