Creating custom Wagtail actions

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

Creating custom Wagtail actions

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.

â„šī¸
At the time of writing, I hadn't got that far, but that's regular Django stuff.

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"
            )
        )
    ]
🤓
Rather than give you the whole 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!

Screenshot segment showing the added "Send to recipients" menu item

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!