Django formset handling with class based views, custom errors and validation

Formset example code
Image: Formset example code (License: CC-BY-SA Marcelo Canina)

How to use formsets taking benefits of generic views

Published:

Overview

In Django, forms and formsets are very similar, but by default it supports just regular forms in class based views, this is a guide to easily use Formsets with Class Based Views (CBVs).

Basic

The most basic view for handling forms is one that:

  1. displays a form. 1.1. On error, redisplays the form with validation errors; 1.2. on success, redirects to a new URL.

That behaviour is already handled at django.views.generic.edit.FormView.

What if we want to take advantage of the above view but display an arbitrary number of forms for the same model?

Let’s assume we have the following in myapp/models.py:

from django.db import models
from django.urls import reverse

class Author(models.Model):
    name = models.CharField(max_length=200)

    def get_absolute_url(self):
        return reverse('author-detail', kwargs={'pk': self.pk})

To use a regular FormView CBV that displays a single form, we would have to define it with a form in myapp/forms.py:

from django import forms

class AuthorForm(forms.Form):
    name = forms.CharField()

and a view at myapp/views.py using the above form specifying in at form_class attribute:

from myapp.forms import AuthorForm
from django.views.generic.edit import FormView

class AuthorFormView(FormView):
    template_name = author.html
    form_class = AuthorForm
    success_url = '/'

with a template like:

<form method="post">{% csrf_token %}
    {{ form.as_p }}
    <input type="submit" value="Add">
</form>

Using a formset

To display two author forms in our template with a single submit button, we slightly change the above code.

Create formset

First of all, we create a new formset in myapp/forms.py:

AuthorFormSet = formset_factory(
    AuthorForm,
    extra=2,
    max_num=2,
    min_num=1
)

We are using formset_factory which is defined at from django.forms import formset_factory like:

def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False,
                    can_delete=False, max_num=None, validate_max=False,
                    min_num=None, validate_min=False):
    ...

Use formset in view

Then use the above formset in the view, instead of a single AuthorForm we use AuthorFormSet:

from myapp.forms import AuthorFormSet
from django.views.generic.edit import FormView

class AuthorFormView(FormView):
    template_name = author.html
    form_class = AuthorFormSet
    success_url = '/'

Now the template would display two forms, a simple test to be sure that the name is required (this test isn’t complete but I left here to give an idea how to use TestCase.assertFormsetError):

from django.test import TestCase
from django.urls import reverse

from myapp.views import 

class AuthorFormView(TestCase):
    def test_language_is_required(self):
        name = 'Richard Morales'
      
        data = {
            'form-TOTAL_FORMS': '2',
            'form-INITIAL_FORMS': '1',
            'form-MAX_NUM_FORMS': '2',
  			#'form-0-name':name, not sent to raise the error
        }

        response = self.client.post(reverse('create-two-authors'), data)

        self.assertFormsetError(response,
                                formset='form',
                                form_index=0,
                                field='name',
                                errors='Please specify the name of the
								author.')

Adding custom validation to formset

To further customize the validation process, i.e.: overriding full_clean(), we need to inherit from BaseFormSet instead of just defining the method in the view, because it is the formset who does the proper validation for each of its forms and the collect all of those forms cleaned data at self.cleaned_data.

Then in the myapp/forms.py we explicitely create a new formset=BaseFormSet to extend from, called AuthorBaseFormSet and then use it in formset_factory.

When defining our custom full_clean function, we can access all forms data in the self.cleaned_data list, which contains the data for each form in a dictionary like: [{'name': 'entered name in first form'},{'name': 'entered name in second form'}].

Finally, we add a specific error message and attach it to the right form field like self.forms[0].add_error('name', "the name of the first form should contain vowels").

Summarizing:

class AuthorBaseFormSet(BaseFormSet):
    def full_clean(self, *args, **kwargs):
        """
        Cleaning and validating fields that depend on each other
		"""
        super().full_clean(*args, **kwargs)

        # if basic clean has any problem stop further processing
        # if not is_valid() it won't have cleaned_data
        if(not self.is_valid()):
            return 

	    # now we can have access to self.cleaned_data
		# and be able to raise any error or attach an error to a form field
		# after some validation we add the errors
	    ...
        msg0 = "First author name should contain vowels"
        msg1 = "Second author name should contain consonants"
        self.forms[0].add_error('rawtext', msg0)
        self.forms[1].add_error('rawtext', msg1)



AuthorFormSet = formset_factory(
    AuthorForm,
    formset=AuthorBaseFormSet, # added to handle validation and error management
    extra=2,
    max_num=2,
    min_num=1
)

Now to test the above expected behaviour:

from django.test import TestCase
from django.urls import reverse

from myapp.views import 

class AuthorFormView(TestCase):
    def test_view_names_have_vowels_and_consonants(self):
        name0 = 'ZZZZZ'
	    name1 = 'AAAAA'

        data = {
            'form-TOTAL_FORMS': '2',
            'form-INITIAL_FORMS': '2',
            'form-MIN_NUM_FORMS': '1',
            'form-MAX_NUM_FORMS': '2',
            'form-0-name': name0,
            'form-1-name': name1,
        }

        response = self.client.post(reverse('create-two-authors'), data)

        self.assertEqual(response.status_code, 200)
        
		# first form
        self.assertFormsetError(response,
                                formset='form',
                                form_index=0,
                                field='name',
                                errors='First author name should contain vowels')
        # second form
        self.assertFormsetError(response,
                                formset='form',
                                form_index=1,
                                field='name',
                                errors='Second author name should contain consonants')

Conclusion

In this way we can get all the benefits of Class Based Views using them with Formsets without having to repeat code for common task already well tested.

References

Uruguay
Marcelo Canina
I'm Marcelo Canina, a developer from Uruguay. I build websites and web-based applications from the ground up and share what I learn here.
comments powered by Disqus

Except as otherwise noted, the content of this page is licensed under CC BY-NC-ND 4.0 . Terms and Policy.

Powered by SimpleIT Hugo Theme

·