Phil Gyford
Phil Gyford

Reputation: 14594

Django generic relations modelling

I'm trying to model something in Django and it doesn't seem quite right. A Book and a Movie can have one or more Persons involved in it, each of whom has a Role (like "Author" or "Director") on that Book or Movie.

I'm doing this with Generic relations something like this:

from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models

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

class Role(models.Model):
    person = models.ForeignKey('Person')
    role_name = models.CharField(max_length=255)
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.PositiveIntegerField()
    content_object = GenericForeignKey('content_type', 'object_id')

class Book(models.Model):
    title = models.CharField(max_length=255)
    roles = GenericRelation('Role', related_query_name='books')

class Movie(models.Model):
    title = models.CharField(max_length=255)
    roles = GenericRelation('Role', related_query_name='movies')

Which seems to work, but it seems odd when getting all the Books a Person has worked on:

person = Person.objects.get(pk=1)

for role in person.role_set.all():
    print(role.role_name)
    for book in role.books.all():
        print(book.title)

The for book in role.books.all() feels wrong - like a role can have multiple books. I think I want a more direct relationship between a person working on a particular book/movie.

Is there a better way to model this?

Upvotes: 4

Views: 3006

Answers (2)

djvg
djvg

Reputation: 14255

tl;dr

Django's generic relations may not be the best choice for this case. Instead, it looks like some form of inheritance is more appropriate.

why not generic relations?

Django works with relational databases. Much of the power, efficiency, and reliability of a relational database originates from the fact that the relations between tables are fixed by the designer (or developer, or whatever): Just by looking at the model definitions, you know how the tables are related. The user cannot influence this.

A GenericForeignKey, on the other hand, breaks this basic concept, by allowing the user to create new relations to any other table in the database. These relations exist only in the data, not in the schema. The designer cannot know which relations will exist at any point in time, nor can the model definitions tell us how the tables are related. For that, we need to look at the actual data from the database. Needless to say this clever trick complicates matters greatly, and makes relational integrity difficult, if not impossible, to maintain.

Still, there are some use-cases where the flexibility of GenericForeignKey is exactly what is needed. Django's own example of a TaggedItem illustrates this well.

However, the OP's case is not one of them, as far as I can see.

what then?

The answer by @tdsymonds points in the right direction, which is, to use some form of inheritance from a Media class. However, I believe using django-polymorphic overcomplicates the matter, as there are several ways of implementing this using plain Django.

Probably the simplest method, although maybe not the most query-efficient one, is to use Django's multi-table inheritance (a.k.a. class-table inheritance).

Applied to the OP's example, this becomes:

from django.db import models

class Person(models.Model):
    name = models.CharField(max_length=255)
    roles = models.ManyToManyField(to='Media', through='Role')

class Role(models.Model):
    person = models.ForeignKey('Person', on_delete=models.CASCADE)
    media = models.ForeignKey('Media', on_delete=models.CASCADE)
    role_name = models.CharField(max_length=255)

class Media(models.Model):
    title = models.CharField(max_length=255)

class Book(Media):
    pass

class Movie(Media):
    pass

Note that Role has a ForeignKey directly to Media, which can be set using either a Media object, a Book object, a Movie object, or any other subclass you decide to add. The subclasses can have additional attributes. We can easily get a list of all roles a Person played, regardless of the type of Media, via person.roles.all() (a basic many-to-many relation).

To view the roles in the admin, add an inline, as explained in the docs:

from django.contrib import admin
from mediaroles.models import Person, Role, Book, Movie, Media

class RoleInline(admin.TabularInline):
    model = Role
    extra = 0

class PersonAdmin(admin.ModelAdmin):
    inlines = (RoleInline,)

admin.site.register(Person, PersonAdmin)
admin.site.register([Role, Book, Movie])

Upvotes: 2

tdsymonds
tdsymonds

Reputation: 1709

If it was me, I'd try an approach as follows using the many to many relationship through the role model. (See https://docs.djangoproject.com/en/1.10/topics/db/models/#s-extra-fields-on-many-to-many-relationships)

As you mentioned above in the comments, the trouble comes because you have several models (book and movie) that would require this relationship. The solution to this, is to use a BaseRole model and create separate models for movie roles and book roles. Something like this:

from django.db import models


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


class BaseRole(models.Model):
    role_name = models.CharField(max_length=255)
    person = models.ForeignKey('Person', on_delete=models.CASCADE)

    class Meta:
        abstract = True


class MovieRole(BaseRole):
    movie = models.ForeignKey('Movie', on_delete=models.CASCADE)


class BookRole(BaseRole):
    book = models.ForeignKey('Book', on_delete=models.CASCADE)


class Book(models.Model):
    title = models.CharField(max_length=255)
    roles = models.ManyToManyField(Person, through='BookRole')


class Movie(models.Model):
    title = models.CharField(max_length=255)
    roles = models.ManyToManyField(Person, through='MovieRole')

That way when you'd like to filter on all the Books a Person has worked on, you can do that as follows:

person = Person.objects.get(pk=1)
books = Book.objects.filter(roles=person).distinct()

The distinct() call is necessary, as a person could have more than one role on a book/movie (e.g. they could be a producer and the director), so without it, the filter would return the an instance of the book for each role that the person had in the movie/book.

UPDATE

In response to you comment, perhaps a better solution would be to use Django Polymorphic (https://django-polymorphic.readthedocs.io/en/stable/quickstart.html).

So you'd set your models up something like this:

from django.db import models

from polymorphic.models import PolymorphicModel


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


class Role(models.Model):
    role_name = models.CharField(max_length=255)
    person = models.ForeignKey('Person', on_delete=models.CASCADE)
    media = models.ForeignKey('Media', on_delete=models.CASCADE)


class Media(PolymorphicModel):
    title = models.CharField(max_length=255)
    roles = models.ManyToManyField(Person, through='Role')


class Book(Media):
    pass

class Movie(Media):
    pass

That way you can still get all the books a person has worked on by:

person = Person.objects.get(pk=1)
books = Book.objects.filter(roles=person).distinct()

But you can also get all of the media that a person has worked on by:

media = person.media_set.all()

And yes you're right, there is no point in having the content_type and object_id, so I've removed these fields.

Upvotes: 2

Related Questions