CNK's Blog

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.

Example

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.

Models

    # models.py
    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(
            Site,
            related_name='locations',
            on_delete=models.CASCADE
        )

        class Meta:
            ordering = ['name']

        def __str__(self):
            return self.name

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.

    # wagtail_hooks.py
    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 = [
            MultiFieldPanel(
                heading='Location',
                children=[
                    FieldPanel('name'),
                    FieldPanel('building_name'),
                    FieldPanel('room_number'),
                ]
            )
        ]
        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(
                label=self.menu_label,
                url=self.menu_url,
                name=self.menu_name,
                icon_name=self.menu_icon,
                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.

    # utils.py
    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'))
            try:
                if self.instance and self.instance.site and self.instance.site != current_site:
                    raise ValidationError(
                        f'The Site associated with this {self.instance.__class__.__name__} is {self.instance.site}, 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 self.instance.site does not resolve.
                pass

            # 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:
                        continue
                    # 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']:
                            self.add_error(
                                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.
                instance.site = 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:
                instance.save()
                self.save_m2m()
            return instance

Snippets Index View

The snippets index view shows counts of objects of each type. We will need those counts scoped to the current site. As discussed in Monkey Patching Wagtail, I subclass the SnippetIndexView and replace the query for snippets with a version of our own. Now the counts on the index page match the number of items on the model index pages.

    patched_url_patterns = [...
        path('admin/snippets/', MultitenantModelIndexView.as_view()),
    ]

    # views/snippets.py
    from wagtail.snippets.views.snippets import ModelIndexView

    class MultitenantModelIndexView(ModelIndexView):
        def _get_snippet_types(self):
            """
            Override this to restrict the model counts to items in the current site
            """
            current_site = Site.find_for_request(get_current_request2('SnippetsView'))
            return [
                {
                    "name": capfirst(model._meta.verbose_name_plural),
                    "count": model.objects.filter(site=current_site).all().count() ,  # PATCH
                    "model": model,
                }
                for model in get_snippet_models()
                if user_can_edit_snippet_type(self.request.user, model)
            ]

Permissions

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/backends.py
    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')
        else:
            # 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
            )