Creating custom Wagtail actions
The as-yet-undocumented method for adding custom items to a Wagtail save/publish menu.

When editing pages in Wagtail, there's a green drop-up button at the bottom of the page where all the actions for saving and publishing are found.
I'm working on a Wagtail site with a newsletter component, and because I'm me I didn't want to use the pre-existing Wagtail Newsletter package.
Traversing the Wagtail source code I was able to piece together how to create an action menu item â something which doesn't seem to be documented anywhere â so I thought it'd be useful to set it down here.
I'm not making any claims about the accuracy or security of this... it's just essentially a log of what I found. Definitely open to hearing better approaches.
That said, I've tried to set this out in the order someone else might want to do it, rather than the order I did it, as that was all over the place.
Register the log entry
When your action's performed, you'll want to log it. That means defining what the log entry will look like. We do that in wagtail_hooks.py
.
from wagtail import hooks
@hooks.register("register_log_actions")
def register_log_actions(actions):
actions.register_action(
"newsletter.send",
"Newsletter: send campaign",
"Newsletter: Campaign sent"
)
From the little I've gathered, the first argument is effectively the "codename" (in Django-permissions terms) of the action, the next one is the verb, and the last is the past-tense description.
Define the action
In your app, create an actions.py
file, with something like this:
from django.core.exceptions import PermissionDenied
from wagtail.log_actions import log
import logging
logger = logging.getLogger("wagtail")
class SendPermissionError(PermissionDenied):
pass
class SendAction:
def __init__(
self,
object,
commit=True,
user=None,
log_action=True,
):
self.object = object
self.commit = commit
self.user = user
self.log_action = log_action
def check(self, skip_permission_checks=False):
if (
self.user
and not skip_permission_checks
and not self.object.permissions_for_user(self.user).can_send()
):
raise SendPermissionError(
"You do not have permission to send this object"
)
def _commit_send(self, object):
# This is where you actually do the thing you want to do
# (in my case, it'll be to send an email).
pass
def _send_object(self, object, commit, user, log_action):
if commit:
self._commit_send(object)
if log_action:
log(
instance=object,
action=(
isinstance(log_action, str) and
log_action or
"newsletter.send"
),
user=user
)
logger.info(
'Queued for sending: "%s" pk=%s',
str(object), str(object.pk)
)
def execute(self, skip_permission_checks=False):
self.check(skip_permission_checks=skip_permission_checks)
self._send_object(
self.object,
commit=self.commit,
user=self.user,
log_action=self.log_action
)
Define the view for performing the action
That's the business logic taken care of. Now to create a Wagtail-compliant view that'll perform the above action:
from django.contrib.admin.utils import quote, unquote
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.views.generic import TemplateView
from wagtail.admin import messages
from wagtail.admin.utils import get_latest_str, get_valid_next_url_from_request
from wagtail.admin.views.generic.base import WagtailAdminTemplateMixin
from wagtail.admin.views.generic.mixins import HookResponseMixin
from .actions import SendAction # The action we just created
from .models import IssuePage # The model you want to perform actions on
class SendIssuePageView(
HookResponseMixin, WagtailAdminTemplateMixin, TemplateView
):
page_title = "Send to recipients" # Change as appropriate
success_message = "'%(object)s' queued to be sent." # Change as appropriate
template_name = "newsletter/admin/send_form.html" # Change as appropriate
def setup(self, request, pk, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.pk = pk
self.object = self.get_object()
def dispatch(self, request, *args, **kwargs):
self.objects_to_send = self.get_objects_to_send()
return super().dispatch(request, *args, **kwargs)
def get_object(self, queryset=None):
return get_object_or_404(IssuePage, pk=unquote(str(self.pk)))
def get_breadcrumbs_items(self):
return []
def get_objects_to_send(self):
return [self.object]
def get_page_subtitle(self):
return get_latest_str(self.object)
def get_success_message(self):
return self.success_message % {
"object": str(self.object)
}
def get_edit_url(self):
return reverse(
"wagtailadmin_pages:edit",
args=(quote(self.object.pk),)
)
def get_next_url(self):
if next_url := get_valid_next_url_from_request(self.request):
return next_url
return reverse(
"wagtailadmin_pages:edit",
args=(self.object.pk,)
)
def get_send_url(self):
return reverse(
"newsletter:send",
args=(quote(self.object.pk),)
)
def get_success_buttons(self):
return [
messages.button(self.get_edit_url(), "Edit")
]
def send(self):
for object in self.objects_to_send:
action = SendAction(object, user=self.request.user)
action.execute(skip_permission_checks=True)
def post(self, request, *args, **kwargs):
if hook_response := self.send():
return hook_response
success_buttons = self.get_success_buttons()
messages.success(
request,
self.get_success_message(),
buttons=success_buttons
)
return redirect(self.get_next_url())
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["model_opts"] = self.object._meta
context["object"] = self.object
context["send_url"] = self.get_send_url()
context["next_url"] = self.get_next_url()
return context
In most cases you'll want to substitute the word "send" for whatever your action is. I was borrowing this vernacular from the Wagtail view used to unpublished pages.
Create the form template
You'll need to create a template in your app with a name following the newsletter/admin/send_form.html
convention (again substituting the app name "newsletter" and verb "send" for whatever you need; see the view definition above for the template name the view is expecting, and change as appropriate).
My template, again borrowing from the "unpublished" action currently looks like this:
{% extends "wagtailadmin/generic/base.html" %}
{% load i18n wagtailadmin_tags %}
{% block main_content %}
{% block confirmation_text %}
<p>{% trans "Send this page to newsletter subscribers?" %}</p>
{% endblock %}
<form action="{{ unpublish_url }}" method="POST">
<input type="hidden" name="next" value="{{ next_url }}">
<div>
<button class="button" type="submit">{% trans "Yes, send it" %}</button>
<a href="{{ next_url }}" class="button button-secondary">{% trans "No, don't send" %}</a>
</div>
{% csrf_token %}
</form>
{% endblock main_content %}
If there's specific stuff you need to do other than confirm an action, you'll want to adapt the post()
method in the view to take care of that, and potentially swap out the TemplateView
mixing for a FormViewMixin
or UpdateViewMixin
, whichever's going to get the job done.
OK, we're about halfway through, but there's more boilerplate to come.
Register the URL
Now the business logic, view, and template are defined, we can expose them to Wagtail's routing engine. Go back to wagtail_hooks.py
and merge the following into it:
from django.urls import include, path
from .views import SendIssuePageView # The view we've defined
@hooks.register("register_admin_urls") # type: ignore
def register_admin_urls():
urls = [
path(
"pages/<int:pk>/send/",
SendIssuePageView.as_view(),
name="send"
)
]
return [
path(
"",
include(
(urls, "newsletter"),
namespace="newsletter"
)
)
]
wagtail_hooks.py
file upfront, I wanted to explain each piece so it makes sense... just in case you're like me and you want to know what you're pasting and what it's doing.Define the menu item
Now in a page_actions.py
file within your app, define the Wagtail admin menu item you want to create.
from django.urls import reverse
from django.utils.translation import gettext as _
from wagtail.admin.action_menu import ActionMenuItem
class SendMenuItem(ActionMenuItem):
label = _("Send to recipients")
name = "action-send"
icon_name = "mail"
def is_shown(self, context):
if context["view"] == "create":
return (
context["parent_page"].permissions_for_user(
context["request"].user
).can_publish_subpage()
)
perms_tester = self.get_user_page_permissions_tester(context)
return not context["locked_for_user"] and perms_tester.can_publish()
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
page = context.get("page")
context["is_scheduled"] = page and page.go_live_at
context["is_revision"] = context["view"] == "revisions_revert"
return context
def get_url(self, context): # The URL we just registered.
return reverse(
"newsletter:send",
args=(context["page"].id,)
)
I'm on slightly shakier ground here as I'm not entirely sure what all the pieces are doing and which are extraneous. What I do know we need (and works) is the get_url()
method, which points to the view we've defined and added to Wagtail's admin routes.
What the get_context()
stuff is doing is rendering a template for the menu item. You can provide your own template if you want, but I found this wasn't necessary.
The icon
referred to above isn't part of Wagtail, so you'll need to register that yourself.
Add the item to the Wagtail page action menu
Now that everything's defined â the business logic, the view and template, the route to the view, and the instructions needed to construct the menu item â we can finally register the menu item with Wagtail.
For the last time, go back to your wagtail_hooks.py
file and merge in the following:
from .page_actions import SendMenuItem
from .models import IssuePage # The model you want to perform actions on
@hooks.register("construct_page_action_menu")
def construct_page_action_menu(menu_items, request, context):
page = context.get("page")
if isinstance(page, IssuePage):
menu_items.append(SendMenuItem())
What we're doing here is making sure only our specific page type gets this added menu item. This should probably be enforced elsewhere in the system, although to be honest, I think the get_object_or_404
line in the view will take care of that, should someone try and "send" a page of the wrong type, just to be a dick. đ
That's it... I think!

Hopefully this is enough to get you where you need to go if you wanted to add your own page menu item.
I guess this is massively subject to change should Wagtail issue an overhaul, but it works for now!