Photo by Chris Ried on Unsplash

Django: How to write a customized bulk edit page using class based views

Adrien Van Thong
10 min readMar 2, 2023

--

The Django framework is an extremely powerful tool at helping build applications quickly while helping drastically reduce the amount of boilerplate code. Django empowers developers to very quickly create pages that let users for view and edit single records for model classes — but what about when we want to edit multiple records, all at once, in bulk?

This article will show how to build a bulk edit form for multiple records which relate to a single record of another model, all while sticking purely within Django’s CBV framework.

Example

The example which will drive the examples in this tutorial is a simple one: a summer day-camp hosting kids split up into several cohort of different age groups is trying to use Django to keep track of each kids’ preferences and how each one is getting home at the end of the day.

The models representing this example are fairly straightforward: the Student model represents each kid, and has a One-to-Many relationship to the Cohort model. There’s also a foreign key relation to the Ride model which helps tell us how each kid is getting home at the end of the day.

class Cohort(models.Model):
name = models.CharField(max_length=128)
teacher = models.CharField(max_length=128)
description = models.CharField(max_length=254)

def __str__(self):
return self.name

class Ride(models.Model):
name = models.CharField(max_length=64)

def __str__(self):
return self.name

class Student(models.Model):
name = models.CharField(max_length=128)
age = models.IntegerField()
active = models.BooleanField(default=True)
cohort = models.ForeignKey(Cohort, on_delete=models.CASCADE)
ride = models.ForeignKey(Ride, on_delete=models.SET_NULL, blank=True, null=True)
fav_snack = models.CharField(max_length=64, blank=True, null=True)
fav_color = models.CharField(max_length=32, blank=True, null=True)
fav_activity = models.CharField(max_length=32, blank=True, null=True)

def __str__(self):
return self.name

The end goal is to build a single View in Django which lets the camp councilors quickly edit all their students’ preferences at once for everyone in their cohort.

UpdateView — editing a single entry

Let’s start with the simple use case of editing a single record. Using Django’s UpdateView CBV helps us very quickly build the edit views for the Cohort and Student models respectively — here is what these views and corresponding templates look like at their most basic:

from django.views.generic import DetailView, UpdateView
from .models import Cohort, Student

class CohortUpdateView(UpdateView):
template_name = 'form.html'
model = Cohort
slug_field = 'id'
slug_url_kwarg = 'id'
fields = ('name', 'teacher', 'description')


class StudentUpdateView(UpdateView):
template_name = 'form.html'
model = Student
slug_field = 'id'
slug_url_kwarg = 'id'
fields = ('name', 'age', 'active', 'cohort', 'ride', 'fav_color', 'fav_snack', 'fav_activity')


class CohortDetailView(DetailView):
template_name = 'cohort_detail.html'
model = Cohort
slug_field = 'id'
slug_url_kwarg = 'id'

On the template side, let’s use an extremely generic and simple form.html template that can be re-used across several views:

{% extends 'base.html' %}

{% block content %}

<form method="post" enctype="multipart/form-data" action="">
{% csrf_token %}
<table class="table">
{{ form.as_table }}
</table>
<input type="submit" class="btn btn-primary" />
</form>

{% endblock %}

Here is what we end up with in the browser:

StudentEditView
StudentEditView — only edits a single entry at a time

We’ve now got edit views which allow the user to change all the model fields specified in the View, but fall short on two fronts:

  • We can only edit a single Student entry at a time.
  • We can’t manage the Many-to-One entries for the Student model from the Cohort model’s edit view.

This is a good first start, next let’s implement the ability to edit every student in a cohort at once on a single page.

Introducing: FormSets — editing multiple entries

Django provides a built-in feature called FormSets, which follow the Factory design pattern to instantiate a collection of Form objects.

Under the covers, Django’s UpdateView CBV automatically instantiate ModelForm objects for us and dynamically populates each form element using the fields of the model classes we provided it. This saves us from writing that code ourselves, though the option to do so is there if we wished to customize the forms.

In order to make our UpdateView have the capability to update multiple records at once, we’ll need to leverage these FormSets as part of the CBV. We have three options for the FormSet Factory methods:

  • formset_factory — used for generating multiple generic forms together, where not all of them are necessarily the same model class.
  • modelformset_factory — Used for generating multiple ModelForms, where all the entries are of the same model class.
  • inlinemodelformset_factory — Used for managing multiple forms of a model class that all relate back to a single instance of a related model. i.e. Edit all the students in a single cohort.

The third option is what we will implement in this article as it fits our use case perfectly.

Our next step is to work the inlineformset_factory into a newCohortBulkUpdateView. To do this we’ll copy from our existing CohortUpdateView class but this time we’ll override the form_class attribute in our view, as shown below:

from django import forms
from django.urls import reverse_lazy
from .models import Cohort, Student

class CohortBulkUpdateView(UpdateView):
template_name = 'form.html'
model = Cohort
form_class = forms.inlineformset_factory(Cohort, Student, fields=('fav_snack', 'fav_color', 'fav_activity', 'ride'))
slug_field = 'id'
slug_url_kwarg = 'id'

def get_success_url(self):
return reverse_lazy('cohort-detail', kwargs={'id': self.get_object().id})

Diving further into the inlineformset_factory method, we’ve passed in the following arguments:

  • Cohort is the “parent model”, meaning the model whose single record relates to all the records we’re bulk updating have in common. Put simply, it’s the “one” part of our Many-to-One relationship.
  • Student is the model whose records we’ll be updating in bulk. It is the “many” part of our Many-to-One relationship.
  • Lastly, we’ve passed in the fields kwarg which is a list representing the fields from the model which the user will be updating. This works in the same way of the fields property of the StudentUpdateView we defined earlier which lets us update a single entry at a time.

We’ve also overwritten the get_success_url method which tells the form where to redirect the user after a successful bulk update. In the code sample above, we’re redirecting the user to the details view page for the corresponding cohort. Without this, the user would be presented with the following exception after submitting the form: No URL to redirect to. Either provide a url or define a get_absolute_url method on the Model.

Our new view reuses our existing form.html template. If we don’t make any changes to that generic template, here is what gets rendered to the browser:

CohortBulkUpdateView — not quite usable yet

Although the form needs a lot of work, we can at least see all our desired form elements are there — users can edit the favorites and ride for all students in this cohort, and changing the values and submitting the form does allow us to edit these students in bulk.

Let’s highlight the following features we get for free from using the formset_factory method:

  • You’ll notice a “delete” checkbox included with each entry — this was generated automatically by the formset factory and can be disabled by passing in the can_delete=False param.
  • There are also 3 blank student entries at the bottom of the form. These allow the user to create up to new 3 Student records in this same form. This can be adjusted or disabled entirely using the extra=0 param in the formset_factory method.

Next, we’ll need to address two glaring issues with this form: first, all the form elements are showing up on their own row, making it difficult for the user to see where one student’s fields end and the next student’s fields start, and secondly, we don’t have any context regarding which student each form field corresponds to. We are going to need to update our very generic form template:

  • Group together all the fields from a single student onto a single row.
  • Provide context for each row as to which student these fields are for.

Working with FormSets on the template side

Now that the UpdateView has everything it needs, the next step is to improve our template. The generic form we’re using isn’t going to cut it so we’re going to need to replace the call to {{ form.as_table }} with our own form so we can format it the way we need it to be.

Now that we’re using a FormSet, the big difference is that the {{ form }} context now has a list of sub-forms within it — one for each Student record being updated (plus the 3 new blank extras). Additionally, we can access the corresponding Student data via the instance field on each form. As we expand this out, it is necessary to include the hidden_fields as well as the management_form otherwise the form won’t work. Making these changes to our template, we end up with:

{% extends 'base.html' %}

{% block content %}
<h1>Bulk Edit for Cohort: {{ cohort.name }}</h1>

<form method="post" enctype="multipart/form-data" action="">
{% for hidden_field in form.hidden_fields %}
{{ hidden_field.errors }}
{{ hidden_field }}
{% endfor %}

{% csrf_token %}

{{ form.management_form }}
{{ form.non_form_errors }}
<table class="table table-striped">
<thead>
<tr>
<th scope="col">Student</th>
<th scope="col">Age</th>
<th scope="col">Ride</th>
<th scope="col">Snack</th>
<th scope="col">Color</th>
<th scope="col">Activity</th>
</tr>
</thead>
<tbody>
{% for student_form in form.forms %}
<tr>
{% for hidden_field in student_form.hidden_fields %}
{{ hidden_field.errors }}
{{ hidden_field }}
{% endfor %}
<td>{{ student_form.instance.name }}</td>
<td>{{ student_form.instance.age }} yrs</td>
<td>{{ student_form.ride }}</td>
<td>{{ student_form.fav_snack }}</td>
<td>{{ student_form.fav_color }}</td>
<td>{{ student_form.fav_activity }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<input type="submit" value="Update" class="btn btn-primary" />
</form>
{% endblock %}

In the updated template code above, note the following important changes:

  • The for-loop over form.forms is where each student’s record form lives. Each of these forms have their own fields, hidden fields, and access to the model instance.
  • Both the top-level form and each individual form.forms have hidden_fields which all need to be on the page for the form to work.
  • The top-level management_form is present outside the for-loop. This is important to help Django keep track of all the sub-forms and which ones are edits vs additions, how many there are, etc.

This new template code now yields a much-improved page in the browser:

Each student now appears on their own row

Much better! But what if we wanted to customize this some more?

Customizing the Forms in the FormSet

Our new bulk edit form looks great, but it’d be even better if we could style the form elements to use Bootstrap like the rest of the page.

The formset_factory method can optionally take in a ModelForm class, which provides a flexible mechanism for customizing our form, so we can now leverage all the features of the ModelForm parent class and overwrite fields as needed. Below is an example of how to create a new ModelForm class to customize the widgets and how feed it back into our UpdateView:

from django.views.generic import DetailView, UpdateView
from django import forms
from django.urls import reverse_lazy
from .models import Cohort, Student

class StudentForm(forms.ModelForm):
class Meta:
model = Student
fields = ['fav_snack', 'fav_color', 'fav_activity', 'ride']
widgets = {
'ride': forms.Select(attrs={'class': 'form-select'}),
'fav_snack': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Favorite Snack'}),
'fav_color': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Favorite Color'}),
'fav_activity': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Favorite Activity'}),
}


class CohortBulkUpdateView(UpdateView):
template_name = 'cohort_bulk_edit.html'
model = Cohort
form_class = forms.inlineformset_factory(Cohort, Student, StudentForm, extra=0)
slug_field = 'id'
slug_url_kwarg = 'id'

def get_success_url(self):
return reverse_lazy('cohort-detail', kwargs={'id': self.get_object().id})

With this updated code, we now have a fully Bootstrap’ed form:

Bulk update with Bootstrap classes

But wait … one of the students on the form was a no-show and has their active field on the model set to False — how can we update the queryset to only show us records for only the active students?

Modifying the queryset

Finally for this example, we want to learn how to modify the queryset that is applied to the related model on the parent class when generating the form set for the bulk edit form. By default, the parent model is showing us every record from the related model — but what if we wanted to apply a queryset filter over that?

One potential approach would be to replace the default manager on the related model Student to only include active entries, however this is quite clumsy and would have the unintended consequence of applying this filter everywhere else in our application where we query for Student objects.

The better approach is to overwrite the get_queryset method on the FormSet class to define the exact query we want to apply in this situation. Since we haven’t already defined one, let’s create a FormSet class and apply it to our existing formset_factory method call in the UpdateView, as shown in the example below: (The StudentForm class is not shown as it remains unchanged from the previous code snippet)

from django.views.generic import DetailView, UpdateView
from django import forms
from django.urls import reverse_lazy
from .models import Cohort, Student

class StudentFormSet(forms.BaseInlineFormSet):
def get_queryset(self):
return super().get_queryset().filter(active=True)

class CohortBulkUpdateView(UpdateView):
template_name = 'cohort_bulk_edit.html'
model = Cohort
form_class = forms.inlineformset_factory(Cohort, Student, StudentForm, formset=StudentFormSet, extra=0)
slug_field = 'id'
slug_url_kwarg = 'id'

def get_success_url(self):
return reverse_lazy('cohort-detail', kwargs={'id': self.get_object().id})

It’s that simple! Now our bulk edit view will only show student entries that are active.

Django FormSets are an extremely powerful and extensible feature of the framework, and like CBVs in general can help create highly-functional and featureful forms and views with very little code in elegant fashion.

Thank you for reading!

Resources

--

--