Simplifying Django Forms with Decorators

Overview

Django forms make getting input from your users a quick and easy process. They handle generating markup, validating input, and type-conversions; mostly leaving you to focus on your business logic. Today, I’m going to walk you through an example inspired by the Django docs, and show you how it can be standardized and simplified in order to reduce code duplication and further speed up your development process.

Creating the Django Form

The example we’ll use today is a simple application that displays a welcome message after the user inputs a valid first name, last name, and age. Before we make improvements, let’s set up a simple Django form.

forms.py

from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _


class PersonForm(forms.Form):
    first_name = forms.CharField()
    last_name = forms.CharField()
    age = forms.IntegerField()

    def clean_first_name(self):
        first_name = self.cleaned_data['first_name']
        return self._clean_name(first_name)

    def clean_last_name(self):
        last_name = self.cleaned_data['last_name']
        return self._clean_name(last_name)

    def _clean_name(self, name):
        if len(name) < 2:
            raise ValidationError(
                _('Must be at least two characters.')
            )
        return name

    def clean_age(self):
        age = self.cleaned_data['age']
        if age < 0:
            raise ValidationError(_('Must not be negative.'))
        return age

This form simply enforces that names are longer than a character each and age is positive.

form_page.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <style>
      input {
        display: block;
        margin-bottom: 20px;
      }
    </style>
  </head>
  <body>
    <form action="{{ url }}" method="post">
      {% csrf_token %}
      {{ form }}
      <input type="submit" value="Submit">
    </form>
  </body>
</html>

This is a base template for form pages. I’ve intentionally kept out specifics in order to make this reusable.

Thankfully, this is all the setup we need. We can now get down to actually writing the view with our business logic.

Creating the View

Let’s create the view.

views.py

from django.http import HttpResponse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods

from .forms import PersonForm


@require_http_methods(['GET', 'POST'])
def enter_name(request):
    form = PersonForm()
    if request.method == 'POST':
        form = PersonForm(request.POST)
        if form.is_valid():
            params = form.cleaned_data

            first_name = params['first_name'].title()
            last_name = params['last_name'].title()
            return HttpResponse(
                'Welcome, {} {}!'.format(first_name, last_name)
            )

    return render(request, 'enter_name.html', {
        'form': form,
    })

This is a standard form view, according to the Django docs. It does the following:

On a GET Request:

  • Presents the empty form

On a POST Request:

  • Validates the form data
    • If the form data is invalid:
      • Re-renders the form with validation errors
    • If the form data is valid:
      • Your business logic

The key takeaway here is that your actual business logic accounts for one of three possible outcomes. The other two outcomes, presenting the form with/without errors, can be likely standardized in most applications. Next, we will generalize this code so that it only has to be written once, and future views will benefit from new simplicity.

Don’t forget to hook up the view to a URL and create the template we just referenced.

urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('enter-name/', views.enter_name,
         name='Enter Name'),
]

enter_name.html

{% url 'Enter Name' as url %}
{% include 'form_page.html' %}

Identifying Improvements

So, how can we make this better? First, let’s identify non-business logic code.

views.py

@require_http_methods(['GET', 'POST'])
def enter_name(request):
    form = PersonForm()
    if request.method == 'POST':
        form = PersonForm(request.POST)
        if form.is_valid():
            params = form.cleaned_data

            first_name = params['first_name'].title()
            last_name = params['last_name'].title()
            return HttpResponse(
                'Welcome, {} {}!'.format(first_name, last_name)
            )

    return render(request, 'enter_name.html', {
        'form': form,
    })

Highlighted are the lines of code that are boilerplate. These are associated with checking the request type, validating with the form, and rendering the template. We should look to generalize these as much as possible.

Creating a Decorator

Ideally, our view would be only for business logic. In order to accomplish this, we need a function to wrap around the view that handles every common outcome and leaves the rest to the view itself. Thankfully, Python has decorators that are able to do exactly this. If you are not familiar with decorators, I recommend you read this article. If you are already comfortable with decorators, you may want to skip to the next section.

This is the basic structure of our new decorator:

decorators.py

import functools

from django.shortcuts import render


def view_form(form_cls, template):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(request, *args, **kwargs):
            ...
        return wrapper
    return decorator

There is a lot going on here already, so let’s break it down.

Starting from the innermost function, wrapper() is the wrapping function we are after. It takes in the same arguments as the view and decides what to do with them. Later, we will use this to our advantage by handling cases with standardized outcomes here and only calling the actual view (func) when it is time to execute business logic. The functools.wraps() decorator simply preserves the identity of the wrapped view. It is optional here, but it’s nice to have. Lastly, it is important to include the *args and **kwargs arguments, as we don’t want to lose any additional arguments the view may receive now or in the future.

decorator() is technically the actual decorator. It takes one argument: the view. For standard decorators, this is all we would need, but our decorator needs to have different behavior for different views, so we’ll need to go up one more level.

view_form() is a function that creates a decorator. We pass in the form class and the template name in order to customize our decorator’s behavior per-view. Doing this creates a closure around the inner functions, which means that these arguments are preserved for each call of the inner functions.

If this sounds confusing, don’t worry. Decorators are much easier to create and use than to understand.

Writing the Decorator Logic

Now that the decorator has its basic structure, it will be relatively easy to fill in the logic we need.

decorators.py

def view_form(form_cls, template):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(request, *args, **kwargs):
            form = form_cls()
            if request.method == 'POST':
                form = form_cls(request.POST)
                if form.is_valid():
                    return func(
                        request, *args, **kwargs,
                        params=form.cleaned_data
                    )

            return render(request, template, {
                'form': form,
            })

        return wrapper
    return decorator

This new logic is actually the logic from our view, generalized. It handles every outcome except for one: a valid POST request. In this case, it will call the view and provide the form-cleaned parameters.

This statement is the most important to note:

return func(
    request, *args, **kwargs,
    params=form.cleaned_data
)

This is the statement that calls the wrapped view. It is important that params is a keyword argument in order to maintain compatibility with other decorators or other logic that may introduce other arguments or keyword arguments.

Applying the Decorator

This is the easiest part. Let’s add the decorator to our view and eliminate the boilerplate logic that we no longer need to include in the view.

views.py

from django.http import HttpResponse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods

from .decorators import view_form
from .forms import PersonForm


@require_http_methods(['GET', 'POST'])
@view_form(PersonForm, 'enter_name.html')
def enter_name(request, params):
    first_name = params['first_name'].title()
    last_name = params['last_name'].title()
    return HttpResponse(
        'Welcome, {} {}!'.format(first_name, last_name)
    )

Now, when this view is executed, it can be certain that a valid POST request has occurred and that the parameters passed in as params are valid and cleaned by the Django form specified in the decorator. This view is now entirely business logic, and other views going forward will be much thinner and more concise.

Code

If you want to see the finished product or try it out, check out the repository on GitHub.

One thought on “Simplifying Django Forms with Decorators”

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.