CNK's Blog

Snippet Choosers

Continuing with our Location snippet from our previous post, we want to use locations in our event pages. So we need to be able to choose locations - but only locations entered into the current site - and we need to enforce the “same site” restriction in our foreign key relationships. Fortunately Django already supports using functions to create a list of valid options for choosers. So in our case, we need a function that does not take any arguments and returns the dictionary Django needs to build the queryset filter. See the Django docs for details.


Our EventPage has a foreign key relationship with Location and we use a helper method to restrict the choices offered to locations in the same site. The help looks like this:

    def limit_to_current_site():
        Use this function to limit a dropdown that lists models that reference a Site to those instances that reference
        the current Site.
        request = get_current_request()
        if request:
            return {'site': Site.find_for_request(request)}
            # If we do not have a request, rely on the validations that ran when inserting this data.
            # NB: Our imports must be sure they are setting up the foreign key relations to data in the current site.
            return Q()

And then we use it in our page model definition like this.

    class EventPage(Page):
        start_date = models.DateTimeField('Start Date/Time')
        end_date = models.DateTimeField('End Date/Time')
        location = models.ForeignKey(
        description = RichTextField(editor='minimal', blank=True)

        content_panels = Page.content_panels + [
                    classname='datetimes-field date-field',
                        FieldPanel('start_date', classname='start-date'),
                        FieldPanel('end_date', classname='end-date')
                        attrs={'data-placeholder': 'Search for Locations...'}

Autocomplete views

You will notice that we have specified a widget in the location FieldPanel. This is because we have too many locations in some sites to easily use a <select> input field. The code above enforces the site restriction for the foreign key relationship but we will need a custom view to allow editors to search for appropriate locations.

    from dal import autocomplete

    class LocationAutocompleteView(autocomplete.Select2QuerySetView):
        An autocompleter that returns Location objects for use in the various forms.
        paginate_by = None

        def get_queryset(self):
            # Start with all of the Locations for the site.
            site = Site.find_for_request(self.request)
            queryset = Location.objects.filter(site=site)

            # If the user has typed anything into the autocomplete widget, filter the queryset down to Locations that match.
            if self.q:
                queryset = queryset.filter(name__icontains=self.q)

            return queryset

    # Then in our we have this line to add the url
    path('location_autocomplete', never_cache(views.LocationAutocompleteView.as_view()), name='location_autocomplete'),
    # Then this url mapping is used as the `url` arg for the autocomplete widget in the form above.


Wagtail snippets also provide chooser views to select instances of a model or create an instance if a suitable one does not already exist. Once again, we need to only offer instances from the current site to be associated with other models on the site. We are currently using the older wagtail-generic-chooser package so we created a mixin to take care of filtering by site.

I’ll update this code once we have converted to using the built-in ChooserViewSet. I think we should be able to subclass ChooserViewSet, customize get_object_list, and then follow the rest of the instructions but I haven’t tried it yet.

    from generic_chooser.views import ModelChooserMixin, ModelChooserViewSet

    class SiteSpecificChooserMixin(ModelChooserMixin):
        Use this ChooserMixin for Site-specific models, to ensure that users can only choose instances of that model
        belonging to the current Site.

        def get_unfiltered_object_list(self):
            objects = super().get_unfiltered_object_list()
            return objects.filter(site=Site.find_for_request(self.request))

    class LocationChooserViewSet(ModelChooserViewSet):
    This viewset defines the views that ae used to choose (and create, from within the chooser) Location objects.
    To make use of them, you must specify widget=LocationChooser in your FieldPanel for the Location field, or use an
    LocationChooserBlock in your StreamField block definition.
    icon = 'map'
    model = Location
    page_title = 'Choose a Location'
    per_page = 40
    order_by = 'name'
    form_class = LocationModelForm
    chooser_mixin_class = SiteSpecificChooserMixin

    class LocationModelForm(SiteSpecificModelForm):
    wagtail-generic-choosers expects an _actual_ ModelForm, rather than a pseudo-ModelForm that Wagtail lets
    you use. This Form class manually specifies the Model it's for and the fields it presents, because that's the
    default way that it works in Django, and wagtail-generic-choosers expects that.

    class Meta:
        model = Location
        fields = [ 'name', 'building_name', 'room_number']

Snippets - CRUD

In addition to the models that Wagtail provides (pages, images, and documents), most web sites also need other models. The easiest way to manage those in the Wagtail admin is to register them as “snippets”. Like all other assets in our multitenant install, we only want people to manage the snippet instances for their own site.


We have maps on our sites and we store the data for map locations via a Django model. We use a SnippetViewSet to manage locations in the Wagtail admin interface. These same locations are also used by our EventPages as the location for the event - so we also need site-specific choosers to associate events and locations with instances on the same site.


    class Location(Orderable, models.Model):
        Represents a location at which an Event can take place.
        name = models.CharField('Location Name', max_length=1024)
        building_name = models.CharField('Building Name', max_length=255, blank=True)
        room_number = models.CharField('Room Number', max_length=255, blank=True)

        # Associate each Location with a particular Site, so that editing Locations on one Site
        # doesn't affect other Sites.
        site = models.ForeignKey(

        class Meta:
            ordering = ['name']

        def __str__(self):

The only thing that makes this model special is that we have a ForeignKey relationship with the Wagtail Sites table.

Views / ViewSets

Because we have a a bunch of site-specific models, we have a couple of view-level classes that help us manage the site segregation. In the code below, note that we inherit from a custom ViewSet and that while our panels list doesn’t include the site_id, we are using SiteSpecificModelForm as the base class for our form.

    class LocationViewSet(MultitenantSnippetViewSet):
        This class defines the SnippetViewSet for Location, which is accessed from the Map menu defined below.
        model = Location
        menu_label = 'Locations'
        menu_order = 100
        list_display = ['name', 'building_name', 'room_number']
        search_fields = ['name', 'building_name', 'room_number']
        icon = 'location-arrow'
        url_prefix = 'map/locations'

        panels = [
        edit_handler = ObjectList(panels, base_form_class=SiteSpecificModelForm)

Our MultitenantSnippetViewSet takes care of adding a filter so the listing view for each site only displays items for that one site. It also has some code that makes it easier to manage whether or not to add a menu item for managing the model.

    class MultitenantSnippetViewSet(SnippetViewSet):
        We subclass SnippetViewSet to apply some functionality that nearly all our of SnippetViewSets need, and to
        simplify some other functionality.

        def get_queryset(self, request):
            Every model that uses MultitenantSnippetViewSet is a Site-specific model, so we need to filter the listing
            to show only those instances that belong to the current Site.
            return self.model._default_manager.filter(site=Site.find_for_request(request))

        def hide_menu_item(self, request):  # noqa
            Override hide_menu_item() to return True when the menu item for this SnippetViewSet should be hidden.
            The logic for this is combined with the permissions-based display logic that's built in to SnippetViewSet.
            This gets called by the is_shown() method in CustomMenuItem, defined inside get_menu_item() below.
            return False

        def get_menu_item(self, order=None):
            We override this method to apply custom is_shown() logic to this ViewSet's menu item.
            # We subclass self.menu_item_class, which implements permissions checking in is_shown(), so that our code can
            # call super().is_shown() to get the "default" display permissions. We do that after determining if we need to
            # be even more strict than that for this class, via hide_menu_item().
            class CustomMenuItem(self.menu_item_class):
                # Assigning CustomMenuItem.hide_menu_item to MultitenantSnippetViewSet.hide_menu_item lets
                # CustomMenuItem.is_shown() access hide_menu_item() as a normal instance method. This will work even for
                # overriden versions of hide_menu_item() in subclasses of MultitenantSnippetViewSet.
                hide_menu_item = self.hide_menu_item

                def is_shown(self, request):
                    If self.hide_menu_item() returns True, hide this menu item.
                    Otherwise, permissions control its visibility.
                    if self.hide_menu_item(request):
                        return False
                    return super().is_shown(request)

            return CustomMenuItem(
                order=order or self.menu_order,

NOTE: Until GitHub issue 10746 is resolved, the filter in get_queryset only limits access for the list view; it does not prevent someone from accessing the edit view for an item on a different site. You might want to subclass the SnippetEditView so you can customize get_object. That would allow you to return a 404 page when the user tries to edit an object from another site. We didn’t do this. Instead we enforce ‘only edit on the correct site’ in the clean method of our SiteSpecificModelForm. This form class adds methods for ensuring items are created and edited on the site to which they belong. Instead of putting the site_id in the model form, we fill it in automatically when creating a model object - and then refuse to allow anything to move the object to a different site.

    class SiteSpecificModelForm(WagtailAdminModelForm):
        Generic form for use on models administered via Wagtail forms that need to generate site-specific objects.

        NOTE: The model's 'panels' list must NOT contain the 'site' field.

        def clean(self):
            cleaned_data = super().clean()

            current_site = Site.find_for_request(get_current_request2('Current Site'))
                if self.instance and and != current_site:
                    raise ValidationError(
                        f'The Site associated with this {self.instance.__class__.__name__} is {}, but the'
                        f'current Site is {current_site}. Changing the Site of an existing object is not allowed.'
            except ObjectDoesNotExist:
                # We're in a create, so does not resolve.

            # If the model has a unique_together constraint that includes the site field, we need to implement the
            # validation for it here, since our shenanigans with that field break django's usual validation code.
            if hasattr(self._meta.model._meta, 'unique_together'):
                # unique_together gets stored as a tuple of tuples, so we need this outer loop to get to the field list.
                for constraint in self._meta.model._meta.unique_together:
                    if 'site' not in constraint:
                    # Build a dict of args for the QuerySet.filter() method, using current_site for the 'site' arg.
                    filter_args = {field_name: cleaned_data.get(field_name) for field_name in constraint}
                    filter_args['site'] = current_site
                    # Check if an instance already exists with the unique_together data, and if so, set an error if that
                    # instance ISN'T the one that's currently being edited.
                    instance_in_db = self._meta.model.objects.filter(**filter_args).first()
                    if instance_in_db and instance_in_db != self.instance:
                        for field in [x for x in constraint if x != 'site']:
                                field, ValidationError(f'A {self._meta.model.__name__} already exists with that {field}.')

            return cleaned_data

        def save(self, commit=True):
            instance = super().save(False)

            if not instance.site_id:
                # This is an instance that's being created for the first time, so we need to give it the current site.
                # Future updates can't change the site field, because it's not in the form.
       = Site.find_for_request(get_current_request2('Current Site'))

            # Some subclasses override this to do additional processing, if they do, they need to call super().save(False)
            if commit:
            return instance


The changes above are the customizations we have made to the Views and ViewSets. What I didn’t mention in my previous post about permission patches is that we also need to make sure we are only checking the model permissions assigned via groups that are used on the current site. This is done by customizing the _get_group_permissions from our Authentication backend. We subclass the ModelBackend from django.contrib.auth.backends and filter Permissions for groups named for the current site.

    # custom_auth/
    def _get_group_permissions(self, user_obj):
        By default, Django's permission system assumes that if you are granted a Permission by ANY Group, you have that
        permission in all contexts. We override this method to ensure that a User is ONLY granted Permissions from the
        Groups they belong to on the current Site.
        request = get_current_request2(f"{user_obj.username}'s Group permissions")
        if user_obj.is_superadmin:
            # Super Admins are treated as being members of the current Site's Admins group.
            return Permission.objects.filter(group__name=f'{Site.find_for_request(request).hostname} Admins')
            # Other users are treated as having only the permissions granted to them by Groups they belong to on
            # the current Site.
            return Permission.objects.filter(
                group__user=user_obj, group__name__startswith=Site.find_for_request(request).hostname

Site Creator

The key to running so many sites in a single Wagtail installation is they all need to be the same (or nearly the same) except for content. And the best way to make something uniform is to manage it in code. The code that manages our site setup (and tear down) lives in our site creator. This is a Django app that overrides Wagtail’s site management forms to add the logic we use to enforce our ideas about multitenancy.

Our site_creator app doesn’t have any models of its own and it only does a little bit of customization to Wagtail’s SiteViewSet. The vast majority of our customizations are implemented via our create and edit forms.

The Wagtail SiteForm has the following fields: “hostname”, “port”, “site_name”, “root_page”, and “is_default_site”. In our multitenanted environment all sites are created as subdomains for the instance. So if our instance is called, then all new sites will have names like So we do not ask for the hostname, our form asks for the subdomain and then builds the hostname by appending the base url for the instance, e.g. “foo” + “”. We don’t for the port; we use port 443 + a wildcard SSL certificate everywhere. And we don’t ask for a root page because we are going to create that as part of our set up script. Our SiteCreationForm does some basic validations on the subdomain and site name and then passes that information to our create_site script.

The nice thing about having all our site creation logic in a separate function is that we can use it from non-form, non-view contexts. So we can use this exact same script to create sites in tests or use it from commands to create new sites as part of an export/import process.

    def create_site(owner, form_data):
        Create a new Site with all the default content and content specified by
        various hooks. "form_data" should be a dict with the following information:

        hostname: full hostname including subdomain, e.g.
        site_name: string for site name
        theme: one of v7.0, v6.5 or v6.1
        # If anything fails, make sure it ALL gets rolled back, so the db won't be corrupted with
        # partially created sites
        with transaction.atomic():
            site = Site()
            # Generate the Site object from the form fields.
            site.hostname = form_data['hostname']
            site.site_name = form_data['site_name']
            site.port = 443

            # Generate the default Page that will act as the Homepage for this Site.
            home_page = get_homepage_model(form_data['theme'])()
            home_page.title = home_page.nav_title = generate_homepage_title(site.site_name)
            home_page.show_title = False
            home_page.nav_title = site.site_name
            home_page.breadcrumb_title = 'Home'
            home_page.owner = owner
            home_page.show_in_menus = False
            home_page.latest_revision_created_at = now()
            home_page.first_published_at = now()

            # We save the home_page by adding it as a child to Page 1, the ultimate root of the page tree.
            tree_root = Page.objects.first()
            home_page = tree_root.add_child(instance=home_page)
            site.root_page = home_page

            site.settings = get_settings_model()()

            # Execute all registered site_creator_settings_post hooks.
            # This allows apps that need to do additional work after the site settings object has been created.
            # All implementations of site_creator_create_site_post must accept one positional parameter:
            # site: a Wagtail Site object
            for func in hooks.get_hooks('site_creator_settings_post'):

            # Generate a blank Features for this Site.

            # Generate a Collection for this Site.
            collection = Collection()
   = site.hostname
            # Much like the homepage, we need to create this Collection as a child of the root Collection.
            collection_root = Collection.objects.first()

            admins = Group.objects.create(name=f'{site.hostname} Admins')
            apply_default_permissions(admins, site, 'admin')

            editors = Group.objects.create(name=f'{site.hostname} Editors')
            apply_default_permissions(editors, site, 'editor')

            # Viewers group doesn't get any permissions; they can log in and look at pages but can't access admin interface.
            Group.objects.create(name=f'{site.hostname} Viewers')

            # Execute all registered site_creator_default_objects hooks. This hook allows apps to tell
            # site_creator to create pages or other objects the site may need. All implementations of
            # site_creator_default_objects will receive the newly created Site (from which the function
            # can derive site.root_page)
            for func in hooks.get_hooks('site_creator_default_objects'):

            return site

If you read the code above, you will notice use creating a associated Features record for each site and that record contains a site_theme. As much as we would like to have a single idea of what a site is, that isn’t the real world. Our multitenanted CMS was created as a proof of concept a year or two before our last redesign and uses a variation on what was then our main web site’s design. Since that was the sixth iteration of our main web site, it was know as Theme 6 - and the redesign, when it happened, was called Theme 7. We didn’t want to change Wagtail’s Site model, so we created a 1:1 model named Features to keep track of the site them (and some feature flags for sites).

The other thing you will have noticed is it delegating the hard work of assigning group permissions to apply_default_permissions. This is where the real work of setting up our standard groups takes place.

    def apply_default_permissions(group, site, group_type):
        Applies the default permissions to the given Group.
        assert group_type in ('admin', 'editor')

        # Allow all groups to access the Wagtail Admin.
        wagtail_admin_permission = Permission.objects.get(codename='access_admin')

        # Gives Admins and Editors full permissions for pages on this Site EXCEPT Bulk Delete. This prevents
        # anyone from accidentally erasing the entire site by deleting the homepage.
        if group_type in ('admin', 'editor'):
            for perm_type, short_label, long_label in PAGE_PERMISSION_TYPES:
                if perm_type != 'bulk_delete_page':
                    permission = Permission.objects.get(content_type__app_label="wagtailcore", codename=perm_type)
                    GroupPagePermission.objects.get_or_create(group=group, page=site.root_page, permission=permission)

        perm_types = ['add', 'change', 'view', 'delete', 'choose']
        # Note we are using the built in image/document content types; this is
        # because the CollectionOwnershipPermissionPolicy uses those models in its checks
        image_ct = ContentType.objects.get(app_label='wagtailimages', model='image')
        doc_ct = ContentType.objects.get(app_label='wagtaildocs', model='document')

        # Give all groups full permissions on the Site's Image and Document Collections.
        collection = Collection.objects.get(name=site.hostname)
        if group_type in ('admin', 'editor'):
            # images
            for perm in perm_types:
                perm = Permission.objects.get(content_type=image_ct, codename=f'{perm}_image')
                GroupCollectionPermission.objects.get_or_create(group=group, collection=collection, permission=perm)
            # documents
            for perm in perm_types:
                perm = Permission.objects.get(content_type=doc_ct, codename=f'{perm}_document')
                GroupCollectionPermission.objects.get_or_create(group=group, collection=collection, permission=perm)

        # Give site admins permission to manage collections under their site's root collection
        if group_type == 'admin':
            for codename in ['add_collection', 'change_collection', 'delete_collection']:
                perm = Permission.objects.get(content_type__app_label='wagtailcore', codename=codename)
                GroupCollectionPermission.objects.get_or_create(group=group, collection=collection, permission=perm)

        # Apply all model-level permissions for the new groups
        if group_type in ('admin', 'editor'):
            permissions = default_model_permissions(group, group_type, settings.SITE_TYPE)

    def default_model_permissions(group, group_type, site_type):
        Collects the model permissions for the given group type.
        wagtail_admin_permission = Permission.objects.get(codename='access_admin')
        group_permissions = [wagtail_admin_permission]

        # Omitted: lots of model permissions that are assigned to both admin and editor groups

        if group_type == 'admin':
            admin_models = [
                ('core', 'DisplayLocation', 'all'),
                ('core', 'SyncTag', 'all'),
                ('custom_auth', 'User', 'all'),
                ('www', 'Settings', ['view', 'change']),

        if group_type == 'editor':
            editor_models = [
                ('core', 'DisplayLocation', ['view']),
                ('core', 'SyncTag', ['view']),

        return group_permissions

    def __permission_objects(config_list):
        Look up the correct permissions objects and return a list of them
        output = []
        for app_label, model_name, perms in config_list:
                ct = ContentType.objects.get(app_label=app_label, model=model_name)
            except ContentType.DoesNotExist:
                logger.error(f'Could not find content type for {app_label} {model_name}')

            if perms == 'all':
                for perm in perms:
                        perm = Permission.objects.get(content_type=ct, codename__startswith=perm)
                    except Permission.DoesNotExist:
                        logger.error(f'Could not find permission {perm} for {app_label} {model_name}')
        return output

Because create_site set up collections and user groups based on the site hostname, our edit form is going to have to do some work to keep those names in sync.

    class SiteEditForm(SiteForm):
        def save(self, commit=True):
            instance = super().save(commit)
            if 'hostname' in self.changed_data:
                # The hostname has been changed, so we need to do a bunch of internal renames to account for that.
                old_hostname = self['hostname'].initial
                new_hostname = instance.hostname

                # Change all the places where the old hostname appears which wouldn't otherwise be changed by this form.
                update_db_for_hostname_change(old_hostname, new_hostname)

                    "{} has been moved from {} to {}.".format(instance.site_name, old_hostname, new_hostname)
            return instance

    def update_db_for_hostname_change(old_hostname, new_hostname):
        This function updates all the tables in the database that utilize the string value of a Site's hostname.
        Those tables are:

        auth_group - We can't define a custom Group class, so we need to use their name as a connection to the related Site.
        wagtailcore_collection - Same as above.

        Note: This function DOES NOT rename the Sites themselves. The code that calls this function is expected to do that.
        commands = [
            "UPDATE auth_group SET `name` = REPLACE(`name`, %(old_hostname)s, %(new_hostname)s)",
            "UPDATE wagtailcore_collection SET `name` = REPLACE(`name`, %(old_hostname)s, %(new_hostname)s)",
            "UPDATE custom_auth_user SET `username` = REPLACE(`username`, %(old_hostname)s, %(new_hostname)s)",

        # Add the commands returned by all registered site_hostname_change_additional_sql hooks.
        # This allows apps to add commands to rename fields in their own tables.
        for func in hooks.get_hooks('site_hostname_change_additional_sql'):

        with connection.cursor() as cursor:
            for command in commands:
                    cursor.execute(command, {'old_hostname': old_hostname, 'new_hostname': new_hostname})
                except Exception as e:
                    logger.error(f'Exception raised in update_db_for_hostname_change while running "{command}": {e}')

Maintenance Considerations

Note that because we use the full hostname in our naming convention, when we copy data between environments, for example from prod to test or test to dev, we will need to call update_db_for_hostname_change to change the base domain. Because we are using MySQL’s REPLACE function, this can be used to replace substrings in bulk; notice the lack of loop around update_db_for_hostname_change in the code below?

    # core/management/commands/convert_server_domain
    from django.conf import settings
    from import BaseCommand
    from wagtail.models import Site

    from ...utils import update_db_for_hostname_change

    class Command(BaseCommand):
        help = ("After loading a dump from a test/staging/prod DB, this command converts all domains to SERVER_DOMAIN.")

        def add_arguments(self, parser):
                help="OPTIONAL. The script will auto-detect the old domain, but you can force it to use a different value"
                     " if needed. This is useful if you need to run convert_server_domain after it's already run once.",
                # This setting makes old_server_domain an _optional_ positional argument. This maintains backwards
                # compatibility with the old way of calling this command: ` convert_server_domain old.domain`.

        def handle(self, **options):
            old_server_domain = options['old_server_domain']
            if not old_server_domain:
                # The user didn't specify an old domain, so auto-detect it from the existing default Site.
                old_server_domain = Site.objects.get(is_default_site=True).hostname

            print(f"Converting from {old_server_domain} to {settings.SERVER_DOMAIN}...")

            # Update the database to match the new SERVER_DOMAIN
            update_db_for_hostname_change(old_server_domain, settings.SERVER_DOMAIN)

            # update_db_for_hostname_change() was designed to be called from within the Site change form, so it doesn't
            # do the last thing we need, which is renaming every Site's hostname.
            for site in Site.objects.filter(hostname__contains=old_server_domain):
                site.hostname = site.hostname.replace(old_server_domain, settings.SERVER_DOMAIN)

Because our associations between sites and collections depend on a naming convention, we added a check to the CollectionForm to prevent renaming a site’s top-level collection.

    def patched_CollectionForm_clean_name(self):
        Monkey patch Wagtail's Collection mechanism to prevent Collections created through the Site
        Creator from being renamed or deleted before their associated Site is deleted. This is
        necessary because several mechanisms assume that a Collection named "" will
        exist alongside the site hosted as "".

        NOTE: There's no "original" CollectionForm.clean_name() function. We are adding it from scratch.
        if in [site.hostname for site in Site.objects.all()]:
            raise ValidationError('Collections named after Sites cannot be renamed.')
        return self.cleaned_data['name']

    # Import the module or class we're patching, then patch it with the above function(s).
    from wagtail.admin.forms.collections import CollectionForm
    CollectionForm.clean_name = patched_CollectionForm_clean_name

Site Deletion

In the Wagtail data model, pages may belong to more than one site so deleting a site does not automatically delete the site’s root_page (and its subpages). In our multitenanted set up, we never allow pages to belong to more than one site, so we will want to delete the pages along with the site. And because our groups and collections do not have foreign keys to the Site model, when we delete a site, we will also need to delete the related objects. We use a post_delete signal to do this work.

    # site_creator/
    from django.apps import apps
    from django.contrib.auth import get_user_model
    from django.db.models.signals import post_delete, pre_delete

    def post_site_delete_cleanup(sender, instance, **kwargs):
        Makes sure Site-specific Collections and Groups are removed after deleting the associated Site.
        hostname = instance.hostname
        Group = apps.get_model('auth', 'Group')
        Collection = apps.get_model('wagtailcore', 'Collection')
        Page = apps.get_model('wagtailcore', 'Page')

        # Delete Local users that were created for this Site. They are identified by having a username prefixed
        # with the Site's hostname.
        for user in get_user_model().objects.filter(username__startswith=hostname):

        # Delete the Groups and Collections for this Site, which also deletes the contents of those Collections.

        # Delete the homepage and all its children.
        Page.objects.descendant_of(instance.root_page, inclusive=True).all().delete()

    post_delete.connect(signals.post_site_delete_cleanup, sender=Site)

User and Group Management for Multitenancy

Once we set up a site, we want to hand over all control to the site admin. This means that they need to be able to add users and give them permissions to do things on their site (and only their site). In Wagtail this means a site admin, needs to be able to create and delete users and assign them to predefined groups.


Wagtail expects permissions to be assigned to users via groups. Users belong to groups and groups come with a set of permissions. Wagtail has a UI for creating groups and editing the permissions but we don’t use it (except sometimes for trouble shooting or on-off groups for Privacy options). The script we use to create a new site creates a standard set of groups (Admin, Editor, and Viewer) and assigns each a specific set of permissions (or in the case of Viewer, no admin privileges at all). Obviously we can’t have 500 groups named Admin so the machine names of these groups are each prefixed with the site’s hostname, e.g. “foo.localhost Admin”.

So far this is all pretty straightforward - except for the fact that there isn’t actually a foreign key relationship between sites and groups. The relationship between sites and groups is entirely based on the group name starting with the site’s hostname. Since we use code to create (and delete) sites, we haven’t had any problems with the lack of database integrity constraints. But that is something one might want to change if you were building a multitenant system where the sites and their associated objects were not so rigidly defined.

The only other down side of the hostname prefix is that is a bit ugly. We prefer our site owners see just “Admin” or “Editor”. So any place they would see the group name, we remove the hostname prefix. The main places that happens are the user forms discussed below and in the privacy restrictions forms (which we already customize to add an additional option). In the initialization method of our user forms, we call the following method to configure the groups section of the form. It takes care of filtering the allowed groups by site and cleaning up the displayed names.

    def configure_shared_fields(self):
        error_messages = self.fields['groups'].error_messages.copy()
        if not self.request.user.is_superuser:
            # Only superusers may grant superuser status to other users.

            # Site admins MUST assign at least one Group. Replace the messages with ones tailored to them.
            self.fields['groups'].required = True
            error_messages['required'] = self.error_messages['group_required']
            self.fields['groups'].help_text = "A user's groups determine their permissions within the site."

            site = Site.find_for_request(self.request)
            # Non-superusers are allowed to see only the Groups that belong to the current Site.
            # This also reduces the displayed Group name from e.g. " Admins" to just "Admins".
            self.fields['groups'].choices = (
                (,, '').strip())
                for g
                in Group.objects.filter(name__startswith=site.hostname)
            # Changing the queryset alone isn't sufficient to change the available choices on the form (the "choices"
            # setting was created during the field's init). But we have to change the queryset anyway because it's
            # what the validation code uses to determine if the specified inputs are valid choices.
            self.fields['groups'].queryset = Group.objects.filter(name__startswith=site.hostname)
            self.fields['groups'].help_text = """Normal users require a Group. However, superusers should NOT be
                in any Groups. Thus, this field is required only when the Superuser checkbox is unchecked."""
            # Replace the "Required" error message with one tailored to superusers.
            error_messages['required'] = self.error_messages['group_required_superuser']
        self.fields['groups'].error_messages = error_messages


I have said that I want a user to see completely different content when logging into Site A vs into Site B. One way to handle that would be to create separate users on the 2 sites. Problem solved, eh? However, most of our services are set up so you can use a standard set of credentials everywhere. Not only is this more convenient for our users (fewer passwords to track) but, since we are using a central store to check usernames and passwords, we can automatically disable access when someone leaves. So we need to use a single user record across all sites.

We need to use a single user record across sites. But we also have to enforce our strict site separation which means we can’t let Site A know that a user also has permissions on Site B. So we built our own user management views. In addition to allowing the views to behave differently when one is logged in as a superuser vs logged in as the admin of a site, it also made it easy for us to combine the steps of creating a user and assigning them to groups.

When adding new users to a site, we enter their username or name into a search which searches for an active account and returns the information we need to create a user record. Then we present a list of groups you can assign to this user. For site admins, this list only only shows the groups for the current site. But what if that user is already an Editor on some other site? If we just saved the form as is, first, that user will already exist in our system, so our “create” form actually needs to be an edit form. And more importantly, we need to not lose the group mappings that already exist - even though they were not included in the form data.

    def save(self, commit=True):
        If a Django User with this username already exists, pull it from the DB and add the specified Groups to it,
        instead of creating a new User object.
        user = super().save(commit=False)
        # Users can access django-admin iff they are a superuser.
        user.is_staff = user.is_superuser

        # Autocompleter note: The "LDAP User" field is actually an autocompleter for ContactInformation objects.
        # Since the form doesn't have a username field, we need to set this manually.
        user.username = self.cleaned_data['contact_info'].uid
        groups = Group.objects.filter(pk__in=self.cleaned_data['groups']).all()
        logger_extras = {
            'target_user': user.username,
            'target_user_superuser': user.is_superuser,
            'groups': ", ".join(str(g) for g in groups)
            existing_user = get_user_model().objects.get(username=user.username)
        except get_user_model().DoesNotExist:
            existing_user = False
            # This is a brand new LDAPUser, so it needs to have its Django password made unusable, ensuring the user
            # can only log in via their LDAP credentials. We do this here so that the password doesn't change when an
            # LDAPUser is "added", but it already exists and is actually just being placed in a new Site's Group(s).
            user = existing_user
            # Don't bulk add groups or we disrupt existing group mappings. Add individual groups from the form
            for group in groups:

            # Set the is_superadmin flag to True if the form data says to. This is necessary only
            # when there's an existing user because unlike 'user', 'existing_user' will not have had
            # these flags set by super().save(commit=False). We don't just set the flag to the form
            # value, because we never want to remove is_superadmin when this code gets executed.
            if self.cleaned_data.get('is_superadmin'):
                user.is_superadmin = True
        if existing_user:
            logger_extras['target_user_id'] =

        # Populate the identifying information for the User form the ContactInformation object.
        user.first_name = self.cleaned_data['contact_info'].first_name
        user.last_name = self.cleaned_data['contact_info'].last_name = self.cleaned_data['contact_info'].email

        if commit:
            if not existing_user:
                # Only call save_m2m() if we're not updating an existing User. It'll try to overwrite the updated
                # user.groups list, AND it'll crash for some reason I haven't figured out.
      'user.ldap.create', **logger_extras)
      'user.ldap.update', **logger_extras)
        return user

The clean and save methods in the edit form is a bit more straightforward because we know that we already have a user. But we still have to do a little fooling around with the form data because site admins can only edit users who belong to their site - which means those users must be assigned to one of the site’s groups. And site admins can’t change a user’s superuser or superadmin attributes.

    def clean(self):

        # Remove any data about groups that may have been included in the form. We need to apply changes to Groups
        # manually, due to how non-superusers get presented with the Groups list.
        if not self.request.user.is_superuser:
            self.new_groups = self.cleaned_data.get('groups', [])
            with suppress(KeyError):
                del self.cleaned_data['groups']
            with suppress(ValueError):

        # Superusers are allowed to be ungrouped.
        if self.cleaned_data.get('is_superuser'):
            if self.errors.get('groups') and self.errors['groups'][0] == self.error_messages['group_required']:
                del self.errors['groups']
                # The clean_groups() function removed self.cleaned_data['groups'] due to the error, but that will cause
                # the groups list to go unchanged upon save. So we need to set it to empty list.
                self.cleaned_data['groups'] = []

        # A superuser must assign a User to a Group, and/or set that User as a Superuser or a Super Admin.
        # If they don't do at least one, throw an informative error.
        if (
            self.request.user.is_superuser and
            not (self.cleaned_data.get('is_superuser') or self.cleaned_data.get('is_superadmin'))
            and not self.cleaned_data.get('groups')
            self.add_error('groups', forms.ValidationError(self.error_messages['group_required_superuser']))

    def save(self, commit=True):
        In case the data in LDAP has changed, or it failed to populate on the previous create/edit, we override save()
        to re-populate this User's personal info from LDAP.
        user = super().save(commit=False)
        # Users can access django-admin iff they are a superuser.
        user.is_staff = user.is_superuser
        if commit:
            if self.has_changed():
                logger_extras = {
                    'target_user': user.username,
                    'target_user_superuser': user.is_superuser,
                for field_name in self.changed_data:
                    logger_extras[field_name] = self.cleaned_data[field_name]
      'user.ldap.update', **logger_extras)

            # Rather than setting the User's entire Groups list to just what's in this form's POST data, we must ensure
            # that only those Groups which belong to the current Site are affected (unless the current user is a
            # superuser), because Groups on other Sites will never be included in the POST data.
            # So, we check if the list of current-Site-Groups that the user belongs to differs from the list that was
            # set in the form.
            existing_local_groups = user.groups.filter(
            if not self.request.user.is_superuser and set(existing_local_groups) != set(self.new_groups):
                # If changes were made to the user's Groups, remove the old list of current-Site-groups
                # and apply the new one.

        return user

When logged in as a superuser, one sees a more normal list view - showing all users with their name and the full list of groups they belong to. And the superuser’s create/edit forms have all the form fields including is_superadmin and is_superuser. The one minor annoyance for the superuser forms is that the group list can get kind of out of hand since it displays all the groups. We never log into any of the sites as root unless we are creating a new site or doing some troubleshooting so we have never done anything about this.

Permission Patches for Multitenancy

To completely separate sites within the Wagtail admin, we need to make changes to page and collection permissions and do some patching of the user management, workflow, and history systems. My previous post covered the mechanics of how we introduce monkey patches into our project. In this post I am going to explain how we have customized Wagtail 5.1’s new PagePermissionPolicy to preserve our version of multitenancy.


It is impractical for us to add our developers to the actual Admin groups of the hundreds of sites on the system, so we invented concept we call “superadmins”. Superadmins are users who the system pretends are in the “Admin” group for whichever site they’re currently logged in to. In this way, our system presents each site to a superadmin as if it’s the only site on the server and lets us see exactly what an actual admin of the site sees. is_superadmin is a boolean field on our user model:

    class User(AbstractUser):
        Replaces the auth.User model with our customized version.
        is_superadmin = models.BooleanField(
            verbose_name='Super Admin',
            help_text='Enable this flag to make this user a Super Admin, which causes the system to treat them like they '
                    'are an Admin on whatever site they are logged into.'

Page Permission Patches

Prior to Wagtail 5.1 we were patching wagtail.admin.auth.user_has_any_page_permission, wagtail.admin.navigation.get_pages_with_direct_explore_permission, and wagtail.core.models.UserPagePermissionsProxy.__init__.

In Wagtail 5.1, UserPagePermissionsProxy and get_pages_with_direct_explore_permission are both deprecated and permission checking has been consolidated into a new PagePermissionPolicy class. I was initially planning to try subclassing PagePermissionPolicy so I could explicitly initialize it with the current site. Because PagePermissionPolicy is instantiated 27 places in 17 different files, switching out the policy class for a subclass is impractical. So I have gone back to our monkey patching strategy.

Method diagram for Wagtail's PagePermissionPolicy

When I diagram the method calls within PagePermissionPolicy, I see that they nearly all go through get_all_page_permissions_for_user - the main method used to query the GroupPagePermissions table. The results of this query are cached and used by other parts of the Wagtail admin interface as needed.

To enforce our site separation requirement, I added a filter for pages on the current site:

    return GroupPagePermission.objects.filter(
        "page", "permission"

To allow superadmins to behave as site admins, I explicitly filtered for the site admin group:

    # Give them the permissions of the site admin group
    group = Group.objects.filter(name=f'{site.hostname} Admins').first()
    return GroupPagePermission.objects.filter(group=group).select_related(
        "page", "permission"

Combining those two, our full version of get_all_page_permissions_for_user is:

    def mutitenant_get_all_page_permissions_for_user(self, user):
        if not user.is_active or user.is_anonymous or user.is_superuser:
            return GroupPagePermission.objects.none()

        # BEGIN PATCH
        request = get_current_request()
        if not request:
                'In PagePermissionPolicy.mutitenant_get_all_page_permissions_for_user but could not get the request.'
            return GroupPagePermission.objects.none()

        # So now restrict checks to permissions for the current site
        site = Site.find_for_request(request)
        if user.is_superadmin:
            # Give them the permissions of the site admin group
            group = Group.objects.filter(name=f'{site.hostname} Admins').first()
            return GroupPagePermission.objects.filter(group=group).select_related(
                "page", "permission"
            # filter for current user and for permissions relevant only to this site
            return GroupPagePermission.objects.filter(
                "page", "permission"
    # Getting this function used is covered below

The behavior changes are both relatively straightforward; the tricky bit is getting the site. In the code above that is taken care of by Site.find_for_request plus our get_current_request method. This could be a problem if get_all_permissions_for_user were called from code that does not have access to the request. Fortunately almost all the places that instantiate PagePermissionPolicy are views or, if the instantiating code is not itself a view, the methods that need the permission policy are only executed from a view. For example, the is_shown method for MenuItem subclasses is only executed when a user is viewing the admin UI.

Looking at the diagram above, you can see in the next to bottom row, in addition to get_all_permissions_for_user, there are two other methods that query GroupPagePermission. Neither of them appear to be in use in the current Wagtail codebase. But for the sake of completeness, I have monkey patched them too:

    def mutitenant_users_with_any_permission(self, actions, include_superusers=True):
        2023-07-22 cnk: I patched this because it had a query in it but as of Wagtail 5.1.1 this
        method is not in use, nor is users_with_permission which delegates to this method
        # User with only "add" permission can still edit their own pages
        actions = set(actions)
        if "change" in actions:

        # BEGIN PATCH
        request = get_current_request()
        if not request:
            logger.error('In PagePermissionPolicy.mutitenant_users_with_any_permission but could not get the request.')
            return get_user_model.objects.none()

        # So now restrict checks to permissions for the current site
        site = Site.find_for_request(request)
        groups = GroupPagePermission.objects.filter(
        ).values_list("group", flat=True)

        q = Q(groups__in=groups)
        # Superadmins will have all page permissions because Admins do
        q |= Q(is_superadmin=True)
        # END PATCH
        if include_superusers:
            q |= Q(is_superuser=True)

        return (

    def multitenant_users_with_any_permission_for_instance(
        self, actions, instance, include_superusers=True
        2023-07-22 cnk: I patched this because it had a query in it but as of Wagtail 5.1.1 the only
        place this is used is send_moderation_notification. Since this is for an instance, it naturally
        filters for just one site - but we need to add in superadmins.
        # Find permissions for all ancestors that match any of the actions
        ancestors = instance.get_ancestors(inclusive=True)
        groups = GroupPagePermission.objects.filter(
        ).values_list("group", flat=True)

        q = Q(groups__in=groups)

        # BEGIN PATCH
        # Superadmins will have all page permissions because Admins do
        q |= Q(is_superadmin=True)
        # END PATCH
        if include_superusers:
            q |= Q(is_superuser=True)

        # If "change" is in actions but "add" is not, then we need to check for
        # cases where the user has "add" permission on an ancestor, and is the
        # owner of the instance
        if "change" in actions and "add" not in actions:
            add_groups = GroupPagePermission.objects.filter(
                permission__codename=get_permission_codename("add", self.model._meta),
            ).values_list("group", flat=True)

            q |= Q(groups__in=add_groups) & Q(pk=instance.owner_id)

        return (

And finally, to get our versions of these files used, we import PagePermissionPolicy and replace the functions:

    from wagtail.permission_policies.pages import PagePermissionPolicy
    PagePermissionPolicy.get_all_permissions_for_user = mutitenant_get_all_page_permissions_for_user
    PagePermissionPolicy.users_with_any_permission = mutitenant_users_with_any_permission
    PagePermissionPolicy.users_with_any_permission_for_instance = multitenant_users_with_any_permission_for_instance

Collection Permission Patches

In addition to managing their own pages, site owners need to be able to manage their own images and documents. Permissions for images and documents are controlled by permissions set on the collection that contains them. When we create a new site, we create a collection for it and allow the site’s Admin group the ability to create collections underneath that parent collection. Permissions for managing the collections are managed by the CollectionManagementPermissionPolicy and permissions that control access to images and documents are controlled by the CollectionOwnershipPermissionPolicy. Both of those use the CollectionPermissionLookupMixin to query GroupCollectionPermission. In the diagrams below, methods coming from CollectionPermissionLookupMixin are denoted with a “*”. Prior to Wagtail 5.1 we were patching CollectionPermissionLookupMixin.check_perm and CollectionPermissionLookupMixin.collections_with_perm but as of Wagtail 5.1 most of the collection permission logic goes through CollectionPermissionLookupMixin.get_all_permissions_for_user.

Document and Image Permissions

The more important set of permissions is in the CollectionOwnershipPermissionPolicy class. This class decides what permissions a user has over the images and documents stored in the site’s collections. As you can see in the diagram below, all of the policy’s queries flow through get_all_permissions_for_user, so we can enforce our rules by patching that one method.

Method diagram for Wagtail's CollectionOwnershipPermissionPolicy

As with page permissions, the first time a Collection model is accessed triggers a query to the GroupCollectionPermission model (via get_all_permissions_for_user) and caches the user’s collection permissions on the user object. So we make similar patches to the ones we made above for pages. We add one line to filter the collection tree to restrict it to permissions for this site and a different change to assign superadmins to the site’s Admin group. Our naming contention ensures the we can find that site’s base collection by knowing the site for this request.

    def mutitenant_get_all_collection_permissions_for_user(self, user):
        This method does a lot of the filtering for collections the user has access to. If we can get a
        request here, we can enforce a lot of our special cases right here.
            1. Users should only see collections for the current site - even if they have permissions on
               other sites. So we need to filter permissions for the site's root collection.
            2. If the user is a superadmin, we need to fake assigning them to the site's Admin group.
        # For these users, we can determine the permissions without querying
        # GroupCollectionPermission by checking it directly in _check_perm()
        if not user.is_active or user.is_anonymous or user.is_superuser:
            return GroupCollectionPermission.objects.none()

        # BEGIN PATCH
        request = get_current_request()
        if not request:
            logger.error('In CollectionPermissionLookupMixin.mutitenant_get_all_permissions_for_user but could not get the request.')
            return GroupCollectionPermission.objects.none()

        # So now restrict checks to the collections for the current site
        site = Site.find_for_request(request)
        collection = Collection.objects.filter(name=site.hostname).first()
        if user.is_superadmin:
            group = Group.objects.filter(name=f'{site.hostname} Admins').first()
            return GroupCollectionPermission.objects.filter(
            ).select_related("permission", "collection")
            return GroupCollectionPermission.objects.filter(
            ).select_related("permission", "collection")
        # END PATCH

    from wagtail.permission_policies.collections import CollectionPermissionLookupMixin
    CollectionPermissionLookupMixin.get_all_permissions_for_user = mutitenant_get_all_collection_permissions_for_user

Collection Management

Collection management permissions allow admins to create their own nested set of collections. As you can see in the diagram below, the CollectionManagementPermissionPolicy’s permissions also all flow through get_all_permissions_for_user so the patch above that we used for managing items stored in collections takes care of most of the policy changes needed for managing the collections themselves.

Collection Management Permissions

Method diagram for Wagtail's CollectionManagementPermissionPolicy

The one additional thing we need to patch is a helper method used to decide which collections a user may delete: _descendants_with_perm. (If we omit this patch, admin’s can’t delete any collections).

    def multitenant__descendants_with_perm(self, user, action):
        Return a queryset of collections descended from a collection on which this user has
        a GroupCollectionPermission record for this action. Used for actions, like edit and
        delete where the user cannot modify the collection where they are granted permission.
        # Get the permission object corresponding to this action
        permission = self._get_permission_objects_for_actions([action]).first()

        # BEGIN PATCH
        # Replace the check for permission on the User's full list of Groups to a check for
        # permissions on only the current Site's Groups. Also take SuperAdmins into account.
        request = get_current_request()
        if not request:
            logger.error('In CollectionManagementPermissionPolicy.multitenant__descendants_with_perm but could not get the request.')
            return Collection.objects.none()

        site = Site.find_for_request(request)
        collection = Collection.objects.filter(name=site.hostname).first()

        # Fill in SuperAdmin groups
        if user.is_superadmin:
            groups = Group.objects.filter(name=f'{site.hostname} Admins').all()
            # user.groups.all() is what is in the original; we could restrict by site but the collection
            # filter will remove permissions not relevant to this site
            groups = user.groups.all()

        # Get the collections that have a GroupCollectionPermission record
        # for this permission and any of the user's groups; create a list of their paths
        # PATCH: restrict to collections belonging to this site
        collection_roots = Collection.objects.descendant_of(collection, inclusive=True).filter(
        ).values("path", "depth")
        # END PATCH

        if collection_roots:
            # build a filter expression that will filter our model to just those
            # instances in collections with a path that starts with one of the above
            # but excluding the collection on which permission was granted
            collection_path_filter = Q(
            ) & Q(depth__gt=collection_roots[0]["depth"])
            for collection in collection_roots[1:]:
                collection_path_filter = collection_path_filter | (
                    & Q(depth__gt=collection["depth"])
            return Collection.objects.all().filter(collection_path_filter)
            # no matching collections
            return Collection.objects.none()

    from wagtail.permission_policies.collections import CollectionManagementPermissionPolicy
    CollectionManagementPermissionPolicy._descendants_with_perm = multitenant__descendants_with_perm

Permissions for other models

We also need per-site permissions to manage other kinds of models - Snippets in Wagtail’s terminology. Please see the last section of Snippets for the code we use in our authentication backend.