Recently I used inline formsets in one of my Django projects and I liked how it worked out very much. I decided to share my example of integration inline formsets with Class-based views, crispy-forms and django-dynamic-formset jQuery plugin. When I researched the topic I didn’t find many examples and Django docs are not really extensive on this matter so I put together this post for those who want to try out this solution and for my future self.
First of all, why to use inline formsets:
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.
Suppose that we have a Collection that can have titles in many languages but we don’t know exactly how many title translations user will provide. We want to allow a user to add as many titles as necessary just by clicking ‘add’ button which adds a new row in a Collection create form.
This is how our models look like.
models.py:
from django.db import models
from django.contrib.auth.models import User
class Collection(models.Model):
subject = models.CharField(max_length=300, blank=True)
owner = models.CharField(max_length=300, blank=True)
note = models.TextField(blank=True)
created_by = models.ForeignKey(User,
related_name="collections", blank=True, null=True,
on_delete=models.SET_NULL)
def __str__(self):
return str(self.id)
class CollectionTitle(models.Model):
"""
A Class for Collection titles.
"""
collection = models.ForeignKey(Collection,
related_name="has_titles", on_delete=models.CASCADE)
name = models.CharField(max_length=500, verbose_name="Title")
language = models.CharField(max_length=3)
Now let's create a form for CollectionTitle and a formset (using inlineformset_factory) that includes parent model Collection and FK related model CollectionTitle.
forms.py
from django import forms
from .models import *
from django.forms.models import inlineformset_factory
class CollectionTitleForm(forms.ModelForm):
class Meta:
model = CollectionTitle
exclude = ()
CollectionTitleFormSet = inlineformset_factory(
Collection, CollectionTitle, form=CollectionTitleForm,
fields=['name', 'language'], extra=1, can_delete=True
)
Next, we add this formset to a CollectionCreate view.
views.py:
from .models import *
from .forms import *
from django.views.generic.edit import CreateView, UpdateView
from django.urls import reverse_lazy
from django.db import transaction
class CollectionCreate(CreateView):
model = Collection
template_name = 'mycollections/collection_create.html'
form_class = CollectionForm
success_url = None
def get_context_data(self, **kwargs):
data = super(CollectionCreate, self).get_context_data(**kwargs)
if self.request.POST:
data['titles'] = CollectionTitleFormSet(self.request.POST)
else:
data['titles'] = CollectionTitleFormSet()
return data
def form_valid(self, form):
context = self.get_context_data()
titles = context['titles']
with transaction.atomic():
form.instance.created_by = self.request.user
self.object = form.save()
if titles.is_valid():
titles.instance = self.object
titles.save()
return super(CollectionCreate, self).form_valid(form)
def get_success_url(self):
return reverse_lazy('mycollections:collection_detail', kwargs={'pk': self.object.pk})
A CollectionUpdate view will look similar except that in get_context_data() the instance object should be passed.
views.py:
def get_context_data(self, **kwargs):
data = super(CollectionUpdate, self).get_context_data(**kwargs)
if self.request.POST:
data['titles'] = CollectionTitleFormSet(self.request.POST, instance=self.object)
else:
data['titles'] = CollectionTitleFormSet(instance=self.object)
return data
Next, we have to create CollectionForm with our formset to be rendered as fields inside of it. This is not straightforward because crispy-forms doesn’t have a layout object for a formset like it has it for a Div or HTML.
The best solution (many thanks!) is to create a custom crispy Layout Object.
custom_layout_object.py:
from crispy_forms.layout import LayoutObject, TEMPLATE_PACK
from django.shortcuts import render
from django.template.loader import render_to_string
class Formset(LayoutObject):
template = "mycollections/formset.html"
def __init__(self, formset_name_in_context, template=None):
self.formset_name_in_context = formset_name_in_context
self.fields = []
if template:
self.template = template
def render(self, form, form_style, context, template_pack=TEMPLATE_PACK):
formset = context[self.formset_name_in_context]
return render_to_string(self.template, {'formset': formset})
Next step is to add a template to render a formset.
To be precise what I want to render: for each new title - a row with fields 'name' and 'language' and a button 'remove' to remove the row (and delete the data in database when updating the collection), and one more button below the row - to add another row for new title.
I am using django-dynamic-formset jQuery plugin to dynamically add more rows.
I suggest to use prefix (docs) in order to have one formset template for all inline formset cases (e.g. when you add several inline formsets in one Form). Formset prefix is a related_name for referenced Class. So in my case it is ‘has_titles’.
formset.html:
{% load crispy_forms_tags %}
<table>
{{ formset.management_form|crispy }}
{% for form in formset.forms %}
<tr class="{% cycle 'row1' 'row2' %} formset_row-{{ formset.prefix }}">
{% for field in form.visible_fields %}
<td>
{# Include the hidden fields in the form #}
{% if forloop.first %}
{% for hidden in form.hidden_fields %}
{{ hidden }}
{% endfor %}
{% endif %}
{{ field.errors.as_ul }}
{{ field|as_crispy_field }}
</td>
{% endfor %}
</tr>
{% endfor %}
</table>
<br>
<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js">
</script>
<script src="{% static 'mycollections/libraries/django-dynamic-formset/jquery.formset.js' %}">
</script>
<script type="text/javascript">
$('.formset_row-{{ formset.prefix }}').formset({
addText: 'add another',
deleteText: 'remove',
prefix: '{{ formset.prefix }}',
});
</script>
Finally, we can construct our own form layout for CollectionForm by using existing layout objects and custom Formset object together.
forms.py:
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Fieldset, Div, HTML, ButtonHolder, Submit
from .custom_layout_object import *
class CollectionForm(forms.ModelForm):
class Meta:
model = Collection
exclude = ['created_by', ]
def __init__(self, *args, **kwargs):
super(CollectionForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = True
self.helper.form_class = 'form-horizontal'
self.helper.label_class = 'col-md-3 create-label'
self.helper.field_class = 'col-md-9'
self.helper.layout = Layout(
Div(
Field('subject'),
Field('owner'),
Fieldset('Add titles',
Formset('titles')),
Field('note'),
HTML("<br>"),
ButtonHolder(Submit('submit', 'save')),
)
)
collection_create.html:
{% extends "mycollections/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container">
<div class="card">
<div class="card-header">
Create collection
</div>
<div class="card-body">
{% crispy form %}
</div>
</div>
</div>
{% endblock content %}
Now everything is in place and we can create a Collection and its titles in one form on one page by hitting Save button just once.
The source code for this post is here.
Credits to this awesome blog post which helped me to sort out inline formsets solution.
Oldest comments (61)
Hello!
How would you do this if you had another "inline_formset" inside of each title?
This should be done using nested formsets but you had to basically do the same thing you did for the titles but inside each title.
Does that make sense to you?
Hit me up with your thoughts.
Cheers,
Hi Gonçalo,
Thanks for the question!
You are right, you will need to use nested form inside each form in formset.
For this you need to create CollectionTitleChildFormSet and pass it in add_fields() method when overriding BaseInlineFormSet. Therefore the CollectionTitleFormSet will look different because now we need to set formset explicitly to BaseTitleChildFormset (check out my commit and this blog post).
The backend logic works fine for me but I didn't figure out how to implement dynamic 'add child': it should work like this - everytime I add a new Title the row with an empty Title and a row with at least One Child followed by button 'add child' is added.
If you figure it out I will be interested to know how ;)
Thanks for the quick reply!
I have my backend working as well. I believe that the jQuery library (django-dynamic-formset) is not prepared for this.
What you're doing with this library on the nested formsets is creating 1 formset with all the childs, and I believe this should create 1 formset with the childs on each title.
I also think your childs' prefix is wrong, let me know what prefix appears on the class when you use "formset_child-(( formset.prefix ))". I'm personally using formset_child-(( nested_form.prefix )) and it joins the formset (title) prefix with the childs' prefix, something like title-0-child-0, title-0-child-1.
I will spend the rest of the day trying to fix this, I'll let you know how it goes.
Can you please share how to do the same thing without using crispy forms?
I have tried to do the same and createview is working fine but in UpdateView unable to save the changes made in inline_formset. It save the data when adding new line.
please help me out.
Hi Sachin,
To implement it without crispy forms check out this blog, the solution is not using crispy forms. The UpdateView is essentially the same as CreateView except you need to pass instance (instance=self.object) in get_context_data() because the instance already exists in the database (the code part for this is here ). Hope it helps!
Super useful code, thank you!
Thank you so much for this tut. How can i limit the number of forms in the form set that can be added?
Thanks!
When you create you inline formset you can limit the number of forms in the formset with max_num parameter
e.g.
Hey thanks a ton for this tutorial, im still quite new to django so I was wondering how would I go about adding a validation so at least one CollectionTitle is required for each Collection, thanks alot!
So I've figured out how to add validation for the CollectionTitle, but im doing it through views.py. Wondering if anybody knows how to add the validation to client side instead of going through views, thanks.
How I am doing it at the moment is adding a min_num and validate_min to the inlineformset_factory.
And by setting an else function in the form_valid. I save the object after validating titles since my project requires at least one title for each collection.
Great code! have been looking all day :/
This works for me
I'm having trouble getting the form to display both the formset fields and the regular form fields. Currently, the template is only displaying the formset fields.
I think I've narrowed it down to something I didn't do right in either the custom_layout_object.py or the formset.html.
I know that's vague, but any advice/tips on what I might need to change from your example to work with my project?
I figured out both the problems I was having! The first one (above) I had improperly configured the self.helper.layout div so it didn't know what to display! Everything seems to be working now in terms of the basic setup.
I was wondering though if you could advise me on how to make the form look better? I've messed with the formset.html but can't seem to figure out where to put formatting to make it look nice.
This is what my form currently looks like:
There is something missing about the usage of the jquery plugin. I do not see the 'add' and remove' buttons added to the html at my end.
The jquery add and remove buttons works only if there set a context['formset'] name. I do not know why. Maybe django 2 version or needs change the class Formset(LayoutObject)
So its works:
forms.py:
Fieldset(
'Add videos',
Formset('formset'),
),
views:
context['formset'] = PostVideoFormSet( self.request.POST, instance=self.object)
but if change this name to:
Fieldset(
'Add videos',
Formset('videos'),
),
and context['videos'] = PostVideoFormSet( self.request.POST, instance=self.object)
the buttons add and remove disappear
Haiiii,
I got some bug in this code.please help me to find out.The bug is
ValueError: Cannot assign ">": "Collection.created_by" must be a "User" instance.
Hey elisa,
I think you have to call localhost:8000/admin and log in as user "admin" and password "admin". If you take a look into the classes
CollectionCreate
orCollectionUpdate
insideviews.py
then you can see that thecreated_by
field is always set to the user who executed the request. And the variablerequest.user
is only set, if a user is logged in.Hope that will help you to get the demo running ;-)
Best regards,
andi
Hey Xenia,
thank you for this helpful post. That's exactly what I was looking for. I've applied a small modification to make your solution a bit more
crispy
;-)Instead of putting all the form layout stuff into the file
formset.html
it would a better solution to add aLayoutHelper
to theCollectionTitleForm
:forms.py
Then you can simplify your
formset.html
The advantage is that the layout is now modified by
crispy_forms
applying the selected template pack (e.g. bootstrap4). Only the CSS.delete-row
must be secified to center the remove button of thedjango-formset
plugin because only a link is added if the containing element is not a HTML-table.Best regards,
andi
Thanks for your contribution! I merged it into a new branch :)
thank you all for the whole code. but my "remove" item is not working :( I tried all solutions with no result :'(
Hey Xenia,
Could you please help me with the following situation:
Assume your model CollectionTitle now have 1 more field
user = models.ForeignKey(User, on_delete=models.CASCADE)
Now, any user can add title and language.
When a user enters title and language and submits the form, auto-save the user field to the currently logged in user.
For example, User A created a new Collection. Now, User B and User C can add titles for this new created Collection. When user B adds a title and language, the user should be auto-saved to this entry along with the title and language.
Thanks in advance for all the support and help.
Regards,
Amey Kelekar
I really appreciated your article! I was looking at the terrible django-crispy doc, trying to also understand djang formset and I was at lost. Then I randomly stumbled on your article and everything clicked in. You resumed everything in a very educational way what others couldn't explain through pages of very unhelpful explanations. Thank you!
Thank you very much!
Thank you so much! You saved my day... no, a WEEK
Hi! Thanks Xenia. A quick question - why do you use with transaction.atomic(): in your form_valid() function?
Hi!
That guarantees the transaction's atomicity, which is a database property.
Take a look at the explanation in Django docs
Hello Xenia, thank you for this post. It is the closest I've come to solvingmy problem in a week! Am working on a project where I want Users to upload up to 3 images for each post they make. So, I need your CollectionTitle model to have just the foreign key field to the Post model and the image field with at least 1 image. Am using Crispy forms and CBVs with LoginRequiredMixin and UserPassesTestMixins. Any help would be much appreciated.
Hi Xenia and public,
I just implemented this but cant figure out why I'm getting "remove" buttons for each field of the formset. Also I can't customize the "Add another" or "delete" texts of the buttons. I have attached a photo of my result. Anyone any idea?
thepracticaldev.s3.amazonaws.com/i...
Had the same problem. Looks like it is a problem of newer versions of jquery.formset.js. Just download the one from the repo
Did you find a solution to this? I have tried different versions of jquery.formset.js and am having the same result.
Zxenia, do you have any comment on why this would be the case?