2

I'm have a simple model using a GenericForeignKey object. I would like to limit the allowed content_objects to a specific, static set of models. Lets say, I would only like it to accept ModelA and ModelB from app_a and app_b, respectively.

I ran across this question that essentially describes what I'm trying to achieve. I implemented the proposed solution, and I end up with a model that looks something like this:

class TaggedItem(models.Model):
    tag = models.SlugField()
    content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
    object_id = models.PositiveIntegerField()
    limit = models.Q(app_label='app_a', model='modela') \
            | models.Q(app_label='app_b', model='modelb')
    content_type = models.ForeignKey(ContentType, limit_choices_to=limit)
    content_object = GenericForeignKey('content_type', 'object_id')

This actually appears to work correctly when using the /admin/ panel to add the object, the content_type pulls from my available options. However, when I write unit tests, or using the shell, it doesn't seem to enforce this.

For instance, I would expect:

TaggedItem.objects.create(content_object=(ModelZ()))

To raise an exception. However, it doesn't. Is there any django-istic way to enforce the content_objects to be an instance of a model given in the limit_choices_to?

rob
  • 2,119
  • 1
  • 22
  • 41
  • 1
    A model never validates `choices`, only the related `ModelForm`(s) do. Of course you could do some extra validation in the `.save()`, but there are ways to "bypass" the `.save()`. – Willem Van Onsem Aug 07 '18 at 18:47

1 Answers1

2

In Django, by default the choices=... are not enforced in the model layer. So if you .save() are .create(..) a model object, it is possible that the values in the columns are no members of the respective choices. A ModelForm however performs a full_clean(..) on the object, and thus enforces this.

A Django model has however a way to enforce this: if you call the .full_clean(..) function, it will raise an error if the values are no valid choices. So we could patch a single model with:

class TaggedItem(models.Model):
    tag = models.SlugField()
    object_id = models.PositiveIntegerField()
    limit = models.Q(app_label='app_a', model='modela') \
            | models.Q(app_label='app_b', model='modelb')
    content_type = models.ForeignKey(ContentType, limit_choices_to=limit)
    content_object = GenericForeignKey('content_type', 'object_id')

    def save(self, *args, **kwargs):
        self.full_clean()
        super().save(*args, **kwargs)

This will thus check the choices each time you .save(..) a function. This question has several answers that provide alternative ways to do this, on specific models, or on all models.

But keep in mind that the Django ORM still allows to bypass this. For example the TaggedItem.objects.update(content_type=1425) can still succeed (since it is mapped directly into a an SQL query), there is no way to enforce this (accross all database systems) in a generic way. The Django ORM allows - partly for performance reasons, and partly because of backwards compatibility - to make queries that can get the database into an "invalid state" (not per se invalid for the database, but invalid for the Django model layer).

Willem Van Onsem
  • 443,496
  • 30
  • 428
  • 555
  • Thank you very much for this great answer- exactly what I needed to know. I'm rethinking the use of GenericForiegnKey and the GenericRelations needed to handle my use case. I'm thinking this may be rotting code by design. – rob Aug 07 '18 at 19:59
  • @rob: well a `GenericForeignKey` is a rather "controversial" topic. Although there can be some use cases, I think that only a limited number of use cases are better off with a `GenericForeignKey`. The question of course remains, what use cases :). – Willem Van Onsem Aug 07 '18 at 20:08