souldeux
souldeux

Reputation: 3755

Conditional form display logic with Django ModelForm

I think I can figure out how to do this in a very ugly way with many, many database queries and lots of horribly cobbled together jQuery, but I'm hoping there's a simpler approach.

I am building a small classification app for a Django website. There are various pieces of media on the site that are classified as belonging to a certain genre, sub-genre, sub-sub-genre, and sub-sub-sub-genre. For instance, a bit of media might be classified as Music (genre), Blues (sub-genre), Memphis Blues (sub-sub-genre), Gospel Blues (a second sub-sub-genre), and 32nd Street Banjo Blues (sub-sub-sub-genre, this one's made up).

Genre, SubGenre, SubSubGenre, and SubSubSubGenre are all models:

class Genre(models.Model):

    description = models.CharField(max_length=200, unique=True)
    slug = models.SlugField(unique=True)

    def __unicode__(self):
        return self.description


class SubGenre(models.Model):

    parent = models.ForeignKey(Genre, related_name='sub_genre')
    description = models.CharField(max_length=200, unique=True)
    slug = models.SlugField(unique=True)

    def __unicode__(self):
        return self.description


class SubSubGenre(models.Model):

    parent = models.ForeignKey(SubGenre, related_name='sub_sub_genre')    
    description = models.CharField(max_length=200, unique=True)
    slug = models.SlugField(unique=True)

    def __unicode__(self):
        return self.description


class SubSubSubGenre(models.Model):

    parent = models.ForeignKey(SubSubGenre, related_name='sub_sub_sub_genre')
    description = models.CharField(max_length=200, unique=True)
    slug = models.SlugField(unique=True)

    def __unicode__(self):
        return self.description

Every piece of media added to the site has a MTM field associating it with one or more of these qualifiers, like so:

class Media(models.Model):
    genres = models.ManyToManyField(Genre)
    sub_genres = models.ManyToManyField(SubGenre)
    sub_sub_genres = models.ManyToManyField(SubSubGenre)
    sub_sub_sub_genres = models.ManyToManyField(SubSubSubGenre)
    #other fields

Every user on our site has a user profile (an extension of the base user model using a one-to-one relationship). In that user profile, they can tell us what type of media they like to consume.

class UserProfile(models.Model):
    user = models.OneToOneField(User)
    preferred_genres = models.ManyToManyField(Genre, related_name='genre_pref')
    preferred_sub_genres = models.ManyToManyField(SubGenre, related_name = 'subgenre_pref')
    preferred_sub_sub_genres = models.ManyToManyField(SubSubGenre, related_name = 'subsubgenre_pref')
    preferred_sub_sub_sub_genres = models.ManyToManyField(SubSubSubGenre, related_name = 'subsubsubgenre_pref')

I want to create two forms: one to create a new user profile, and one to create a new piece of Media. The user should be able to define their preferred genres, sub genres, etc. A media uploader should be able to classify their Media in the same way.

We only have a couple of Genres, but each one has a dozen or more sub-genres. Each sub-genre has 20+ sub-sub genres. Most sub-sub genres has 20+ sub-sub-sub genres. I can't just throw all of these options onto the page at once - it's horribly overwhelming.

Here's what I'd like to have happen. Take the "new user profile form" for example. Instead of having the user set their preferred genres, sub-genres, sub-sub genres, and sub-sub-sub genres all at once, I'd like for there to just be a multiple-choice form where the user can set their preferred genre. Then, when the preferred genre is selected (but before the form is submitted), sub-genre choices specific to that genre appear. When a sub-genre is selected, the sub-sub genres who have that sub-genre as a parent then appear as options, etc.

For example, say we have three Genres defined in the system: Music, Book and Podcast. Our new user encounters the question to set their preferred genres and clicks the checkboxes for Music and Book, but leaves Podcast blank. I would like for that user to then be able to set their preferred sub-genres for Music and for Book, and for the CheckboxSelectMultiple fields to be populated only with the sub-genres that have either Music or Book as their parent (Music.subgenre_set.all() or Book.subgenre_set.all() would return the appropriate choices).

For another example, say we have a user who uploads a podcast. On the upload form they encounter the question asking what Genre(s) the Media is in. The user checks the box for "podcast." Now we want to know what sub-genres the Media is in. How do we populate the CheckboxSelectMultiple field with only the sub-genres who have parent "podcast?"

In either example, if "Podcast" is the genre selected, then the appropriate sub-genre choices for the next part of the form would be represented by Podcast.subgenre_set.all(). If one of that podcast's subgenres is "nonfiction," then selecting that subgenre should also bring up the appropriate sub-sub genre choices: Nonfiction.subsubgenre_set.all(). I can't just hardcode the genre, sub-genre, sub-sub genre, and sub-sub-sub genre names however since new ones are certain to be added in the future and that would create a scaling nightmare.

I also feel like just "loading up" every possible whatever_set behind the scenes then unveiling it with javascript trickery puts a heck of a lot of strain on the DB.

If I spread the form out over multiple pages (and multiple queries to the DB), it's easy. But how do I do this all in one single form without annihilating my database?

Thanks for reading all of this!

Upvotes: 2

Views: 1415

Answers (2)

Alex Crawley
Alex Crawley

Reputation: 166

Firstly, I would reconsider how you are modelling your genres. I think a nicer solution is to have just one model Genre, rather than specifying new models for each SubGenre. This would also allow you to extend to an arbitrary depth without needing to change your model structure.

class Genre(models.Model):
    parent = models.ForeignKey(Genre, null=True, related_name='sub_genre')
    description = models.CharField(max_length=200, unique=True)
    slug = models.SlugField(unique=True)

    def __unicode__(self):
        return self.description

    @property
    def is_top_level(self):
        return bool(self.parent)

Then I would use Form rather than ModelForm with something like this:

class GenreForm(forms.Form):
    genre = forms.ModelChoiceField(
        queryset=Genre.objects.filter(parent__is_null=True))
    sub_genre = forms.ModelChoiceField(
        queryset=Genre.objects.filter(
            parent__is_null=False, parent__parent__is_null=True))

For the front end, I think the easiest solution is to start with giving each sub_genre options a class='hidden', and use a simple javascript snippet to unhide the options on subsequent fields as parents are selected.

If you have a really large number of genres you might find the most efficient method in terms of page load time is to use ajax to populate the subsequent fields.

Hope it helps!

Upvotes: 4

Daniel Roseman
Daniel Roseman

Reputation: 600059

Firstly, I think you have too many models. All the models have exactly the same fields, so you would be better off just having one model (call it Classification) with name, description, parent, and level: so Genre would have level 0 and no parent, SubGenre would have level 1 and a parent of level 0, and so on. (You could also do this with something like MPTT, but that's probably overkill since ordering is not significant.)

To answer your question though, I think Javascript is the way to go, but not by preloading everything. Instead you should do it with Ajax: on selecting a genre, you do an Ajax request to the server which returns the relevant sub-genres. Since that's such a lightweight request the delay should not be noticeable to the user, so you can then construct the checkboxes or whatever for the user to select.

Upvotes: 2

Related Questions