Skip to content

Latest commit

 

History

History
722 lines (492 loc) · 17.2 KB

django_form_processing_deep_dive.rst

File metadata and controls

722 lines (492 loc) · 17.2 KB

Django Forms Deep Dive - Nathan R. Yergler from EventBrite

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

Form Basics

Forms in context

   
Views Convert request to response
Forms Convert input to Python objects (input is not necessarily HTML)
Models Data and business logic

Defining Forms

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/

Instantiating a Form: unbound forms vs. bound forms

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)

Accessing fields

Two ways:

  • form.fields['name'] returns the Field object
  • form['name'] returns a BoundField

Initial data

form = ContactForm(
    initial={
        'name': 'First and Last Name',
    }
)

>>> form['name'].value()
'First and Last Name'

Validation

Validating the form

  • 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

Field validation

  • 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.

Field cleaning

  • .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

.clean_email() example

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', '')

Form validation

  • .clean() performs cross-field validation - Example: Check that email and confirm_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 in form.non_field_errors() by default

.clean() example

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 != Default 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')

Tracking changes

  • 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" />'

Testing

Not clear whether they're unit tests or functional tests, etc. but nonetheless useful

Testing forms

  • Remember what Forms are for
  • Testing strategies
    • Initial states
    • Field validation
    • Final state of cleaned_data

Unit tests

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())

Test Data

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())

Rendering Forms

Idiomatic form usage

(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()

Form Output

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>

Controlling form output

{% 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

Customizing rendering

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'
    ...

Customizing error messages

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']

Error Class

  • 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

Error Class

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)

Multiple Forms

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>

Forms for Models

Model Forms

  • 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

Model Form Example

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
        ...

Limiting Fields

  • 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',)

Overriding Fields

  • 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

Instantiating Model Forms

model_form = ContactForm()

model_form = ContactForm(
    instance=Contact.objects.get(id=2)
    )

ModelForm.is_valid()

  • ModelForms have an additional method, _post_clean()
  • Sets cleaned fields on the Model instance
  • Called regardless of whether the form is valid

Testing

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'
        )

Form sets

Form sets

  • Handles multiple copies of the same form
  • Adds a unique prefix to each form:
form-1-name
  • Support for insertion, deletion, and reordering

Defining form sets

from django.forms import formsets

ContactFormSet = formsets.formset_factory(
    ContactForm,
)

formset = ContactFormSet(data=request.POST)

Factory kwargs:

  • can_delete
  • extra
  • max_num

Using form sets

<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>

Management form

  • formset.management_form provides fields for tracking the member forms
    • TOTAL_FORMS
    • INITIAL_FORMS
    • MAX_NUM_FORMS
  • Management form data must be present to validate a Form Set

formset.is_valid()

  • 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

FormSet.clean()

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
)

Testing

  • FormSets can be tested in the same ways as Forms
  • Helpers to generate test form data:
    • flatten_to_dict works with FormSets just like Forms
    • empty_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))
)

Model Formsets

  • ModelFormSets:FormSets :: ModelForms:Forms
  • queryset argument specifies intial set of objects
  • .save() returns the list of saved instances
  • If can_delete is True, .save() also deletes the models flagged for deletion

Advanced & Miscellaneous Detritus

  • 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

Localizing fields example

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

Dynamic forms

  • Declarative syntax is just sugar
  • Forms use a metaclass to populate form.fields
  • After __init__ finishes, you can manipulate form.fields without impacting other instances

State validators

  • 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

State validators example

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'}

The End

http://yergler.net/2012/pycon-forms