Micrified
Micrified

Reputation: 3660

Populating CheckboxSelectMultiple widget using my own model in Wagtail admin

Context

I've created a model, corresponding field model, and intend to reuse the built-in CheckboxSelectMultiple widget for use within the Wagtail admin. The concept is a multiple-select permission field that is saved as a bit-field:

# Model class
class Perm(IntFlag):
    Empty  = 0
    Read   = 1
    Write  = 2

I used Django's model field's documentation to create a field model that can translate my Perm type to and from my database (saved as an integer field that bitwise OR's the respective permission bits):

# Model field class
class PermField(models.Field):
    description = "Permission field"
    def __init__(self, value=Perm.Empty.value, *args, **kwargs):
        self.value = value
        kwargs["default"] = Perm.Empty.value
        super().__init__(*args, **kwargs)
    def deconstruct(self):
        name, path, args, kwargs = super().deconstruct()
        args += [self.value]
        return name, path, args, kwargs
    def db_type(self, connection):
        return "bigint" # PostgresSQL
    def from_db_value(self, value, expression, connection):
        if value is None:
            return Perm.Empty
        return Perm(value)
    def to_python(self, value):
        if isinstance(value, Perm):
            return value
        if isinstance(value, str):
            return self.parse(value)
        if value is None:
            return value
        return Perm(value)
    def parse(self, value):
        v = Perm.Empty
        if not isinstance(ast.literal_eval(value), list):
            raise ValueError("%s cannot be converted to %s", value, type(Perm))
        for n in ast.literal_eval(value):
            v = v | Perm(int(n))
        return v

Then, I also created a Wagtail snippet to use this new field and type:

perm_choices = [
    (Perm.Read.value, Perm.Read.name),
    (Perm.Write.value, Perm.Write.name)
]

@register_snippet
class Permission(models.Model):
    name = models.CharField(max_length=32, default="None")
    perm = PermField()
    panels = [FieldPanel("perm", widget=forms.CheckboxSelectMultiple(choices=perm_choices))]


Problem

Creating new snippets works fine, but editing an existing one simply shows an empty CheckboxSelectMultiple widget:

Empty Wagtail CheckboxSelectMultiple upon editing an existing snippet


Solution attempts

I clearly need to populate the form when it's initialised. Ideally, making use of the built-in CheckboxSelectMultiple widget. To do that, I tried defining the following form:


@register_snippet
class Permission(models.Model):
    # ...

# Custom form subclass for snippets per documentation
# https://docs.wagtail.org/en/v2.15/advanced_topics/customisation/page_editing_interface.html
class Permission(WagtailAdminModelForm):
    p = forms.IntegerField(
        widget=forms.CheckboxSelectMultiple(
            choices=perm_choices,
        ),
        label="Permission field",
    )
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['p'].initial = {
            k.name for k in [Perm.Read, Perm.Write] if k.value in Perm(int(p))
        }

    def clean_selected_permissions(self):
        selected_p = self.cleaned_data["p"]
        value = Perm.Empty
        for k in selected_p:
            value |= Perm.__members__[k]
        return value

    class Meta:
        model=Permission
        fields=["perm"]

# Models not defined yet error here!
Permission.base_form_class = PermissionForm

However, I cannot get this form to work. There's a cycle where PermissionForm requires Permission to be defined or vice-versa. Using a global model form assignment as seen here by gasman did not work for me. I'm also wondering if there's a simpler approach to solving the problem I'm facing that I'm just not seeing.


Similar questions that didn't address my problem

Upvotes: 1

Views: 79

Answers (1)

Micrified
Micrified

Reputation: 3660

Okay, I figured it out. In summary, you'll have to create the following:

  1. Field type: Create your field class (e.g. like my PermissionField type in the question).
  2. Model: Create your normal Django/Wagtail model (which uses your custom field, and form class)
  3. Form: Create your model form, preferably subclassing something already from Wagtail or Django like CheckboxSelectMultiple
    • Override the get_context method of the form to add your "checked" state.
  4. Template: Use a custom template to be able to set the checkbox state.

Field Type

This is basically unchanged from the question

class PermField(models.Field):
    description = "Permission field"
    def __init__(self, value=Perm.Empty.value, *args, **kwargs):
        self.value = value
        kwargs["default"] = Perm.Empty.value
        super().__init__(*args, **kwargs)
    def deconstruct(self):
        name, path, args, kwargs = super().deconstruct()
        args += [self.value]
        return name, path, args, kwargs
    def db_type(self, connection):
        return "bigint" # PostgresSQL
    def from_db_value(self, value, expression, connection):
        if value is None:
            return Perm.Empty
        return Perm(value)
    def to_python(self, value):
        if isinstance(value, Perm):
            return value
        if isinstance(value, str):
            return self.parse(value)
        if value is None:
            return value
        return Perm(value)
    def parse(self, value):
        v = Perm.Empty
        if isinstance(ast.literal_eval(value), int):
            return Perm(ast.literal_eval(value))
        if not isinstance(ast.literal_eval(value), list):
            raise ValueError("%s cannot be converted to %s", value, type(Perm))

        # Note: The form will use a list: "['1','2',...]" that you need to
        #.      convert back into an actual value of your field type.
        for n in ast.literal_eval(value):
            v = v | Perm(int(n))
        return v

Pay attention to the parse method, since when your form is saved, the to_python method of your field type will be called with a string encoded list containing the selected options. So you need to (1) detect that and (2) convert that back to a value of your field type.


Model

The model ends up being pretty simple. Just specify your custom form (see next section)

@register_snippet
class Permission(models.Model):
    perm = PermField()
    panels = [
        FieldPanel("perm", widget=PermissionForm(choices=perm_choices))
    ]

Model Form

I just subclassed CheckboxSelectMultiple, since you then get the builtin choices keyword argument etc.

class PermissionForm(forms.CheckboxSelectMultiple):
    template_name = "permission_form.html"
    def get_context(self, name, value, attrs):
        context = super().get_context(name, value, attrs)
        # Set the "selected" flag here however you need for your type. 
        for option in context["widget"]["optgroups"]:
            option[1][0]["selected"] = Perm(option[1][0]["value"]) in Perm(value)
        return context

Overriding the context

You can then override get_context (described here in the docs albeit not very well) and provide your own context to the template. What I did was use pdb to inspect the context myself. That yields a dictionary structure a bit as follows:

 "widget": {
        "name": "perm",
        "is_hidden": False,
        "required": True,
        "value": ["0"],
        "attrs": {"id": "id_perm"},
        "template_name": "permission_form.html",
        "optgroups": [
            (
                None,
                [
                    {
                        "name": "perm",
                        "value": 1,
                        "label": "Read",
                        "selected": False,
                        "index": "0",
                        "attrs": {"id": "id_perm_0"},
                        "type": "checkbox",
                        "template_name": "django/forms/widgets/checkbox_option.html",
                        "wrap_label": True,
                    }
                ],
                0,
            ),
            (
                None,
                [
                    {
                        "name": "perm",
                        "value": 2,
                        "label": "Write",
                        "selected": False,
                        "index": "1",
                        "attrs": {"id": "id_perm_1"},
                        "type": "checkbox",
                        "template_name": "django/forms/widgets/checkbox_option.html",
                        "wrap_label": True,
                    }
                ],
                1,
            ),
        ],
    }

You can see I am overwriting the selected boolean field of each option tuple within the list using the value argument to get_context (that should be the value of your field at the moment the form is created).

I was hoping that would be enough to make the checkboxes already checked when the form was created with the default template, but that didn't work. So I ended up using my own template (see next)


Template

I copied the produced HTML for the CheckboxMultipleSelect widget and edited it a bit to use the context I'm providing:

<!--mysite/templates/permission_form.html-->

<div>
{% for option in widget.optgroups %}
<label for="{{widget.attrs.id}}_{{option.2}}">
  <input type="checkbox"
     name="{{widget.name}}"
     value="{{option.1.0.value}}"
     id="{{widget.attrs.id}}_{{option.2}}"
     {% if option.1.0.selected %}
     checked
     {% endif %}
  >
{{option.1.0.label}}
</label>
{% endfor %}
</div>

In order to use your own Widget template without an error occurring, you do need to make some adjustments to your settings first (kudos to https://stackoverflow.com/a/46208414/1883304 for solving that one):

# In wagtail, it should be something like mysite/settings.py
INSTALLED_APPS = [
  ...
  "django.forms",
  ...
]

FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'

Edit: Okay, it seems not all is well if you try to expand the number of fields on the model and keep using the form. I will update later when I've debugged that more.

Upvotes: 0

Related Questions