Upgrading to Wagtail 3.0

Wagtail 3.0 is out with a lot of significant changes in architecture requiring code updates listed in the release notes.

A lot of the libraries have moved, requiring updating. Thankfully, there's a useful tool to help you with this which you can run from your root directory:

wagtail updatemodulepaths --list  # list the files to be changed without updating them
wagtail updatemodulepaths --diff  # show the changes to be made, without updating files
wagtail updatemodulepaths  # actually update the files

After that, you'll need to go through and replace the specific panel types: StreamFieldPanel, RichTextFieldPanel, ImageChooserPanel, DocumentChooserPanel and SnippetChooserPanel should now be replaced with FieldPanel.

By far, the biggest work I found was the change in the way custom edit panels are handled in the admin interface, which isn't really documented yet. In particular, the event methods on_request_bound, on_instance_bound and on_form_bound are no longer used. Instead, you need to use the new BoundPanel as an inner class to your custom panel definition.

In general, in 2.x, you would have something along the lines of:

class CustomPanel(FieldPanel):
    def __init__(self, arg1, arg2, *args, **kwargs):
        self.arg1= arg1
        self.arg2 = arg2
        super().__init__(field_name, *args, **kwargs)

    def clone_kwargs(self):
        kwargs = super().clone_kwargs()
        kwargs.update(
            arg1=self.arg1,
            arg2=self.arg2,
        )
        return kwargs

    field_template = "edit_handlers/custom_panel.html"

    def on_form_bound(self):
        some_function(self.arg1, self.arg2)
        super().on_form_bound()

Which will need to be updated to:

class CustomPanel(FieldPanel):
    def __init__(self, arg1, arg2, *args, **kwargs):
        self.arg1= arg1
        self.arg2 = arg2
        super().__init__(field_name, *args, **kwargs)

    def clone_kwargs(self):
        kwargs = super().clone_kwargs()
        kwargs.update(
            arg1=self.arg1,
            arg2=self.arg2,
        )
        return kwargs

    class BoundPanel(FieldPanel.BoundPanel):
        def __init__(self, **kwargs):
            super().__init__(**kwargs)           
            some_function(self.panel.arg1, self.panel.arg2)

        field_template_name = "edit_handlers/custom_panel.html"

The BoundPanel needs to be inherited from the panel type being subclassed (FieldPanel in this case).

The init and clone remain in the custom panel class, everything else gets wrapped in the BoundPanel. Anything in the event trigger just goes into the init of the BoundPanel. If you're using a custom template, the template declaration needs to go inside the BoundPanel also, with the parameter renamed from field_template to field_template_name.

Attributes of the parent custom panel class are exposed in the BoundPanel via self.panel (e.g. self.panel.arg1 in the example above).


Here are a couple of examples I worked through to get up and running:

The first example is a custom FieldPanel that takes a regex pattern and passes it through to a template to control which key presses are allowed on a field input.

The 2.x code looked like this:

from wagtail.admin.edit_handlers import FieldPanel

class RegexPanel(FieldPanel):
    def __init__(self, field_name, pattern, *args, **kwargs):
        self.pattern = pattern
        super().__init__(field_name, *args, **kwargs)

    def clone_kwargs(self):
        kwargs = super().clone_kwargs()
        kwargs.update(
            field_name=self.field_name,
            pattern=self.pattern,
        )
        return kwargs

    field_template = "edit_handlers/regex_panel_field.html"

    def on_form_bound(self):
        self.form.fields[self.field_name].__setattr__('pattern',self.pattern)
        super().on_form_bound()

Updated to 3.x:

from wagtail.admin.edit_handlers import FieldPanel

class RegexPanel(FieldPanel):

    def __init__(self, field_name, pattern, *args, **kwargs):
        self.pattern = pattern
        super().__init__(field_name, *args, **kwargs)

    def clone_kwargs(self):
        kwargs = super().clone_kwargs()
        kwargs.update(
            pattern=self.pattern,
        )
        return kwargs
        
    class BoundPanel(FieldPanel.BoundPanel):
        def __init__(self, **kwargs):
            super().__init__(**kwargs)           
            self.form.fields[self.field_name].__setattr__('pattern', self.panel.pattern)

        field_template_name = "edit_handlers/regex_panel_field.html"

The on_form_bound event code has been shifted into the BoundPanel init method with field_template renamed to field_template_name and also declared within the BoundPanel.

The next example subclasses the EditHandler directly to create a read-only field with an option to include a hidden input field so that the data can be parsed in the clean method before saving.

2.x code:

class ReadOnlyPanel(EditHandler):
    """ ReadOnlyPanel EditHandler Class
        Usage:
        attr:               name of field to display
        style:              optional, any valid style string
        add_hidden_input:   optional, add a hidden input field to allow retrieving data in form_clean (self.data['field'])
        If the field name is invalid, or an error is received getting the value, empty string is returned.
        """
    def __init__(self, attr, style=None, add_hidden_input=False, *args, value=None, **kwargs):
        # error if attr is not string
        if type(attr)=='str':
            self.attr = attr
        else:
            try:
                self.attr = str(attr)
            except:
                pass
        self.style = style
        self.add_hidden_input = add_hidden_input
        super().__init__(*args, **kwargs)

    def get_value(self):
        # try to get the value of field, return empty string if failed
        try:
            value = getattr(self.instance, self.attr)
            if callable(value):
                value = value()
        except AttributeError:
            value = ''
        return value
        
    def clone(self):
        return self.__class__(
            attr=self.attr,
            heading=self.heading,
            classname=self.classname,
            help_text=self.help_text,
            style=self.style,
            add_hidden_input=self.add_hidden_input,
            value=None,
        )

    def render(self):
        # return formatted field value
        self.value = self.get_value()
        return format_html('<div style="padding-top: 1.2em;">{}</div>', self.value)

    def render_as_object(self):
        return format_html(
            '<fieldset>{}'
            '<ul class="fields"><li><div class="field">{}</div></li></ul>'
            '</fieldset>',
            self.heading('legend'), self.render())

    def hidden_input(self):
        # add a hidden input field if selected, field value can be retrieved in form_clean with self.data['field']
        if self.add_hidden_input:
            input = f'<input type="hidden" name="{self.attr}" value="{self.value}" id="id_{self.attr}">'
            return format_html(input)
        return ''

    def heading_tag(self, tag):
        # add the label/legen tags only if heading supplied
        if self.heading:
            if tag == 'legend':
                return format_html('<legend>{}</legend>', self.heading)
            return format_html('<label>{}{}</label>', self.heading, ':')
        return ''

    def get_style(self):
        # add style if supplied
        if self.style:
            return format_html('style="{}"', self.style)
        return ''

    def render_as_field(self):
        # render the final output
        return format_html(
            '<div class="field" {}>'
            '{}'
            '<div class="field-content">{}</div>'
            '{}'
            '</div>',
            format_html(self.get_style()), self.heading_tag('label'), self.render(), self.hidden_input())

Without updating, this will throw an unhandled error 'BoundPanel' object has no attribute 'template_name' - you're expected to define the BoundPanel as a template component. This is already done for you when you create the BoundPanel inside the outer component.

Again, to make this 3.x compatible, everything other than init and clone gets wrapped in the BoundPanel, which this time is a subclass of the EditHandler class (EditHandler.BoundPanel)

Retrieving the value of the form field is done directly from the BoundPanel, all other attributes are accessed via self.panel.

Updated to 3.x:

class ReadOnlyPanel(EditHandler):
    """ ReadOnlyPanel EditHandler Class
        Usage:
        fieldname:          name of field to display
        style:              optional, any valid style string
        add_hidden_input:   optional, add a hidden input field to allow retrieving data in form_clean (self.data['field'])
        If the field name is invalid, or an error is received getting the value, empty string is returned.
        """
    def __init__(self, fieldname, style=None, add_hidden_input=False, *args, **kwargs):
        # error if fieldname is not string
        if type(fieldname)=='str':
            self.fieldname = fieldname
        else:
            try:
                self.fieldname = str(fieldname)
            except:
                pass
        self.style = style
        self.add_hidden_input = add_hidden_input
        super().__init__(*args, **kwargs)

    def clone(self):
        return self.__class__(
            fieldname=self.fieldname,
            heading=self.heading,
            help_text=self.help_text,
            style=self.style,
            add_hidden_input=self.add_hidden_input,
        )
        
    class BoundPanel(EditHandler.BoundPanel):

        def get_value(self):
            # try to get the value of field, return empty string if failed
            try:
                value = getattr(self.instance, self.panel.fieldname)
                if callable(value):
                    value = value()
            except AttributeError:
                value = ''
            return value
        
        def render_html(self):
            # return formatted field value
            self.value = self.get_value()
            return format_html('<div style="padding-top: 1.2em;">{}</div>', self.value)

        def render(self):
            # return formatted field value
            self.value = self.get_value()
            return format_html('<div style="padding-top: 1.2em;">{}</div>', self.value)

        def render_as_object(self):
            return format_html(
                '<fieldset>{}'
                '<ul class="fields"><li><div class="field">{}</div></li></ul>'
                '</fieldset>',
                self.panel.heading('legend'), self.render())

        def hidden_input(self):
            # add a hidden input field if selected, field value can be retrieved in form_clean with self.data['field']
            if self.panel.add_hidden_input:
                input = f'<input type="hidden" name="{self.panel.fieldname}" value="{self.value}" id="id_{self.panel.fieldname}">'
                return format_html(input)
            return ''

        def heading_tag(self, tag):
            # add the label/legend tags only if heading supplied
            if self.heading:
                if tag == 'legend':
                    return format_html('<legend>{}</legend>', self.panel.heading)
                return format_html('<label>{}{}</label>', self.panel.heading, ':')
            return ''

        def get_style(self):
            # add style if supplied
            if self.panel.style:
                return format_html('style="{}"', self.panel.style)
            return ''

        def render_as_field(self):
            # render the final output
            return format_html(
                '<div class="field" {}>'
                '{}'
                '<div class="field-content">{}</div>'
                '{}'
                '</div>',
                format_html(self.get_style()), self.heading_tag('label'), self.render(), self.hidden_input())

One other thing to watch out for that I came across, the panel request object no longer has a get_raw_uri() method:

path = self.request.get_raw_uri()

but you can coerce this to a string to return the uri:

path = str(self.request)


Hopefully you'll find this useful when coming to upgrade to Wagtail 3.x and you'll have a much smoother transition armed with some prior knowledge.

Feel free to add any questions/comments below, or contact me directly.