Upgrading to Wagtail 3.0
Introduction
Wagtail 3.0 is out with a lot of significant changes in architecture requiring code updates listed in the release notes.
Updating Module Paths
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
.
Updating Custom Panels
Using the New BoundPanel
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).
Custom Panel Updating Examples
Here are a couple of examples I worked through to get up and running:
Regex Panel
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.
Original 2.x code:
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
.
ReadOnly Field
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.
Original 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('{}', self.value)
def render_as_object(self):
return format_html(
'{}'
'{}'
'',
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''
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('{}', self.heading)
return format_html('{}{}', 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(
''
'{}'
'{}'
'{}'
'',
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('{}', self.value)
def render(self):
# return formatted field value
self.value = self.get_value()
return format_html('{}', self.value)
def render_as_object(self):
return format_html(
'{}'
'{}'
'',
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''
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('{}', self.panel.heading)
return format_html('{}{}', 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(
''
'{}'
'{}'
'{}'
'',
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.