How to save multiple child objects to a parent using ModelForms and inline_formsets

I’m making a recipe application in Django. Each user has their own OneToOne RecipeBook object, which in turn can hold as many recipes as needed, as each Recipe has a ForeignKey relationship to the RecipeBook object. There are also Ingredient and Direction objects that each have a FK relationship to the Recipe object.

I am trying to make a form that allows the user to create a Recipe, and then dynamically add/remove Ingredient/Direction objects as needed. I’m using Class Based Views, ModelForms, and inlineformsets_factory to accomplish this, as well as using the django-dynamic-formset jQuery plugin, and I’ve been following this guide.

My issue is that I am only able to create two child Ingredient/Direction objects for each recipe. I want to make it so users can create as many Ingredient/Direction objects as they like. Previously I was only able to add one instance of each child object, but through some changes I don’t recall, I am now able to add two. I previously thought that I would need to iterate over each object in my view to allow multiple objects, but since I can now create two I’m not sure if that is the case. So my question is, what is the correct way to allow users to create multiple child objects using inlineformsets?

My CreateView:

class RecipeCreate(LoginRequiredMixin, CreateView):
    model = models.Recipe
    fields = ['title', 'description', 'servings', 'prep_time', 'cook_time', 'url']

    def get_context_data(self, **kwargs):
        data = super(RecipeCreate, self).get_context_data(**kwargs)

        if self.request.POST:
            data['ingredients'] = IngredientFormset(self.request.POST)
            data['directions'] = DirectionFormset(self.request.POST)
        else:
            data['ingredients'] = IngredientFormset()
            data['directions'] = DirectionFormset()
        return data

    def form_valid(self, form):
        form.instance.recipebook = self.request.user.recipebook
        context = self.get_context_data()
        ingredients = context['ingredients']
        directions = context['directions']
        with transaction.atomic():
            self.object = form.save()

            if ingredients.is_valid() and directions.is_valid():
                ingredients.instance = self.object
                directions.instance = self.object
                ingredients.save()
                directions.save()
        return super(RecipeCreate, self).form_valid(form)

My Models:

class RecipeBook(models.Model):
    """Each user has a single associated RecipeBook object, linked in this OneToOne field"""
    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)


@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_user_recipebook(sender, **kwargs):
    """Using a django signal to automatically create model object when user is created"""
    if kwargs.get('created', False):
        RecipeBook.objects.get_or_create(user=kwargs.get('instance'))


class Recipe(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4())
    recipebook = models.ForeignKey(RecipeBook, related_name='recipe_set', on_delete=models.CASCADE)
    title = models.CharField(max_length=150, help_text='Title of the recipe')
    description = models.TextField(help_text='Description of the recipe', blank=True)
    # image = models.ImageField(height_field=, width_field=, help_text='Image of the recipe', blank=True)
    servings = models.PositiveSmallIntegerField(help_text='The amount of servings the recipe will yield', default=0, blank=True)
    prep_time = models.PositiveSmallIntegerField(help_text='The preparation time', default=0, blank=True)
    cook_time = models.PositiveSmallIntegerField(help_text='The cooking time', default=0, blank=True)
    url = models.URLField(blank=True)

    TIME_UNITS = (
        ('m', 'Minutes'),
        ('h', 'Hours')
    )

    def get_absolute_url(self):
        return reverse('recipe_book:recipe-detail', args=[str(self.id)])

    def __str__(self):
        return self.title


class Ingredient(models.Model):
    recipe = models.ForeignKey(Recipe, related_name='ingredient_set', on_delete=models.CASCADE)
    name = models.CharField(max_length=100)
    amount = models.CharField(max_length=20)

    def __str__(self):
        return self.name


class Direction(models.Model):
    recipe = models.ForeignKey(Recipe, related_name='direction_set', on_delete=models.CASCADE)
    step_instructions = models.TextField(help_text='Write the instructions of the step here')

My Forms:

class AddRecipeForm(ModelForm):
    
    class Meta:
        model = Recipe
        fields = ['title',
                'description',
                'servings',
                'prep_time',
                'cook_time',
                'url']


class AddIngredientForm(ModelForm):

    class Meta:       
        model = Ingredient
        fields = ['name', 'amount']


IngredientFormset = inlineformset_factory(Recipe, Ingredient, form=AddIngredientForm, extra=1, can_delete=True)


class AddDirectionForm(ModelForm):

    class Meta:
        model = Direction
        fields = ['step_instructions']


DirectionFormset = inlineformset_factory(Recipe, Direction, form=AddDirectionForm, extra=1, can_delete=True)

And my template:

{% extends 'base-recipe.html' %}

{% block content %}
    <form action="" method="POST"> {% csrf_token %}
        {{ form.as_p }}
        <table class="table">
            {{ ingredients.management_form }}

            {% for form in ingredients.forms %}
                {% if forloop.first %}
                <thead>
                    <tr>
                        {% for field in form.visible_fields %}
                            <th>{{ field.label|capfirst }}</th>
                        {% endfor %}
                    </tr>
                </thead>
                {% endif %}

                <tr class="{% cycle row1 row2 %} formset_row-{{ ingredients.prefix }}">
                {% for field in form.visible_fields %}
                    <td>
                        {# include the hidden fields in the form #}
                        {% if forloop.first %}
                            {% for field in form.hidden_fields %}
                                {{ hidden }}
                            {% endfor %}
                        {% endif %}
                        {{ field.errors.as_ul }}
                        {{ field }}
                    </td>
                {% endfor %}
                </tr>
            {% endfor %}
        </table>

        <table class="table">
            {{ directions.management_form }}

            {% for form in directions.forms %}
                {% if forloop.first %}
                <thead>
                    <tr>
                    {% for field in form.visible_fields %}
                        <th>{{ field.label|capfirst }}</th>
                    {% endfor %}
                    </tr>
                </thead>
                {% endif %}

                <tr class="{% cycle row1 row2 %} formset_row-{{ directions.prefix }}">
                {% for field in form.visible_fields %}
                    <td>
                        {# include the hidden fields #}
                        {% if forloop.first %}
                            {% for field in form.hidden_fields %}
                                {{ hidden }}
                            {% endfor %}
                        {% endif %}
                        {{ field.errors.as_ul }}
                        {{ field }}
                    </td>
                {% endfor %}
                </tr>
            {% endfor %}
        </table>
        <input class="btn btn-primary" type="submit" value="Submit">
        <a class="btn btn-danger" href="{% url 'recipe_book:index' %}">Back to the recipe list</a>
    </form>
    {% load static %}
    <script src="{% static 'js/jquery.formsets.js' %}"></script>
    <script type="text/javascript">
        $('.formset_row-{{ ingredients.prefix }}').formset({
        addText: 'Add another',
        deleteText: 'Remove',
        prefix: '{{ ingredients.prefix }}',
        });
        $('.formset_row-{{ directions.prefix }}').formset({
        addText: 'Add another',
        deleteText: 'Remove',
        prefix: '{{ directions.prefix }}',
        });
    </script>
{% endblock %}

Right now it looks like your formsets are being created with “extra=1” - that will give you one blank row in the formset.
You can make it dynamic by adding some javascript to add another row - just remember that you need to modify the management form as well to make sure that Django recognizes the additional form being added.

2 Likes

Hi, thank you so much for the quick reply! So the jQuery plugin I mentioned made it so I can dynamically add the rows, but like you said I need to make the management_form aware of those additional rows. Do you know how I should go about that? I’m reading the management_form documentation, but I’m not exactly sure how to make the JS aware of the additional rows. Do I need to iterate over the context[] variables to get all of the fields?
I’m also confused because the extra=1 value is there, but I’m able to save 2 child objects.

Thanks for any help you can provide!

It’s the TOTAL_FORMS entry that needs to be updated. You do not want to update the INITIAL_FORMS number.

Also, you want to make sure that you’re setting the html id attributes of every field on the new instances of the forms. There’s an “empty_form” attribute on the formset that you can use to render a template. Our pattern is to render the empty_form in a hidden div in the template, and copy it when a new instance of the form needs to be created.

We’ve never used any plugins for this - it’s not really a lot of code to manage the forms.

2 Likes

How do I update the TOTAL_FORMS entry?

This is a snippet from one of my projects:

$('#add_start_time').click(function() {
    var form_idx = $('#id_starttime_set-TOTAL_FORMS').val();
    $('#start-time-formset tr:last').after(
        $('#start-time-formset tr.empty-form')[0].innerHTML
            .replace(/__prefix__/g, form_idx)
    )
    $('#id_starttime_set-TOTAL_FORMS').val(parseInt(form_idx) + 1);
})

hope this gives you some ideas.

2 Likes

For those that are stumbling across this, I figured out that the issue was with the version of the plugin I was using (1.5). The git issues pointed out that a recent change was breaking the plugin for some people that were using inline formsets. I reverted to version 1.3 and it worked normally. However, @KenWhitesell 's above answer also solved the issue, if you didn’t feel like importing an entire plugin for this.