For a recent project, I had to use Django inline formset factory using class-based views. You can update a number of identical forms on the same page using a Django inline formset factory. In essence, it enables you to bulk edit a number of things at once.
I saw some articles. They are using some external javascript packages for this work. Here I am using custom JS. And it's super easy.
First of all, why we need Django inline formset:
To allow a user to create and update related via Foreign Key objects from the create/update views of a referenced object, all on one page.
Scenario: Add Images and Variants to Product.
Suppose we are working on an e-commerce website and we have a Product model. As we know that one product may have many images and variants and we don’t know how many images or variants users will choose for one product. So we want a user to be able to add as many images and variants, as they need simply by pressing the "add more" button, which creates a new row in Add product form.
What will we do in this article?
1) List all products.
2) Create new products with images and variants.
3) Update products with images and variants.
4) Delete formsets using django builtin delete checkboxes. And also custom coded delete button functionality.
Here is what the end result looks like:
1) List all products.
2) Create new products with images and variants.
3) Update the product with images and variants.
What is Inline Formset Factory?
Inline formsets is a small abstraction layer on top of model formsets. These simplify the case of working with related objects via a foreign key.
function:: inlineformset_factory(parent_model, model, form=ModelForm, formset=BaseInlineFormSet, fk_name=None, fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, formfield_callback=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, absolute_max=None, can_delete_extra=True, renderer=None, edit_only=False)
In Django 3.2 version two new arguments added:
absolute_max
: Limiting the maximum number of instantiated forms. Theabsolute_max
parameter allows limiting the number of forms that can be instantiated when supplying POST data. This protects against memory exhaustion attacks using forged POST requestscan_delete_extra
(The newcan_delete_extra
argument allows the removal of the option to delete extra forms)
In Django 4.1 one new argument is added:
edit_only
(The newedit_only
argument for inlineformset_factory allows preventing new objects creation.)
Inline Formset function parameters detail:
parent_model*
: Parent model is your main model. In our example Product is the parent_model. It is required field.
model*
: Model which has foriegn key to the parent model. In our example Image and Variant are model.
form*
: Define the Model form. In our case ImageForm and VariantForm are form.
fromset
: You can provide model formset by overriding BaseInlineFormSet, by default BaseInlineFormSet is provided by django.
fk_name
: If your model has more than one: class:`~django.db.models.ForeignKey` to the parent_model
, you must specify a fk_name
.
fields
: is an optional list of field names. If provided, only the named fields will be included in the returned fields.
exclude
: is an optional list of field names. If provided, the named fields will be excluded from the returned fields, even if they are listed in the fields
argument.
extra
: Number of extra inline forms you want to show. By default it is 3.
can_order
: to help with ordering of forms in formsets. By default False.
can_delete
: to help with the deletion of forms in formsets. By default True.
max_num
: gives you the ability to limit the number of forms the formset will display
formfield_callback
: is a callable that takes a model field and returns a form field. This method will run before converting a model field into a form field.
widgets
: is a dictionary of model field names mapped to a widget. You can add widgets for form field customization.
validate_max
: If validate_max=True is passed then validation will also check that the number of forms in the data set, minus those marked for deletion is less than or equal to max_num
. It validates against max_num
strictly even if max_num was exceeded because the amount of initial data supplied was excessive.
localized_fields
: is a list of names of fields that should be localized just like a model form meta option. It adds a formset field with support for different languages.
labels
: is a dictionary of model field names mapped to a label. Used for overriding formset fields labels just like model form meta option.
help_texts
: is a dictionary of model field names mapped to a help text. Used for adding help text to formset fields just like model form meta option.
error_messages
: is a dictionary of model field names mapped to a dictionary of error messages. Used for adding error messages to formset fields.
min_num
: gives you the ability to limit the minimum number of forms the formset will display.
validate_min
: If ``validate_min=True`` is passed then validation will also check that the number of forms in the data set, minus those marked for deletion, is greater than or equal to min_num
.
Related article about min_num and validate_min: How to set at least one inline form required in django inline formset
field_classes
: is a dictionary of model field names mapped to a form field class.
absolute_max
: Limiting the maximum number of instantiated forms. The absolute_max
parameter allows limiting the number of forms that can be instantiated when supplying POST data. This protects against memory exhaustion attacks using forged POST requests
can_delete_extra
: The new can_delete_extra
argument allows the removal of the option to delete extra forms. While setting can_delete
=True, specifying can_delete_extra
=False will remove the option to delete extra forms.
edit_only
: The new edit_only
argument for inlineformset_factory allows preventing new objects creation.
Let's move toward coding.
Requirements.
django==3.2.15 pillow
Models.
Here we have 3 simple models.
# models.py
from django.db import models
class Product(models.Model):
title = models.CharField(max_length=150)
short_description = models.TextField(max_length=100)
def __str__(self):
return self.title
class Image(models.Model):
product = models.ForeignKey(
Product, on_delete=models.CASCADE, null=True
)
image = models.ImageField(blank=True, upload_to='images')
def __str__(self):
return self.product.title
class Variant(models.Model):
product = models.ForeignKey(
Product, on_delete=models.CASCADE
)
size = models.CharField(max_length=100)
quantity = models.PositiveIntegerField(default=1)
price = models.DecimalField(max_digits=12, decimal_places=2)
def __str__(self):
return self.product.title
Forms.
# forms.py
from django import forms
from django.forms import inlineformset_factory
from .models import (
Product, Image, Variant
)
class ProductForm(forms.ModelForm):
class Meta:
model = Product
fields = '__all__'
widgets = {
'title': forms.TextInput(
attrs={
'class': 'form-control'
}
),
'short_description': forms.TextInput(
attrs={
'class': 'form-control'
}
),
}
class ImageForm(forms.ModelForm):
class Meta:
model = Image
fields = '__all__'
class VariantForm(forms.ModelForm):
class Meta:
model = Variant
fields = '__all__'
widgets = {
'size': forms.TextInput(
attrs={
'class': 'form-control'
}
),
'quantity': forms.NumberInput(
attrs={
'class': 'form-control'
}
),
'price': forms.NumberInput(
attrs={
'class': 'form-control'
}
),
}
VariantFormSet = inlineformset_factory(
Product, Variant, form=VariantForm,
extra=1, can_delete=True, can_delete_extra=True
)
ImageFormSet = inlineformset_factory(
Product, Image, form=ImageForm,
extra=1, can_delete=True, can_delete_extra=True
)
In this article, I am using django formset builtin delete by specifying can_delete=True and can_delete_extra=True in inlineformse_factory function and also I am building the custom-coded delete button.
Views.
Importing modules
# views.py
from django.shortcuts import render, redirect
from django.contrib import messages
from django.views.generic import ListView
from django.views.generic.edit import (
CreateView, UpdateView
)
from .forms import (
ProductForm, VariantFormSet, ImageFormSet
)
from .models import (
Image,
Product,
Variant
)
In this article, we are following the DRY (don't repeat yourself) principle. So that's why I am creating a parent class ProductInline. And our ProductCreate and ProductUpdate classes inherit it.
# views.py
class ProductInline():
form_class = ProductForm
model = Product
template_name = "products/product_create_or_update.html"
def form_valid(self, form):
named_formsets = self.get_named_formsets()
if not all((x.is_valid() for x in named_formsets.values())):
return self.render_to_response(self.get_context_data(form=form))
self.object = form.save()
# for every formset, attempt to find a specific formset save function
# otherwise, just save.
for name, formset in named_formsets.items():
formset_save_func = getattr(self, 'formset_{0}_valid'.format(name), None)
if formset_save_func is not None:
formset_save_func(formset)
else:
formset.save()
return redirect('products:list_products')
def formset_variants_valid(self, formset):
"""
Hook for custom formset saving.Useful if you have multiple formsets
"""
variants = formset.save(commit=False) # self.save_formset(formset, contact)
# add this 2 lines, if you have can_delete=True parameter
# set in inlineformset_factory func
for obj in formset.deleted_objects:
obj.delete()
for variant in variants:
variant.product = self.object
variant.save()
def formset_images_valid(self, formset):
"""
Hook for custom formset saving. Useful if you have multiple formsets
"""
images = formset.save(commit=False) # self.save_formset(formset, contact)
# add this 2 lines, if you have can_delete=True parameter
# set in inlineformset_factory func
for obj in formset.deleted_objects:
obj.delete()
for image in images:
image.product = self.object
image.save()
We have two inline forms. 1) for images 2) for variants. You need to specify a function for each inline formsets and we did it in the above code snippet. like
def formset_variants_valid(self, formset): def formset_images_valid(self, formset):
Now add ProductCreate and ProductUpdate classes.
# views.py
class ProductCreate(ProductInline, CreateView):
def get_context_data(self, **kwargs):
ctx = super(ProductCreate, self).get_context_data(**kwargs)
ctx['named_formsets'] = self.get_named_formsets()
return ctx
def get_named_formsets(self):
if self.request.method == "GET":
return {
'variants': VariantFormSet(prefix='variants'),
'images': ImageFormSet(prefix='images'),
}
else:
return {
'variants': VariantFormSet(self.request.POST or None, self.request.FILES or None, prefix='variants'),
'images': ImageFormSet(self.request.POST or None, self.request.FILES or None, prefix='images'),
}
class ProductUpdate(ProductInline, UpdateView):
def get_context_data(self, **kwargs):
ctx = super(ProductUpdate, self).get_context_data(**kwargs)
ctx['named_formsets'] = self.get_named_formsets()
return ctx
def get_named_formsets(self):
return {
'variants': VariantFormSet(self.request.POST or None, self.request.FILES or None, instance=self.object, prefix='variants'),
'images': ImageFormSet(self.request.POST or None, self.request.FILES or None, instance=self.object, prefix='images'),
}
get_named_formsets functions in both classes are very easy to understand.
Now we are adding a view for listing the Products.
# views.py
class ProductList(ListView):
model = Product
template_name = "products/product_list.html"
context_object_name = "products"
Now add functions for deleting images and variants.
# views.py
'''This 2 functions are for custom added delete button functionality. If you don't want to use custom delete buttons than don't add this'''
def delete_image(request, pk):
try:
image = Image.objects.get(id=pk)
except Image.DoesNotExist:
messages.success(
request, 'Object Does not exit'
)
return redirect('products:update_product', pk=image.product.id)
image.delete()
messages.success(
request, 'Image deleted successfully'
)
return redirect('products:update_product', pk=image.product.id)
def delete_variant(request, pk):
try:
variant = Variant.objects.get(id=pk)
except Variant.DoesNotExist:
messages.success(
request, 'Object Does not exit'
)
return redirect('products:update_product', pk=variant.product.id)
variant.delete()
messages.success(
request, 'Variant deleted successfully'
)
return redirect('products:update_product', pk=variant.product.id)
Urls.
# products/urls.py
from django.urls import path
from .views import (
ProductList, ProductCreate, ProductUpdate,
delete_image, delete_variant
)
app_name = 'products'
urlpatterns = [
path('products/', ProductList.as_view(), name='list_products'),
path('create/', ProductCreate.as_view(), name='create_product'),
path('update/<int:pk>/', ProductUpdate.as_view(), name='update_product'),
path('delete-image/<int:pk>/', delete_image, name='delete_image'),
path('delete-variant/<int:pk>/', delete_variant, name='delete_variant'),
]
Templates.
<!-- product_list.html -->
{% extends "base.html" %}
{% block content %}
<div class="card">
<div class="card-header card-header-secondary">
<h4 class="card-title">Products</h4>
<a href="{% url 'products:create_product' %}">Add more products</a>
</div>
<ul>
{% for product in products %}
<li class="card-body">{{ product.title }} | <a href="{% url 'products:update_product' product.id %}">Edit</a></li>
{% endfor %}
</ul>
</div>
{% endblock content %}
Now add product_create_or_update.html
Note: Please read all comments added to this file (product_create_or_update.html). Because all comments are very important to read and understand.
<!-- product_create_or_update.html -->
{% extends "base.html" %}
{% block content %}
<form enctype="multipart/form-data" class="container" method="post" id="product_form">
{% csrf_token %}
<!-- main form start --- in our case product form -->
<div class="card">
<div class="card-header card-header-secondary">
<h4 class="card-title">Add Products</h4>
</div>
{% for field in form %}
<div class="form-group card-body">
<label>{{field.label}}</label>
{% if field.field.required %}
<span style="color: red;" class="required">*</span>
{% endif %}
{{field}}
{% if field.help_text %}
<small style="color: grey">{{ field.help_text }}</small>
{% endif %}
{% for error in field.errors %}
<p style="color: red">{{ error }}</p>
{% endfor %}
</div>
{% endfor %}
</div>
<!-- main form end --- in our case product form -->
<!-- inline form for Images start -->
<!-- EXPLAINING with named_formsets.images as formset -->
<!-- Note: named_formsets is used in get_context_data function in views.py -->
<!-- Note: here images is our ImageFormSet name, used in get_named_formsets function in views.py -->
{% with named_formsets.images as formset %}
{{ formset.management_form }}
<script type="text/html" id="images-template"> // id="inlineformsetname-template"
<tr id="images-__prefix__" class= hide_all> // id="inlineformsetname-__prefix__"
{% for fields in formset.empty_form.hidden_fields %}
{{ fields }}
{% endfor %}
{% for fields in formset.empty_form.visible_fields %}
<td>{{fields}}</td>
{% endfor %}
</tr>
</script>
<div class="table-responsive card mt-4">
<div class="card-header card-header-secondary">
<h4 class="card-title">Add Images</h4>
</div>
<table class="table card-body">
<thead class="text-secondary">
<th>Image <span style="color: red;" class="required">*</span></th>
<th>Delete?</th>
<th>Custom Delete btn</th>
</thead>
<tbody id="item-images"> <!-- id="item-inlineformsetname" -->
<!-- formset non forms errors -->
{% for error in formset.non_form_errors %}
<span style="color: red">{{ error }}</span>
{% endfor %}
{% for formss in formset %}
{{ formss.management_form }}
<tr id="images-{{ forloop.counter0 }}" class= hide_all> <!-- id="inlineformsetname-counter" -->
{{ formss.id }}
{% for field in formss.visible_fields %}
<td>
{{field}}
{% for error in field.errors %}
<span style="color: red">{{ error }}</span>
{% endfor %}
</td>
{% endfor %}
<!-- delete code -->
{% if formss.instance.pk %}
<td>
<button type="button" class="btn btn-danger" data-toggle="modal" data-target="#exampleModal{{formss.instance.pk}}">
Delete
</button>
<!-- Modal -->
<div class="modal fade" id="exampleModal{{formss.instance.pk}}" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel{{formss.instance.pk}}" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel{{formss.instance.pk}}">Are Your Sure You Want To Delete This?</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-footer">
<a href="{% url 'products:delete_image' formss.instance.pk %}" type="button" class="btn btn-primary">Yes, Delete</a>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
<a href="#" id="add-image-button" class="btn btn-secondary add-images">Add More</a> <!-- id="add-inlineformsetname-button" -->
</div>
{% endwith %}
<!-- inline form for Images end -->
<!-- inline form for Variant start -->
<!-- EXPLAINING with named_formsets.variants as formset -->
<!-- Note: named_formsets is used in get_context_data function in views.py -->
<!-- Note: here variants is our VariantFormSet name, used in get_named_formsets function in views.py -->
{% with named_formsets.variants as formset %}
{{ formset.management_form }}
<script type="text/html" id="variants-template"> // id="inlineformsetname-template"
// id='inlineformsetname-__prefix__'
<tr id="variants-__prefix__" class= hide_all>
{% for fields in formset.empty_form.hidden_fields %}
{{ fields }}
{% endfor %}
{% for fields in formset.empty_form.visible_fields %}
<td>{{fields}}</td>
{% endfor %}
</tr>
</script>
<div class="table-responsive card mt-4">
<div class="card-header card-header-secondary">
<h4 class="card-title">Add Variants</h4>
</div>
<table class="table card-header">
<thead class="text-secondary">
<th>Size <span style="color: red;" class="required">*</span></th>
<th>Quantity <span style="color: red;" class="required">*</span></th>
<th>Price <span style="color: red;" class="required">*</span></th>
<th>Delete?</th>
<th>Custom Delete btn</th>
</thead>
<tbody id="item-variants"> <!-- id="item-inlineformsetname" -->
<!-- formset non forms errors -->
{% for error in formset.non_form_errors %}
<span style="color: red">{{ error }}</span>
{% endfor %}
{% for formss in formset %}
{{ formss.management_form }}
<tr id="variants-{{ forloop.counter0 }}" class= hide_all> <!-- id="inlineformsetname-counter" -->
{{ formss.id }}
{% for field in formss.visible_fields %}
<td>
{{field}}
{% for error in field.errors %}
<span style="color: red">{{ error }}</span>
{% endfor %}
{% comment %} {{ field.DELETE }} {% endcomment %}
</td>
{% endfor %}
{% comment %} for delete {% endcomment %}
{% if formss.instance.pk %}
<td>
<button type="button" class="btn btn-danger" data-toggle="modal" data-target="#exampleModal{{formss.instance.pk}}">
Delete
</button>
<!-- Modal -->
<div class="modal fade" id="exampleModal{{formss.instance.pk}}" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel{{formss.instance.pk}}" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel{{formss.instance.pk}}">Are Your Sure You Want To Delete This?</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-footer">
<a href="{% url 'products:delete_variant' formss.instance.pk %}" type="button" class="btn btn-primary">Yes, Delete</a>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
<a href="#" id="add-variant-button" class="btn btn-secondary add-variants">Add More</a> <!-- id="add-inlineformsetname-button" -->
</div>
{% endwith %}
<!-- inline form for Images end -->
<div class="form-group">
<button type="submit" class="btn btn-secondary btn-block">Submit</button>
</div>
</form>
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script>
$(document).ready(function() {
// when user clicks add more btn of images
$('.add-images').click(function(ev) {
ev.preventDefault();
var count = $('#item-images').children().length;
var tmplMarkup = $('#images-template').html();
var compiledTmpl = tmplMarkup.replace(/__prefix__/g, count);
$('#item-images').append(compiledTmpl);
// update form count
$('#id_images-TOTAL_FORMS').attr('value', count+1);
});
});
$(document).ready(function() {
// when user clicks add more btn of variants
$('.add-variants').click(function(ev) {
ev.preventDefault();
var count = $('#item-variants').children().length;
var tmplMarkup = $('#variants-template').html();
var compiledTmpl = tmplMarkup.replace(/__prefix__/g, count);
$('#item-variants').append(compiledTmpl);
// update form count
$('#id_variants-TOTAL_FORMS').attr('value', count+1);
});
});
</script>
{% endblock content %}
That’s it for now. Hope you find it useful. Don't forget to press the clap button if you like it and you can also show support by buying me a coffee. If you have any questions or facing any issues then feel free to comment in the comment section. And for more upcoming articles don't forget to subscribe to our newsletter.
Source code can be found here.
Thank You.
Happy coding.
Source:
Related Article.
How to set at least one inline form required in django inline formset
Django Inline Admin with examples
I would greatly appreciate it if you subscribe to my YouTube channel, Let's Code More