DEV Community

Xenia
Xenia

Posted on

Django inline formsets with Class-based views and crispy forms

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)

Enter fullscreen mode Exit fullscreen mode

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
    )

Enter fullscreen mode Exit fullscreen mode

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})

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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})

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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')),
                )
            )

Enter fullscreen mode Exit fullscreen mode

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 %}
Enter fullscreen mode Exit fullscreen mode

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.

inline-formsets

The source code for this post is here.

Credits to this awesome blog post which helped me to sort out inline formsets solution.

Latest comments (61)

Collapse
 
sasikumar1978k profile image
sasikumar1978k

is there any better and updated code to Django inline formsets with Class-based views and crispy forms

Collapse
 
sasikumar1978k profile image
sasikumar1978k

TypeError: Formset.render() missing 1 required positional argument: 'context'

Collapse
 
mvitsor profile image
Vitor Sternlicht

Hi @zxenia, thanks a lot for the post. Do you have an idea how one could nest a third model and edit all three models at the same time? Using your example with Collections and Titles, suppose that additionally every Title had multiple Editions; so I would want to keep the form as we have it, but add the ability to add/remove/edit Editions under each of the titles. Tks

Collapse
 
stevesmith0722 profile image
Steve Smith

Formsets are great! However, I've noticed recently they can be time consuming to retrieve when performing updates on them. Has anyone else encountered this issue?

Collapse
 
cryptedx profile image
crypted

Hey,

thank you for your great tutorial. This helped me a lot. I want to modify it because I want a table layout in my inline formsets. For this I am trying to use

"helper.template = 'bootstrap/table_inline_formset.html'
Enter fullscreen mode Exit fullscreen mode

from here: django-crispy-forms.readthedocs.io...

I tired every possibility which I have found on the internet. Among other things this stackoverflow.com/questions/217549...

But it is not working. What would be an proper way to implement it in your code from this tutorial?

Thank you in advance.

Collapse
 
razilator profile image
Vladislav

Xenia, how to save current user for child formset, when saving it doesn't save?

Collapse
 
nhatrang profile image
NhaTrang

Hello, how can we display the lines now in the html file?

Collapse
 
nhatrang profile image
NhaTrang

Hello,

Would you have an idea on this issue? stackoverflow.com/q/66101476/13613237

Many Thanks

Collapse
 
st0ked56 profile image
Michael

Thank you for the example. This works great!

What would be the best way to wrap the formset fields in a DIV? I have in the helper.Layout the below logic.

        Fieldset('Logistics',
                 Formset('logistics')
                 ),
Enter fullscreen mode Exit fullscreen mode

I would like to Div('Formset_field', css_class="", data-toggle="", etc.)

Collapse
 
juliazanon profile image
juliazanon

Nothing wrong, just want to thank you so much for this, helped me a lot!

Collapse
 
juliazanon profile image
juliazanon

actually, I do have a problem.
Imagine you create a collection and add some titles. When you try to update that same collection, it doesn't seem to be able to remove the titles you added before. At least in my program, when I enter the update page, only forms for new titles show up, and not the old ones. What do I need to do to fix this problem?

Collapse
 
dantgo profile image
Daniel Gore

Thanks again for this.

I have a FileField as part of my Formset, which when added to the ModelForm is displayed correctly and allows for file selection, but when saved, the file is not uploaded to the model. I think this is something todo with 'request.FILES' not existing in the View I'm just not sure how to rewrite it to include it.

Can anyone help?

Collapse
 
mustafamond profile image
Mustafamond

Thanks again for your post it was a great help! I am having trouble with validation of the formset, though - if the main form does not have any errors but one of the forms in the formset does, the form still submits as if there were no errors, the main form saves, but the formset does not. Validation errors in the formset do not get displayed. In the CreateView, the form_valid method is as follows:

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)

It checks if titles is valid, but does nothing if titles is not valid. Would this be the place to add an else:?

if titles.is_valid():
    save the data
else:
   refresh the form showing errors in the formset

Or does that logic belong earlier in the form processing flow?

Thank you for any ideas on how to resolve this.

Collapse
 
mustafamond profile image
Mustafamond • Edited

This was a great post and easy to follow - did exactly what I needed. However, I am having a problem on the validation side. I am unable to get any validation errors to show up on fields in the the formset. If the data in any field in the formset is not valid (forms.ValidationError raised using the form's clean method), or if the formset itself is not valid (for example, unique_together violated in the rows to be added), the overall form still posts and is the data is saved to the database, but the formset is simply ignored - nothing is saved and the form never chows the errors and the user never has a chance to correct any incorrect data.

Validation works correctly on the main form, just not the formset inserted using the Crispy layouts.

Any hints on how to get validation errors to cause the form to display the errors for the formset and subform during the post would be greatly appreciated.

Thanks for the great post!

Collapse
 
unknown9421 profile image
Hoàng • Edited

I did like you. But it was not working. Somebody can help me.

==================================================================
class CompanyEdit(UpdateView):
model = Company
form_class = CompanyForm
template_name = 'frontend/company/createdit.html'
context_object_name = 'company'
success_message = 'Company Information Updated!'

def get_context_data(self, **kwargs):
    context = super(CompanyEdit, self).get_context_data(**kwargs)
    if self.request.POST:
        context['pictures'] = CompanyPictureFormSet(self.request.POST, self.request.FILES, instance=self.object)
    else:
        context['pictures'] = CompanyPictureFormSet(instance=self.object)
    return context

def form_valid(self, form):
    # update_instace = form.save(commit=False)
    context = self.get_context_data()
    pictures = context['pictures']
    with transaction.atomic():
        form.instance.created_by = self.request.user.username
        self.object = form.save()
        pictures.instance = self.object
        if pictures.is_valid():   # This is always return False.
            print(pictures.instance)
            pictures.save()
Collapse
 
mzakonek profile image
Mateusz • Edited

Thanks a lot for your post! I am facing a problem at using the inline formset in the same way that you described, but at the manytomany relation.
Let's assume that the Language is a separated Model. Now I want to create inline formset between Collection and CollectionTitle:

class Language(models.Model):
    name = models.CharField(max_length=3)


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)

    titles_with_language = models.ManyToManyField(Language, through="CollectionTitle", related_name='collections')

    def __str__(self):
        return str(self.id)


class CollectionTitle(models.Model):

    collection = models.ForeignKey(Collection, related_name="has_titles", on_delete=models.CASCADE)
    language = models.ForeignKey(Language, related_name='titles', on_delete=models.CASCADE)
    name = models.CharField(max_length=500, verbose_name="Title")
Enter fullscreen mode Exit fullscreen mode

I updated forms and verything is displayed properly, I can add/remove CollectionTitles, but when I hit submit, form is always invalid. I can't find the reason why... Can someone help me/give advice what should I look at or what am I doing wrong?

Collapse
 
katbotkowska profile image
KatBot

It could be a problem with your views or forms. Paste that code. I've faced the similiar problem with formsets for m2m trought table and generic views maybe I can help you.

Collapse
 
nad2000 profile image
Radomirs Cirskis

adding the manytomany fields to the excluded field list solved this issue:

class CollectionTitleForm(forms.ModelForm):

    class Meta:
        model = CollectionTitle
        exclude = ["titles_with_language"]
Enter fullscreen mode Exit fullscreen mode
Collapse
 
mzakonek profile image
Mateusz

Or if you could share snippet with your implementation for m2m, then it will also be very helpful.

Collapse
 
mzakonek profile image
Mateusz • Edited

Hi,
I am sending link to code: github.com/mzakonek/laboratory_app...
It will open the Cart app in my project and inline formset is implemented at the 'views.py' and 'forms.py' files.
Actually I don't even need those 'remove' or 'add' buttons in the form. I need only to update the fields in the form.
Thanks a lot for your time!

Thread Thread
 
katbotkowska profile image
KatBot

I have a project concerning budget managing with model task, articles (expenditure category) and model m2m task_articles. Here, you have my code for create, update view for m2m model:

add articles to task

class AddArticlesToTaskView(PermissionRequiredMixin, FormView):
permission_required = 'budget.add_taskarticles'
raise_exception = False
login_url = reverse_lazy('budget:login')
permission_denied_message = 'You dont\'t have permission to add articles to task'
template_name = 'budget/add_articles_to_task.html'
pk_url_kwarg = 'task_slug'
success_url = ''

def get_task(self):
    task_slug = self.kwargs.get('task_slug')
    return Task.objects.get(slug=task_slug)

def get_form_class(self):
    return formset_factory(AddArticlesToTaskForm, extra=6)

def get_success_url(self):
    return reverse('budget:task_details', kwargs={'task_slug': self.kwargs.get('task_slug')})

def form_valid(self, form):
    for single_form in form:
        instance = single_form.save(commit=False)
        instance.task = self.get_task()
        instance.save()
    return super().form_valid(form)

def get_context_data(self, **kwargs):
    ctx = super().get_context_data(**kwargs)
    ctx['task'] = self.get_task()
    return ctx

class EditArticlesInTaskView(PermissionRequiredMixin, UpdateView):
permission_required = 'budget.change_taskarticles'
raise_exception = False
login_url = reverse_lazy('budget:login')
permission_denied_message = 'You dont\'t have permission to edit articles to task'
model = TaskArticles
form_class = EditArticlesInTaskForm
template_name = 'budget/task_edit_articles.html'
slug_url_kwarg = 'task_slug'
success_url = ''

def get_object(self, queryset=None):
    return self.get_task()

def get_queryset(self):
    return TaskArticles.objects.filter(task=self.get_task())

def get_task(self):
    task_slug = self.kwargs.get('task_slug')
    return Task.objects.get(slug=task_slug)

def get_success_url(self):
    return reverse('budget:task_details', kwargs={'task_slug': self.kwargs.get('task_slug')})

def post(self, request, *args, **kwargs):
    self.object = self.get_object()
    formset = EditArticlesInTaskFormSet(request.POST)
    if formset.is_valid():
        return self.form_valid(formset)
    return self.form_invalid(request, formset)

def form_invalid(self, request, formset):
    return render(request, self.template_name, {"formset": formset})

def form_valid(self, form):
    for single_form in form:
        instance = single_form.save(commit=False)
        instance.task = self.get_task()
        instance.save()
    return super().form_valid(form)

def get_context_data(self, **kwargs):
    ctx = super().get_context_data(**kwargs)
    ctx['task'] = self.get_task()
    ctx['formset'] = EditArticlesInTaskFormSet(queryset=TaskArticles.objects.filter(task=self.get_task()))
    return ctx

I hope it would help you. If you need more code, write :)

Thread Thread
 
katbotkowska profile image
KatBot

I can't use generic create view for add articles to task, because it doesn't work, so I used form view, update view works but only for that model m2m.
My other models m2m -add articles to contracts needs diffrent piece of code, I don't understand why, but solution for task doesn't work.

Thread Thread
 
mzakonek profile image
Mateusz

Thanks a lot! I will try to implement this in my project during this weekend and let you know how it goes.

And your EditArticlesInTaskFormSet is inlineformset or just modelformset_factory of TaskArticle ? Could you share it??
Once again, thanks a lot!

Thread Thread
 
katbotkowska profile image
KatBot

I didn't use inline formsets but model formset factory. I set I'd do it but with my data validations and fields querysets it was too complicated.

class EditArticlesInTaskForm(ModelForm):

def clean(self):
    super().clean()
    #many lines of code to validate accounting
        return self.cleaned_data

class Meta:
    model = TaskArticles
    fields = ('article', 'value')

EditArticlesInTaskFormSet = modelformset_factory(TaskArticles, fields=('article', 'value'),
extra=0, form=EditArticlesInTaskForm)

Thread Thread
 
mzakonek profile image
Mateusz

Now everything works, thanks so much!!!