django newforms for models

The way django does forms is in a state of transition. There's the old (or, depending on your point of view, current) way, manipulators, and the new way, newforms. newforms is only available in the svn version of django at present, and is still incomplete. Despite this, newforms is usable and provides several compelling advantages over the old way. The docs at present are a little thin (although improving quickly), but the unit tests are full of useful examples (the stuff towards the end is the most interesting, and shows off some of the flexibility of newforms).

One thing that would be nice, though, is a way of easily creating a form class that uses a model class's fields. If we have this, we can avoid needless and tedious repetition.

Django recently got a function that can be used for this, newforms.form_for_model. It's a start, but there are a few things I'd like it to do that it currently can't. I'd like to use the model's specified defaults, if any, as the default values for fields, and I'd like the help_text entries to be available in the fields for rendering. This saves having to specify these seperately in each form that uses the model fields. It'd also be nice to be able to specify declaritively additional fields to be used in the form and I'd like a way to easily override the default widgets for a field.

It turns out it's fairly quick and easy to write our own solution to this. I've gone with a metaclass because I like doing things declaritively, and django already provides a metaclass for specifying fields that we can conveniently inherit from to get our "extra fields" feature for free. So here it is:


from django.newforms import forms, widgets
from django.db.models.fields import NOT_PROVIDED

class DuplicateFieldError(Exception):
pass

class MetaModelForm(forms.DeclarativeFieldsMetaclass):
"""Creates form fields from the model in the 'model' class attribute and
adds them to the created form."""

# The default custom widgets we will use.
_default_widgets = {
'TextField': lambda _: widgets.Textarea({'rows': '10', 'cols': '40'}),
'ImageField': lambda _: widgets.FileInput()
}

# This can be overridden to provide custom widgets for particular model
# fields. It should be a mapping from model field class names (ie
# 'CharField') to callables that take one argument, the model field,
# returning a Widget instance. See _default_widgets above.
model_widgets = None
model = None

def __init__(cls, name, bases, attrs):
super(MetaModelForm, cls).__init__(name, bases, attrs)
if cls.model is None:
# We don't need to do anything.
return
if cls.model_widgets is None:
cls.model_widgets = {}

cls._update_fields()

def _update_fields(cls):
"""Update the form's fields with fields created from cls.model. Bits
of this are stolen from newforms.form_for_model"""
opts = cls.model._meta
# Create cls._widgets from defaults + overrides
widgets = cls._default_widgets.copy()
widgets.update(cls.model_widgets)
cls._widgets = widgets
for f in opts.fields + opts.many_to_many:
if f.name in cls.fields:
# We wimp out and start crying rather than try to resolve this
raise DuplicateFieldError('Field %s is duplicated in the '\
'model' % f.name)
# Attempt to create a form field for the model field.
form_field = cls._create_field(f)
if form_field:
# If we successfully get a form field, update the Form's fields
# attribute with the new field.
cls.fields[f.name] = form_field

def _create_field(cls, f):
"""Create a form field from the given model field if possible,
otherwise return None."""
if f.default == NOT_PROVIDED:
formfield = f.formfield()
else:
# Take the inital field value from the model field's default.
formfield = f.formfield(f.default)

if formfield:
try:
# Check if there is a given field-specific widget
widget = cls._widgets[type(f).__name__](f)
except KeyError:
# If not, use the form field's default widget.
widget = formfield.widget
else:
# If a custom widget was found, the field must be updated
# with it. We check for extra attributes provided for this
# widget by the field, and update the widget accordingly.
extra_attrs = formfield.widget_attrs(widget)
if extra_attrs:
widget.attrs.update(extra_attrs)
formfield.widget = widget

# Add help text. Until there is a better way. ;)
formfield.help_text = f.help_text
return formfield


I have tried to comment the code so it is clear what is going on. model_widgets is a dictionary of callables that take the model field as an argument, rather than just a bunch of widget instances. This is so aspects of the model can be taken into account when deciding the widget (or widget attributes) to use.

Say we have a model as follows:


class Person(models.Model):
first_name = models.CharField(maxlength=20)
last_name = models.CharField(maxlength=20)
address = models.TextField(help_text='The home address of the person.')
age = models.PositiveSmallIntegerField(default=21)


We can create a form like so:


>>> class PersonForm(forms.BaseForm):
... __metaclass__ = MetaModelForm
... model_widgets = {
... 'PositiveSmallIntegerField': lambda _: widgets.TextInput({'size': 3})
... }
... model = Person
... date_of_birth = fields.EmailField()
... email = fields.EmailField()
...
>>> form = PersonForm()
>>> print form
<tr><th><label for="id_date_of_birth">Date of birth:</label></th><td><input type="text" name="date_of_birth" id="id_date_of_birth" /></td></tr>
<tr><th><label for="id_email">Email:</label></th><td><input type="text" name="email" id="id_email" /></td></tr>
<tr><th><label for="id_first_name">First name:</label></th><td><input id="id_first_name" type="text" name="first_name" maxlength="20" /></td></tr>
<tr><th><label for="id_last_name">Last name:</label></th><td><input id="id_last_name" type="text" name="last_name" maxlength="20" /></td></tr>
<tr><th><label for="id_address">Address:</label></th><td><textarea id="id_address" rows="10" cols="40" name="address"></textarea></td></tr>
<tr><th><label for="id_age">Age:</label></th><td><input id="id_age" type="text" name="age" value="21" size="3" /></td></tr>
>>>


Defining model_widgets is optional, but I wanted to show that it works (notice that the "age" field has a size="3" attribute). This shouldn't be hard to modify or extend so that it does exactly what you want. One possibility I'm considering is copying validator methods from the model to the form.

I should mention field ordering. date_of_birth and email appear first because they were created first (before the metaclass methods ran). newforms uses an internal counter to keep track of the order in which fields are created, and by default renders them in order of creation. This isn't difficult to work around if you desire it. However, instead of working around it, I use a different method, taking advantage of the fact it is very simple to subclass BaseForm and customise the automatic rendering.

Tomorrow I will post a simple form subclass for rendering a form using fieldsets (specified in a similar way to django's auto-generated admin), including a custom row format-string that includes help_text. I'll link to it from here when I post it.

It's late here, though, and I have a lecture tomorrow at the disgustingly early time of 11am.

Popular posts from this blog

PyGTK, Py2exe, and Inno setup for single-file Windows installers

ESP-IDF for Arduino Users, Tutorials Part 2: Delay

How to install IronPython2 with Mono on Ubuntu