If you want to run several sites from the same Wagtail codebase, you have a couple of options which are summarized in the Wagtail docs.
Wagtail fully supports “multi-site” installations where “where content creators go into a single admin interface and manage the content of multiple websites”. But at work, we would like our Wagtail installation to treat every site as if it were completely independent. So if you have permissions on Site A and Site B, when you’re logged in to Site A, you should only see content, images, etc. from Site A. We also want site owners to be able to manage just about everything for their site. This means that they need to be able to configure their own site’s settings, manage their own collections, images, and documents and manage their own users. This series of blog posts will cover the changes we have made to enforce our version of multitenancy for sites built with the Wagtail CMS.
- Monkey Patching Wagtail
- Permission Patches for Multitenancy
- Users and Groups
- Site Creator
- Snippets
- Snippet Choosers
- Reports
These posts were originally written describing our patches while running Wagtail 5.1 (and Django 3.2). I have subsequently updated them for additional patches I made to upgrade to Wagtail 6.0 (and Django 4.2).
Determining the current site
When we first started using Wagtail, it included its own site middleware so request.site
was
available in all views. When this was removed in Wagtail 2.9, we started using
CRequestMiddleware to make the request information
available from a variety of contexts. We generally access the request via our own
get_current_request
method which allows us to provide a useful error message if the request is
not available.
def get_current_request(default=None, silent=True, label='__DEFAULT_LABEL__'):
"""
Returns the current request.
You can optionally use ``default`` to pass in a fake request object to act as the default if there
is no current request, e.g. when ``get_current_request()`` is called during a manage.py command.
:param default: (optional) a fake request object
:type default: an object that emulates a Django request object
:param silent: If ``False``, raise an exception if CRequestMiddleware can't get us a request object. Default: True
:type silent: boolean
:param label: If ``silent`` is ``False``, put this label in our exception message
:type label: string
:rtype: a Django request object
"""
request = CrequestMiddleware.get_request(default)
if request is None and not silent:
raise NoCurrentRequestException(
"{} failed because there is no current request. Try using djunk.utils.FakeCurrentRequest.".format(label)
)
return request
NOTE: get_current_request
has a parameter for setting a default site if none is available
when the method is called but in practice we never provide a default site in code that is trying to
access the request. Instead we use one of the methods below to fake the request and then let
get_current_request
use that to determine the site.
Setting current site in scripts and tests
Our data imports, manage.py scripts, and tests do not have a browser context, so
get_current_request
will fail in those circumstances. We have created a couple of methods to
help set the request and site in those circumstances. This is working but it remains a bit of a pain
point.
class FakeRequest:
"""
FakeRequest takes the place of the django HTTPRequest object in various testing scenarios where
a real one doesn't exist, but the code under test expects one to be there.
Wagtail 2.9 now determines the current Site by looking at the hostname and port in the request object,
which means it calls get_host() on our faked out requests. Thus, we need to emulate it.
"""
def __init__(self, site=None, user=None, **kwargs):
self.user = user
# Include empty GET and POST attrs, so code which expects request.GET or request.POST to exist won't crash.
self.GET = self.POST = {}
# Callers can override GET and POST, or override/add any other attribute using kwargs.
self.__dict__.update(kwargs)
self._wagtail_site = site
def get_host(self):
if not self._wagtail_site:
return 'fakehost'
return self._wagtail_site.hostname
def get_port(self):
# It should be safe to pretend all test traffic is on port 443.
# HTTPRequest.get_port() explicitly returns a string, so we do, too.
return '443'
def set_fake_current_request(site=None, user=None, request=None, **kwargs):
"""
Sets the current request to either a specified request object or a FakeRequest object built from the given Site
and/or User. Any additional keyword args are added as attributes on the FakeRequest.
"""
# If the caller didn't provide a request object, create a FakeRequest.
if request is None:
request = FakeRequest(site, user, **kwargs)
# Set the created (or provided) request as the "current request".
CrequestMiddleware.set_request(request)
return request
class FakeCurrentRequest():
"""
Implements set_fake_current_request() as a context manager. Use like this:
with FakeCurrentRequest(some_site, some_user):
// .. do stuff
OR
with FakeCurrentRequest(request=some_request):
// .. do stuff
When the context manager exits, the current request will be automatically reverted to its previous state.
"""
NO_CURRENT_REQUEST = 'no_current_request'
def __init__(self, site=None, user=None, request=None, **kwargs):
self.site = site
self.user = user
self.request = request
self.kwargs = kwargs
def __enter__(self):
# Store a copy of the original current request, so we can restore it when the context manager exits.
self.old_request = CrequestMiddleware.get_request(default=self.NO_CURRENT_REQUEST)
return set_fake_current_request(self.site, self.user, self.request, **self.kwargs)
def __exit__(self, *args):
if self.old_request == self.NO_CURRENT_REQUEST:
# If there wasn't a current request when we entered the contact manager, remove the current request.
CrequestMiddleware.del_request()
else:
# Otherwise, set the current request back to whatever it was when we entered.
CrequestMiddleware.set_request(self.old_request)