diff --git a/docs/api-guide/relations.md b/docs/api-guide/relations.md index cc4f55851..9e781dc96 100644 --- a/docs/api-guide/relations.md +++ b/docs/api-guide/relations.md @@ -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]. diff --git a/rest_framework/genericrelations.py b/rest_framework/genericrelations.py new file mode 100644 index 000000000..c193c12eb --- /dev/null +++ b/rest_framework/genericrelations.py @@ -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] diff --git a/rest_framework/tests/relations_generic.py b/rest_framework/tests/relations_generic.py new file mode 100644 index 000000000..1873b5b6a --- /dev/null +++ b/rest_framework/tests/relations_generic.py @@ -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[0-9]+)/$', dummy_view, name='bookmark-detail'), + url(r'^note/(?P[0-9]+)/$', dummy_view, name='note-detail'), + url(r'^tag/(?P[0-9]+)/$', dummy_view, name='tag-detail'), + url(r'^contact/(?P[-\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) \ No newline at end of file