CNK's Blog

Reports and Filters

Most of the heavy lifting for our multitenancy changes is taken care of by our permission patches. But there are still a few places where we need to filter items by site - or remove an explicit site filter.

Page Explorer Filters

Wagtail 6.0, introduced “Universal Listings”, a way to combine full text search with a series of filters to hone in on the content you want to edit. One of the included filters is a filter for the site - but we never want someone navigating between sites even if they have permissions to edit more than one site. So we’ll want to remove the site filter. We also need to pare down the filters that offer you a list of users. Out of the box, these filters will list everyone who has edited a page, or has unlock permission across the entire installation. We need to limit these filters to only users who belong to one of the current site’s groups.

As discussed in Monkey Patching Wagtail, to patch a filter, we need to alter the view that uses it. The filterset_class is an attribute of the index view and the easiest way to alter the view is to subclass it and then map your subclass to the same url as the original view. Let me give you the code snippets in the opposite direction. Starting from the url and working our way down through the view to the filters.

    # 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()),
    ]

In MultitenantPageIndexView, you can override whatever you need to to change the PageExplorer. Our permission patches take care of limiting the pages to the current site, so the only thing we need to change is the filters. This is done by setting the filterset_class attribute.

    # wagtail_patches/views/page_explorer.py
    from wagtail.admin.views.pages.listing import IndexView

    class MultitenantPageIndexView(IndexView):
        filterset_class = MultitenantPageFilterSet

OK now, finally, let’s mess with the filters. If we were only tweaking one thing, it might be easier to subclass the existing PageFilterSet and change a specific method. But given the number of changes, including removing the site attribute, I thought it was clearer to just copy the PageFilterSet logic into my function and then alter it.

Our init method pulls up some of the “infer the base queryset” information from django_filters to enforce starting with pages from this site. Then I patched the 2 filters that provide a list of users who have performed some action. And finally, I omitted the site filter all together.

    # wagtail_patches/views/page_explorer.py
    class MultitenantPageFilterSet(WagtailFilterSet):
        def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
            # BEGIN PATCH/Override
            request = request or get_current_request()
            # If we weren't sent the request, and couldn't get it from the middleware, we return nothing.
            if not request:
                queryset = Page.objects.none()
            else:
                root_path = Site.find_for_request(request).root_page.path
                queryset = Page.objects.filter(path__startswith=root_path)
            # END PATCH
            super().__init__(data, queryset, request=request, prefix=prefix)

        content_type = MultipleContentTypeFilter(
            label=_("Page type"),
            queryset=lambda request: get_page_content_types_for_theme(request, include_base_page_type=False),
            widget=CheckboxSelectMultiple,
        )
        latest_revision_created_at = DateFromToRangeFilter(
            label=_("Date updated"),
            widget=DateRangePickerWidget,
        )
        owner = MultipleUserFilter(
            label=_("Owner"),
            queryset=(
                lambda request: get_user_model().objects.filter(
                    # BEGIN PATCH
                    pk__in=Page.objects.descendant_of(Site.find_for_request(request).root_page)
                    # END PATCH
                    .values_list("owner_id", flat=True)
                    .distinct()
                )
            ),
            widget=CheckboxSelectMultiple,
        )
        edited_by = EditedByFilter(
            label=_("Edited by"),
            queryset=(
                lambda request: get_user_model().objects.filter(
                    pk__in=PageLogEntry.objects
                    # BEGIN PATCH
                    .filter(page__path__startswith=Site.find_for_request(request).root_page.path, action="wagtail.edit")
                    # END PATCH
                    .order_by()
                    .values_list("user_id", flat=True)
                    .distinct()
                )
            ),
            widget=CheckboxSelectMultiple,
        )
        has_child_pages = HasChildPagesFilter(
            label=_("Has child pages"),
            empty_label=_("Any"),
            choices=[
                ("true", _("Yes")),
                ("false", _("No")),
            ],
            widget=RadioSelect,
        )

        class Meta:
            model = Page
            fields = []  # only needed for filters being generated automatically

Reports

In a number of cases, we need to make similar patches to our report views - to limit the pages or users offered in the filter widget. Here is our current set of overridden report views:

    # patched_urls.py
    # Inside the urlpatterns list... these override the wagtailadmin_reports:* views.
    path('admin/reports/locked/', MultitenantLockedPagesView.as_view()),
    # two views related to page type use
    path('admin/reports/page-types-usage/', MultitenantPageTypesUsageReportView.as_view()),
    path('admin/pages/usage/<slug:content_type_app_name>/<slug:content_type_model_name>/',
         MultitenantContentTypeUseView.as_view()),
    path('admin/reports/site-history/', MultitenantSiteHistoryView.as_view()),
    # This overrides the wagtailadmin_pages:history view.
    path('admin/pages/<int:page_id>/history/', MultitenantPageHistoryView.as_view()),

Locked Pages

The locked pages report needed 2 changes - the first to remove instances the user may have permission to edit but which is not in the current site. The second one customizes the filter to restrict the list of users displayed in the filter to only those who have locked pages on this site.

    # wagtail_patches/views/reports/locked_pages.py
    def site_specific_get_users_for_filter():
        """
        Only show users who have locked pages on the current Site.
        """
        request = get_current_request()
        # If we weren't sent the request, and couldn't get it from the middleware, we have to give up and return nothing.
        if not request:
            return get_user_model().objects.none()

        site = Site.find_for_request(request)
        User = get_user_model()
        return User.objects.filter(
            locked_pages__isnull=False,
            groups__name__startswith=site.hostname
        ).distinct().order_by(User.USERNAME_FIELD)


    class MultitenantLockedPagesReportFilterSet(LockedPagesReportFilterSet):
        locked_by = django_filters.ModelChoiceFilter(
            field_name="locked_by", queryset=lambda request: site_specific_get_users_for_filter()
        )


    class MultitenantLockedPagesView(LockedPagesView):
        filterset_class = MultitenantLockedPagesReportFilterSet

        def get_queryset(self):
            # BEGIN PATCH
            # The original had an "OR locked by you" that we needed to get rid of
            pages = (
                PagePermissionPolicy().instances_user_has_permission_for(
                    self.request.user, "change"
                ).filter(locked=True)
                .specific(defer=True)
            )

            self.queryset = pages
            # Skip Wagtail's version of LockedPagesView and go to its parent PageReportView
            return super(LockedPagesView, self).get_queryset()
            # END PATCH

Page Type Usage

There is a new PageTypes report that arrived in Wagtail 6.0. We need to restrict its counts to the pages on this site. This view only needed the base queryset changed but I also wanted to remove the site list (we don’t want to leak that information to owners of other sites). And we are don’t use internationalization, so I wanted to get rid of the filters completely. The way I did this was kind of hacky. If I only set the filterset_class to None, it was still getting called - so I monkey patched all of the report’s get_queryset and removed the part that was calling for the existing queryset to be filtered by our useless site and local options.

    #  wagtail_patches/views/reports/page_usage.py
    class MultitenantPageTypesUsageReportView(PageTypesUsageReportView):
        # BEGIN PATCH
        filterset_class = None
        # END PATCH

        def get_queryset(self):
            # BEGIN PATCH
            page_models = get_page_models_for_theme(self.request)
            queryset = ContentType.objects.filter(
                model__in=[model.__name__.lower() for model in page_models]
            ).all()

            # Removed code for multisite support and removed locale & site filters
            # Cheat and hard-code filter values for locale and site to search the current site only.
            language_code = None
            site_root_path = Site.find_for_request(self.request).root_page.path
            # END PATCH

            queryset = _annotate_last_edit_info(queryset, language_code, site_root_path)

            queryset = queryset.order_by("-count", "app_label", "model")

            return queryset


    class MultitenantContentTypeUseView(ContentTypeUseView):

        def get_queryset(self):
            # BEGIN PATCH
            root_page = Site.find_for_request(self.request).root_page
            return self.page_class.objects.descendant_of(root_page, inclusive=True).all().specific(defer=True)
            # END PATCH

Site History report

Wagtail’s site history report tracks changes for all pages and snippet models. Per usual, we only want to show information for the current site. It is relatively easy to do this for pages - we can filter using the page tree. In theory we could also filter snippet model information using the site_id but that would involve writing a gigantic query that joined the model logging table to all the model tables. That isn’t feasible so we only show model history to superusers who can see all of the information anyway.

    def site_specific_base_viewable_by_user(self, user):
        if user.is_superuser:
            return self.all()
        else:
            return self.none()
    from wagtail.models import BaseLogEntryManager  # noqa
    BaseLogEntryManager.viewable_by_user = site_specific_base_viewable_by_user

The site history page has a filter by content types, so we need to remove all the non-page models for normal users.

    class MultitenantSiteHistoryView(LogEntriesView):
        """
        We force this view to be used in place of LogEntriesView through the use of wagtail_patches/patched_urls.py.
        """
        filterset_class = MultitenantSiteHistoryReportFilterSet


    class MultitenantSiteHistoryReportFilterSet(SiteHistoryReportFilterSet):
        user = django_filters.ModelChoiceFilter(field_name='user', queryset=site_specific_users_who_have_edited_pages)
        object_type = ContentTypeFilter(
            label='Type',
            method='filter_object_type',
            queryset=lambda request: site_specific_get_content_types_for_filter(),
        )


    def site_specific_get_content_types_for_filter():
        """
        This is a tweaked version of wagtail.admin.views.reports.audit_logging.get_content_types_for_filter() that
        only returns Page content types, unless the user is a Superuser, and thus allowed to edit Snippets directly.
        """
        content_type_ids = set()
        for log_model in registry.get_log_entry_models():
            request = get_current_request()
            if log_model.__name__ == 'PageLogEntry' or (request and request.user.is_superuser):
                content_type_ids.update(log_model.objects.all().get_content_type_ids())

        return ContentType.objects.filter(pk__in=content_type_ids).order_by('model')

    def site_specific_users_who_have_edited_pages(request):
        """
        Only show users who have modified pages on the current Site.
        """
        request = request or get_current_request()
        # If we weren't sent the request, and couldn't get it from the middleware, we have to give up and return nothing.
        if not request:
            return get_user_model().objects.none()

        root_path = Site.find_for_request(request).root_page.path
        user_pks = set(PageLogEntry.objects.filter(page__path__startswith=root_path).values_list('user__pk', flat=True))
        return get_user_model().objects.filter(pk__in=user_pks).order_by('last_name')

Page History view

History information for pages is also available from a link on the edit form sidebar and in the page listing. To make sure that is only allowing history for pages on the current site, we replaced the get_object_or_404 with equivalent code that checks the page belongs to the site. And we patched the filters to use the same user query as above.

    class MultitenantPageHistoryView(PageHistoryView):
        """
        This subclass reports the Page history for only the current Site, rather than the entire server.
        We force this view to be used in place of PageHistoryView through the use of wagtail_patches/patched_urls.py.
        """
        filterset_class = MultitenantPageHistoryReportFilterSet

        @method_decorator(user_passes_test(user_has_any_page_permission))
        def dispatch(self, request, *args, **kwargs):
            # BEGIN PATCH
            # Unwrap get_object_or_404 so we can adjust the query
            root_page = Site.find_for_request(request).root_page
            page = Page.objects.filter(pk=kwargs['page_id']).descendant_of(root_page, inclusive=True).first()
            if page:
                self.page = page.specific
            else:
                raise Http404("No page matches the given query.")
            # END PATCH

            return super(PageHistoryView, self).dispatch(request, *args, **kwargs)

    class MultitenantPageHistoryReportFilterSet(PageHistoryReportFilterSet):
        # This class lets us redefine user's 'queryset' callable to the same one as MultitenantSiteHistoryReportFilterSet.
        user = django_filters.ModelChoiceFilter(field_name='user', queryset=site_specific_users_who_have_edited_pages)