This commit is contained in:
Lukas Bünger 2014-04-10 14:51:14 +00:00
commit 93b549730b
3 changed files with 497 additions and 27 deletions

View File

@ -308,7 +308,7 @@ See the Django documentation on [reverse relationships][reverse-relationships] f
## Generic relationships
If you want to serialize a generic foreign key, you need to define a custom field, to determine explicitly how you want serialize the targets of the relationship.
If you want to serialize a generic foreign key, you need to define a `GenericRelatedField` with a configuration dictionary as first argument, that describes the representation of each model you possibly want to connect to the generic foreign key.
For example, given the following model for a tag, which has a generic relationship with other arbitrary models:
@ -343,40 +343,112 @@ And the following two models, which may be have associated tags:
text = models.CharField(max_length=1000)
tags = GenericRelation(TaggedItem)
We could define a custom field that could be used to serialize tagged instances, using the type of each instance to determine how it should be serialized.
Now we define serializers for each model that may get associated with tags.
class TaggedObjectRelatedField(serializers.RelatedField):
class BookmarkSerializer(serializers.ModelSerializer):
"""
A custom field to use for the `tagged_object` generic relationship.
A simple `ModelSerializer` subclass for serializing `Bookmark` objects.
"""
class Meta:
model = Bookmark
exclude = ('id', )
def to_native(self, value):
class NoteSerializer(serializers.ModelSerializer):
"""
A simple `ModelSerializer` subclass for serializing `Note` objects.
"""
class Meta:
model = Note
exclude = ('id', )
The model serializer for the `Tag` model could look like this:
class TagSerializer(serializers.ModelSerializer):
"""
A `Tag` serializer with a `GenericRelatedField` mapping all possible
models to their respective serializers.
"""
tagged_object = serializers.GenericRelatedField({
Bookmark: BookmarkSerializer(),
Note: NoteSerializer()
}, read_only=True)
class Meta:
model = Tag
exclude = ('id', )
The JSON representation of a `Tag` object with `name='django'` and its generic foreign key pointing at a `Bookmark` object with `url='https://www.djangoproject.com/'` would look like this:
{
'tagged_object': {
'url': 'https://www.djangoproject.com/'
},
'tag_name': 'django'
}
If you want to have your generic foreign key represented as hyperlink, simply use `HyperlinkedRelatedField` objects:
class TagSerializer(serializers.ModelSerializer):
"""
A `Tag` serializer with a `GenericRelatedField` mapping all possible
models to properly set up `HyperlinkedRelatedField`s.
"""
tagged_object = serializers.GenericRelatedField({
Bookmark: serializers.HyperlinkedRelatedField(view_name='bookmark-detail'),
Note: serializers.HyperlinkedRelatedField(view_name='note-detail'),
}, read_only=True)
class Meta:
model = Tag
exclude = ('id', )
The JSON representation of the same `Tag` example object could now look something like this:
{
'tagged_object': '/bookmark/1/',
'tag_name': 'django'
}
These examples cover the default behavior of generic foreign key representation. However, you may also want to write to generic foreign key fields through your API.
By default, a `GenericRelatedField` iterates over its nested serializers and returns the value of the first serializer, that is actually able to perform `from_native`` on the input value without any errors.
Note, that (at the moment) only `HyperlinkedRelatedField` is able to serialize model objects out of the box.
This `Tag` serializer is able to write to it's generic foreign key field:
class TagSerializer(serializers.ModelSerializer):
"""
Serialize tagged objects to a simple textual representation.
"""
if isinstance(value, Bookmark):
return 'Bookmark: ' + value.url
elif isinstance(value, Note):
return 'Note: ' + value.text
raise Exception('Unexpected type of tagged object')
If you need the target of the relationship to have a nested representation, you can use the required serializers inside the `.to_native()` method:
def to_native(self, value):
A `Tag` serializer with a `GenericRelatedField` mapping all possible
models to properly set up `HyperlinkedRelatedField`s.
"""
Serialize bookmark instances using a bookmark serializer,
and note instances using a note serializer.
"""
if isinstance(value, Bookmark):
serializer = BookmarkSerializer(value)
elif isinstance(value, Note):
serializer = NoteSerializer(value)
else:
raise Exception('Unexpected type of tagged object')
tagged_object = serializers.GenericRelatedField({
Bookmark: serializers.HyperlinkedRelatedField(view_name='bookmark-detail'),
Note: serializers.HyperlinkedRelatedField(view_name='note-detail'),
}, read_only=False)
return serializer.data
class Meta:
model = Tag
exclude = ('id', )
Note that reverse generic keys, expressed using the `GenericRelation` field, can be serialized using the regular relational field types, since the type of the target in the relationship is always known.
The following operations would create a `Tag` object with it's `tagged_object` property pointing at the `Bookmark` object found at the given detail end point.
tag_serializer = TagSerializer(data={
'tag_name': 'python'
'tagged_object': '/bookmark/1/'
})
tag_serializer.is_valid()
tag_serializer.save()
If you feel that this default behavior doesn't suit your needs, you can subclass `GenericRelatedField` and override its `determine_deserializer_for_data` or `determine_serializer_for_data` respectively to implement your own way of decision-making.
A few things you should note:
* Although `GenericForeignKey` fields can be set to any model object, the `GenericRelatedField` only handles models explicitly defined in its configuration dictionary.
* Reverse generic keys, expressed using the `GenericRelation` field, can be serialized using the regular relational field types, since the type of the target in the relationship is always known.
* Please take into account that the order in which you register serializers matters as far as write operations are concerned.
* Unless you provide custom serializer determination, only `HyperlinkedRelatedFields` provide write access to generic model relations.
For more information see [the Django documentation on generic relations][generic-relations].

View File

@ -0,0 +1,87 @@
from __future__ import unicode_literals
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
from django import forms
from rest_framework import six
from rest_framework import serializers
from rest_framework.exceptions import ConfigurationError
class GenericRelatedField(serializers.WritableField):
"""
Represents a generic relation foreign key.
It's actually more of a wrapper, that delegates the logic to registered serializers based on the `Model` class.
"""
default_error_messages = {
'no_model_match': _('Invalid model - model not available.'),
'no_url_match': _('Invalid hyperlink - No URL match'),
'incorrect_url_match': _('Invalid hyperlink - view name not available'),
}
form_field_class = forms.URLField
def __init__(self, serializers, *args, **kwargs):
"""
Needs an extra parameter `serializers` which has to be a dict key: value being `Model`: serializer.
"""
super(GenericRelatedField, self).__init__(*args, **kwargs)
self.serializers = serializers
for model, serializer in six.iteritems(self.serializers):
# We have to do it, because the serializer can't access a explicit manager through the
# GenericForeignKey field on the model.
if hasattr(serializer, 'queryset') and serializer.queryset is None:
serializer.queryset = model._default_manager.all()
def field_to_native(self, obj, field_name):
"""
Delegates to the `to_native` method of the serializer registered under obj.__class__
"""
value = super(GenericRelatedField, self).field_to_native(obj, field_name)
serializer = self.determine_deserializer_for_data(value)
# Necessary because of context, field resolving etc.
serializer.initialize(self.parent, field_name)
return serializer.to_native(value)
def to_native(self, value):
# Override to prevent the simplifying process of value as present in `WritableField.to_native`.
return value
def from_native(self, value):
# Get the serializer responsible for input resolving
try:
serializer = self.determine_serializer_for_data(value)
except ConfigurationError as e:
raise ValidationError(e)
serializer.initialize(self.parent, self.source)
return serializer.from_native(value)
def determine_deserializer_for_data(self, value):
try:
model = value.__class__
serializer = self.serializers[model]
except KeyError:
raise ValidationError(self.error_messages['no_model_match'])
return serializer
def determine_serializer_for_data(self, value):
# While one could easily execute the "try" block within from_native and reduce operations, I consider the
# concept of serializing is already very naive and vague, that's why I'd go for stringency with the deserialization
# process here.
serializers = []
for serializer in six.itervalues(self.serializers):
try:
serializer.from_native(value)
# Collects all serializers that can handle the input data.
serializers.append(serializer)
except:
pass
# If no serializer found, raise error.
l = len(serializers)
if l < 1:
raise ConfigurationError('Could not determine a valid serializer for value %r.' % value)
elif l > 1:
raise ConfigurationError('There were multiple serializers found for value %r.' % value)
return serializers[0]

View File

@ -0,0 +1,311 @@
from __future__ import unicode_literals
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.generic import GenericRelation, GenericForeignKey
from django.core.exceptions import ValidationError
from django.db import models
from django.test import TestCase, RequestFactory
from rest_framework import serializers
from rest_framework.compat import patterns, url
from rest_framework.exceptions import ConfigurationError
from rest_framework.genericrelations import GenericRelatedField
from rest_framework.reverse import reverse
factory = RequestFactory()
request = factory.get('/') # Just to ensure we have a request in the serializer context
def dummy_view(request, pk):
pass
urlpatterns = patterns('',
url(r'^bookmark/(?P<pk>[0-9]+)/$', dummy_view, name='bookmark-detail'),
url(r'^note/(?P<pk>[0-9]+)/$', dummy_view, name='note-detail'),
url(r'^tag/(?P<pk>[0-9]+)/$', dummy_view, name='tag-detail'),
url(r'^contact/(?P<my_own_slug>[-\w]+)/$', dummy_view, name='contact-detail'),
)
class Tag(models.Model):
"""
Tags have a descriptive slug, and are attached to an arbitrary object.
"""
tag = models.SlugField()
content_type = models.ForeignKey(ContentType)
object_id = models.PositiveIntegerField()
tagged_item = GenericForeignKey('content_type', 'object_id')
def __unicode__(self):
return self.tag
class Bookmark(models.Model):
"""
A URL bookmark that may have multiple tags attached.
"""
url = models.URLField()
tags = GenericRelation(Tag)
def __unicode__(self):
return 'Bookmark: %s' % self.url
class Note(models.Model):
"""
A textual note that may have multiple tags attached.
"""
text = models.TextField()
tags = GenericRelation(Tag)
def __unicode__(self):
return 'Note: %s' % self.text
class BookmarkSerializer(serializers.ModelSerializer):
class Meta:
model = Bookmark
exclude = ('id', )
class NoteSerializer(serializers.ModelSerializer):
class Meta:
model = Note
exclude = ('id', )
class TestGenericRelatedFieldDeserialization(TestCase):
urls = 'rest_framework.tests.relations_generic'
def setUp(self):
self.bookmark = Bookmark.objects.create(url='https://www.djangoproject.com/')
Tag.objects.create(tagged_item=self.bookmark, tag='django')
Tag.objects.create(tagged_item=self.bookmark, tag='python')
self.note = Note.objects.create(text='Remember the milk')
Tag.objects.create(tagged_item=self.note, tag='reminder')
def test_relations_as_hyperlinks(self):
class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField({
Bookmark: serializers.HyperlinkedRelatedField(view_name='bookmark-detail'),
Note: serializers.HyperlinkedRelatedField(view_name='note-detail'),
}, source='tagged_item', read_only=True)
class Meta:
model = Tag
exclude = ('id', 'content_type', 'object_id', )
serializer = TagSerializer(Tag.objects.all(), many=True)
expected = [
{
'tagged_item': '/bookmark/1/',
'tag': 'django',
},
{
'tagged_item': '/bookmark/1/',
'tag': 'python',
},
{
'tagged_item': '/note/1/',
'tag': 'reminder'
}
]
self.assertEqual(serializer.data, expected)
def test_relations_as_nested(self):
class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField({
Bookmark: BookmarkSerializer(),
Note: NoteSerializer(),
}, source='tagged_item', read_only=True)
class Meta:
model = Tag
exclude = ('id', 'content_type', 'object_id', )
serializer = TagSerializer(Tag.objects.all(), many=True)
expected = [
{
'tagged_item': {
'url': 'https://www.djangoproject.com/'
},
'tag': 'django'
},
{
'tagged_item': {
'url': 'https://www.djangoproject.com/'
},
'tag': 'python'
},
{
'tagged_item': {
'text': 'Remember the milk',
},
'tag': 'reminder'
}
]
self.assertEqual(serializer.data, expected)
def test_mixed_serializers(self):
class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField({
Bookmark: BookmarkSerializer(),
Note: serializers.HyperlinkedRelatedField(view_name='note-detail'),
}, source='tagged_item', read_only=True)
class Meta:
model = Tag
exclude = ('id', 'content_type', 'object_id', )
serializer = TagSerializer(Tag.objects.all(), many=True)
expected = [
{
'tagged_item': {
'url': 'https://www.djangoproject.com/'
},
'tag': 'django'
},
{
'tagged_item': {
'url': 'https://www.djangoproject.com/'
},
'tag': 'python'
},
{
'tagged_item': '/note/1/',
'tag': 'reminder'
}
]
self.assertEqual(serializer.data, expected)
def test_invalid_model(self):
# Leaving out the Note model should result in a ValidationError
class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField({
Bookmark: BookmarkSerializer(),
}, source='tagged_item', read_only=True)
class Meta:
model = Tag
exclude = ('id', 'content_type', 'object_id', )
serializer = TagSerializer(Tag.objects.all(), many=True)
def call_data():
return serializer.data
self.assertRaises(ValidationError, call_data)
class TestGenericRelatedFieldSerialization(TestCase):
urls = 'rest_framework.tests.relations_generic'
def setUp(self):
self.bookmark = Bookmark.objects.create(url='https://www.djangoproject.com/')
Tag.objects.create(tagged_item=self.bookmark, tag='django')
Tag.objects.create(tagged_item=self.bookmark, tag='python')
self.note = Note.objects.create(text='Remember the milk')
def test_hyperlink_serialization(self):
class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField({
Bookmark: serializers.HyperlinkedRelatedField(view_name='bookmark-detail'),
Note: serializers.HyperlinkedRelatedField(view_name='note-detail'),
}, source='tagged_item', read_only=False)
class Meta:
model = Tag
exclude = ('id', 'content_type', 'object_id', )
serializer = TagSerializer(data={
'tag': 'reminder',
'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk})
})
serializer.is_valid()
expected = {
'tagged_item': '/note/1/',
'tag': 'reminder'
}
self.assertEqual(serializer.data, expected)
def test_configuration_error(self):
class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField({
Bookmark: BookmarkSerializer(),
Note: serializers.HyperlinkedRelatedField(view_name='note-detail'),
}, source='tagged_item', read_only=False)
class Meta:
model = Tag
exclude = ('id', 'content_type', 'object_id', )
serializer = TagSerializer(data={
'tag': 'reminder',
'tagged_item': 'just a string'
})
with self.assertRaises(ConfigurationError):
serializer.fields['tagged_item'].determine_serializer_for_data('just a string')
def test_not_registered_view_name(self):
class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField({
Bookmark: serializers.HyperlinkedRelatedField(view_name='bookmark-detail'),
}, source='tagged_item', read_only=False)
class Meta:
model = Tag
exclude = ('id', 'content_type', 'object_id', )
serializer = TagSerializer(data={
'tag': 'reminder',
'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk})
})
self.assertFalse(serializer.is_valid())
def test_invalid_url(self):
class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField({
Bookmark: serializers.HyperlinkedRelatedField(view_name='bookmark-detail'),
}, source='tagged_item', read_only=False)
class Meta:
model = Tag
exclude = ('id', 'content_type', 'object_id', )
serializer = TagSerializer(data={
'tag': 'reminder',
'tagged_item': 'foo-bar'
})
expected = {
'tagged_item': ['Could not determine a valid serializer for value %r.' % 'foo-bar']
}
self.assertFalse(serializer.is_valid())
self.assertEqual(expected, serializer.errors)
def test_serializer_save(self):
class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField({
Bookmark: serializers.HyperlinkedRelatedField(view_name='bookmark-detail'),
Note: serializers.HyperlinkedRelatedField(view_name='note-detail'),
}, source='tagged_item', read_only=False)
class Meta:
model = Tag
exclude = ('id', 'content_type', 'object_id', )
serializer = TagSerializer(data={
'tag': 'reminder',
'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk})
})
serializer.is_valid()
expected = {
'tagged_item': '/note/1/',
'tag': 'reminder'
}
serializer.save()
tag = Tag.objects.get(pk=3)
self.assertEqual(tag.tagged_item, self.note)