Dynamic Inlines in the Django Admin

Django    Django1.9    2016-08-03

I'm not sure how many of you know this, but in the interest of having a well-rounded life, I make a lot of things in my spare time. Over the years, I've been a costumer, a jeweller, a photographer, and a glass artist, among other things.

And I don't just mean dabbling - when I get interested in learning something, I throw myself into it almost to the point of fanaticism.

I make a lot of things. Which means I have a lot of things filling up my closets and drawers. There's only so much I can gift for birthdays and holidays. So lately I've opened up a shop on Etsy.

Opening shops in online marketplaces has meant keeping track of a lot of things around inventory. Etsy's listings manager is nice, but it doesn't really give me everything I want. For a while, I've been using a spreadsheet to keep track of my Etsy listings. But I'm also about to open a shop on Spoonflower. And I'm considering ArtFire. And I need a better way to manage the supplies I use. And I want to keep all this stuff in one place.

Naturally, because I'm a programmer, I'm building my own inventory manager.

I just started a day ago, so there's obviously a lot of work ahead - I hadn't given a lot of deep thought to what the account and item models needed to look like, so I've been making liberal use of migrations. (I should point out that I'm using Django 1.9. I love it - I've been mired in 1.3 for a while at work, used 1.6 for a few small personal projects, and made it onto 1.8 for some more recent work projects. I love the slight changes in the admin look, and finally having the built-in migrations is a dream come true.)

The items I'm storing use a base Item model with a lot of common fields like name, description, and price. But Etsy listings also use some values that are unique, such as Etsy-specific categories, when the item was made, a list of materials to use as search tags, and so on. Meanwhile, Spoonflower listings take a totally different set of parameters, such as material type, colors, and the type of repeat you want to use for your image.

For each marketplace (so far just Etsy and Spoonflower), I've added models for values that are only needed when the item is listed on that market.

class EtsyItem(models.Model):
    """
    Fields used when the item
    is listed on 'Etsy'
    """
    item = models.ForeignKey(Item)
    ...

class SpoonflowerItem(models.Model):
    """
    Fields used when the item
    is listed on 'Spoonflower'
    """
    item = models.ForeignKey(Item)
    ...

This structure does assume that an Item wouldn't be listed on multiple markets - I may leave it that way and just add functionality to allow one Item's basic values to be copied to another record, to be associated with a different market so that each listing is unique.

I did also consider subclassing the Item model, but I'm serious about wanting to keep everything in one place - I'd rather not have to manage items in an Etsy list versus a Spoonflower list, etc. Maybe I'll change my mind about that and rewrite this whole thing.

But in the meantime, keeping everything together under that Item model presented a challenge - how to add/display the marketplace-specific data for an Item in the admin? I wanted to be able to show the Etsy model as an inline for an item listed for Etsy, a Spoonflower inline for a Spoonflower item, and so on.

Here's what I did (and of course you can see this in the inventory/admin.py in the repository):

from .models import EtsyItem, SpoonflowerItem

class EtsyItemInline(admin.StackedInline):
    model = EtsyItem
    extra = 1
    max_num = 1

class SpoonflowerItemInline(admin.StackedInline):
    model = SpoonflowerItem
    extra = 1
    max_num = 1

I started with inlines for each of the custom models. Each Item should have only one of its respective inline objects - setting "extra=1" and "max_num=1" ensures that one instance of that inline will load but that no additional instances can be added to the page.

class ItemAdmin(admin.ModelAdmin):
    ...
    inlines = [
        EtsyItemInline,
        SpoonflowerItemInline,
    ]

But I still needed a way to prevent all of the inlines from being loaded. An Item sold on Etsy should only have the EtsyItemInline, an Item to be sold on Spoonflower should only load SpoonflowerItemInline, and so on.

The first thing I needed to do was make it clear which online marketplace an Item is being sold on. So I went back to the model. My base Item model has a ForeignKey relationship to a seller account, which is in turn associated with a market name - I added a method extra_fields_by_market() to return a string (the desired inline name, based on the market name).

class Item(models.Model):
    account = models.ForeignKey(SellerAccount)

    ...

    def extra_fields_by_market(self):
        extra_inline_model = ''
        if self.account.market:
            extra_inline_model = str(self.account.market)+'ItemInline'
        return extra_inline_model

Then back in the admin, I overrode get_formsets_with_inlines(). This method yields formset/inline pairs, which allows me to limit which inlines are displayed for a given object.

I was able to use it to display a specific inline only when it matches the market on a base Item:

class ItemAdmin(admin.ModelAdmin):

    ...

    def get_formsets_with_inlines(self, request, obj=None):
        for inline in self.get_inline_instances(request, obj):
            # hide/show market-specific inlines based on market name
            if obj and obj.extra_fields_by_market() == inline.__class__.__name__:
                yield inline.get_formset(request, obj), inline

The value of obj.extra_fields_by_market() is that string returned by the model - 'EtsyItemInline', 'SpoonflowerItemInline', etc. If that matches the name one of the defined inlines, that inline is returned.

I should also note the other condition - if obj. We don't know what market an Item will be listed on until after it's become an object in the database - this means a user would have to fill in the base Item fields, save, then come back to find the additional fields displayed as an inline.

If I were better at Javascript, I would have gone in a different direction immediately and had the inline load triggered by the account/market dropdown selection in the base Item.

I'll figure that out in a day or two, whenever I get a chance to turn my attention back to the project. Or I'll rewrite the whole thing a few times because I'm not quite satisfied with the data structure. But for now hopefully I've given you some new ideas for ways to manage inlines in your admins.