CNK's Blog

Monkey Patching Wagtail

At work we run a large multitenant version of Wagtail (~500 separate websites on a single installation). To achieve this and to make some other changes to the way Wagtail behaves, we have a number of monkey patches. So we have consolidated all of them in their own Django app which we called wagtail_patches. This is loaded into our INSTALLED_APPS after most of our own apps but before any of the Wagtail apps:

    # settings.py
    INSTALLED_APPS = [
        # Multitenant apps. These are ordered with regard to template overrides.
        'core',
        'search',
        'site_creator',
        'calendar',
        'theme_v6_5',
        'theme_v7_0',
        'robots_txt',
        'wagtail_patches',  #####
        'sitemap',
        'features',
        'custom_auth',

        # Wagtail apps.
        'wagtail.embeds',
        'wagtail.sites',
        'wagtail.users',
        'wagtail.snippets',
        'wagtail.documents',
        # We use a custom replacement for wagtail.images that makes it add decoding="async" and loading="lazy" attrs.
        # 'wagtail.images',
        'wagtail_patches.apps.MultitenantImagesAppConfig',
        'wagtail.search',
        'wagtail.admin',
        'wagtail',
        'wagtail.contrib.modeladmin',
        'wagtail.contrib.settings',
        'wagtail.contrib.routable_page',

        # Wagtail dependencies, django, etc.....
    ]

And then in that app, we use the apps.py file to load everything from the patches directory:

    from django.apps import AppConfig
    from wagtail.images.apps import WagtailImagesAppConfig


    class WagtailPatchesConfig(AppConfig):
        name = 'wagtail_patches'
        verbose_name = 'Wagtail Patches'
        ready_is_done = False
        # If there are multiple AppConfigs in a single apps.py, one of them needs to be default=True.
        default = True

        def ready(self):
            """
            This function runs as soon as the app is loaded. It executes our monkey patches to various parts of Wagtail
            that change it to support our architecture of fully separated tenants.
            """
            # As suggested by the Django docs, we need to make absolutely certain that this code runs only once.
            if not self.ready_is_done:
                # The act of performing this import executes all the code in patches/__init__.py.
                from . import patches  # noqa
                self.ready_is_done = True
            else:
                print("{}.ready() executed more than once! This method's code is skipped on subsequent runs.".format(
                    self.__class__.__name__
                ))


    class MultitenantImagesAppConfig(WagtailImagesAppConfig):
        default_attrs = {"decoding": "async", "loading": "lazy"}

You will note that the first of our customizations is right in apps.py. We use this file to configure default html attributes for image tags generated by Wagtail - per the instructions in “Adding default attributes to all images”.

Patching views

We have a handful of views that need overrides. Mostly these involve changing querysets or altering filters so the choices are limited to users belonging to the current site. The easiest option is to subclass the existing view, make our changes, then assign our subclass to the same path as the original.

I use the show_urls command from django_extensions to find the existing mapping. And then I map my replacement view to the same pattern. So for replacing the page explorer view, I added the following two lines:

    # patched_urls.py
    from .views.page_explorer import MultitenantPageIndexView

    patched_wagtail_urlpatterns = [
        # This overrides the wagtailadmin_explore_page (aka page listing view) so we can monkey patch the filters
        path('admin/pages/', MultitenantPageIndexView.as_view()),
        path('admin/pages/<int:parent_page_id>/', MultitenantPageIndexView.as_view()),
    ]

Because we have a bunch of overrides, we have a patched_urls.py in our wagtail_patches app. Then, in our main urls.py file, we add that pattern before our other mappings:

    # urls.py
    from wagtail import views as wagtailcore_views
    from wagtail_patches.patched_urls import patched_wagtail_urlpatterns

    # We override several /admin/* URLs with our own custom versions
    urlpatterns = patched_wagtail_urlpatterns + [
        # We now include wagtails' own admin URLs.
        path('admin/', include('wagtail.admin.urls')),
        path('documents/', include('wagtail.documents.urls')),
        ... our custom urls and the rest of the standard Wagtail url mappings
    ]

I then use show_urls to check my mapping. As long as our version is the second one, then it will get used. If you feel like your changes are getting ignored, start by checking to see that the url pattern for your override exactly matches the original pattern.