pythondjangowagtailwagtail-admin

Correct way to add dynamic form fields to WagtailModelAdminForm


I have a use case where I need to add dynamic form fields to a WagtailModelAdminForm. With standard django I would normally just create a custom subclass and add the fields in the __init__ method of the form. In Wagtail, because the forms are built up with the edit_handlers, this becomes a nightmare to deal with.

I have the following dynamic form:

class ProductForm(WagtailAdminModelForm):
    class Meta:
        model = get_product_model()
        exclude = ['attributes', 'state', 'variant_of']

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.instance:
            self.inject_attribute_fields()

    def inject_attribute_fields(self):
        for k, attr in self.instance.attributes.items():
            field_klass = None
            field_data = attr.get("input")
            field_args = {
                'label': field_data['name'],
                'help_text': field_data['help_text'],
                'required': field_data['is_required'],
                'initial': attr['value'],
            }

            if 'choices' in field_data:
                field_args['choices'] = (
                    (choice["id"], choice["value"])
                    for choice in field_data['choices']
                )

                if field_data['is_multi_choice']:
                    field_klass = forms.MultipleChoiceField
                else:
                    field_klass = forms.ChoiceField
            else:
                typ = field_data['attr_type']
                if typ == 'text':
                    field_klass = forms.CharField

                elif typ == 'textarea':
                    field_klass = forms.CharField
                    field_args['widget'] = forms.Textarea

                elif typ == 'bool':
                    field_klass = forms.BooleanField

                elif typ == 'int':
                    field_klass = forms.IntegerField

                elif typ == 'decimal':
                    field_klass = forms.DecimalField

                elif typ == 'date':
                    field_klass = forms.DateField
                    field_args['widget'] = AdminDateInput

                elif typ == 'time':
                    field_klass = forms.TimeField
                    field_args['widget'] = AdminTimeInput

                elif typ == 'datetime':
                    field_klass = forms.DateTimeField
                    field_args['widget'] = AdminDateTimeInput

            if field_klass is None:
                raise AttributeError('Cannot create widgets for invalid field types.')

            # Create the custom key
            self.fields[f"attributes__{k}"] = field_klass(**field_args)

Next I customized the ModelAdmin EditView (attributes are not present in the create view):

class EditProductView(EditView):
    def get_edit_handler(self):
        summary_panels = [
            FieldPanel('title'),
            FieldPanel('description'),
            FieldPanel('body'),
        ]

        # NOTE: Product attributes are dynamic, so we generate them
        attributes_panel = get_product_attributes_panel(self.instance)

        variants_panel = []
        if self.instance.is_variant:
            variants_panel.append(
                InlinePanel(
                    'stockrecords',
                    classname="collapsed",
                    heading="Variants & Prices"
                )
            )
        else:
            variants_panel.append(ProductVariantsPanel())

          return TabbedInterface([
            ObjectList(summary_panels, heading='Summary'),
            
            # This panel creates dynamic panels related to the dynamic form fields,
            # but raises an error saying that the "fields are missing".
            # Understandable because it's not present on the original model
            # ObjectList(attributes_panel, heading='Attributes'),

            ObjectList(variants_panel, heading='Variants'),
            ObjectList(promote_panels, heading='Promote'),
            ObjectList(settings_panels, heading='Settings'),
        ], base_form_class=ProductForm).bind_to_model(self.model_admin.model)

Here is the get_product_attributes_panel() function for reference:

def get_product_attributes_panel(product) -> list:
    panels = []
    for key, attr in product.attributes.items():
        widget = None
        field_name = "attributes__" + key
        attr_type = attr['input'].get('attr_type')

        if attr_type == 'date':
            widget = AdminDateInput()

        elif attr_type == 'datetime':
            widget = AdminDateTimeInput()

        else:
            if attr_type is None and 'choices' in attr['input']:
                if attr['input']['is_multi_choice']:
                    widget = forms.SelectMultiple
                else:
                    widget = forms.Select
            else:
                widget = forms.TextInput()

        if widget:
            panels.append(FieldPanel(field_name, widget=widget))
        else:
            panels.append(FieldPanel(field_name))

    return panels

So the problem is...

A) Adding the ProductForm in the way I did above (by using it as the base_form_class in TabbedInterface) almost works; It adds the fields to the form; BUT I have no control over the rendering.

B) If I uncomment the line ObjectList(attributes_panel, heading='Attributes'), (to get nice rendering of the fields), then I get an error for my dynamic fields, saying that they are missing.

This is a very important requirement in the project I'm working on.

A temporary workaround is to create a custom panel to render the dynamic fields directly in the html template; But then I lose the Django Form validation, which is also an important requirement for this.

Is there any way to add dynamic fields the the WagtailModelAdminForm, that preserves the modeladmin features such as formsets, permissions etc.


Solution

  • I ended up creating a separate AttributeForm for the attributes.

    A custom Panel then looks for this new form instance as an attribute of the primary form. As an instance on the primary form, I can "clean" this internal form when the primary form clean() is called and, raise any errors that I need to on both forms.

    I then customized the EditView.post() method to make sure that I add the instance of AttributesForm to our primary model form.

    It's a bit of a workaround, but works well enough for now. I wish there was an easier way, but it doesn't look like it right now.