Custom field plugins¶
This chapter covers how to define your own form field plugin types beyond the
built-in SimpleFieldBase proxies.
Compound field types¶
Compound fields generate multiple Django form fields from a single plugin.
Always prefix field names with f"{self.name}_" to ensure uniqueness across
multiple instances of the same plugin:
from functools import partial
from django import forms
from django.db import models
from feincms3_forms.models import FormFieldBase, simple_loader
class Duration(FormFieldBase, ConfiguredFormPlugin):
label_from = models.CharField("from label", max_length=1000)
label_until = models.CharField("until label", max_length=1000)
class Meta:
verbose_name = "duration"
def __str__(self):
return f"{self.label_from} - {self.label_until}"
def get_fields(self, **kwargs):
return {
f"{self.name}_from": forms.DateField(
label=self.label_from,
required=True,
widget=forms.DateInput(attrs={"type": "date"}),
),
f"{self.name}_until": forms.DateField(
label=self.label_until,
required=True,
widget=forms.DateInput(attrs={"type": "date"}),
),
}
def get_loaders(self):
return [
partial(simple_loader, label=self.label_from, name=f"{self.name}_from"),
partial(simple_loader, label=self.label_until, name=f"{self.name}_until"),
]
Custom templates for compound fields¶
Use the strip_name_prefix parameter in get_form_fields() to access
compound field values by their short names (without the plugin name prefix)
inside templates:
class AddressBlock(FormFieldBase, ConfiguredFormPlugin):
def get_fields(self):
return {
f"{self.name}_first_name": forms.CharField(label="First name"),
f"{self.name}_last_name": forms.CharField(label="Last name"),
f"{self.name}_street": forms.CharField(label="Street"),
f"{self.name}_postal_code": forms.CharField(label="Postal code"),
f"{self.name}_city": forms.CharField(label="City"),
}
renderer.register(
models.AddressBlock,
lambda plugin, context: render_in_context(
context,
"forms/address-block.html",
{
"plugin": plugin,
"fields": context["form"].get_form_fields(plugin, strip_name_prefix=True),
},
),
)
Template (forms/address-block.html):
<div class="address-block">
<div class="field field-50-50">
{{ fields.first_name }}
{{ fields.last_name }}
</div>
<div class="field field-25-75">
{{ fields.postal_code }}
{{ fields.city }}
</div>
</div>
Without strip_name_prefix=True you would need fields.address_first_name,
etc.
File upload fields¶
File upload fields require a custom widget to handle the case where a file has already been uploaded (e.g. when re-displaying a form after a validation error):
from django import forms
from django.core.files import File
from django.db import models
from django.conf import settings
from pathlib import PurePath
from feincms3_forms.models import FormField
class UploadFileInput(forms.FileInput):
template_name = "forms/upload_file_input.html"
def format_value(self, value):
return value
def get_context(self, name, value, attrs):
if value:
attrs["required"] = False
context = super().get_context(name, None, attrs)
if value and not isinstance(value, File):
# PurePath prevents directory traversal attacks
context["current_value"] = PurePath(value).name
return context
class Upload(FormField, ConfiguredFormPlugin):
class Meta:
verbose_name = "upload field"
def get_fields(self, **kwargs):
return super().get_fields(
form_class=forms.FileField,
widget=UploadFileInput,
**kwargs,
)
def get_loaders(self):
def loader(data):
row = {"label": self.label, "name": self.name}
row["value"] = (
f"{settings.DOMAIN}{uploads_storage.url(data[self.name])}"
if data.get(self.name)
else ""
)
return row
return [loader]
Template (forms/upload_file_input.html):
{% load i18n %}
<input type="file" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
{% if widget.current_value %}
<p>{% trans "Current file:" %} {{ widget.current_value }}</p>
{% endif %}
Custom field types with validation¶
Create custom form field classes for specialised validation:
import phonenumbers
from django import forms
from django.utils.translation import gettext_lazy as _
from feincms3_forms.models import FormField
class PhoneNumberFormField(forms.CharField):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add HTML5 pattern for immediate client-side feedback
self.widget.attrs.setdefault(
"pattern",
r"^[\+\(\)\s]*[0-9][\+\(\)0-9\s]*$",
)
def clean(self, value):
value = super().clean(value)
if not value:
return value
try:
number = phonenumbers.parse(value, "CH")
except phonenumbers.NumberParseException as exc:
raise forms.ValidationError(_("Unable to parse as phone number.")) from exc
if phonenumbers.is_valid_number(number):
return phonenumbers.format_number(
number, phonenumbers.PhoneNumberFormat.E164
)
raise forms.ValidationError(_("Phone number invalid."))
class PhoneNumber(FormField, ConfiguredFormPlugin):
class Meta:
verbose_name = "phone number field"
def get_fields(self, **kwargs):
return super().get_fields(form_class=PhoneNumberFormField, **kwargs)
JSON plugins with proxy mixins¶
For fields whose configuration involves nested data (e.g. an array of choices), use django-json-schema-editor together with feincms3-forms proxy models.
The key advantage over regular Django models is that nested data — such as an array of choice options — can be edited inline without requiring nested inlines (which Django admin does not support).
Base class
from django_json_schema_editor.plugins import JSONPluginBase
from feincms3_forms.models import FormFieldBase
class JSONPlugin(JSONPluginBase, FormFieldBase, ConfiguredFormPlugin):
pass
Field mixin
class SingleChoiceMixin:
def get_fields(self):
return {
self.name: forms.ChoiceField(
widget=forms.RadioSelect,
choices=[
(choice["name"], mark_safe(choice["description"]))
for choice in self.data["choices"]
],
label=self.data["label"],
required=self.data["is_required"],
help_text=self.data.get("help_text", ""),
)
}
def get_loaders(self):
return [partial(simple_loader, name=self.name, label=self.data["label"])]
Proxy model with schema
SingleChoice = JSONPlugin.proxy(
"single_choice",
verbose_name=_("single choice"),
mixins=[SingleChoiceMixin],
schema={
"type": "object",
"properties": {
"label": {"type": "string", "title": _("label")},
"is_required": {
"type": "boolean",
"title": _("is required"),
"format": "checkbox",
},
"help_text": {"type": "string", "title": _("help text")},
"choices": {
"type": "array",
"format": "table",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "title": _("value"), "minLength": 1},
"description": {
"type": "string",
"format": "prose",
"title": _("label"),
},
},
},
},
},
},
)
Admin integration
from django_json_schema_editor.plugins import JSONPluginInline
@admin.register(models.ConfiguredForm)
class FormAdmin(ConfiguredFormAdmin):
inlines = [
# ... SimpleFieldInline instances ...
JSONPluginInline.create(model=models.SingleChoice, icon="radio_button_checked"),
]
Template rendering
Use strip_name_prefix=True for compound JSON plugin fields:
renderer.register(
models.JSONPlugin,
"", # base class is not rendered directly
)
renderer.register(
[models.SingleChoice],
lambda plugin, context: render_in_context(
context,
[f"forms/{plugin.type}-field.html", "forms/simple-field.html"],
{
"plugin": plugin,
"fields": context["form"].get_form_fields(plugin, strip_name_prefix=True),
},
),
fetch=False,
)
Whether to prefer JSON plugins or regular proxy models is largely a matter of preference. Both integrate seamlessly with feincms3-forms. JSON plugins are most useful when field configuration requires nested data that would otherwise need a separate model and inline.