CNK's Blog

Wagtail 3 Upgrade: Per Site Features

At work we run a large multitenant Wagtail application. Most of the time when one of our customers asks for a feature, we add it and make it available to everyone. But occasionally we get a request that we are willing to add for a specific site (or handful of sites) but do not want to make generally available. A few of our customers have interactive displays in their building and they would like to display content from their web site but don’t want to devote space to some items that are on every page - for example the header, footer, and navigation. This makes a lot of sense for this use case, but we don’t want other groups abusing this feature to opt out of our branding. So, we use feature flags to enable “bare pages” on only a few sites.

We have one code base for all our sites, but within that we have two different sets of features (such as page types and the front end look and feel). Which set of features a site gets is controlled by its theme. Because of some history, the current themes are named ‘v6.1’ and ‘v7.0.

Because every site will need a theme, every site will have a Features setting. And every request will need to start by figuring out what site it is for and then what theme it should use. To set request.site on for each request, we use a version of Wagtail’s SiteMiddleware, which is still available in wagtail.contrib.legacy. so our MIDDLEWARE setting looks something like:

    MIDDLEWARE = [
        # Django's "default" middleware, in the appropriate order according to Django 3 docs.
        ...

        # Wagtail's SiteMiddleware
        wagtail.contrib.legacy.SiteMiddleware',

        # Enables the use of the get_current_request() and get_current_user() functions.
        'crequest.middleware.CrequestMiddleware',
    ]

Our Features model looks like the code below. Please note that we have never used the ability to “disable a default feature” so if you want to copy this code, I would remove that.

    @register_setting
    class Features(BaseSetting):
        """
        This is a Settings model that has a one-to-one relationship with each Site in the system.
        It stores json blobs that configure its Site's available features. Features are defined through the
        "register_feature" hook, and can have two types:

        1) Default Features. These features ere enabled by default on all Sites, but can be explicitly disabled via the
           Features form. These include features like particular Block types.
        2) Special Features. These are features that are only used by a small subset of the Sites on a system, and are
           therefore disabled by default. They can be enabled through the Features form.
           These include features like HSS's Working Papers, or the Startup Map used by OTTCP.

        Implementing what "disabling" a Default Feature, or "enabling" a Special Feature actually means is left up to the
        code that registers the feature. This module only stores the data for which Site enables/disables which Features.
        """
        THEMES = [
            (THEME_61, 'v6.1'),
            (THEME_70, 'v7.0'),
        ]

        # FIELDS
        disabled_defaults = jsonfield.JSONField(default=[])
        enabled_specials = jsonfield.JSONField(default=[])
        site_theme = models.CharField(
            "Site Theme",
            max_length=10,
            choices=THEMES,
            default=THEME_70,
            help_text="This setting is only visible to superusers. DO NOT CHANGE THIS SETTING ON ESTABLISHED SITES."
        )

        # FORM CONFIG
        panels = [
            FieldPanel('disabled_defaults', classname='disabled-defaults'),
            FieldPanel('enabled_specials', classname='enabled-specials'),
            FieldPanel('site_theme', classname='site-theme'),
        ]
        base_form_class = FeaturesForm

        def feature_is_enabled(self, machine_name):
            """
            Returns True if the Feature with the given machine name is enabled on the associated Site.
            Since machine names cannot be shared across Special and Default features, this method works for both types.
            """
            if machine_name in registry['special']:
                return machine_name in self.enabled_specials
            elif machine_name in registry['default']:
                return machine_name not in self.disabled_defaults
            else:
                raise UnknownFeatureMachineNameError("No Feature exists with the machine name '{}'".format(machine_name))

        class Meta:
            verbose_name = 'Site Features'

Various parts of the code can register features, we don’t know all the available features at class definition time, so we need a form that will create the list at the time the form is instantiated.

    class FeaturesForm(WagtailAdminModelForm):
        css_class = "features-form rich-settings"

        disabled_defaults = forms.MultipleChoiceField(
            required=False,
            widget=forms.CheckboxSelectMultiple,
            label='Disabled Default Features',
            choices=[],
            help_text=mark_safe("Select Default Features that should be <b>disabled</b> on this Site.")
        )
        enabled_specials = forms.MultipleChoiceField(
            required=False,
            widget=forms.CheckboxSelectMultiple,
            label='Enabled Special Features',
            choices=[],
            help_text=mark_safe("Select Special Features that should be <b>enabled</b> on this Site.")
        )

        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.fields['disabled_defaults'].choices = sorted(registry['default'].items())
            self.fields['enabled_specials'].choices = sorted(registry['special'].items())

Our “bare page” feature is available for a couple of different page types - so they get a “bare_page” field and then their page templates have the necessary code to remove parts of the page when that page attribute is true. The ‘bare_page’ FieldPanel is included in the panels just like a normal field, but then we have a custom form for those page types and it takes care of removing the field from the form unless the “bare page” feature is enabled for the site.

    class FlexPage(BasePage):
        # field definitions here
        bare_page = models.BooleanField(default=False, help_text="Render the page without a header or footer.")

        # Editor Panels Configuration
        flex_content_panels = [
            FieldPanel('title', classname='full title'),
            FieldPanel('body')
        ]

        flex_settings_panels = [
            MultiFieldPanel(
                heading='Page Settings',
                children=[
                    FieldPanel('slug'),
                    FieldPanel('bare_page'),
                ]
            )
        ]

        edit_handler = TabbedInterface(
            base_form_class=BarePageForm,
            children=[
                ObjectList(flex_content_panels, heading='Content'),
                ObjectList(flex_settings_panels, heading='Settings', classname='settings'),
                ObjectList(flex_publishing_panels, heading='Publishing'),
            ]
        )

--------------------------------

    class BarePageForm(WagtailAdminPageForm):

        def __init__(self, *args, **kwargs):
            """
            Starting with Wagtail 3, we do our form manipulation in the form class initializer,
            not in a get_edit_handler class method.
            """
            super().__init__(*args, **kwargs)

            request = get_current_request()
            if request and not Site.find_for_request(request).features.feature_is_enabled('bare_page'):
                del self.fields['bare_page']