Reputation: 3660
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))]
Creating new snippets works fine, but editing an existing one simply shows an empty CheckboxSelectMultiple
widget:
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.
ModelForm
, which links the CheckBoxSelectMultiple
right up to a models.ManyToManyField
. This works since the ManyToManyField
type is automatically compatible with the widget. In my case, I have to set it up myself.MultiSubscriptionForm
, which itself contains a keyword argument for populating the existing fields. This does not exist in my situation.CheckboxSelectMultiple
template in order to show a special arrangement. The answers provide template HTML to do this, but otherwise rely on the ManyToMany
field type/relation that automagically links/fills the checkboxes.Upvotes: 1
Views: 79
Reputation: 3660
Okay, I figured it out. In summary, you'll have to create the following:
PermissionField
type in the question).CheckboxSelectMultiple
get_context
method of the form to add your "checked" state.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.
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))
]
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
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)
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