Skip to Content

Technology Blog

Technology Blog

How to Pre-Populate a Django Inline Form for New Instances of the Base Object

Recently updated on

The client wanted the base model -- let’s call it Foo -- to have a one-to-many relation with a secondary model -- let’s call it Bar -- configured via an inline form in the Django admin. So far, so simple, right? You just set it up like this:

foos/models.py

from django.db import models

class Foo(models.Model):
    name = models.CharField(max_length=255)

bars/models.py

from django.db import models
from foos.models import Foo

class Bar(models.Model):
    name = models.CharField(max_length=255)
    foo = models.ForeignKey(Foo, on_delete=models.CASCADE)

foos/admin.py

from django.contrib import admin
from bars.models import Bar
from .models import Foo

class BarInline(admin.StackedInline):
    model = Bar
    extra = 1


class FooAdmin(admin.ModelAdmin):
    inlines = [BarInline, ]


admin.site.register(Foo, FooAdmin)

And the view to add a new Foo instance looks like this:

But here’s where it got tricky. When a new Foo is being added, the client wanted the first inline Bar instance to appear with a default name value, like this:

We might first try to do this by setting an initial value for the name field by overriding the __init__() method in a custom form. For example:

foos/admin.py

from django import forms
from django.contrib import admin
from bars.models import Bar
from .models import Foo

class BarInlineForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        initial = kwargs.get("initial", {})
        initial["name"] = "DEFAULT"
        kwargs["initial"] = initial
        super().__init__(*args, **kwargs)


class BarInline(admin.StackedInline):
    model = Bar
    extra = 1
    form = BarInlineForm


class FooAdmin(admin.ModelAdmin):
    inlines = [BarInline, ]


admin.site.register(Foo, FooAdmin)

The result looks promising, as the form is rendered with the desired value in place. But there are a couple of problems.

First, we only want this value to appear for the first inline on a new Foo instance, but now the DEFAULT value appears every time we add a new inline, regardless of whether the object is new or already has an ID.

Second, and more importantly, the if the user saves this new Foo instance, the inline Bar object will *not* be created. This happens because Django won’t create or save the related object if it detects that no changes have been made to the inline form. It makes this determination by comparing each field’s initial value with its value as submitted in the POST request. In our case, the form for the related Bar instance was initialized with DEFAULT in the name field. Since we made no other changes to it before saving, the Bar object was not created.

Let’s tackle the first of these two problems first. How can we know if we are dealing with the first inline on the Foo instance and not the second, third, etc.? And how can we know if this is a new Foo instance or an already existing one?

Since we’re already using a custom form, figuring out whether this is the first, second, (third, etc.) inline is pretty easy. You can get that information from the prefix keyword argument that gets passed to the form’s __init__() method. It’s trickier to know whether the parent Foo is a new instance or an existing one because the inline Bar form has no knowledge of its parent. So our first challenge will be to pass some reference to the parent into the inline form. Let’s consider that now:

One place where Django offers us a handle on the parent object is in the inline admin’s get_formset() method. The parent object will be passed in with the obj keyword. The method then gets a base formset class (by default, django.forms.models.BaseInlineFormset) and returns a subclass of that class tailored to the specific admin form in question.

If we could override the get_form_kwargs() method on that formset class, we could pass the parent object as a keyword argument to the child form. Here’s how we’ll do it. Let’s redefine our BarInline class above like this:

class BarInline(admin.StackedInline):
    model = Bar
    extra = 1
    form = BarInlineForm

    def get_formset(self, request, obj=None, **kwargs):
        # First get the base formset class
        BaseFormSet = kwargs.pop("formset", self.formset)

        # Now make a custom subclass with an overridden “get_form_kwargs()”
        class CustomFormSet(BaseFormSet):
            def get_form_kwargs(self, index):
                kwargs = super().get_form_kwargs(index)
                kwargs["parent_obj"] = obj
                return kwargs

        # Finally, pass our custom subclass to the superclass’s method. This
        # will override the default.
        kwargs["formset"] = CustomFormSet
        return super().get_formset(request, obj, **kwargs)

Those are all the changes we need to make to the custom admin class. But now we need to override the __init__() method of our custom form class to handle several tasks.

The method needs to remove our new parent_obj keyword argument from the kwargs variable before passing it to the super() call.

It needs to check the prefix keyword argument to see if this is the first inline Bar instance.

If this is the first inline Bar and parent_obj is `None` (meaning this is a new Foo instance), then we need to take special action.

Our BarInlineForm class will now look like this:

class BarInlineForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        # Pop the ‘parent_obj’ keyword
        parent_obj = kwargs.pop("parent_obj")
        # Check if this is the first “bar” on a new “Foo” instance.
        if kwargs.get("prefix", "").endswith("-0") and parent_obj is None:
            # do stuff
        super().__init__(*args, **kwargs)

The last step is to replace the “do stuff” comment with real code. This addresses the second of the two problems above, and ensures that a new Bar will be created on a new Foo instance even when no other changes are made. Here’s the full form class:

class BarInlineForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        # Pop the ‘parent_obj’ keyword
        parent_obj = kwargs.pop("parent_obj")
        # Check if this is the first “bar” on a new “Foo” instance.
        if kwargs.get("prefix", "").endswith("-0") and parent_obj is None:
           self.base_fields["name"] = type(
                "HackedCharField",
                (forms.CharField,),
                {"has_changed": lambda self, initial, data:
                    super(forms.CharField, self).has_changed("", data)
                }
            )(initial="DEFAULT")
        super().__init__(*args, **kwargs)

Here, we’re using Python’s type function to create a subclass of forms.CharField called “HackedCharField” (though the exact name doesn’t matter). We assign this class a custom implementation of the has_changed() method using a lambda function. The regular has_changed() method accepts the initial and current field values as arguments to determine whether the field has changed since initialization. Our version accepts these same values (as it must), but then calls the superclass’s implementation using the empty string instead of the field’s true initialization value. Thus the field will recognize that same initialization value (the string DEFAULT) as a *changed* value. As such, it will trigger creation of the related Bar object.

Note that we must use the “old-school” Python syntax of passing the base class and self to the super() call. The use of super() without arguments that has been available since Python 3 has problems when used in classes created with the type() function. See bugs.python.org/issue29944 for more details.

And that’s it! If you liked this blog post or have any questions, please let us know!

Happy coding!

 

Need Upgrade Assistance?

Django 3.2 reaches its end of life in April 2024.  If your site is on this or an earlier version, please fill out this form and we will reach out and provide a cost and time estimate for your consideration.  No obligation.

*

*



Share , ,
If you're getting even a smidge of value from this post, would you please take a sec and share it? It really does help.