This is an extremely flexible forms builder for the Django admin interface. It allows using django-content-editor for your form which enables:
- Build your own form in the CMS and not have to ask programmers to change anything.
- Reorder, add and remove pre-existing fields.
- Add content (text, images, anything) between form fields.
- Use regions to add additional structure to a form, e.g. to build configurable multi-step forms (wizards).
- Add your own form field plugins with all the flexibility and configurability you desire.
If you only want to integrate short and simple forms (e.g. a contact form) you're probably better off using form_designer. The feincms3 documentation contains a guide showing how to integrate it.
Form fields have to inherit FormFieldBase. FormFieldBase only has a
name field. This field can be checked for clashes etc. The base class is
used instead of duck typing in various places where the code may encounter not
only form field plugins but also other django-content-editor plugins. The
latter are useful e.g. to add blocks of text or other content between form
fields.
The FormFieldBase model defines the basic API of form fields:
get_fields(): Return a dictionary of form fields.get_initial(): Return initial values of said fields.get_cleaners(): Return a list of callables which receive the form instance, return the cleaned data and may raiseValidationErrorexceptions.get_loaders(): Return a list of loaders. The purpose of loaders is to load form submissions, e.g. for reporting purposes. Loaders are callables which receive the serialized form data and return a dictionary of the following shape:{"name": ..., "label": ..., "value": ...}.
The FormField offers a basic set of attributes for standard fields such as
a label, a help text and whether the field should be required or not. You do
not have to use this model if you want to define your own. It's purpose is just
to offer a few good defaults.
The SimpleFieldBase should be instantiated in your project and can be used
to cheaply add support for many basic field types such as text fields, email
fields, checkboxes, choice fields and more with a single backing database table
and model.
The SimpleFieldBase has a corresponding SimpleFieldInline in the
feincms3_forms.admin module which shows and hides fields depending on the
field type. For example, it makes no sense to define placeholders for
checkboxes (browsers do not support them) therefore the field is omitted in the
CMS.
The renderer functions are responsible for creating and instantiating the form class. Form class creation and instantiation happens at once.
The validation module offers utilities to validate a form when it is defined in
the CMS. For example, the backend code may require that an email field always
exists and always has a certain predefined name (for example email 😏).
These rules are not enforced at the moment but the user is always notified and
can therefore choose to heed them. Or bad things may happen depending on the
code you write.
The feincms3_forms.validation module provides the following validators:
validate_uniqueness(fields): Checks for duplicate field names and returns warnings if any fields appear more than once.validate_required_fields(fields, required): Ensures that all specified required field names are present in the form, returning errors for any missing required fields.validate_fields(fields, schema): Validates that fields match a given schema, checking attributes liketypeandis_required. Returns warnings for missing expected fields and errors for fields that don't match the expected attributes.
These validators can be used in your form type's validate function. For
example:
from feincms3_forms.validation import (
validate_fields,
validate_required_fields,
validate_uniqueness,
)
def validate_contact_form(configured_form):
fields = configured_form.get_formfields_union(
plugins=renderer.plugins(), attributes=["type", "is_required"]
)
return [
*validate_uniqueness(fields),
*validate_required_fields(fields, {"email"}),
*validate_fields(
fields,
{
"email": {"type": "email", "is_required": True},
},
),
]Then reference this validation function in your ConfiguredForm model:
class ConfiguredForm(forms_models.ConfiguredForm):
FORMS = [
forms_models.FormType(
key="contact",
label="contact form",
regions=[Region(key="form", title="form")],
validate="app.forms.forms.validate_contact_form",
process="app.forms.forms.process_contact_form",
),
]Loaders are responsible for extracting and formatting submitted form data for display and export purposes. They convert the serialized form data (typically stored as JSON) back into a human-readable format.
Each form field plugin that inherits from FormFieldBase should implement a
get_loaders() method that returns a list of loader callables. Each loader
receives the serialized form data dictionary and returns a dictionary with the
following structure:
{
"name": "field_name", # The field name/key in the data
"label": "Field Label", # Human-readable label
"value": "field value", # The actual value
}Example loader implementation for a simple field:
from functools import partial
from feincms3_forms.models import simple_loader
class MyField(FormFieldBase, ConfiguredFormPlugin):
label = models.CharField(max_length=200)
def get_loaders(self):
return [
partial(simple_loader, label=self.label, name=self.name)
]For compound fields that generate multiple form fields, return multiple loaders:
class Duration(FormFieldBase, ConfiguredFormPlugin):
label_from = models.CharField(max_length=1000)
label_until = models.CharField(max_length=1000)
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 loaders can perform additional processing on the data:
class Upload(FormField, ConfiguredFormPlugin):
def get_loaders(self):
def loader(data):
row = {"label": self.label, "name": self.name}
if value := data.get(self.name):
# Convert file path to full URL
row["value"] = f"{settings.DOMAIN}{storage.url(value)}"
else:
row["value"] = ""
return row
return [loader]The reporting module provides utilities for working with submitted form data. The main functions are:
get_loaders(plugins)
Collects all loaders from form field plugins. Takes a list of plugin instances and returns a flat list of all loader callables.
from content_editor.contents import contents_for_item
from feincms3_forms.reporting import get_loaders
contents = contents_for_item(configured_form, plugins=renderer.plugins())
loaders = get_loaders(contents)
# Apply loaders to extract data
for loader in loaders:
row = loader(submitted_data)
print(f"{row['label']}: {row['value']}")simple_report(contents, data)
Generates an HTML representation of submitted form data for display in the Django admin interface. Returns a safe HTML string with formatted field labels and values.
from django.contrib.admin import display
from feincms3_forms.reporting import simple_report
@display(description="Submitted Data")
def pretty_data(self, obj):
return simple_report(
contents=contents_for_item(obj.configured_form, plugins=renderer.plugins()),
data=obj.data,
)value_default(row, default="Ø")
Helper function that returns a default value if the field value is empty.
from feincms3_forms.reporting import value_default
values = [value_default(loader(data)) for loader in loaders]A common use case is exporting submitted form data to Excel files. Here's a complete example showing how to create an admin action for exporting data:
from content_editor.contents import contents_for_items
from django.utils import timezone
from feincms3_forms.reporting import get_loaders
from xlsxdocument import XLSXDocument
def export_submissions(modeladmin, request, queryset):
submissions = list(queryset.select_related("configured_form"))
configured_forms = {sub.configured_form for sub in submissions}
# Get loaders for all configured forms
cf_contents = contents_for_items(configured_forms, plugins=renderer.plugins())
loaders = {cf: get_loaders(contents) for cf, contents in cf_contents.items()}
cf_values = {}
for submission in submissions:
line = [
{"label": "ID", "name": "", "value": submission.id},
{"label": "Email", "name": "", "value": submission.email},
{"label": "Created", "name": "", "value": submission.created_at},
] + [loader(submission.data) for loader in loaders[submission.configured_form]]
if submission.configured_form not in cf_values:
cf_values[submission.configured_form] = [
[cell["label"] for cell in line], # Header row
]
cf_values[submission.configured_form].append([cell["value"] for cell in line])
# Create Excel file
xlsx = XLSXDocument()
for configured_form, values in cf_values.items():
xlsx.add_sheet(str(configured_form)[:30])
xlsx.table(None, values)
return xlsx.to_response("submissions.xlsx")Add this action to your ModelAdmin:
@admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin):
actions = [export_submissions]Create a module containing the models for the form builder (app.forms.models):
from content_editor.models import Region, create_plugin_base
from django.db import models
from feincms3 import plugins
from feincms3_forms import models as forms_models
class ConfiguredForm(forms_models.ConfiguredForm):
FORMS = [
forms_models.FormType(
key="contact",
label="contact form",
regions=[Region(key="form", title="form")],
# Base class for the dynamically created form:
# form_class="...",
# Validation hook for configured form (the bundled ModelAdmin
# class calls this):
# validate="...",
# Processing function which you can call after submission
# (feincms3-forms never calls this function itself, but it
# may be a nice convention):
process="app.forms.forms.process_contact_form",
),
]
ConfiguredFormPlugin = create_plugin_base(ConfiguredForm)
class SimpleField(forms_models.SimpleFieldBase, ConfiguredFormPlugin):
pass
Text = SimpleField.proxy(SimpleField.Type.TEXT)
Email = SimpleField.proxy(SimpleField.Type.EMAIL)
URL = SimpleField.proxy(SimpleField.Type.URL)
Date = SimpleField.proxy(SimpleField.Type.DATE)
Integer = SimpleField.proxy(SimpleField.Type.INTEGER)
Textarea = SimpleField.proxy(SimpleField.Type.TEXTAREA)
Checkbox = SimpleField.proxy(SimpleField.Type.CHECKBOX)
Select = SimpleField.proxy(SimpleField.Type.SELECT)
Radio = SimpleField.proxy(SimpleField.Type.RADIO)
SelectMultiple = SimpleField.proxy(SimpleField.Type.SELECT_MULTIPLE)
CheckboxSelectMultiple = SimpleField.proxy(SimpleField.Type.CHECKBOX_SELECT_MULTIPLE)
class RichText(plugins.richtext.RichText, ConfiguredFormPlugin):
passAdd the processing function referenced above (app.forms.forms):
from django.core.mail import mail_managers
from django.http import HttpResponse
def process_contact_form(request, form, *, configured_form):
mail_managers("Contact form", repr(form.cleaned_data))
return HttpResponseRedirect(".")Add the renderer and the view (app.forms.views):
from content_editor.contents import contents_for_item
from django.shortcuts import render
from feincms3.renderer import RegionRenderer, render_in_context, template_renderer
from feincms3_forms.renderer import create_form, short_prefix
from app.forms import models
renderer = RegionRenderer()
renderer.register(models.RichText, template_renderer("plugins/richtext.html"))
renderer.register(
models.SimpleField,
lambda plugin, context: render_in_context(
context,
"forms/simple-field.html",
{"plugin": plugin, "fields": context["form"].get_form_fields(plugin)},
),
)
def form(request):
context = {}
cf = models.ConfiguredForm.objects.first()
contents = contents_for_item(cf, plugins=renderer.plugins())
# Add a prefix in case more than one form exists on the same page:
form_kwargs = {"prefix": short_prefix(cf, "form")}
if request.method == "POST":
form_kwargs |= {"data": request.POST, "files": request.FILES}
form = create_form(
contents["form"],
form_class=cf.type.form_class,
form_kwargs=form_kwargs,
)
if form.is_valid():
return cf.type.process(request, form, configured_form=cf)
context["form"] = form
context["form_other_fields"] = form.get_form_fields(None)
context["form_regions"] = renderer.regions_from_contents(contents)
return render(request, "forms/form.html", context)The forms/simple-field.html template referenced above might look as
follows:
{% for field in fields.values %}{{ field }}{% endfor %}An example forms/form.html:
{% extends "base.html" %}
{% load feincms3 i18n %}
{% block content %}
<div class="content">
<form class="form" method="post">
{% csrf_token %}
{{ form.errors }}
{% render_region form_regions 'form' %}
{% for field in form_other_fields.values %}{{ field }}{% endfor %}
<button type="submit">Submit</button>
</form>
</div>
{% endblock content %}Finally, the form would have to be added to the admin site (app.forms.admin):
from content_editor.admin import ContentEditorInline
from django.contrib import admin
from feincms3 import plugins
from feincms3_forms.admin import ConfiguredFormAdmin, SimpleFieldInline
from app.forms import models
@admin.register(models.ConfiguredForm)
class ConfiguredFormAdmin(ConfiguredFormAdmin):
inlines = [
plugins.richtext.RichTextInline.create(model=models.RichText),
SimpleFieldInline.create(
model=models.Text,
button='<i class="material-icons">short_text</i>',
),
SimpleFieldInline.create(
model=models.Email,
button='<i class="material-icons">alternate_email</i>',
),
SimpleFieldInline.create(
model=models.URL,
button='<i class="material-icons">link</i>',
),
SimpleFieldInline.create(
model=models.Date,
button='<i class="material-icons">event</i>',
),
SimpleFieldInline.create(
model=models.Integer,
button='<i class="material-icons">looks_one</i>',
),
SimpleFieldInline.create(
model=models.Textarea,
button='<i class="material-icons">notes</i>',
),
SimpleFieldInline.create(
model=models.Checkbox,
button='<i class="material-icons">check_box</i>',
),
SimpleFieldInline.create(
model=models.Select,
button='<i class="material-icons">arrow_drop_down_circle</i>',
),
SimpleFieldInline.create(
model=models.Radio,
button='<i class="material-icons">radio_button_checked</i>',
),
]And last but not least, create and apply migrations. That should be basically it.
This section contains practical recipes and patterns for common use cases.
Compound fields generate multiple form fields from a single plugin. Here's an example of a Duration field that creates "from" and "until" date fields:
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):
# Always use the f"{self.name}_" prefix to make field names unique!
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):
# Always use the f"{self.name}_" prefix in loaders too!
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"),
]When rendering compound fields with custom templates, you may want to access
fields by their simplified names instead of their full prefixed names. Use the
strip_name_prefix parameter in get_form_fields() to achieve this.
Important: When creating compound fields, always prefix your field names with
f"{self.name}_" in get_fields() to ensure field names are unique across
multiple instances of the same plugin. The strip_name_prefix parameter then
allows you to access these fields in templates without the prefix.
class AddressBlock(FormFieldBase, ConfiguredFormPlugin):
def get_fields(self):
# Always use the f"{self.name}_" prefix to make field names unique!
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"),
}
# Register in renderer with strip_name_prefix
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),
},
),
)This allows you to reference fields in templates using their simple names:
{% load i18n %}
<div class="address-block">
<h3>{% translate "Address Information" %}</h3>
<div class="address-block__fields">
{# First name and last name on one line #}
<div class="field field-50-50">
{{ fields.first_name }}
{{ fields.last_name }}
</div>
{# Postal code and city #}
<div class="field field-25-75">
{{ fields.postal_code }}
{{ fields.city }}
</div>
</div>
</div>Without strip_name_prefix=True, you would need to use the full prefixed
names like fields.address_first_name, fields.address_last_name, etc.
File upload fields require special handling for display and storage:
from django import forms
from django.db import models
from django.conf import settings
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):
context["current_value"] = os.path.basename(value)
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}
if value := data.get(self.name):
row["value"] = f"{settings.DOMAIN}{uploads_storage.url(value)}"
else:
row["value"] = ""
return row
return [loader]The corresponding 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 %}Create custom form field classes for specialized validation:
import phonenumbers
from django import forms
from feincms3_forms.models import FormField
class PhoneNumberFormField(forms.CharField):
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(str(exc))
else:
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)Create collapsible groups for better form organization:
class Group(ConfiguredFormPlugin):
subregion = "group"
title = models.CharField(
"title",
max_length=200,
blank=True,
help_text="Use an empty title to finish an existing group without starting a new one.",
)
class Meta:
verbose_name = "group"
def __str__(self):
return self.titleRegister it in the renderer:
renderer.register(models.Group, "")Handle groups in your view using a custom Regions class:
from feincms3.regions import Regions, matches
from feincms3.renderer import render_in_context
class FormRegions(Regions):
def handle_group(self, items, context):
group = items.popleft()
if not group.title:
# Terminate group without creating output
return []
content = []
while items and not matches(items[0], subregions={"group"}):
content.append(
self.renderer.render_plugin_in_context(items.popleft(), context)
)
return render_in_context(
context,
"forms/group.html",
{"group": group, "content": content}
)
# In your view:
context["form_regions"] = FormRegions.from_contents(contents, renderer=renderer)Save file uploads properly when storing form submissions:
from django.core.files import File
from app.storage import uploads_storage
def save_files(instance, form):
"""Extract files from form data and save them to storage."""
data = form.cleaned_data.copy()
for key, value in data.items():
if isinstance(value, File):
data[key] = uploads_storage.save(
f"{instance._meta.label_lower}/{instance.pk}/{value.name}",
value,
)
return data
def process_form(request, form, *, configured_form):
instance = MyModel.objects.create(
email=form.cleaned_data["email"],
configured_form=configured_form,
)
instance.data = save_files(instance, form)
instance.save()Send formatted emails to managers when forms are submitted:
from content_editor.contents import contents_for_item
from feincms3_forms.reporting import get_loaders, value_default
from authlib.email import render_to_mail
def send_notifications_to_managers(data, *, configured_form, url=""):
recipients = configured_form.send_notifications_to or [
row[1] for row in settings.MANAGERS
]
contents = contents_for_item(configured_form, plugins=renderer.plugins())
loaders = get_loaders(contents)
values = [value_default(loader(data)) for loader in loaders]
mail = render_to_mail(
"forms/notification_mail",
{
"configured_form": configured_form,
"values": values,
"url": url
},
to=recipients,
)
mail.send()Email template (forms/notification_mail.txt):
A new {{ configured_form.name }} has been submitted.
{% for value in values %}
{{ value.label }}: {{ value.value }}
{% endfor %}
{% if url %}View in admin: {{ url }}{% endif %}
Allow users to save progress and continue filling out forms later:
from django.core import signing
# In your ConfiguredForm model:
class ConfiguredForm(forms_models.ConfiguredForm):
FORMS = [
forms_models.FormType(
key="grant-proposal",
label="grant proposal",
regions=[Region(key="form", title="form")],
form_class="app.forms.forms.GrantProposalForm",
process="app.forms.forms.process_grant_proposal_form",
allow_continue_later=True, # Enable continue later
),
]
# In your Proposal model:
def get_proposal_url(self):
return reverse_app(
"forms-grant-proposal",
"code",
kwargs={"code": signing.dumps(self.pk)},
)
# In your view:
def form(request, code=None):
context = page_context(request)
cf = context["page"].form
form_kwargs = {"request": request, "prefix": short_prefix(cf, "form")}
if code is not None:
with contextlib.suppress(Exception):
form_kwargs["instance"] = Proposal.objects.get(pk=signing.loads(code))
# ... rest of view code
# In your form processing:
def process_grant_proposal_form(request, form, *, configured_form):
form.instance.proposal = form.instance.proposal | save_files(form.instance, form)
form.instance.save()
if "_continue" in request.POST:
messages.success(
request,
"The proposal has been saved. You may continue editing it later.",
)
return HttpResponseRedirect(form.instance.get_proposal_url())
messages.success(request, "The proposal has been sent.")
# Send notifications...Template button for "Continue later":
<button type="submit" name="_submit">{% trans "Submit" %}</button>
{% if configured_form.type.allow_continue_later %}
<button type="submit" name="_continue">{% trans "Save and continue later" %}</button>
{% endif %}Show formatted submission data in the admin interface:
from django.contrib import admin
from django.contrib.admin import display
from content_editor.contents import contents_for_item
from feincms3_forms.reporting import simple_report
@admin.register(Proposal)
class ProposalAdmin(admin.ModelAdmin):
def get_fields(self, request, obj=None):
fields = super().get_fields(request, obj)
# Exclude raw JSON fields from the form
return [field for field in fields if field not in {"outline", "proposal"}]
def get_readonly_fields(self, request, obj=None):
return [field.name for field in self.model._meta.fields] + [
"pretty_outline",
"pretty_proposal",
]
@display(description="outline")
def pretty_outline(self, obj):
return simple_report(
contents=contents_for_item(
obj.outline_form,
plugins=renderer.plugins()
),
data=obj.outline,
)
@display(description="proposal")
def pretty_proposal(self, obj):
return simple_report(
contents=contents_for_item(
obj.proposal_form,
plugins=renderer.plugins()
),
data=obj.proposal,
)Create form regions dynamically based on database content. This is useful for building questionnaires where the structure is entirely configurable via the admin:
from content_editor.models import Region
from admin_ordering.models import OrderableModel
class ConfiguredForm(forms_models.ConfiguredForm):
FORMS = [
forms_models.FormType(
key="questionnaire",
label="questionnaire",
# Regions are dynamically generated from database
regions=lambda cf: [
Region(key="cover", title="Cover"),
] + [group.region for group in cf.groups.all()],
form_class="app.tools.forms.Form",
process="app.forms.forms.process_questionnaire_form",
),
]
class Group(OrderableModel):
parent = models.ForeignKey(
ConfiguredForm,
on_delete=models.CASCADE,
related_name="groups",
)
title = models.CharField(max_length=200)
@property
def region(self):
return Region(
key=f"group_{self.pk}",
title=self.title,
)In the admin, add an inline for managing groups:
from admin_ordering.admin import OrderableAdmin
class GroupInline(OrderableAdmin, admin.TabularInline):
model = models.Group
extra = 0
@admin.register(models.ConfiguredForm)
class ConfiguredFormAdmin(ConfiguredFormAdmin):
inlines = [GroupInline, ...other inlines...]Render only specific regions of a form, useful for multi-page forms:
def start(request):
cf = get_configured_form()
# Only render the first region (cover page)
contents = contents_for_item(
cf,
plugins=renderer.plugins(),
regions=cf.regions[:1], # Only first region
)
form = create_form(contents, form_class=cf.type.form_class, form_kwargs={...})
if form.is_valid():
# Save and redirect to main questionnaire
return HttpResponseRedirect(...)
def questionnaire(request):
cf = get_configured_form()
# Render remaining regions (skip cover)
contents = contents_for_item(
cf,
plugins=renderer.plugins(),
regions=cf.regions[1:], # Skip first region
)
form = create_form(contents, form_class=cf.type.form_class, form_kwargs={...})Subregions allow you to group related plugins together and handle them as a unit. This is useful for creating nested structures like accordions, tabs, or hierarchical form groups.
Example: Create a custom renderer that groups form fields under collapsible headings:
from feincms3.renderer import RegionRenderer, render_in_context
class CustomFormRenderer(RegionRenderer):
def handle_groupitems(self, plugins, context):
"""Collect all items in a groupitems subregion."""
items = [
self.render_plugin(plugin, context)
for plugin in self.takewhile_subregion(plugins, "groupitems")
]
if items:
yield render_in_context(
context,
"forms/group-items.html",
{"items": items}
)
def handle_groupheaders(self, plugins, context):
"""Handle group headers and their nested items."""
header = plugins.popleft()
items = self.handle_groupitems(plugins, context)
if items:
yield render_in_context(
context,
"forms/group.html",
{"header": header, "items": items}
)
renderer = CustomFormRenderer()
renderer.register(
models.GroupHeader,
lambda p, c: {"plugin": p},
subregion="groupheaders"
)
renderer.register(
models.SimpleField,
template_renderer("forms/simple-field.html", simple_field_context),
subregion="groupitems"
)This pattern allows you to create forms where plugins are automatically grouped
under headers, with custom rendering for each level of nesting. The key methods
are takewhile_subregion() which collects plugins until it encounters a
different subregion, and handle_<subregion>() methods which are automatically
called by the renderer
Use different renderers for form input vs. viewing submitted data:
# Form renderer for data entry
form_renderer = RegionRenderer()
form_renderer.register(
models.SimpleField,
template_renderer("forms/simple-field.html", simple_field_context),
)
# Report renderer for viewing submitted data
def report_simple_field_context(plugin, context):
return {
"plugin": plugin,
"rows": [
loader(context["submission"].data)
for loader in plugin.get_loaders()
],
}
report_renderer = RegionRenderer()
report_renderer.register(
models.SimpleField,
template_renderer("forms/report-simple-field.html", report_simple_field_context),
)
# In views:
def questionnaire(request):
contents = contents_for_item(cf, plugins=form_renderer.plugins())
form = create_form(contents, ...)
def report(request, submission):
contents = contents_for_item(cf, plugins=report_renderer.plugins())
context["report_regions"] = report_renderer.regions_from_contents(contents)Use Django's signing framework to create secure, tamper-proof URLs for accessing submissions without authentication:
from django.core.signing import Signer
_signer = Signer(salt="submissions")
class SubmissionQuerySet(models.QuerySet):
def get_by_code(self, code):
return self.get(pk=_signer.unsign(code))
class Submission(models.Model):
# ... fields ...
objects = SubmissionQuerySet.as_manager()
def get_report_url(self):
return reverse_app(
"forms",
"report",
kwargs={"code": _signer.sign(self.pk)}
)
# View decorator for handling signed submissions
def signed_submission(func):
@wraps(func)
def view(request, **kwargs):
if "code" not in kwargs:
return func(request, **kwargs)
try:
submission = Submission.objects.get_by_code(kwargs.pop("code"))
except Submission.DoesNotExist:
messages.error(request, "The submission does not exist.")
except Exception:
messages.error(request, "The link is invalid.")
else:
return func(request, submission=submission, **kwargs)
return HttpResponseRedirect("../../../")
return view
# Use in views
@signed_submission
def report(request, submission):
# submission is automatically loaded from the signed code
...Add custom actions to individual objects in the admin using django-object-actions:
from django_object_actions import DjangoObjectActions
@admin.register(models.Submission)
class SubmissionAdmin(DjangoObjectActions, admin.ModelAdmin):
change_actions = ["view_report"]
@admin.display(description="View report")
def view_report(self, request, obj):
return HttpResponseRedirect(obj.get_report_url())This adds a "View report" button at the top of the change form for each submission.
Merge form data incrementally across multiple form submissions (useful for multi-page forms):
def process_step_one(request, form, *, configured_form):
submission = Submission.objects.create(
title=form.cleaned_data["title"],
email=form.cleaned_data["email"],
configured_form=configured_form,
data={},
)
# Save initial data
submission.data = save_files(submission, form)
submission.save()
return HttpResponseRedirect(submission.get_next_step_url())
def process_step_two(request, form, *, submission):
# Merge new data with existing data using | operator
submission.data = submission.data | save_files(submission, form)
submission.save()
return HttpResponseRedirect(submission.get_report_url())The merge operator (|) ensures that data from previous steps is preserved
while new fields are added or updated.
Add client-side validation patterns to form fields:
class PhoneNumberFormField(forms.CharField):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add HTML5 pattern for client-side validation
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
else:
if phonenumbers.is_valid_number(number):
return phonenumbers.format_number(
number, phonenumbers.PhoneNumberFormat.E164
)
raise forms.ValidationError(_("Phone number invalid."))This provides immediate feedback to users before server-side validation.
Show different inlines in the admin depending on the selected form type:
from admin_ordering.admin import OrderableAdmin
class StepInline(OrderableAdmin, admin.TabularInline):
model = models.Step
extra = 0
@admin.register(models.ConfiguredForm)
class ConfiguredFormAdmin(ConfiguredFormAdmin):
def get_inlines(self, request, obj):
if not obj:
return []
# Base inlines for all form types
inlines = [
ContentEditorInline.create(models.RichText),
SimpleFieldInline.create(models.Text),
SimpleFieldInline.create(models.Email),
# ... more inlines
]
# Add type-specific inlines
if obj.type.key == "consulting":
return [StepInline, *inlines]
return inlinesThis allows different form types to have different configuration options.
Create process functions that receive the validation state and control behavior based on whether the form is partially or fully submitted:
def process_form(
request,
form,
is_valid,
*,
configured_form,
submission,
viewname="form",
is_last=False,
):
# Ensure we have a submission for intermediate steps
if not is_last and not submission:
return HttpResponseBadRequest()
if not submission:
submission = Submission.objects.create(
configured_form=configured_form,
data={},
email=form.cleaned_data.get("email", ""),
)
# Always save data, even if validation failed (for auto-save)
submission.data = submission.data | save_files(submission, form)
submission.email = submission.data.get("email") or submission.email
submission.save()
# Send notification only on final valid submission
if is_last and is_valid and (email := submission.data.get("email")):
mail = render_to_mail(
"forms/notification_mail",
{"submission": submission},
to=[email],
bcc=["[email protected]"],
)
mail.send(fail_silently=True)
namespaces = (request.resolver_match.namespaces[-1], "forms")
url = reverse_app(
namespaces,
viewname,
kwargs={"code": submission._code},
)
return HttpResponseRedirect(url)Call this from your view:
def form(request, submission):
# ... form setup ...
if request.method == "POST":
should_continue = request.POST.get("_continue")
is_valid = form.is_valid()
return cf.type.process(
request,
form,
is_valid,
configured_form=cf,
submission=submission,
viewname="form" if should_continue else "thanks",
is_last=not should_continue,
)Allow users to delete their submissions via AJAX:
from django.http import JsonResponse
@signed_submission
def form(request, submission):
# ... existing GET/POST handling ...
if request.method == "DELETE":
if submission:
submission.delete()
return JsonResponse({})
# ... render form ...Client-side JavaScript:
// Delete submission button
deleteButton.addEventListener('click', async () => {
await fetch(window.location.href, { method: 'DELETE' });
window.location.href = '/';
});Use pathlib.PurePath for secure filename extraction from paths:
from pathlib import PurePath
class UploadFileInput(forms.FileInput):
template_name = "forms/upload_file_input.html"
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):
# Safely extract filename without directory traversal
context["current_value"] = PurePath(value).name
return contextPurePath handles paths safely regardless of the operating system and
prevents directory traversal attacks.
For maximum flexibility with JSON-based field configuration, you can combine django-json-schema-editor with the proxy pattern to create highly configurable form fields. This approach allows non-technical users to configure complex field structures entirely through the CMS while maintaining clean Python APIs for field rendering and validation.
First, create a base JSON plugin class that inherits from both JSONPluginBase
and FormFieldBase:
from django_json_schema_editor.plugins import JSONPluginBase
from feincms3_forms.models import FormFieldBase
class JSONPlugin(JSONPluginBase, FormFieldBase, ConfiguredFormPlugin):
passThis base class combines JSON schema capabilities with form field functionality.
Define mixins that implement the form field behavior for specific field types.
Mixins should implement get_fields() and get_loaders() methods:
from functools import partial
from django import forms
from django.utils.html import mark_safe
from feincms3_forms.models import simple_loader
class SingleChoiceMixin:
"""Mixin for a radio button choice field."""
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"])]Use the JSONPlugin.proxy() method to create proxy models with JSON schemas
and mixins:
from django.utils.translation import gettext_lazy as _
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": _("Machine-readable value"),
"minLength": 1,
},
"description": {
"type": "string",
"format": "prose",
"title": _("Label shown to users"),
"options": {
"extensions": {
"Bold": True,
"Link": True,
}
},
},
},
},
},
},
},
)This creates a fully functional form field plugin where:
- The schema defines the JSON structure for configuring the field in the admin
- The mixin implements how the field behaves in the actual form
- The proxy model ties everything together with a single database table
JSON plugins excel at creating compound fields that generate multiple form fields. Here's an example of a full address field:
class FullAddressMixin:
def get_fields(self):
return {
f"{self.name}_first_name": forms.CharField(
label=_("First name"),
max_length=100,
required=True,
),
f"{self.name}_last_name": forms.CharField(
label=_("Last name"),
max_length=100,
required=True,
),
f"{self.name}_date_of_birth": forms.DateField(
label=_("Date of birth"),
required=True,
widget=forms.DateInput(attrs={"type": "date"}),
),
f"{self.name}_email": forms.EmailField(
label=_("Email"),
required=True,
),
f"{self.name}_street": forms.CharField(
label=_("Street address"),
max_length=200,
required=True,
),
f"{self.name}_postal_code": forms.CharField(
label=_("Postal code"),
max_length=20,
required=True,
),
f"{self.name}_city": forms.CharField(
label=_("City"),
max_length=100,
required=True,
),
}
def get_loaders(self):
return [
partial(simple_loader, name=f"{self.name}_first_name", label=_("First name")),
partial(simple_loader, name=f"{self.name}_last_name", label=_("Last name")),
partial(simple_loader, name=f"{self.name}_date_of_birth", label=_("Date of birth")),
partial(simple_loader, name=f"{self.name}_email", label=_("Email")),
partial(simple_loader, name=f"{self.name}_street", label=_("Street address")),
partial(simple_loader, name=f"{self.name}_postal_code", label=_("Postal code")),
partial(simple_loader, name=f"{self.name}_city", label=_("City")),
]
FullAddress = JSONPlugin.proxy(
"full_address",
verbose_name=_("full address"),
mixins=[FullAddressMixin],
schema={"type": "object"}, # Minimal schema if no configuration needed
)This creates a reusable address block that can be added to any form with a single click in the admin interface.
Register JSON plugins in the admin using JSONPluginInline:
from django_json_schema_editor.plugins import JSONPluginInline
from feincms3_forms.admin import ConfiguredFormAdmin
@admin.register(models.ConfiguredForm)
class FormAdmin(ConfiguredFormAdmin):
inlines = [
# ... SimpleFieldInline instances for simple fields ...
JSONPluginInline.create(
model=models.SingleChoice,
icon="radio_button_checked",
),
JSONPluginInline.create(
model=models.FullAddress,
icon="home",
),
]JSON plugins can be rendered with custom templates just like other form fields.
Use strip_name_prefix=True for compound fields to access fields by their
simple names:
# In your renderer configuration
renderer.register(
models.JSONPlugin,
"", # Empty string for base class (not directly rendered)
)
renderer.register(
[models.SingleChoice, models.FullAddress],
lambda plugin, context: render_in_context(
context,
[
f"forms/{plugin.type}-field.html",
"forms/simple-field.html", # Fallback template
],
{
"plugin": plugin,
"fields": context["form"].get_form_fields(plugin, strip_name_prefix=True),
},
),
fetch=False,
)Example template (forms/full_address-field.html):
{% load i18n %}
<div class="full-address">
<h3>{% translate "Personal Information" %}</h3>
<div class="field field-50-50">
{{ fields.first_name }}
{{ fields.last_name }}
</div>
<div class="field">
{{ fields.date_of_birth }}
</div>
<h3>{% translate "Contact Information" %}</h3>
<div class="field">
{{ fields.email }}
</div>
<div class="field">
{{ fields.street }}
</div>
<div class="field field-25-75">
{{ fields.postal_code }}
{{ fields.city }}
</div>
</div>This pattern provides several advantages:
Flexibility: Content editors can configure field behavior without code changes
Reusability: Complex field groups can be packaged as single plugins
Maintainability: Field logic (mixin) is separate from configuration (schema)
Type safety: Each proxy model is a distinct type in the database and admin
Single table: All JSON plugins share one database table (efficient)
Rich configuration: JSON Schema provides validation, nested objects, arrays, and rich text editing
Admin integration: Automatic admin interface generation from JSON schema
Whether you use JSON plugins or regular Django models for your form fields is largely a matter of preference and project requirements. Both approaches work seamlessly with feincms3-forms and use the same rendering and validation infrastructure.
The key advantage of JSON plugins is the ability to store nested, structured data without additional database tables. This is particularly valuable when you need inline editing of complex structures like arrays or nested objects.
For example, the SingleChoice plugin allows users to configure an array of
choices, each with a name and rich text description. Since Django's admin doesn't
support inlines within inlines (you can't have an inline for choices within the
form field inline), JSON plugins provide a practical solution. The JSON Schema
editor renders the choices as an editable table directly in the form field
configuration.
The same applies to any field type where configuration requires nested data:
- Choice fields with configurable options
- Fields with multiple related text snippets (labels, help texts, descriptions)
- Fields that reference arrays of values
- Any configuration that would otherwise require a separate model and inline
Regular Django models work well for simpler field types where configuration is straightforward (text fields, email fields, dates) or when you prefer working with traditional Django model fields and forms.
In practice, you'll likely use both approaches in the same project. The
SimpleFieldBase proxy pattern works well for basic field types, while JSON
plugins handle more complex, user-configurable fields. Both integrate seamlessly
with django-content-editor and can be mixed freely in the same form.