Presenter: Nathan Yergler (http://yergler.net/) (@nyergler) from Eventbrite
PyCon 2012 presentation page: https://us.pycon.org/2012/schedule/presentation/420/
Slides: http://yergler.net/2012/pycon-forms/
Video: http://pyvideo.org/video/698/django-form-processing-deep-dive
Views | Convert request to response |
Forms | Convert input to Python objects (input is not necessarily HTML) |
Models | Data and business logic |
Forms are composed of fields, which have a widget.
from django.utils.translation import gettext_lazy as _
from django import forms
class ContactForm(forms.Form):
name = forms.CharField(label=_("Your Name"),
max_length=255,
widget=forms.TextInput,
)
email = forms.EmailField(label=_("Email address"))
Form API: https://docs.djangoproject.com/en/dev/ref/forms/api/
Unbound forms don't have data associated with them, but they can be rendered:
form = ContactForm()
Bound forms have specific data associated, which can be validated:
form = ContactForm(data=request.POST, files=request.FILES)
Two ways:
form.fields['name']
returns theField
objectform['name']
returns aBoundField
form = ContactForm(
initial={
'name': 'First and Last Name',
}
)
>>> form['name'].value()
'First and Last Name'
- Only bound forms can be validated
- Calling
form.is_valid()
triggers validation if needed - Validated, cleaned data is stored in
form.cleaned_data
- Calling
form.full_clean()
performs the full cycle
- Three phases for fields: To Python, Validation, and Cleaning
- If validation raises an Error, cleanning is skipped
- Validators are callables that can raise a
ValidationError
- Django includes generic ones for some common tasks
- Examples: URL, Min/Max Value, Min/Max Length, Regex, Email, etc.
.clean_fieldname()
method is called after validators- Input has already been converted to Python objects
- Methods can still raise
ValidationError
- Methods must return the cleaned value
class ContactForm(forms.Form):
name = forms.CharField(
label=_("Name"),
max_length=255,
)
email = forms.EmailField(
label=_("Email address"),
)
def clean_email(self):
if (self.cleaned_data.get('email', '').endswith('hotmail.com')):
raise ValidationError("Invalid email address.")
return self.cleaned_data.get('email', '')
.clean()
performs cross-field validation - Example: Check thatemail
andconfirm_email
fields match- Called even if errors were raised by Fields
- Must return the cleaned data dictionary
ValidationError``s raised by ``.clean()
will be grouped inform.non_field_errors()
by default
Example of cross-field validation: Check that email
and confirm_email
fields match:
class ContactForm(forms.Form):
name = forms.CharField(
label=_("Name"),
max_length=255,
)
email = forms.EmailField(label=_("Email address"))
confirm_email = forms.EmailField(label=_("Confirm"))
def clean(self):
if (self.cleaned_data.get('email') !=
self.cleaned_data.get('confirm_email')):
raise ValidationError("Email addresses do not match.")
return self.cleaned_data
- Initial data is used as a starting point
- It does not automatically propagate to
cleaned_data
- Defaults for non-required fields should be specified when accessing the dict:
self.cleaned_data.get('name', 'default')
- Forms use initial data to track change fields
form.has_changed()
form.changed_fields
- Fields can render a hidden input with the initial value, as well:
>>> changed_date = forms.DateField(show_hidden_initial=True)
>>> print form['changed_date']
'<input type="text" name="changed_date" id="id_changed_date" /><input type="hidden" name="initial-changed_date" id="initial-id_changed_date" />'
Not clear whether they're unit tests or functional tests, etc. but nonetheless useful
- Remember what Forms are for
- Testing strategies
- Initial states
- Field validation
- Final state of
cleaned_data
import unittest
class FormTests(unittest.TestCase):
def test_validation(self):
form_data = {
'name': 'X' * 300,
}
form = ContactForm(data=form_data)
self.assertFalse(form.is_valid())
Eventbrite just released a library on GitHub called rebar -- https://github.com/eventbrite/rebar
from rebar.testing import flatten_to_dict
form_data = flatten_to_dict(ContactForm())
form_data.update({
'name': 'X' * 300,
})
form = ContactForm(data=form_data)
assert(not form.is_valid())
(Plug for class-based views....)
from django.views.generic.edit import FormMixin, ProcessFormView
class ContactView(FormMixin, ProcessFormView):
form_class = ContactForm
success_url = '/contact/sent'
def get_form_kwargs(self):
return super(ContactView, self).get_form_kwargs()
Three primary "whole-form" output modes:
form.as_p()
form.as_ul()
form.as_table()
<tr><th><label for="id_name">Name:</label></th>
<td><input id="id_name" type="text" name="name" maxlength="255" /></td></tr>
<tr><th><label for="id_email">Email:</label></th>
<td><input id="id_email" type="text" name="email" maxlength="Email address" /></td></tr>
<tr><th><label for="id_confirm_email">Confirm email:</label></th>
<td><input id="id_confirm_email" type="text" name="confirm_email" maxlength="Confirm" /></td></tr>
{% for field in form %}
{{ field.label_tag }}: {{ field }}
{{ field.errors }}
{% endfor %}
{{ field.non_form_errors }}
Additional rendering properties:
field.label
field.label_tag
field.html_id
field.help_text
http://yergler.net/2012/pycon-forms/slides/forms/#26
Libraries that make some of this stuff easier:
You can specify additional attributes for widgets as part of the form definition.
class ContactForm(forms.Form):
name = forms.CharField(
max_length=255,
widget=forms.Textarea(
attrs={'class': 'custom'},
),
)
You can also specify form-wide CSS classes to add for error and required states.
class ContactForm(forms.Form):
error_css_class = 'error'
required_css_class = 'required'
...
Built-in validators have default error messages
>>> generic = forms.CharField()
>>> generic.clean('')
Traceback (most recent call last):
...
ValidationError: [u'This field is required.']
error_messages
lets you customize those messages
>>> name = forms.CharField(
... error_messages={'required': 'Please enter your name'})
>>> name.clean('')
Traceback (most recent call last):
...
ValidationError: [u'Please enter your name']
ValidationError
exceptions raised are wrapped in a class- This class controls HTML formatting
- By default,
ErrorList
is used; outputs as<ul>
- Specify the
error_class
kwarg when constructing the form to override
from django.forms.util import ErrorList
class ParagraphErrorList(ErrorList):
def __unicode__(self):
return self.as_paragraphs()
def as_paragraphs(self):
return "<p>%s</p>" % (
",".join(e for e in self.errors)
)
form = ContactForm(data=form_data, error_class=ParagraphErrorList)
Avoid potential name collisions with prefix:
contact_form = ContactForm(prefix='contact')
Adds the prefix to HTML name and ID:
<tr><th><label for="id_contact-name">Name:</label></th>
<td><input id="id_contact-name" type="text" name="contact-name"
maxlength="255" /></td></tr>
<tr><th><label for="id_contact-email">Email:</label></th>
<td><input id="id_contact-email" type="text" name="contact-email"
maxlength="Email address" /></td></tr>
<tr><th><label for="id_contact-confirm_email">Confirm
email:</label></th>
<td><input id="id_contact-confirm_email" type="text"
name="contact-confirm_email" maxlength="Confirm" /></td></tr>
- ModelForms map a Model to a Form
- Validation includes Model validators by default
- Supports creating and editing instances
- Key differences from forms
- A field for the primary key (usually
id
) save()
method.instance
property
- A field for the primary key (usually
from django.db import models
from django import forms
class Contact(models.Model):
name = models.CharField(max_length=100)
email = models.EmailField()
notes = models.TextField()
class ContactForm(forms.ModelForm):
class Meta:
model = Contact
...
- You don't need to expose all the fields in your form
- You can either specify fields to expose, or fields to exclude
class ContactForm(forms.ModelForm):
class Meta:
model = Contact
fields = ('name', 'email',)
class ContactForm(forms.ModelForm):
class Meta:
model = Contact
exclude = ('notes',)
- Django will generate fields and widgets based on the model
- These can be overridden as well
class ContactForm(forms.ModelForm):
name = forms.CharField(widget=forms.TextInput)
class Meta:
model = Contact
model_form = ContactForm()
model_form = ContactForm(
instance=Contact.objects.get(id=2)
)
- ModelForms have an additional method,
_post_clean()
- Sets cleaned fields on the Model instance
- Called regardless of whether the form is valid
class ModelFormTests(unittest.TestCase):
def test_validation(self):
form_data = {
'name': 'Test Name',
}
form = ContactForm(data=form_data)
self.assert_(form.is_valid())
self.assertEqual(form.instance.name, 'Test Name')
form.save()
self.assertEqual(
Contact.objects.get(id=form.instance.id).name,
'Test Name'
)
- Handles multiple copies of the same form
- Adds a unique prefix to each form:
form-1-name
- Support for insertion, deletion, and reordering
from django.forms import formsets
ContactFormSet = formsets.formset_factory(
ContactForm,
)
formset = ContactFormSet(data=request.POST)
Factory kwargs:
can_delete
extra
max_num
<form action="" method="POST">
{% formset %}
</form>
or more control over output:
<form action="." method="POST">
{% formset.management_form %}
{% for form in formset %}
{% form %}
{% endfor %}
</form>
formset.management_form
provides fields for tracking the member formsTOTAL_FORMS
INITIAL_FORMS
MAX_NUM_FORMS
- Management form data must be present to validate a Form Set
- Performs validation on each member form
- Calls
.clean()
method on the FormSet formset.clean()
can be overridden to validate across Forms- Errors raised are collected in
formset.non_form_errors
from django.forms import formsets
class BaseContactFormSet(formsets.BaseFormSet):
def clean(self):
names = []
for form in self.forms:
if form.cleaned_data.get('name') in names:
raise ValidationError()
names.append(form.cleaned_data.get('name'))
ContactFormSet = formsets.formset_factory(
ContactForm,
formset=BaseContactFormSet
)
- FormSets can be tested in the same ways as Forms
- Helpers to generate test form data:
flatten_to_dict
works with FormSets just like Formsempty_form_data
takes a FormSet and index, returns a dict of data for an empty form
from rebar.testing import flatten_to_dict, empty_form_data
formset = ContactFormSet()
form_data = flatten_to_dict(formset)
form_data.update(
empty_form_data(formset, len(formset))
)
- ModelFormSets:FormSets :: ModelForms:Forms
queryset
argument specifies intial set of objects.save()
returns the list of saved instances- If
can_delete
isTrue
,.save()
also deletes the models flagged for deletion
- Django's i18n/l10n framework supports localized input formats
- For example: 10,00 vs. 10.00
Enable in settings.py
:
USE_L10N = True
USE_THOUSAND_SEPARATOR = True # optional
And then use the localize
kwarg
>>> from django import forms
>>> class DateForm(forms.Form):
... pycon_ends = forms.DateField(localize=True)
>>> DateForm({'pycon_ends': '3/15/2012'}).is_valid()
True
>>> DateForm({'pycon_ends': '15/3/2012'}).is_valid()
False
>>> from django.utils import translation
>>> translation.activate('en_GB')
>>> DateForm({'pycon_ends':'15/3/2012'}).is_valid()
True
- Declarative syntax is just sugar
- Forms use a metaclass to populate
form.fields
- After
__init__
finishes, you can manipulateform.fields
without impacting other instances
- Validation isn't necessarily all or nothing
- State Validators define validation for specific states, on top of basic validation
- Your application can take action based on whether the form is valid, or valid for a particular state
from django import forms
from rebar.validators import StateValidator, StateValidatorFormMixin
class PublishValidator(StateValidator):
validators = {
'title': lambda x: bool(x),
}
class EventForm(StateValidatorFormMixin, forms.Form):
state_validators = {
'publish': PublishValidator,
}
title = forms.CharField(required=False)
Using it:
>>> form = EventForm(data={})
>>> form.is_valid()
True
>>> form.is_valid('publish')
False
>>> form.errors('publish')
{'title': 'This field is required'}