Fixed #22207 -- Added support for GenericRelation reverse lookups
GenericRelation now supports an optional related_query_name argument. Setting related_query_name adds a relation from the related object back to the content type for filtering, ordering and other query operations. Thanks to Loic Bistuer for spotting a couple of important issues in his review.
This commit is contained in:
parent
c627da0ccc
commit
b77f26313c
|
@ -168,7 +168,7 @@ class NestedObjects(Collector):
|
||||||
|
|
||||||
def collect(self, objs, source=None, source_attr=None, **kwargs):
|
def collect(self, objs, source=None, source_attr=None, **kwargs):
|
||||||
for obj in objs:
|
for obj in objs:
|
||||||
if source_attr:
|
if source_attr and not source_attr.endswith('+'):
|
||||||
related_name = source_attr % {
|
related_name = source_attr % {
|
||||||
'class': source._meta.model_name,
|
'class': source._meta.model_name,
|
||||||
'app_label': source._meta.app_label,
|
'app_label': source._meta.app_label,
|
||||||
|
|
|
@ -6,7 +6,7 @@ from django.core import checks
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db import models, router, transaction, DEFAULT_DB_ALIAS
|
from django.db import models, router, transaction, DEFAULT_DB_ALIAS
|
||||||
from django.db.models import signals, FieldDoesNotExist
|
from django.db.models import signals, FieldDoesNotExist, DO_NOTHING
|
||||||
from django.db.models.base import ModelBase
|
from django.db.models.base import ModelBase
|
||||||
from django.db.models.fields.related import ForeignObject, ForeignObjectRel
|
from django.db.models.fields.related import ForeignObject, ForeignObjectRel
|
||||||
from django.db.models.related import PathInfo
|
from django.db.models.related import PathInfo
|
||||||
|
@ -243,8 +243,10 @@ class GenericRelation(ForeignObject):
|
||||||
def __init__(self, to, **kwargs):
|
def __init__(self, to, **kwargs):
|
||||||
kwargs['verbose_name'] = kwargs.get('verbose_name', None)
|
kwargs['verbose_name'] = kwargs.get('verbose_name', None)
|
||||||
kwargs['rel'] = GenericRel(
|
kwargs['rel'] = GenericRel(
|
||||||
self, to, related_name=kwargs.pop('related_name', None),
|
self, to,
|
||||||
limit_choices_to=kwargs.pop('limit_choices_to', None),)
|
related_query_name=kwargs.pop('related_query_name', None),
|
||||||
|
limit_choices_to=kwargs.pop('limit_choices_to', None),
|
||||||
|
)
|
||||||
# Override content-type/object-id field names on the related class
|
# Override content-type/object-id field names on the related class
|
||||||
self.object_id_field_name = kwargs.pop("object_id_field", "object_id")
|
self.object_id_field_name = kwargs.pop("object_id_field", "object_id")
|
||||||
self.content_type_field_name = kwargs.pop("content_type_field", "content_type")
|
self.content_type_field_name = kwargs.pop("content_type_field", "content_type")
|
||||||
|
@ -300,11 +302,16 @@ class GenericRelation(ForeignObject):
|
||||||
return [(self.rel.to._meta.get_field_by_name(self.object_id_field_name)[0],
|
return [(self.rel.to._meta.get_field_by_name(self.object_id_field_name)[0],
|
||||||
self.model._meta.pk)]
|
self.model._meta.pk)]
|
||||||
|
|
||||||
def get_reverse_path_info(self):
|
def get_path_info(self):
|
||||||
opts = self.rel.to._meta
|
opts = self.rel.to._meta
|
||||||
target = opts.get_field_by_name(self.object_id_field_name)[0]
|
target = opts.get_field_by_name(self.object_id_field_name)[0]
|
||||||
return [PathInfo(self.model._meta, opts, (target,), self.rel, True, False)]
|
return [PathInfo(self.model._meta, opts, (target,), self.rel, True, False)]
|
||||||
|
|
||||||
|
def get_reverse_path_info(self):
|
||||||
|
opts = self.model._meta
|
||||||
|
from_opts = self.rel.to._meta
|
||||||
|
return [PathInfo(from_opts, opts, (opts.pk,), self, not self.unique, False)]
|
||||||
|
|
||||||
def get_choices_default(self):
|
def get_choices_default(self):
|
||||||
return super(GenericRelation, self).get_choices(include_blank=False)
|
return super(GenericRelation, self).get_choices(include_blank=False)
|
||||||
|
|
||||||
|
@ -312,13 +319,6 @@ class GenericRelation(ForeignObject):
|
||||||
qs = getattr(obj, self.name).all()
|
qs = getattr(obj, self.name).all()
|
||||||
return smart_text([instance._get_pk_val() for instance in qs])
|
return smart_text([instance._get_pk_val() for instance in qs])
|
||||||
|
|
||||||
def get_joining_columns(self, reverse_join=False):
|
|
||||||
if not reverse_join:
|
|
||||||
# This error message is meant for the user, and from user
|
|
||||||
# perspective this is a reverse join along the GenericRelation.
|
|
||||||
raise ValueError('Joining in reverse direction not allowed.')
|
|
||||||
return super(GenericRelation, self).get_joining_columns(reverse_join)
|
|
||||||
|
|
||||||
def contribute_to_class(self, cls, name):
|
def contribute_to_class(self, cls, name):
|
||||||
super(GenericRelation, self).contribute_to_class(cls, name, virtual_only=True)
|
super(GenericRelation, self).contribute_to_class(cls, name, virtual_only=True)
|
||||||
# Save a reference to which model this class is on for future use
|
# Save a reference to which model this class is on for future use
|
||||||
|
@ -326,9 +326,6 @@ class GenericRelation(ForeignObject):
|
||||||
# Add the descriptor for the relation
|
# Add the descriptor for the relation
|
||||||
setattr(cls, self.name, ReverseGenericRelatedObjectsDescriptor(self, self.for_concrete_model))
|
setattr(cls, self.name, ReverseGenericRelatedObjectsDescriptor(self, self.for_concrete_model))
|
||||||
|
|
||||||
def contribute_to_related_class(self, cls, related):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def set_attributes_from_rel(self):
|
def set_attributes_from_rel(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -527,5 +524,7 @@ def create_generic_related_manager(superclass):
|
||||||
|
|
||||||
|
|
||||||
class GenericRel(ForeignObjectRel):
|
class GenericRel(ForeignObjectRel):
|
||||||
def __init__(self, field, to, related_name=None, limit_choices_to=None):
|
def __init__(self, field, to, related_name=None, limit_choices_to=None, related_query_name=None):
|
||||||
super(GenericRel, self).__init__(field, to, related_name, limit_choices_to)
|
super(GenericRel, self).__init__(field=field, to=to, related_name=related_query_name or '+',
|
||||||
|
limit_choices_to=limit_choices_to, on_delete=DO_NOTHING,
|
||||||
|
related_query_name=related_query_name)
|
||||||
|
|
|
@ -449,9 +449,7 @@ class Options(object):
|
||||||
for f, model in self.get_fields_with_model():
|
for f, model in self.get_fields_with_model():
|
||||||
cache[f.name] = cache[f.attname] = (f, model, True, False)
|
cache[f.name] = cache[f.attname] = (f, model, True, False)
|
||||||
for f in self.virtual_fields:
|
for f in self.virtual_fields:
|
||||||
if hasattr(f, 'related'):
|
cache[f.name] = (f, None if f.model == self.model else f.model, True, False)
|
||||||
cache[f.name] = cache[f.attname] = (
|
|
||||||
f.related, None if f.model == self.model else f.model, True, False)
|
|
||||||
if apps.ready:
|
if apps.ready:
|
||||||
self._name_map = cache
|
self._name_map = cache
|
||||||
return cache
|
return cache
|
||||||
|
@ -530,8 +528,9 @@ class Options(object):
|
||||||
proxy_cache = cache.copy()
|
proxy_cache = cache.copy()
|
||||||
for klass in self.apps.get_models(include_auto_created=True):
|
for klass in self.apps.get_models(include_auto_created=True):
|
||||||
if not klass._meta.swapped:
|
if not klass._meta.swapped:
|
||||||
for f in klass._meta.local_fields:
|
for f in klass._meta.local_fields + klass._meta.virtual_fields:
|
||||||
if f.rel and not isinstance(f.rel.to, six.string_types) and f.generate_reverse_relation:
|
if (hasattr(f, 'rel') and f.rel and not isinstance(f.rel.to, six.string_types)
|
||||||
|
and f.generate_reverse_relation):
|
||||||
if self == f.rel.to._meta:
|
if self == f.rel.to._meta:
|
||||||
cache[f.related] = None
|
cache[f.related] = None
|
||||||
proxy_cache[f.related] = None
|
proxy_cache[f.related] = None
|
||||||
|
|
|
@ -373,6 +373,15 @@ Reverse generic relations
|
||||||
|
|
||||||
This class used to be defined in ``django.contrib.contenttypes.generic``.
|
This class used to be defined in ``django.contrib.contenttypes.generic``.
|
||||||
|
|
||||||
|
.. attribute:: related_query_name
|
||||||
|
|
||||||
|
.. versionadded:: 1.7
|
||||||
|
|
||||||
|
The relation on the related object back to this object doesn't exist by
|
||||||
|
default. Setting ``related_query_name`` creates a relation from the
|
||||||
|
related object back to this one. This allows querying and filtering
|
||||||
|
from the related object.
|
||||||
|
|
||||||
If you know which models you'll be using most often, you can also add
|
If you know which models you'll be using most often, you can also add
|
||||||
a "reverse" generic relationship to enable an additional API. For example::
|
a "reverse" generic relationship to enable an additional API. For example::
|
||||||
|
|
||||||
|
@ -392,6 +401,20 @@ be used to retrieve their associated ``TaggedItems``::
|
||||||
>>> b.tags.all()
|
>>> b.tags.all()
|
||||||
[<TaggedItem: django>, <TaggedItem: python>]
|
[<TaggedItem: django>, <TaggedItem: python>]
|
||||||
|
|
||||||
|
.. versionadded:: 1.7
|
||||||
|
|
||||||
|
Defining :class:`~django.contrib.contenttypes.fields.GenericRelation` with
|
||||||
|
``related_query_name`` set allows querying from the related object::
|
||||||
|
|
||||||
|
tags = GenericRelation(TaggedItem, related_query_name='bookmarks')
|
||||||
|
|
||||||
|
This enables filtering, ordering, and other query operations on ``Bookmark``
|
||||||
|
from ``TaggedItem``::
|
||||||
|
|
||||||
|
>>> # Get all tags belonging to books containing `django` in the url
|
||||||
|
>>> TaggedItem.objects.filter(bookmarks__url__contains='django')
|
||||||
|
[<TaggedItem: django>, <TaggedItem: python>]
|
||||||
|
|
||||||
Just as :class:`~django.contrib.contenttypes.fields.GenericForeignKey`
|
Just as :class:`~django.contrib.contenttypes.fields.GenericForeignKey`
|
||||||
accepts the names of the content-type and object-ID fields as
|
accepts the names of the content-type and object-ID fields as
|
||||||
arguments, so too does
|
arguments, so too does
|
||||||
|
|
|
@ -1165,6 +1165,11 @@ Miscellaneous
|
||||||
* The ``shortcut`` view in ``django.contrib.contenttypes.views`` now supports
|
* The ``shortcut`` view in ``django.contrib.contenttypes.views`` now supports
|
||||||
protocol-relative URLs (e.g. ``//example.com``).
|
protocol-relative URLs (e.g. ``//example.com``).
|
||||||
|
|
||||||
|
* :class:`~django.contrib.contenttypes.fields.GenericRelation` now supports an
|
||||||
|
optional ``related_query_name`` argument. Setting ``related_query_name`` adds
|
||||||
|
a relation from the related object back to the content type for filtering,
|
||||||
|
ordering and other query operations.
|
||||||
|
|
||||||
.. _deprecated-features-1.7:
|
.. _deprecated-features-1.7:
|
||||||
|
|
||||||
Features deprecated in 1.7
|
Features deprecated in 1.7
|
||||||
|
|
|
@ -68,7 +68,7 @@ class Animal(models.Model):
|
||||||
common_name = models.CharField(max_length=150)
|
common_name = models.CharField(max_length=150)
|
||||||
latin_name = models.CharField(max_length=150)
|
latin_name = models.CharField(max_length=150)
|
||||||
|
|
||||||
tags = GenericRelation(TaggedItem)
|
tags = GenericRelation(TaggedItem, related_query_name='animal')
|
||||||
comparisons = GenericRelation(Comparison,
|
comparisons = GenericRelation(Comparison,
|
||||||
object_id_field="object_id1",
|
object_id_field="object_id1",
|
||||||
content_type_field="content_type1")
|
content_type_field="content_type1")
|
||||||
|
@ -116,7 +116,7 @@ class Rock(Mineral):
|
||||||
|
|
||||||
class ManualPK(models.Model):
|
class ManualPK(models.Model):
|
||||||
id = models.IntegerField(primary_key=True)
|
id = models.IntegerField(primary_key=True)
|
||||||
tags = GenericRelation(TaggedItem)
|
tags = GenericRelation(TaggedItem, related_query_name='manualpk')
|
||||||
|
|
||||||
|
|
||||||
class ForProxyModelModel(models.Model):
|
class ForProxyModelModel(models.Model):
|
||||||
|
|
|
@ -3,6 +3,7 @@ from __future__ import unicode_literals
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.forms import generic_inlineformset_factory
|
from django.contrib.contenttypes.forms import generic_inlineformset_factory
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import FieldError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import six
|
from django.utils import six
|
||||||
|
|
||||||
|
@ -42,6 +43,17 @@ class GenericRelationsTests(TestCase):
|
||||||
# You can easily access the content object like a foreign key.
|
# You can easily access the content object like a foreign key.
|
||||||
t = TaggedItem.objects.get(tag="salty")
|
t = TaggedItem.objects.get(tag="salty")
|
||||||
self.assertEqual(t.content_object, bacon)
|
self.assertEqual(t.content_object, bacon)
|
||||||
|
qs = TaggedItem.objects.filter(animal__isnull=False).order_by('animal__common_name', 'tag')
|
||||||
|
self.assertQuerysetEqual(
|
||||||
|
qs, ["<TaggedItem: hairy>", "<TaggedItem: yellow>", "<TaggedItem: fatty>"]
|
||||||
|
)
|
||||||
|
mpk = ManualPK.objects.create(id=1)
|
||||||
|
mpk.tags.create(tag='mpk')
|
||||||
|
from django.db.models import Q
|
||||||
|
qs = TaggedItem.objects.filter(Q(animal__isnull=False) | Q(manualpk__id=1)).order_by('tag')
|
||||||
|
self.assertQuerysetEqual(
|
||||||
|
qs, ["fatty", "hairy", "mpk", "yellow"], lambda x: x.tag)
|
||||||
|
mpk.delete()
|
||||||
|
|
||||||
# Recall that the Mineral class doesn't have an explicit GenericRelation
|
# Recall that the Mineral class doesn't have an explicit GenericRelation
|
||||||
# defined. That's OK, because you can create TaggedItems explicitly.
|
# defined. That's OK, because you can create TaggedItems explicitly.
|
||||||
|
@ -151,6 +163,12 @@ class GenericRelationsTests(TestCase):
|
||||||
"<Animal: Platypus>"
|
"<Animal: Platypus>"
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def test_generic_relation_related_name_default(self):
|
||||||
|
# Test that GenericRelation by default isn't usable from
|
||||||
|
# the reverse side.
|
||||||
|
with self.assertRaises(FieldError):
|
||||||
|
TaggedItem.objects.filter(vegetable__isnull=True)
|
||||||
|
|
||||||
def test_multiple_gfk(self):
|
def test_multiple_gfk(self):
|
||||||
# Simple tests for multiple GenericForeignKeys
|
# Simple tests for multiple GenericForeignKeys
|
||||||
# only uses one model, since the above tests should be sufficient.
|
# only uses one model, since the above tests should be sufficient.
|
||||||
|
|
|
@ -245,5 +245,5 @@ class GenericRelationTests(TestCase):
|
||||||
form = GenericRelationForm({'links': None})
|
form = GenericRelationForm({'links': None})
|
||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
form.save()
|
form.save()
|
||||||
links = HasLinkThing._meta.get_field_by_name('links')[0].field
|
links = HasLinkThing._meta.get_field_by_name('links')[0]
|
||||||
self.assertEqual(links.save_form_data_calls, 1)
|
self.assertEqual(links.save_form_data_calls, 1)
|
||||||
|
|
|
@ -145,11 +145,11 @@ class TaggedItem(models.Model):
|
||||||
|
|
||||||
class Bookmark(models.Model):
|
class Bookmark(models.Model):
|
||||||
url = models.URLField()
|
url = models.URLField()
|
||||||
tags = GenericRelation(TaggedItem, related_name='bookmarks')
|
tags = GenericRelation(TaggedItem, related_query_name='bookmarks')
|
||||||
favorite_tags = GenericRelation(TaggedItem,
|
favorite_tags = GenericRelation(TaggedItem,
|
||||||
content_type_field='favorite_ct',
|
content_type_field='favorite_ct',
|
||||||
object_id_field='favorite_fkey',
|
object_id_field='favorite_fkey',
|
||||||
related_name='favorite_bookmarks')
|
related_query_name='favorite_bookmarks')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['id']
|
ordering = ['id']
|
||||||
|
|
Loading…
Reference in New Issue