From 79575919109075fc4cdd6ec7509da0d830192cc8 Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Tue, 26 Mar 2013 01:02:09 +0100 Subject: [PATCH 01/13] Introducing implementation and tests --- rest_framework/genericrelations.py | 164 +++++++++++ rest_framework/tests/relations_generic.py | 327 ++++++++++++++++++++++ 2 files changed, 491 insertions(+) create mode 100644 rest_framework/genericrelations.py create mode 100644 rest_framework/tests/relations_generic.py diff --git a/rest_framework/genericrelations.py b/rest_framework/genericrelations.py new file mode 100644 index 000000000..b0e188a92 --- /dev/null +++ b/rest_framework/genericrelations.py @@ -0,0 +1,164 @@ +from __future__ import unicode_literals + +import urlparse + +from django.core.exceptions import ValidationError +from django.core.urlresolvers import get_script_prefix, resolve +from django.utils.translation import ugettext_lazy as _ +from django import forms + +from rest_framework import serializers +from rest_framework.settings import api_settings + + +class GenericRelationOption(object): + """ + This object is responsible for setting up the components needed for providing a generic relation with a given model. + """ + + #TODO: Far more strict evaluation of custom related_field and serializer objects + + # Trying to be inline with common practices + model_serializer_class = api_settings.DEFAULT_MODEL_SERIALIZER_CLASS + + def __init__(self, model, view_name, as_hyperlink=True, related_field=None, serializer=None): + self.model = model + self.view_name = view_name + self.as_hyperlink = as_hyperlink + self.related_field = related_field or self.get_default_related_field() + self.serializer = serializer or self.get_default_serializer() + + def get_output_resolver(self): + """ + Should return a class that implements the `to_native` method, i.e. `HyperlinkedRelatedField` or `ModelSerializer`. + """ + if self.as_hyperlink: + return self.get_prepared_related_field() + else: + return self.serializer + + def get_input_resolver(self): + """ + Should return a class that implements the `from_native` method that can handle URL values, + i.e. `HyperlinkedRelatedField`. + """ + return self.get_prepared_related_field() + + def get_prepared_related_field(self): + """ + Provides the related field with a queryset if not present, based on `self.model`. + """ + if self.related_field.queryset is None: + self.related_field.queryset = self.model.objects.all() + return self.related_field + + def get_default_related_field(self): + """ + Creates and returns a minimalist ``HyperlinkedRelatedField` instance if none has been passed to the constructor. + """ + return serializers.HyperlinkedRelatedField(view_name=self.view_name) + + def get_default_serializer(self): + """ + Creates and returns a minimalist ``ModelSerializer` instance if none has been passed to the constructor. + """ + class DefaultSerializer(self.model_serializer_class): + class Meta: + model = self.model + return DefaultSerializer() + + +class GenericRelatedField(serializers.WritableField): + """ + Represents a generic relation foreign key. + + It's actually more of a wrapper, that delegates the logic to registered fields / serializers based on some + contenttype framework criteria. + """ + default_error_messages = { + 'no_model_match': _('Invalid model - model not available.'), + 'no_match': _('Invalid hyperlink - No URL match'), + 'incorrect_match': _('Invalid hyperlink - view name not available'), + } + + form_field_class = forms.URLField + + def __init__(self, options, *args, **kwargs): + """ + Needs an extra parameter ``options`` which has to be a list of `GenericRelationOption` objects. + """ + super(GenericRelatedField, self).__init__(*args, **kwargs) + + # Map for option identifying based on a `Model` class (deserialization cycle) + self._model_map = dict() + # Map for option identifying based on a `view_name` (serialization cycle) + self._view_name_map = dict() + + # Adding the options to the maps. + for option in options: + self._model_map[option.model] = option + self._view_name_map[option.view_name] = option + + def field_to_native(self, obj, field_name): + """ + Identifies the option object that is responsible for this `value.__class__` (a model) object and returns + its output serializer's `to_native` method. + """ + value = super(GenericRelatedField, self).field_to_native(obj, field_name) + + # Retrieving the model class. + model = value.__class__ + + try: + option = self._model_map[model] + except KeyError: + raise ValidationError(self.error_messages['no_model_match']) + + # Get the serializer responsible for output formatting + serializer = option.get_output_resolver() + + # 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): + + # This excerpt is an exact copy of ``rest_framework.relations.HyperlinkedRelatedField``, Line 363 + # From here until ... + try: + http_prefix = value.startswith('http:') or value.startswith('https:') + except AttributeError: + msg = self.error_messages['incorrect_type'] + raise ValidationError(msg % type(value).__name__) + + if http_prefix: + # If needed convert absolute URLs to relative path + value = urlparse.urlparse(value).path + prefix = get_script_prefix() + if value.startswith(prefix): + value = '/' + value[len(prefix):] + try: + match = resolve(value) + except Exception: + raise ValidationError(self.error_messages['no_match']) + + # ... here. Thinking about putting that in ``rest_framework.utils.py``. Of course With more appropriate exceptions. + + # Try to find the derived `view_name` in the map. + try: + view_name = match.url_name + option = self._view_name_map[view_name] + except KeyError: + raise ValidationError(self.error_messages['incorrect_match']) + + # Get the serializer responsible for input resolving + serializer = option.get_input_resolver() + + # Necessary because of context, field resolving etc. + serializer.initialize(self.parent, self.source) + return serializer.from_native(value) diff --git a/rest_framework/tests/relations_generic.py b/rest_framework/tests/relations_generic.py new file mode 100644 index 000000000..31f0a7135 --- /dev/null +++ b/rest_framework/tests/relations_generic.py @@ -0,0 +1,327 @@ +from __future__ import unicode_literals +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.generic import GenericRelation, GenericForeignKey +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.genericrelations import GenericRelationOption, 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 Contact(models.Model): + """ + A textual note that may have multiple tags attached. + """ + name = models.TextField() + slug = models.SlugField() + tags = GenericRelation(Tag) + + def __unicode__(self): + return 'Contact: %s' % self.name + + +class TestGenericRelationOptions(TestCase): + + def test_default_related_field(self): + option = GenericRelationOption(Bookmark, 'bookmark-detail') + self.assertIsInstance(option.related_field, serializers.HyperlinkedRelatedField) + + def test_default_related_field_view_name(self): + option = GenericRelationOption(Bookmark, 'bookmark-detail') + self.assertEqual(option.related_field.view_name, 'bookmark-detail') + + def test_default_serializer(self): + option = GenericRelationOption(Bookmark, 'bookmark-detail') + self.assertIsInstance(option.serializer, serializers.ModelSerializer) + + def test_default_serializer_meta_model(self): + option = GenericRelationOption(Bookmark, 'bookmark-detail') + self.assertEqual(option.serializer.Meta.model, Bookmark) + + def test_get_url_output_resolver(self): + option = GenericRelationOption(Bookmark, 'bookmark-detail') + self.assertIsInstance(option.get_output_resolver(), serializers.HyperlinkedRelatedField) + + def test_get_url_output_resolver_with_queryset(self): + option = GenericRelationOption(Bookmark, 'bookmark-detail') + self.assertIsNotNone(option.get_output_resolver().queryset) + + def test_get_input_resolver(self): + option = GenericRelationOption(Bookmark, 'bookmark-detail') + self.assertIsInstance(option.get_input_resolver(), serializers.HyperlinkedRelatedField) + + def test_get_input_resolver_with_queryset(self): + option = GenericRelationOption(Bookmark, 'bookmark-detail') + self.assertIsNotNone(option.get_output_resolver().queryset) + + def test_get_serializer_resolver(self): + option = GenericRelationOption(Bookmark, 'bookmark-detail', as_hyperlink=False) + self.assertIsInstance(option.get_output_resolver(), serializers.ModelSerializer) + + def test_custom_related_field(self): + related_field = serializers.HyperlinkedRelatedField(view_name='bookmark-detail', format='xml') + option = GenericRelationOption(Bookmark, 'bookmark-detail', + related_field=related_field) + self.assertEqual(option.related_field.format, 'xml') + + def test_custom_serializer(self): + + class BookmarkSerializer(serializers.ModelSerializer): + class Meta: + model = Bookmark + exclude = ('id', ) + + serializer = BookmarkSerializer() + option = GenericRelationOption(Bookmark, 'bookmark-detail', as_hyperlink=False, serializer=serializer) + self.assertIn('id', option.get_output_resolver().Meta.exclude) + + +class TestGenericRelatedFieldToNative(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([ + GenericRelationOption(Bookmark, 'bookmark-detail'), + GenericRelationOption(Note, 'note-detail'), + ], source='tagged_item') + + class Meta: + model = Tag + exclude = ('id', 'content_type', 'object_id', ) + + serializer = TagSerializer(Tag.objects.all(), many=True) + expected = [ + { + 'tagged_item': '/bookmark/1/', + 'tag': u'django' + }, + { + 'tagged_item': '/bookmark/1/', + 'tag': u'python' + }, + { + 'tagged_item': '/note/1/', + 'tag': u'reminder' + } + ] + self.assertEqual(serializer.data, expected) + + def test_relations_as_nested(self): + + class TagSerializer(serializers.ModelSerializer): + tagged_item = GenericRelatedField([ + GenericRelationOption(Bookmark, 'bookmark-detail'), + GenericRelationOption(Note, 'note-detail', as_hyperlink=False), + ], source='tagged_item') + + class Meta: + model = Tag + exclude = ('id', 'content_type', 'object_id', ) + + serializer = TagSerializer(Tag.objects.all(), many=True) + expected = [ + { + 'tagged_item': '/bookmark/1/', + 'tag': u'django' + }, + { + 'tagged_item': '/bookmark/1/', + 'tag': u'python' + }, + { + 'tagged_item': { + 'id': 1, + 'text': 'Remember the milk', + }, + 'tag': u'reminder' + } + ] + self.assertEqual(serializer.data, expected) + + def test_custom_related_field(self): + contact = Contact.objects.create(name='Lukas Buenger', slug='lukas-buenger') + Tag.objects.create(tagged_item=contact, tag='developer') + + contact_related_field = serializers.HyperlinkedRelatedField(view_name='contact-detail', + slug_url_kwarg='my_own_slug') + + class TagSerializer(serializers.ModelSerializer): + tagged_item = GenericRelatedField([ + GenericRelationOption(Bookmark, 'bookmark-detail'), + GenericRelationOption(Note, 'note-detail', as_hyperlink=False), + GenericRelationOption(Contact, 'contact-detail', related_field=contact_related_field), + ], source='tagged_item') + + class Meta: + model = Tag + exclude = ('id', 'content_type', 'object_id', ) + + serializer = TagSerializer(Tag.objects.all(), many=True) + expected = [ + { + 'tagged_item': '/bookmark/1/', + 'tag': u'django' + }, + { + 'tagged_item': '/bookmark/1/', + 'tag': u'python' + }, + { + 'tagged_item': { + 'id': 1, + 'text': 'Remember the milk', + }, + 'tag': u'reminder' + }, + { + 'tagged_item': '/contact/lukas-buenger/', + 'tag': u'developer' + } + ] + self.assertEqual(serializer.data, expected) + + def test_custom_serializer(self): + contact = Contact.objects.create(name='Lukas Buenger', slug='lukas-buenger') + Tag.objects.create(tagged_item=contact, tag='developer') + + contact_related_field = serializers.HyperlinkedRelatedField(view_name='contact-detail', + slug_url_kwarg='my_own_slug') + + class ContactSerializer(serializers.ModelSerializer): + class Meta: + model = Contact + exclude = ('id', 'slug', ) + + + class TagSerializer(serializers.ModelSerializer): + tagged_item = GenericRelatedField([ + GenericRelationOption(Bookmark, 'bookmark-detail'), + GenericRelationOption(Note, 'note-detail', as_hyperlink=False), + GenericRelationOption(Contact, 'contact-detail', as_hyperlink=False, related_field=contact_related_field, + serializer=ContactSerializer()), + ], source='tagged_item') + + class Meta: + model = Tag + exclude = ('id', 'content_type', 'object_id', ) + + serializer = TagSerializer(Tag.objects.all(), many=True) + expected = [ + { + 'tagged_item': '/bookmark/1/', + 'tag': u'django' + }, + { + 'tagged_item': '/bookmark/1/', + 'tag': u'python' + }, + { + 'tagged_item': { + 'id': 1, + 'text': 'Remember the milk', + }, + 'tag': u'reminder' + }, + { + 'tagged_item': { + 'name': 'Lukas Buenger' + }, + 'tag': u'developer' + } + ] + self.assertEqual(serializer.data, expected) + + +class TestGenericRelatedFieldFromNative(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_default(self): + + class TagSerializer(serializers.ModelSerializer): + tagged_item = GenericRelatedField([ + GenericRelationOption(Bookmark, 'bookmark-detail'), + GenericRelationOption(Note, 'note-detail'), + ], source='tagged_item') + + 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': u'reminder' + } + self.assertEqual(serializer.data, expected) From 67b891bda2d0c9f820db305a2b898d1b3552e7ac Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Tue, 26 Mar 2013 01:18:35 +0100 Subject: [PATCH 02/13] Introducing basic inline documentation --- rest_framework/genericrelations.py | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/rest_framework/genericrelations.py b/rest_framework/genericrelations.py index b0e188a92..707f8026a 100644 --- a/rest_framework/genericrelations.py +++ b/rest_framework/genericrelations.py @@ -1,3 +1,75 @@ +""" +# Generic relations + +## Introduction + +This is an attempt to implement generic relation foreign keys as provided by Django's +[contenttype framework](https://docs.djangoproject.com/en/dev/ref/contrib/contenttypes/) and I tried to follow the +suggestions and requirements described here: +* https://github.com/tomchristie/django-rest-framework/issues/606 +* http://stackoverflow.com/a/14454629/1326146 + +The `GenericRelatedField` field enables you to read and write `GenericForeignKey`s within the scope of Django REST +Framework. While you can decide whether to output `GenericForeignKey`s as nested objects (`ModelSerializer`) or valid +resource URLs (`HyperlinkedRelatedField`), the input is restricted to resource URLs mainly because of two reasons: +* The sheer impossibility of deriving a valid `Model` class from a simple data dictionary. +* My inner storm of indignation when thinking of exposing the actual `ContentType`s to the public scope. + +## Disclaimer + +Although I'm pretty experienced with Django and REST etc, please note that this piece of code is also my first +experience with Django REST framework at all. I am the maintainer of a pretty large REST service application based on +[tastypie](tastypieapi.org), but Daniel's recent +[blog post](http://toastdriven.com/blog/2013/feb/05/committers-needed-tastypie-haystack/) made me feel a little +uncomfortable. As much I'd like to spend more time contributing, I fear that my current work-life balance doesn't allow +me to do so at the level I'd expect from myself. So I thought it would be a good thing to reach out for other solutions. +It's a good thing anyway, I guess. But a sane and approved way of representing `GenericForeignKeys` is just essential to +the data structure I'm working with. So that's basically why I got in the mix. + + +## Minimalist example + +This is based on the models mentioned in +[this article](http://django-rest-framework.org/api-guide/relations.html#generic-relationships). The examples are also +based on working URL patterns for each model fitting the pattern `-detail`. + +A minimalist but still working example of a `TagSerializer` with `GenericRelatedField` would look like this: + + class TagSerializer(serializers.ModelSerializer): + tagged_item = GenericRelatedField([ + GenericRelationOption(Bookmark, 'bookmark-detail'), + GenericRelationOption(Note, 'note-detail'), + ], source='tagged_item') + + class Meta: + model = Tag + exclude = ('id', 'content_type', 'object_id', ) + +## ``GenericRelationOption`` + +Constructor: + + GenericRelationOption(model, view_name, as_hyperlink=True, related_field=None, serializer=None) + +**`model`** +The model class. + +**`view_name`** +The view name as used in url patterns. + +**`as_hyperlink`** +Decides whether the output of the `GenericForeignKey` should be as end point or nested object. In the case of the +latter a generic serializer will be used unless you pass a specific one. + +**`related_field`** +A specific subclass of `HyperlinkedRelatedField` that should be used for resolving input data and resolving output data +in case of `as_hyperlink` is `True`. + +**`serializer`** +A specific subclass of `ModelSerializer` that should be used for resolving output data +in case of `as_hyperlink` is `True`. + +""" from __future__ import unicode_literals import urlparse From 6d17f3ac3026c2aa8a869f0f6b20409b052ca60c Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Tue, 26 Mar 2013 01:33:01 +0100 Subject: [PATCH 03/13] unicode errors --- rest_framework/tests/relations_generic.py | 30 +++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/rest_framework/tests/relations_generic.py b/rest_framework/tests/relations_generic.py index 31f0a7135..2705bec05 100644 --- a/rest_framework/tests/relations_generic.py +++ b/rest_framework/tests/relations_generic.py @@ -153,15 +153,15 @@ class TestGenericRelatedFieldToNative(TestCase): expected = [ { 'tagged_item': '/bookmark/1/', - 'tag': u'django' + 'tag': 'django', }, { 'tagged_item': '/bookmark/1/', - 'tag': u'python' + 'tag': 'python', }, { 'tagged_item': '/note/1/', - 'tag': u'reminder' + 'tag': 'reminder' } ] self.assertEqual(serializer.data, expected) @@ -182,18 +182,18 @@ class TestGenericRelatedFieldToNative(TestCase): expected = [ { 'tagged_item': '/bookmark/1/', - 'tag': u'django' + 'tag': 'django' }, { 'tagged_item': '/bookmark/1/', - 'tag': u'python' + 'tag': 'python' }, { 'tagged_item': { 'id': 1, 'text': 'Remember the milk', }, - 'tag': u'reminder' + 'tag': 'reminder' } ] self.assertEqual(serializer.data, expected) @@ -220,22 +220,22 @@ class TestGenericRelatedFieldToNative(TestCase): expected = [ { 'tagged_item': '/bookmark/1/', - 'tag': u'django' + 'tag': 'django' }, { 'tagged_item': '/bookmark/1/', - 'tag': u'python' + 'tag': 'python' }, { 'tagged_item': { 'id': 1, 'text': 'Remember the milk', }, - 'tag': u'reminder' + 'tag': 'reminder' }, { 'tagged_item': '/contact/lukas-buenger/', - 'tag': u'developer' + 'tag': 'developer' } ] self.assertEqual(serializer.data, expected) @@ -269,24 +269,24 @@ class TestGenericRelatedFieldToNative(TestCase): expected = [ { 'tagged_item': '/bookmark/1/', - 'tag': u'django' + 'tag': 'django' }, { 'tagged_item': '/bookmark/1/', - 'tag': u'python' + 'tag': 'python' }, { 'tagged_item': { 'id': 1, 'text': 'Remember the milk', }, - 'tag': u'reminder' + 'tag': 'reminder' }, { 'tagged_item': { 'name': 'Lukas Buenger' }, - 'tag': u'developer' + 'tag': 'developer' } ] self.assertEqual(serializer.data, expected) @@ -322,6 +322,6 @@ class TestGenericRelatedFieldFromNative(TestCase): serializer.is_valid() expected = { 'tagged_item': '/note/1/', - 'tag': u'reminder' + 'tag': 'reminder' } self.assertEqual(serializer.data, expected) From 850e696608835ec75722ccb4eb065085ec5356a5 Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Wed, 27 Mar 2013 13:46:23 +0100 Subject: [PATCH 04/13] Fixes travis Py3k issues. --- rest_framework/genericrelations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/genericrelations.py b/rest_framework/genericrelations.py index 707f8026a..c30ebccac 100644 --- a/rest_framework/genericrelations.py +++ b/rest_framework/genericrelations.py @@ -72,13 +72,13 @@ in case of `as_hyperlink` is `True`. """ from __future__ import unicode_literals -import urlparse from django.core.exceptions import ValidationError from django.core.urlresolvers import get_script_prefix, resolve from django.utils.translation import ugettext_lazy as _ from django import forms +from rest_framework.compat import urlparse from rest_framework import serializers from rest_framework.settings import api_settings From b894b3c15d54a97643ddb96d08c1203f7294f5c4 Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Wed, 27 Mar 2013 19:15:16 +0100 Subject: [PATCH 05/13] refactored GenericRelatedField with dict argument as discussed here: https://github.com/tomchristie/django-rest-framework/pull/755 --- rest_framework/genericrelations.py | 218 ++++------------- rest_framework/tests/relations_generic.py | 277 ++++++++++------------ 2 files changed, 168 insertions(+), 327 deletions(-) diff --git a/rest_framework/genericrelations.py b/rest_framework/genericrelations.py index c30ebccac..3c27e8009 100644 --- a/rest_framework/genericrelations.py +++ b/rest_framework/genericrelations.py @@ -1,197 +1,50 @@ -""" -# Generic relations - -## Introduction - -This is an attempt to implement generic relation foreign keys as provided by Django's -[contenttype framework](https://docs.djangoproject.com/en/dev/ref/contrib/contenttypes/) and I tried to follow the -suggestions and requirements described here: -* https://github.com/tomchristie/django-rest-framework/issues/606 -* http://stackoverflow.com/a/14454629/1326146 - -The `GenericRelatedField` field enables you to read and write `GenericForeignKey`s within the scope of Django REST -Framework. While you can decide whether to output `GenericForeignKey`s as nested objects (`ModelSerializer`) or valid -resource URLs (`HyperlinkedRelatedField`), the input is restricted to resource URLs mainly because of two reasons: -* The sheer impossibility of deriving a valid `Model` class from a simple data dictionary. -* My inner storm of indignation when thinking of exposing the actual `ContentType`s to the public scope. - -## Disclaimer - -Although I'm pretty experienced with Django and REST etc, please note that this piece of code is also my first -experience with Django REST framework at all. I am the maintainer of a pretty large REST service application based on -[tastypie](tastypieapi.org), but Daniel's recent -[blog post](http://toastdriven.com/blog/2013/feb/05/committers-needed-tastypie-haystack/) made me feel a little -uncomfortable. As much I'd like to spend more time contributing, I fear that my current work-life balance doesn't allow -me to do so at the level I'd expect from myself. So I thought it would be a good thing to reach out for other solutions. -It's a good thing anyway, I guess. But a sane and approved way of representing `GenericForeignKeys` is just essential to -the data structure I'm working with. So that's basically why I got in the mix. - - -## Minimalist example - -This is based on the models mentioned in -[this article](http://django-rest-framework.org/api-guide/relations.html#generic-relationships). The examples are also -based on working URL patterns for each model fitting the pattern `-detail`. - -A minimalist but still working example of a `TagSerializer` with `GenericRelatedField` would look like this: - - class TagSerializer(serializers.ModelSerializer): - tagged_item = GenericRelatedField([ - GenericRelationOption(Bookmark, 'bookmark-detail'), - GenericRelationOption(Note, 'note-detail'), - ], source='tagged_item') - - class Meta: - model = Tag - exclude = ('id', 'content_type', 'object_id', ) - -## ``GenericRelationOption`` - -Constructor: - - GenericRelationOption(model, view_name, as_hyperlink=True, related_field=None, serializer=None) - -**`model`** -The model class. - -**`view_name`** -The view name as used in url patterns. - -**`as_hyperlink`** -Decides whether the output of the `GenericForeignKey` should be as end point or nested object. In the case of the -latter a generic serializer will be used unless you pass a specific one. - -**`related_field`** -A specific subclass of `HyperlinkedRelatedField` that should be used for resolving input data and resolving output data -in case of `as_hyperlink` is `True`. - -**`serializer`** -A specific subclass of `ModelSerializer` that should be used for resolving output data -in case of `as_hyperlink` is `True`. - -""" from __future__ import unicode_literals - from django.core.exceptions import ValidationError from django.core.urlresolvers import get_script_prefix, resolve from django.utils.translation import ugettext_lazy as _ from django import forms from rest_framework.compat import urlparse +from rest_framework import six from rest_framework import serializers -from rest_framework.settings import api_settings - - -class GenericRelationOption(object): - """ - This object is responsible for setting up the components needed for providing a generic relation with a given model. - """ - - #TODO: Far more strict evaluation of custom related_field and serializer objects - - # Trying to be inline with common practices - model_serializer_class = api_settings.DEFAULT_MODEL_SERIALIZER_CLASS - - def __init__(self, model, view_name, as_hyperlink=True, related_field=None, serializer=None): - self.model = model - self.view_name = view_name - self.as_hyperlink = as_hyperlink - self.related_field = related_field or self.get_default_related_field() - self.serializer = serializer or self.get_default_serializer() - - def get_output_resolver(self): - """ - Should return a class that implements the `to_native` method, i.e. `HyperlinkedRelatedField` or `ModelSerializer`. - """ - if self.as_hyperlink: - return self.get_prepared_related_field() - else: - return self.serializer - - def get_input_resolver(self): - """ - Should return a class that implements the `from_native` method that can handle URL values, - i.e. `HyperlinkedRelatedField`. - """ - return self.get_prepared_related_field() - - def get_prepared_related_field(self): - """ - Provides the related field with a queryset if not present, based on `self.model`. - """ - if self.related_field.queryset is None: - self.related_field.queryset = self.model.objects.all() - return self.related_field - - def get_default_related_field(self): - """ - Creates and returns a minimalist ``HyperlinkedRelatedField` instance if none has been passed to the constructor. - """ - return serializers.HyperlinkedRelatedField(view_name=self.view_name) - - def get_default_serializer(self): - """ - Creates and returns a minimalist ``ModelSerializer` instance if none has been passed to the constructor. - """ - class DefaultSerializer(self.model_serializer_class): - class Meta: - model = self.model - return DefaultSerializer() +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 fields / serializers based on some - contenttype framework criteria. + 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_match': _('Invalid hyperlink - No URL match'), - 'incorrect_match': _('Invalid hyperlink - view name 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, options, *args, **kwargs): + def __init__(self, serializers, *args, **kwargs): """ - Needs an extra parameter ``options`` which has to be a list of `GenericRelationOption` objects. + Needs an extra parameter `serializers` which has to be a dict key: value being `Model`: serializer. """ super(GenericRelatedField, self).__init__(*args, **kwargs) - - # Map for option identifying based on a `Model` class (deserialization cycle) - self._model_map = dict() - # Map for option identifying based on a `view_name` (serialization cycle) - self._view_name_map = dict() - - # Adding the options to the maps. - for option in options: - self._model_map[option.model] = option - self._view_name_map[option.view_name] = option + 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): """ - Identifies the option object that is responsible for this `value.__class__` (a model) object and returns - its output serializer's `to_native` method. + Delegates to the `to_native` method of the serializer registered under obj.__class__ """ value = super(GenericRelatedField, self).field_to_native(obj, field_name) - - # Retrieving the model class. - model = value.__class__ - - try: - option = self._model_map[model] - except KeyError: - raise ValidationError(self.error_messages['no_model_match']) - - # Get the serializer responsible for output formatting - serializer = option.get_output_resolver() + 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): @@ -199,6 +52,24 @@ class GenericRelatedField(serializers.WritableField): return value def from_native(self, value): + # Get the serializer responsible for input resolving + serializer = self.determine_serializer_for_data(value) + 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): + for serializer in six.itervalues(self.serializers): + if not isinstance(serializer, serializers.HyperlinkedRelatedField): + raise ConfigurationError('Please use HyperlinkedRelatedField as serializers on GenericRelatedField \ + instances with read_only=False or set read_only=True.') # This excerpt is an exact copy of ``rest_framework.relations.HyperlinkedRelatedField``, Line 363 # From here until ... @@ -217,20 +88,15 @@ class GenericRelatedField(serializers.WritableField): try: match = resolve(value) except Exception: - raise ValidationError(self.error_messages['no_match']) + raise ValidationError(self.error_messages['no_url_match']) - # ... here. Thinking about putting that in ``rest_framework.utils.py``. Of course With more appropriate exceptions. + # ... here - # Try to find the derived `view_name` in the map. - try: - view_name = match.url_name - option = self._view_name_map[view_name] - except KeyError: - raise ValidationError(self.error_messages['incorrect_match']) + matched_serializer = None + for serializer in six.itervalues(self.serializers): + if serializer.view_name == match.url_name: + matched_serializer = serializer - # Get the serializer responsible for input resolving - serializer = option.get_input_resolver() - - # Necessary because of context, field resolving etc. - serializer.initialize(self.parent, self.source) - return serializer.from_native(value) + if matched_serializer is None: + raise ValidationError(self.error_messages['incorrect_url_match']) + return matched_serializer \ No newline at end of file diff --git a/rest_framework/tests/relations_generic.py b/rest_framework/tests/relations_generic.py index 2705bec05..9f991648f 100644 --- a/rest_framework/tests/relations_generic.py +++ b/rest_framework/tests/relations_generic.py @@ -1,12 +1,14 @@ 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.genericrelations import GenericRelationOption, GenericRelatedField +from rest_framework.exceptions import ConfigurationError +from rest_framework.genericrelations import GenericRelatedField from rest_framework.reverse import reverse factory = RequestFactory() @@ -58,75 +60,19 @@ class Note(models.Model): return 'Note: %s' % self.text -class Contact(models.Model): - """ - A textual note that may have multiple tags attached. - """ - name = models.TextField() - slug = models.SlugField() - tags = GenericRelation(Tag) - - def __unicode__(self): - return 'Contact: %s' % self.name +class BookmarkSerializer(serializers.ModelSerializer): + class Meta: + model = Bookmark + exclude = ('id', ) -class TestGenericRelationOptions(TestCase): - - def test_default_related_field(self): - option = GenericRelationOption(Bookmark, 'bookmark-detail') - self.assertIsInstance(option.related_field, serializers.HyperlinkedRelatedField) - - def test_default_related_field_view_name(self): - option = GenericRelationOption(Bookmark, 'bookmark-detail') - self.assertEqual(option.related_field.view_name, 'bookmark-detail') - - def test_default_serializer(self): - option = GenericRelationOption(Bookmark, 'bookmark-detail') - self.assertIsInstance(option.serializer, serializers.ModelSerializer) - - def test_default_serializer_meta_model(self): - option = GenericRelationOption(Bookmark, 'bookmark-detail') - self.assertEqual(option.serializer.Meta.model, Bookmark) - - def test_get_url_output_resolver(self): - option = GenericRelationOption(Bookmark, 'bookmark-detail') - self.assertIsInstance(option.get_output_resolver(), serializers.HyperlinkedRelatedField) - - def test_get_url_output_resolver_with_queryset(self): - option = GenericRelationOption(Bookmark, 'bookmark-detail') - self.assertIsNotNone(option.get_output_resolver().queryset) - - def test_get_input_resolver(self): - option = GenericRelationOption(Bookmark, 'bookmark-detail') - self.assertIsInstance(option.get_input_resolver(), serializers.HyperlinkedRelatedField) - - def test_get_input_resolver_with_queryset(self): - option = GenericRelationOption(Bookmark, 'bookmark-detail') - self.assertIsNotNone(option.get_output_resolver().queryset) - - def test_get_serializer_resolver(self): - option = GenericRelationOption(Bookmark, 'bookmark-detail', as_hyperlink=False) - self.assertIsInstance(option.get_output_resolver(), serializers.ModelSerializer) - - def test_custom_related_field(self): - related_field = serializers.HyperlinkedRelatedField(view_name='bookmark-detail', format='xml') - option = GenericRelationOption(Bookmark, 'bookmark-detail', - related_field=related_field) - self.assertEqual(option.related_field.format, 'xml') - - def test_custom_serializer(self): - - class BookmarkSerializer(serializers.ModelSerializer): - class Meta: - model = Bookmark - exclude = ('id', ) - - serializer = BookmarkSerializer() - option = GenericRelationOption(Bookmark, 'bookmark-detail', as_hyperlink=False, serializer=serializer) - self.assertIn('id', option.get_output_resolver().Meta.exclude) +class NoteSerializer(serializers.ModelSerializer): + class Meta: + model = Note + exclude = ('id', ) -class TestGenericRelatedFieldToNative(TestCase): +class TestGenericRelatedFieldDeserialization(TestCase): urls = 'rest_framework.tests.relations_generic' @@ -140,10 +86,10 @@ class TestGenericRelatedFieldToNative(TestCase): def test_relations_as_hyperlinks(self): class TagSerializer(serializers.ModelSerializer): - tagged_item = GenericRelatedField([ - GenericRelationOption(Bookmark, 'bookmark-detail'), - GenericRelationOption(Note, 'note-detail'), - ], source='tagged_item') + 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 @@ -169,10 +115,10 @@ class TestGenericRelatedFieldToNative(TestCase): def test_relations_as_nested(self): class TagSerializer(serializers.ModelSerializer): - tagged_item = GenericRelatedField([ - GenericRelationOption(Bookmark, 'bookmark-detail'), - GenericRelationOption(Note, 'note-detail', as_hyperlink=False), - ], source='tagged_item') + tagged_item = GenericRelatedField({ + Bookmark: BookmarkSerializer(), + Note: NoteSerializer(), + }, source='tagged_item', read_only=True) class Meta: model = Tag @@ -181,16 +127,19 @@ class TestGenericRelatedFieldToNative(TestCase): serializer = TagSerializer(Tag.objects.all(), many=True) expected = [ { - 'tagged_item': '/bookmark/1/', + 'tagged_item': { + 'url': 'https://www.djangoproject.com/' + }, 'tag': 'django' }, { - 'tagged_item': '/bookmark/1/', + 'tagged_item': { + 'url': 'https://www.djangoproject.com/' + }, 'tag': 'python' }, { 'tagged_item': { - 'id': 1, 'text': 'Remember the milk', }, 'tag': 'reminder' @@ -198,19 +147,12 @@ class TestGenericRelatedFieldToNative(TestCase): ] self.assertEqual(serializer.data, expected) - def test_custom_related_field(self): - contact = Contact.objects.create(name='Lukas Buenger', slug='lukas-buenger') - Tag.objects.create(tagged_item=contact, tag='developer') - - contact_related_field = serializers.HyperlinkedRelatedField(view_name='contact-detail', - slug_url_kwarg='my_own_slug') - + def test_mixed_serializers(self): class TagSerializer(serializers.ModelSerializer): - tagged_item = GenericRelatedField([ - GenericRelationOption(Bookmark, 'bookmark-detail'), - GenericRelationOption(Note, 'note-detail', as_hyperlink=False), - GenericRelationOption(Contact, 'contact-detail', related_field=contact_related_field), - ], source='tagged_item') + tagged_item = GenericRelatedField({ + Bookmark: BookmarkSerializer(), + Note: serializers.HyperlinkedRelatedField(view_name='note-detail'), + }, source='tagged_item', read_only=True) class Meta: model = Tag @@ -219,80 +161,42 @@ class TestGenericRelatedFieldToNative(TestCase): serializer = TagSerializer(Tag.objects.all(), many=True) expected = [ { - 'tagged_item': '/bookmark/1/', + 'tagged_item': { + 'url': 'https://www.djangoproject.com/' + }, 'tag': 'django' }, { - 'tagged_item': '/bookmark/1/', + 'tagged_item': { + 'url': 'https://www.djangoproject.com/' + }, 'tag': 'python' }, { - 'tagged_item': { - 'id': 1, - 'text': 'Remember the milk', - }, + 'tagged_item': '/note/1/', 'tag': 'reminder' - }, - { - 'tagged_item': '/contact/lukas-buenger/', - 'tag': 'developer' } ] self.assertEqual(serializer.data, expected) - def test_custom_serializer(self): - contact = Contact.objects.create(name='Lukas Buenger', slug='lukas-buenger') - Tag.objects.create(tagged_item=contact, tag='developer') - - contact_related_field = serializers.HyperlinkedRelatedField(view_name='contact-detail', - slug_url_kwarg='my_own_slug') - - class ContactSerializer(serializers.ModelSerializer): - class Meta: - model = Contact - exclude = ('id', 'slug', ) - - + def test_invalid_model(self): + # Leaving out the Note model should result in a ValidationError class TagSerializer(serializers.ModelSerializer): - tagged_item = GenericRelatedField([ - GenericRelationOption(Bookmark, 'bookmark-detail'), - GenericRelationOption(Note, 'note-detail', as_hyperlink=False), - GenericRelationOption(Contact, 'contact-detail', as_hyperlink=False, related_field=contact_related_field, - serializer=ContactSerializer()), - ], source='tagged_item') + 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) - expected = [ - { - 'tagged_item': '/bookmark/1/', - 'tag': 'django' - }, - { - 'tagged_item': '/bookmark/1/', - 'tag': 'python' - }, - { - 'tagged_item': { - 'id': 1, - 'text': 'Remember the milk', - }, - 'tag': 'reminder' - }, - { - 'tagged_item': { - 'name': 'Lukas Buenger' - }, - 'tag': 'developer' - } - ] - self.assertEqual(serializer.data, expected) + + def call_data(): + return serializer.data + self.assertRaises(ValidationError, call_data) -class TestGenericRelatedFieldFromNative(TestCase): +class TestGenericRelatedFieldSerialization(TestCase): urls = 'rest_framework.tests.relations_generic' @@ -302,13 +206,12 @@ class TestGenericRelatedFieldFromNative(TestCase): Tag.objects.create(tagged_item=self.bookmark, tag='python') self.note = Note.objects.create(text='Remember the milk') - def test_default(self): - + def test_hyperlink_serialization(self): class TagSerializer(serializers.ModelSerializer): - tagged_item = GenericRelatedField([ - GenericRelationOption(Bookmark, 'bookmark-detail'), - GenericRelationOption(Note, 'note-detail'), - ], source='tagged_item') + 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 @@ -318,10 +221,82 @@ class TestGenericRelatedFieldFromNative(TestCase): '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': reverse('note-detail', kwargs={'pk': self.note.pk}) + }) + self.assertRaises(ConfigurationError, serializer.is_valid) + + 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' + }) + self.assertFalse(serializer.is_valid()) + + 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 From 4ca19d6ec3db8b19c8d836d1b094ba93c93a093f Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Mon, 1 Apr 2013 19:30:01 +0200 Subject: [PATCH 06/13] docs updated for generic relationships --- docs/api-guide/relations.md | 125 ++++++++++++++++++++++++++++-------- 1 file changed, 98 insertions(+), 27 deletions(-) diff --git a/docs/api-guide/relations.md b/docs/api-guide/relations.md index 623fe1a90..84dc964eb 100644 --- a/docs/api-guide/relations.md +++ b/docs/api-guide/relations.md @@ -322,7 +322,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: @@ -357,40 +357,111 @@ 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': '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': '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, the `GenericRelatedField` can be used with `read_only=False` only if you use `HyperlinkedRelatedField` as representation for every model you register with your `GenericRelatedField`. + +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': '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_serializer_for_data` method 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. +* You can mix `ModelSerializer` and `HyperlinkedRelatedField` in one `GenericRelatedField` configuration dictionary for deserialization purposes. It is considered bad practice though. +* If you mix `ModelSerializer` and `HyperlinkedRelatedField` in one `GenericRelatedField` configuration dictionary, the serialization process (PUT/POST) will raise a `ConfigurationError`. For more information see [the Django documentation on generic relations][generic-relations]. From 3bd1348529217d9485e28427eec59bb3cd1e844c Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Mon, 1 Apr 2013 19:33:45 +0200 Subject: [PATCH 07/13] fixes minor example error in docs --- docs/api-guide/relations.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api-guide/relations.md b/docs/api-guide/relations.md index 84dc964eb..eb6fca890 100644 --- a/docs/api-guide/relations.md +++ b/docs/api-guide/relations.md @@ -398,7 +398,7 @@ The JSON representation of a `Tag` object with `name='django'` and its generic f 'tagged_object': { 'url': 'https://www.djangoproject.com/' }, - 'tag': 'django' + 'tag_name': 'django' } If you want to have your generic foreign key represented as hyperlink, simply use `HyperlinkedRelatedField` objects: @@ -421,7 +421,7 @@ The JSON representation of the same `Tag` example object could now look somethin { 'tagged_object': '/bookmark/1/', - 'tag': 'django' + '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. @@ -447,7 +447,7 @@ This `Tag` serializer is able to write to it's generic foreign key field: 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': 'python' + 'tag_name': 'python' 'tagged_object': '/bookmark/1/' }) From 610da51d65fd8e0357a298508b4a690d8a534ad0 Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Sun, 7 Apr 2013 16:36:05 +0200 Subject: [PATCH 08/13] fixed from_native delegation when it accepts files --- rest_framework/genericrelations.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rest_framework/genericrelations.py b/rest_framework/genericrelations.py index 3c27e8009..963871799 100644 --- a/rest_framework/genericrelations.py +++ b/rest_framework/genericrelations.py @@ -55,6 +55,13 @@ class GenericRelatedField(serializers.WritableField): # Get the serializer responsible for input resolving serializer = self.determine_serializer_for_data(value) serializer.initialize(self.parent, self.source) + + # The following is necessary due to the inconsistency of the `from_native` argument count when a serializer + # accepts files. + args = [value] + import inspect + if len(inspect.getargspec(serializer.from_native).args) > 2: + args.append(None) return serializer.from_native(value) def determine_deserializer_for_data(self, value): From 529effdfc7d500a1844bdf76e178b77089a8da6b Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Mon, 16 Sep 2013 15:37:31 +0200 Subject: [PATCH 09/13] Implementation of serialization process as discussed here: https://groups.google.com/forum/?fromgroups#\!topic/django-rest-framework/BRsLJ92JRrk. Including docs and tests. --- docs/api-guide/relations.md | 9 ++-- rest_framework/genericrelations.py | 54 ++++++----------------- rest_framework/tests/relations_generic.py | 3 +- 3 files changed, 21 insertions(+), 45 deletions(-) diff --git a/docs/api-guide/relations.md b/docs/api-guide/relations.md index eb6fca890..5bb381d32 100644 --- a/docs/api-guide/relations.md +++ b/docs/api-guide/relations.md @@ -426,7 +426,8 @@ The JSON representation of the same `Tag` example object could now look somethin 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, the `GenericRelatedField` can be used with `read_only=False` only if you use `HyperlinkedRelatedField` as representation for every model you register with your `GenericRelatedField`. +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: @@ -454,14 +455,14 @@ The following operations would create a `Tag` object with it's `tagged_object` p 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_serializer_for_data` method to implement your own way of decision-making. +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. -* You can mix `ModelSerializer` and `HyperlinkedRelatedField` in one `GenericRelatedField` configuration dictionary for deserialization purposes. It is considered bad practice though. -* If you mix `ModelSerializer` and `HyperlinkedRelatedField` in one `GenericRelatedField` configuration dictionary, the serialization process (PUT/POST) will raise a `ConfigurationError`. +* 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 index 963871799..e9c649384 100644 --- a/rest_framework/genericrelations.py +++ b/rest_framework/genericrelations.py @@ -54,14 +54,11 @@ class GenericRelatedField(serializers.WritableField): def from_native(self, value): # Get the serializer responsible for input resolving serializer = self.determine_serializer_for_data(value) + if serializer is None: + raise ConfigurationError('Could not determine a valid serializer for value "%r"' % value) serializer.initialize(self.parent, self.source) - - # The following is necessary due to the inconsistency of the `from_native` argument count when a serializer - # accepts files. - args = [value] - import inspect - if len(inspect.getargspec(serializer.from_native).args) > 2: - args.append(None) + import pdb + pdb.set_trace() return serializer.from_native(value) def determine_deserializer_for_data(self, value): @@ -73,37 +70,14 @@ class GenericRelatedField(serializers.WritableField): 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. for serializer in six.itervalues(self.serializers): - if not isinstance(serializer, serializers.HyperlinkedRelatedField): - raise ConfigurationError('Please use HyperlinkedRelatedField as serializers on GenericRelatedField \ - instances with read_only=False or set read_only=True.') - - # This excerpt is an exact copy of ``rest_framework.relations.HyperlinkedRelatedField``, Line 363 - # From here until ... - try: - http_prefix = value.startswith('http:') or value.startswith('https:') - except AttributeError: - msg = self.error_messages['incorrect_type'] - raise ValidationError(msg % type(value).__name__) - - if http_prefix: - # If needed convert absolute URLs to relative path - value = urlparse.urlparse(value).path - prefix = get_script_prefix() - if value.startswith(prefix): - value = '/' + value[len(prefix):] - try: - match = resolve(value) - except Exception: - raise ValidationError(self.error_messages['no_url_match']) - - # ... here - - matched_serializer = None - for serializer in six.itervalues(self.serializers): - if serializer.view_name == match.url_name: - matched_serializer = serializer - - if matched_serializer is None: - raise ValidationError(self.error_messages['incorrect_url_match']) - return matched_serializer \ No newline at end of file + try: + serializer.from_native(value) + # Returns the first serializer that can handle the value without errors. + return serializer + except Exception: + pass + return None diff --git a/rest_framework/tests/relations_generic.py b/rest_framework/tests/relations_generic.py index 9f991648f..481e6f84f 100644 --- a/rest_framework/tests/relations_generic.py +++ b/rest_framework/tests/relations_generic.py @@ -241,10 +241,11 @@ class TestGenericRelatedFieldSerialization(TestCase): serializer = TagSerializer(data={ 'tag': 'reminder', - 'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk}) + 'tagged_item': 'just a string' }) self.assertRaises(ConfigurationError, serializer.is_valid) + def test_not_registered_view_name(self): class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField({ From 37976b11fae1170104d0c1af0d07c01e142de813 Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Tue, 17 Sep 2013 09:16:08 +0200 Subject: [PATCH 10/13] move and extend error raising when determining serializers --- rest_framework/genericrelations.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/rest_framework/genericrelations.py b/rest_framework/genericrelations.py index e9c649384..4f5417af5 100644 --- a/rest_framework/genericrelations.py +++ b/rest_framework/genericrelations.py @@ -1,11 +1,9 @@ from __future__ import unicode_literals from django.core.exceptions import ValidationError -from django.core.urlresolvers import get_script_prefix, resolve from django.utils.translation import ugettext_lazy as _ from django import forms -from rest_framework.compat import urlparse from rest_framework import six from rest_framework import serializers from rest_framework.exceptions import ConfigurationError @@ -54,8 +52,6 @@ class GenericRelatedField(serializers.WritableField): def from_native(self, value): # Get the serializer responsible for input resolving serializer = self.determine_serializer_for_data(value) - if serializer is None: - raise ConfigurationError('Could not determine a valid serializer for value "%r"' % value) serializer.initialize(self.parent, self.source) import pdb pdb.set_trace() @@ -73,11 +69,18 @@ class GenericRelatedField(serializers.WritableField): # 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) - # Returns the first serializer that can handle the value without errors. - return serializer + # Collects all serializers that can handle the input data. + serializers.append(serializer) except Exception: pass - return None + # 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] From 83bdfcb479de3054acd28c01a702f360882f6611 Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Tue, 17 Sep 2013 09:21:35 +0200 Subject: [PATCH 11/13] bugfix. tests adjusted --- rest_framework/genericrelations.py | 5 ++--- rest_framework/tests/relations_generic.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/rest_framework/genericrelations.py b/rest_framework/genericrelations.py index 4f5417af5..aad6e4e6a 100644 --- a/rest_framework/genericrelations.py +++ b/rest_framework/genericrelations.py @@ -53,8 +53,6 @@ class GenericRelatedField(serializers.WritableField): # Get the serializer responsible for input resolving serializer = self.determine_serializer_for_data(value) serializer.initialize(self.parent, self.source) - import pdb - pdb.set_trace() return serializer.from_native(value) def determine_deserializer_for_data(self, value): @@ -75,7 +73,8 @@ class GenericRelatedField(serializers.WritableField): serializer.from_native(value) # Collects all serializers that can handle the input data. serializers.append(serializer) - except Exception: + except Exception as e: + print e pass # If no serializer found, raise error. l = len(serializers) diff --git a/rest_framework/tests/relations_generic.py b/rest_framework/tests/relations_generic.py index 481e6f84f..18899f624 100644 --- a/rest_framework/tests/relations_generic.py +++ b/rest_framework/tests/relations_generic.py @@ -245,7 +245,6 @@ class TestGenericRelatedFieldSerialization(TestCase): }) self.assertRaises(ConfigurationError, serializer.is_valid) - def test_not_registered_view_name(self): class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField({ @@ -263,6 +262,7 @@ class TestGenericRelatedFieldSerialization(TestCase): self.assertFalse(serializer.is_valid()) def test_invalid_url(self): + # Should fail ATM class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField({ Bookmark: serializers.HyperlinkedRelatedField(view_name='bookmark-detail'), From 1adbd96ba5b0de15dd63d6d6af81878ffdecc3ce Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Tue, 17 Sep 2013 09:22:47 +0200 Subject: [PATCH 12/13] remove print statement in serializer --- rest_framework/genericrelations.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rest_framework/genericrelations.py b/rest_framework/genericrelations.py index aad6e4e6a..5a20b5296 100644 --- a/rest_framework/genericrelations.py +++ b/rest_framework/genericrelations.py @@ -73,8 +73,7 @@ class GenericRelatedField(serializers.WritableField): serializer.from_native(value) # Collects all serializers that can handle the input data. serializers.append(serializer) - except Exception as e: - print e + except Exception: pass # If no serializer found, raise error. l = len(serializers) From 07056717315ec5415a2c59ee39b3670158ae5633 Mon Sep 17 00:00:00 2001 From: lukasbuenger Date: Tue, 17 Sep 2013 12:22:01 +0200 Subject: [PATCH 13/13] minor error message typos. ConfigurationError caught and raised as ValidationError. Tests --- rest_framework/genericrelations.py | 11 +++++++---- rest_framework/tests/relations_generic.py | 12 ++++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/rest_framework/genericrelations.py b/rest_framework/genericrelations.py index 5a20b5296..c193c12eb 100644 --- a/rest_framework/genericrelations.py +++ b/rest_framework/genericrelations.py @@ -51,7 +51,10 @@ class GenericRelatedField(serializers.WritableField): def from_native(self, value): # Get the serializer responsible for input resolving - serializer = self.determine_serializer_for_data(value) + 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) @@ -73,12 +76,12 @@ class GenericRelatedField(serializers.WritableField): serializer.from_native(value) # Collects all serializers that can handle the input data. serializers.append(serializer) - except Exception: + 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) + 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) + 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 index 18899f624..1873b5b6a 100644 --- a/rest_framework/tests/relations_generic.py +++ b/rest_framework/tests/relations_generic.py @@ -243,7 +243,9 @@ class TestGenericRelatedFieldSerialization(TestCase): 'tag': 'reminder', 'tagged_item': 'just a string' }) - self.assertRaises(ConfigurationError, serializer.is_valid) + + 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): @@ -262,7 +264,7 @@ class TestGenericRelatedFieldSerialization(TestCase): self.assertFalse(serializer.is_valid()) def test_invalid_url(self): - # Should fail ATM + class TagSerializer(serializers.ModelSerializer): tagged_item = GenericRelatedField({ Bookmark: serializers.HyperlinkedRelatedField(view_name='bookmark-detail'), @@ -276,7 +278,13 @@ class TestGenericRelatedFieldSerialization(TestCase): '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):