The recording for this lesson has not been published yet.
So far one page meant one form and one object. But a post has a gallery, a recipe has
ingredients, an invoice has lines. Rendering ten separate <form> tags with
ten submit buttons is painful for the user and worse for you. A formset renders several
copies of the same form inside one HTML form, validates them together and saves
them together.
| Factory | Builds forms from | Typical use |
|---|---|---|
formset_factory |
A plain Form |
Three search filters, a list of email addresses to invite |
modelformset_factory |
A ModelForm over a queryset |
Edit every published post's title on one page |
inlineformset_factory |
A ModelForm for children of one parent |
Images belonging to a single post |
HTTP sends a flat dictionary of strings. Nothing in it says "there were three forms, two of which already existed". The management form carries exactly that bookkeeping in hidden inputs:
images-TOTAL_FORMS 3 how many forms were rendered
images-INITIAL_FORMS 2 how many of them map to existing rows
images-MIN_NUM_FORMS 0
images-MAX_NUM_FORMS 10
Django uses TOTAL_FORMS to know how many prefixes to look for, and
INITIAL_FORMS to tell an update from an insert.
This is the error you will meet, and it always means the same thing: you forgot
{{ formset.management_form }} in the
template, or you rendered the forms with a different prefix than you posted.
# blog/views.py
from django.forms import modelformset_factory
from django.shortcuts import redirect, render
from .models import Post
PostFormSet = modelformset_factory(
Post,
fields=["title", "published_date"],
extra=1, # how many blank forms to add
can_delete=True, # renders a DELETE checkbox per form
max_num=20,
)
def post_bulk_edit(request):
queryset = Post.objects.order_by("-created_date")
formset = PostFormSet(request.POST or None, queryset=queryset)
if request.method == "POST" and formset.is_valid():
formset.save()
return redirect("blog:post_list")
return render(request, "blog/post_bulk_edit.html", {"formset": formset})
formset.save() does three things in one call: it updates the changed existing
rows, inserts the filled-in extra forms and deletes the ones whose DELETE box
is ticked. If you need to touch the objects first, use
formset.save(commit=False), which gives you
formset.new_objects, formset.changed_objects and
formset.deleted_objects.
This is the case you actually want for the blog. First a child model:
# blog/models.py
class PostImage(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="images")
image = models.ImageField(upload_to="posts/gallery/")
caption = models.CharField(max_length=140, blank=True)
position = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ["position", "pk"]
def __str__(self):
return self.caption or self.image.name
# blog/forms.py
from django.forms import inlineformset_factory
from .models import Post, PostImage
PostImageFormSet = inlineformset_factory(
Post, # parent
PostImage, # child
fields=["image", "caption", "position"],
extra=2,
max_num=10,
can_delete=True,
widgets={"caption": forms.TextInput(attrs={"class": "form-control"})},
)
The factory knows the foreign key, so it fills post_id for you. You never
render or trust that field.
# blog/views.py
from django.db import transaction
def post_edit(request, pk):
post = get_object_or_404(Post, pk=pk)
form = PostForm(request.POST or None, request.FILES or None, instance=post)
formset = PostImageFormSet(
request.POST or None, request.FILES or None, instance=post
)
if request.method == "POST" and form.is_valid() and formset.is_valid():
with transaction.atomic():
post = form.save()
formset.instance = post # matters when the post is brand new
formset.save()
messages.success(request, "Post and gallery saved.")
return redirect(post.get_absolute_url())
return render(
request, "blog/post_edit.html", {"form": form, "formset": formset}
)
Use and on two is_valid() calls before you save anything, and
wrap the saves in transaction.atomic(). Otherwise a bad caption can leave
you with a saved post and a lost gallery.
<form method="post" enctype="multipart/form-data" novalidate>
{% csrf_token %}
{{ form.as_div }}
<h3 class="mt-4">Gallery</h3>
{{ formset.management_form }}
{% for error in formset.non_form_errors %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endfor %}
<div id="gallery-forms">
{% for image_form in formset %}
<div class="card mb-2 formset-row">
<div class="card-body">
{{ image_form.id }}
{{ image_form.as_div }}
{% if image_form.instance.pk and image_form.instance.image %}
<img src="{{ image_form.instance.image.url }}" height="80" alt="">
{% endif %}
</div>
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">Save everything</button>
</form>
Two things are easy to get wrong here. Render {{ image_form.id }}
(or the whole form, which includes it) or Django cannot match a posted row to an existing
object. And never put a nested <form> inside the outer one - HTML does not
allow it and the inner one is silently dropped.
extra gives you a fixed number of blank rows. To add one on demand, clone the
hidden template form that Django provides as formset.empty_form - its indexes
are the literal string __prefix__ - and bump
TOTAL_FORMS:
<template id="empty-form">
<div class="card mb-2 formset-row">{{ formset.empty_form.as_div }}</div>
</template>
<button type="button" class="btn btn-outline-secondary" id="add-image">
Add another image
</button>
const total = document.querySelector("#id_images-TOTAL_FORMS");
document.querySelector("#add-image").addEventListener("click", () => {
const index = Number(total.value);
const html = document.querySelector("#empty-form").innerHTML
.replaceAll("__prefix__", index);
document.querySelector("#gallery-forms").insertAdjacentHTML("beforeend", html);
total.value = index + 1;
});
Deleting is simpler: with can_delete=True each row has a
DELETE checkbox, and ticking it removes the object on save. For a row that was
never saved, just hide it and decrement TOTAL_FORMS.
A rule that spans forms - "captions must be unique", "at least one image" - belongs in
clean() on a BaseInlineFormSet subclass, where an error becomes a
non_form_error.
from django.core.exceptions import ValidationError
from django.forms import BaseInlineFormSet
class BasePostImageFormSet(BaseInlineFormSet):
def clean(self):
super().clean()
if any(self.errors):
return # per-form errors first, do not pile on
captions = []
for form in self.forms:
if not form.cleaned_data or form.cleaned_data.get("DELETE"):
continue
caption = form.cleaned_data.get("caption", "")
if caption and caption in captions:
raise ValidationError(f"The caption '{caption}' is used twice.")
captions.append(caption)
if not captions:
raise ValidationError("Add at least one image to the gallery.")
PostImageFormSet = inlineformset_factory(
Post,
PostImage,
formset=BasePostImageFormSet,
fields=["image", "caption"],
extra=2,
)
Note the DELETE check and the empty cleaned_data check: an untouched
extra form has nothing in it and must be skipped, or your "at least one" rule will reject
perfectly good input. If you want Django to enforce a minimum for you, pass
min_num=1, validate_min=True to the factory.
PostImage model with a related_name of
images and migrate.PostImageFormSet with inlineformset_factory and render
it below PostForm on the edit page.{{ formset.management_form }}, submit,
and read the exception carefully. Put it back.DELETE box and confirm the row
disappears.non_form_errors, not next to a field.empty_form trick and add a
third image without reloading the page.