JF's Dev Blog

Django, Vue, and other things, too

Writing reusable ModelForm templates with Django

Introduction

One of the things that drew me to Django was how easy Django makes it to interact with items in a database. Once you've defined your models, Django's model forms can handle the heavy lifting of allowing your users to create, update, and delete entries in your project's database.

The purpose of this post is to show a strategy for writing form templates that can be shared among all models in your project without modification. In doing so, we're employing at least three of Django's design philosophies:

  • Don't repeat yourself (DRY)
  • Less code
  • Discourage redundancy

ModelForm and its template

Let's get our feet wet with Django's ModelForms and their templates before we DRY them off.

Here's a quick example from the Django docs of class-based model editing views to create, update, and delete an Author (check out the Django ModelForm class docs to brush up on the class that provides forms to these views):

# author/models.py
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.urls import reverse_lazy
from myapp.models import Author

class AuthorCreate(CreateView):
    model = Author
    fields = ['name']
    template_name = 'author/author_form.html'

class AuthorUpdate(UpdateView):
    model = Author
    fields = ['name']
    template_name = 'author/author_form.html'

class AuthorDelete(DeleteView):
    model = Author
    success_url = reverse_lazy('author-list')
    template_name = 'author/author_confirm_delete.html'

I've added an explicit template_name to each of the classes, and we can see that Django practices what it preaches: we have three different ModelForms but we only need two templates--for this model. Before we look into making a single template we can share across all models in a project, let's look to see how we can reuse a single template for both a CreateView and an UpdateView.

A single Create/Update template

Create and update forms aren't all that different. We can see above that AuthorCreate and AuthorUpdate specify the same model and the same fields. Although the forms do different things once they're submitted (one creates an entry in the database, the other updates an entry), the forms are the same before they're submitted. They're just a bunch of HTML form inputs.

Let's take a look at author_form.html:

{% extends 'base.html' %}
{# author_form.html #}

{% block content %}
  <h2>{% if object %}Update{% else %}Create{% endif %} Author</h2>

  <form action="" method="post">
    {% csrf_token %}

    {{ form }}

    <button>Submit</button>
  </form>
{% endblock %}

Irregular pluralizationt because this form pretty DRY. The action="" method="post" attributes mean that the form will perform an HTTP POST to the current URL, so we don't have to specify diffent form actions if the template is being used in our CreateView or UpdateView. The {% if object %} at the top of the form shows "Update Form" if the template is used in a view that has an object variable in its context, which an UpdateView does. A CreateView doesn't have an object because that view is responsible for creating an object in the database.

The only thing preventing us from using this form for every model in our project is the sneaky "Author" in the template's header (<h2>). To make this template reusable, we'll need to replace that hard-coded model name with something more dynamic.

Call me by my verbose_name

Django models have a user-friendly representation used in places like the Django admin. "CoverPhoto", the name of a model class, is less legible than "cover photo" for most users--camels being a special case. This user-friendly string is the model's verbose_name.

By default, Django derives a model's verbose_name from the name of the model class, splitting the string by how it's capitalized. "CoverPhoto" becomes "cover photo", for instance. There's also verbose_name_plural, which by default just tacks an 's' to the end of verbose_name. This works well quite a lot of the time, but there are at least three cases where the default verbose_name and verbose_name_plural don't pass muster:

  1. Irregular pluralizations
  2. Initialisms and acronyms
  3. Internationalized applications

Given this model for an information technology person:

from django.db import models

class ITPerson(models.Model):
    pass

here are Django's default verbose names:

>>> from .models import ITPerson
>>> str(ITPerson._meta.verbose_name)
it person
>>> str(ITPerson._meta.verbose_name_plural)
it persons

To use verbose_name in our user-facing templates, we'd expect to see 'IT person' and 'IT people' instead. Specifying verbose names for our model fixes this up:

from django.db import models
from django.utils.translation import ugettext_lazy as _

class ITPerson(models.Model):
    Meta:
        verbose_name = _('IT person')
        verbose_name_plural = _('IT people')

(See the Django translation docs for information about ugettext_lazy.)

With these changes, our model's verbose_name and verbose_name_plural are user-ready:

>>> from .models import ITPerson
>>> str(ITPerson._meta.verbose_name)
IT person
>>> str(ITPerson._meta.verbose_name_plural)
IT people

So, now we know how to specify user-friendly names for our models and how to reference them (Model._meta.verbose_name). Let's use this in our form template!

Thou shall not pass _meta attributes to a template variable

Not so fast. If we try to use the model's verbose names directly in our template like this:

<h2>{% if object %}Update{% else %}Create{% endif %} {{ view.model._meta.verbose_name }}</h2>

Django will raise a TemplateSyntaxError:

TemplateSyntaxError at /author/create/
Variables and attributes may not begin with underscores: 'view.model._meta.verbose_name'

Attributes that begin with an underscore are considered private in Python. And while that isn't enforced at the language-level, Django's template syntax does enforce that convention. (See the Python docs for a discussion on private variables.)

Although Python says that private attributes could change at any time, Django models' verbose_name and verbose_name_plural aren't going to change on us any time soon (they're used in the Django admin). So, we can still use this information in our ModelForm template, just not directly. What we really want is a function that accepts a model form instance as an argument and returns the model's verbose_name.

Template filters

Django does offer the ability to pass a variable through a function and output the result into a template using a filter. A filter modifies how a variable is displayed in a template. For example, Django's built-in capfirst filter capitalizes the first letter of a string:

{% with curved_fruit='banana' %}
<p>{{ curved_fruit|capfirst }}</p>
{% endwith %}

<!-- The above renders: -->

<p>Banana</p>

While Django has a pile of filters, we'll need to write our own to return a model's verbose name given a form instance.

Custom filters

Filters live alongside template tags, and Django recognizes custom template tags and filters in the templatetags directory in each of your installed apps.

Let's make a template filter accepts a ModelForm instance and spits out the appropriate model's verbose_name:

# project/templatetags/modelform_title.py
"""Template tag to get a model's name from a ModelForm"""
from django import template
register = template.Library()


@register.filter
def modelform_title(modelform):
    """Returns a modelform's titlized Model.verbose_name"""
    return modelform._meta.model._meta.verbose_name.title()

There's a bit of _meta madness going on here. Under the hood of a CreateView or UpdateView is a ModelForm class that itself takes a model argument in its Meta class:

class AuthorForm(ModelForm):
    class Meta:
        model = Author

So if we have an AuthorForm instance, we can reference the Author model with AuthorForm._meta.model. We already know that a model's verbose_name property is in its Meta class, so given an AuthorForm instance named form, form._meta.model._meta.verbose_name gives us the Author model's verbose name: "author".

By appending title(), we capitalize the first letter of every word in the model's verbose name. Given an ITPerson ModelForm instance, that filter will output "IT Person".

Using it

To use custom template tags and filters in a template, they need to be loaded at the top of any template that uses them. Django knows where to look for files containing template tags and filters (the templatetags directory in any installed app), and we use those filenames to load template tags and filters so we can use them in a template (see line 2).

Here's our new template, model_form.html:

{% extends 'base.html' %}
{# model_form.html #}
{% load modelform_title %}

{% block content %}
  <h2>{% if object %}Update{% else %}Create{% endif %} {{ form|modelform_title }}</h2>
  <form action="" method="post">
    {% csrf_token %}

    {{ form }}

    <button>Submit</button>
  </form>
{% endblock %}

You may have to squint to see how our new universal create/update template differs from the old one. There are two changes:

  1. We're loading our new template filter & tag library at the top of the template (named after our modelform_title.py file): {% load modelform_title %}
  2. We're using our fancy new template filter: {{ form|modelform_title }}

If we move model_form.html beside our base.html template file, we can update our Author views to use this new template:

# author/models.py
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.urls import reverse_lazy
from myapp.models import Author

class AuthorCreate(CreateView):
    model = Author
    fields = ['name']
    template_name = 'model_form.html'

class AuthorUpdate(UpdateView):
    model = Author
    fields = ['name']
    template_name = 'model_form.html'

class AuthorDelete(DeleteView):
    model = Author
    success_url = reverse_lazy('author-list')
    template_name = 'confirm_delete.html'

What about a delete form template?

I didn't forget! It's easy to create a reusable delete form template without having to use a custom template tag. Because every DeleteView is guaranteed to have an object, we can use that to differentiate the forms across models:

{% extends 'base.html' %}
{# confirm_delete.html #}

{% block content %}
  <h2>Confirm Delete</h2>

  <form action="" method="post">
    {% csrf_token %}
    <p>
      Are you sure you want to delete {{ object }}?
    </p>
    <button>Delete</button>
  </form>
{% endblock %}

Just make sure that your model has a user-friendly string representation (what your model returns in its __str__() method).

Conclusion

After a bit of a walkabout, we've reached our destination and covered a fair bit of ground. By leveraging a Django model's verbose_name, we can glean user-friendly names for every model in our project. With a simple custom template filter, we're able to write one template (model_form.html) and reuse it for every CreateView and UpdateView in a project, regardless of which model those views use. In a project with just 5 models, model_form.html and confirm_delete.html do the work of 10 templates.

You don't need a hygrometer to see that that's pretty darn DRY.