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)