52

I'm trying to create a messaging system where a message's sender and recipients can be generic entities. This seems fine for the sender, where there is only object to reference (GenericForeignKey) but I can't figure out how to go about this for the recipients (GenericManyToManyKey ??)

Below is a simplified example. PersonClient and CompanyClient inherit attributes from Client but have their own specific details. The last line is the sticking point. How do you allow message recipients to be a set of CompanyClients and PersonClients

  class Client(models.Model):
      city = models.CharField(max_length=16)

      class Meta:
          abstract = True

  class PersonClient(Client):
      first_name = models.CharField(max_length=16)
      last_name = models.CharField(max_length=16)
      gender = models.CharField(max_length=1)

  class CompanyClient(Client):
      name = models.CharField(max_length=32)
      tax_no = PositiveIntegerField()

  class Message(models.Model):
      msg_body = models.CharField(max_length=1024)
      sender = models.ForeignKey(ContentType)
      recipients = models.ManyToManyField(ContentType)
Noel Evans
  • 8,113
  • 8
  • 48
  • 58

3 Answers3

66

You can implement this using generic relationships by manually creating the junction table between message and recipient:

from django.db import models
from django.contrib.contenttypes import generic
from django.contrib.contenttypes.models import ContentType

class Client(models.Model):
    city = models.CharField(max_length=16)

    # These aren't required, but they'll allow you do cool stuff
    # like "person.sent_messages.all()" to get all messages sent
    # by that person, and "person.received_messages.all()" to
    # get all messages sent to that person.
    # Well...sort of, since "received_messages.all()" will return
    # a queryset of "MessageRecipient" instances.
    sent_messages = generic.GenericRelation('Message',
        content_type_field='sender_content_type',
        object_id_field='sender_id'
    )
    received_messages = generic.GenericRelation('MessageRecipient',
        content_type_field='recipient_content_type',
        object_id_field='recipient_id'
    )

    class Meta:
        abstract = True

class PersonClient(Client):
    first_name = models.CharField(max_length=16)
    last_name = models.CharField(max_length=16)
    gender = models.CharField(max_length=1)

    def __unicode__(self):
        return u'%s %s' % (self.last_name, self.first_name)

class CompanyClient(Client):
    name = models.CharField(max_length=32)
    tax_no = models.PositiveIntegerField()

    def __unicode__(self):
        return self.name

class Message(models.Model):
    sender_content_type = models.ForeignKey(ContentType)
    sender_id = models.PositiveIntegerField()
    sender = generic.GenericForeignKey('sender_content_type', 'sender_id')
    msg_body = models.CharField(max_length=1024)

    def __unicode__(self):
        return u'%s...' % self.msg_body[:25]

class MessageRecipient(models.Model):
    message = models.ForeignKey(Message)
    recipient_content_type = models.ForeignKey(ContentType)
    recipient_id = models.PositiveIntegerField()
    recipient = generic.GenericForeignKey('recipient_content_type', 'recipient_id')

    def __unicode__(self):
        return u'%s sent to %s' % (self.message, self.recipient)

You'd use the above models like so:

>>> person1 = PersonClient.objects.create(first_name='Person', last_name='One', gender='M')
>>> person2 = PersonClient.objects.create(first_name='Person', last_name='Two', gender='F')
>>> company = CompanyClient.objects.create(name='FastCompany', tax_no='4220')
>>> company_ct = ContentType.objects.get_for_model(CompanyClient)
>>> person_ct = ContentType.objects.get_for_model(person1) # works for instances too.

# now we create a message:

>>> msg = Message.objects.create(sender_content_type=person_ct, sender_id=person1.pk, msg_body='Hey, did any of you move my cheese?')

# and send it to a coupla recipients:

>>> MessageRecipient.objects.create(message=msg, recipient_content_type=person_ct, recipient_id=person2.pk)
>>> MessageRecipient.objects.create(message=msg, recipient_content_type=company_ct, recipient_id=company.pk)
>>> MessageRecipient.objects.count()
2

As you can see, this is a far more verbose (complicated?) solution. I'd probably keep it simple and go with Prariedogg's solution below.

Lord Elrond
  • 13,430
  • 7
  • 40
  • 80
elo80ka
  • 14,837
  • 3
  • 36
  • 43
  • In `Client` model, I don't understand why `MessageRecipient` is in `received_messages = generic.GenericRelation('MessageRecipient', ...)`? Does it have to be `Message`? – user3595632 Apr 11 '17 at 06:01
  • 2
    @user3595632 `received_messages` is a many-to-many relation between `Client` and `Message`. That's why it has to be on `MessageRecipient`, which explicitly models that relationship, since there is no `GenericManyToManyField`. Does that make sense? – elo80ka Apr 12 '17 at 00:28
  • Nice solution. I've just used it for my use case. Thanks! – Jorge Arévalo Sep 07 '18 at 10:00
  • Would this create a join table? – Jango May 31 '20 at 03:36
  • Why does `sent_messages` need to be Generic as well? There is only one type of `Message`, so can it be a normal relation? Or this example assumes there would be multiple types of message (i.e. email. text, postal) added? – JHS May 05 '21 at 16:58
  • @JHS There's no direct `ForeignKey` relationship between clients and the messages they send. Different clients (`Company`, `Person`, etc) can send messages, so `sender` is a `GenericForeignKey`. `sent_messages` models the reverse of this relationship, so it is itself, a `GenericRelation`. – elo80ka May 08 '21 at 03:07
10

The absolute best way to go about this is to use a library called django-gm2m

pip install django-gm2m

Then if we have our models

>>> from django.db import models
>>>
>>> class Video(models.Model):
>>>       class Meta:
>>>           abstract = True
>>>
>>> class Movie(Video):
>>>     pass
>>>
>>> class Documentary(Video):
>>>     pass

And a user

>>> from gm2m import GM2MField
>>>
>>> class User(models.Model):
>>>     preferred_videos = GM2MField()

We can do

>>> user = User.objects.create()
>>> movie = Movie.objects.create()
>>> documentary = Documentary.objects.create()
>>>
>>> user.preferred_videos.add(movie)
>>> user.preferred_videos.add(documentary)

Sweet right?

For more info go here:

http://django-gm2m.readthedocs.org/en/stable/quick_start.html

Dr Manhattan
  • 13,537
  • 6
  • 45
  • 41
  • 1
    Unfortunately, it doesn't work with admin and it seems to be no longer supported. – Ted Klein Bergman Feb 19 '20 at 15:46
  • This package breaks with django rest framework > 3.9 – nickswiss Jun 03 '20 at 16:03
  • I am now of the opinion that if you need to use this then you have modelled your system incorrectly. look at my example above >>> user.preferred_videos.add(movie) >>> user.preferred_videos.add(documentary) the best thing is to just have a Video model with a choice filed of movie_type – Dr Manhattan Jun 04 '20 at 17:19
  • yeah, gm2m is not maintained anymore: https://github.com/tkhyn/django-gm2m/issues/48 – steffres Jan 27 '21 at 21:52
7

You might get around this problem by simplifying your schema to include a single Client table with a flag to indicate what type of client it was, instead of having two separate models.

from django.db import models
from django.utils.translation import ugettext_lazy as _

class Client(models.Model):
    PERSON, CORPORATION = range(2)
    CLIENT_TYPES = (
                    (PERSON, _('Person')),
                    (CORPORATION, _('Corporation')),
                   )
    type = models.PositiveIntegerField(choices=CLIENT_TYPES, default=PERSON)
    city = models.CharField(max_length=16)
    first_name = models.CharField(max_length=16, blank=True, null=True)
    last_name = models.CharField(max_length=16, blank=True, null=True)
    corporate_name = models.CharField(max_length=16, blank=True, null=True)
    tax_no = models.PositiveIntegerField(blank=True, null=True)

    def save(self, *args, **kwargs):
        """
        Does some validation ensuring that the person specific fields are
        filled in when self.type == self.PERSON, and corporation specific
        fields are filled in when self.type == self.CORPORATION ...

        """
        # conditional save logic goes here
        super(Client, self).save(*args, **kwargs)

If you do things this way you might not have to mess around with Generic Foreign Keys at all. As an added convenience you can also write custom managers for the Client model like Client.corporate.all(), Client.person.all(), to return pre-filtered querysets containing only the type of clients that you want.

This also may not be the best way of solving your problem. I'm just throwing it out there as one potential possibility. I don't know if there's conventional wisdom about smashing together two similar models and using a save override to ensure data integrity. It seems like it could be potentially problematic ... I'll let the community learn me on this one.

prairiedogg
  • 6,323
  • 8
  • 44
  • 52
  • Thanks @Prairiedogg. Agree with everything you said. Am still interested to see if there's a solution using generic relationships... – Noel Evans Jun 01 '09 at 08:58
  • Honestly I am starting to prefer the idea of a "type" column for my use case even though it is the "wrong" way to design the schema / do polymorphism, because it is just so much simpler and does not impose limitations on the Django ORM that get introduced by trying to do the generic relation magic. For example the Django ORM [can't handle filtering a generic relation](https://docs.djangoproject.com/en/3.2/ref/contrib/contenttypes/#generic-relations). Feels like nothing but trouble in regard to querying and expecting display in admin. – JHS May 05 '21 at 17:54