refactored GenericRelatedField with dict argument as discussed here: https://github.com/tomchristie/django-rest-framework/pull/755

This commit is contained in:
lukasbuenger 2013-03-27 19:15:16 +01:00
parent 850e696608
commit b894b3c15d
2 changed files with 168 additions and 327 deletions

View File

@ -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 `<model_lowercase>-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 __future__ import unicode_literals
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.urlresolvers import get_script_prefix, resolve from django.core.urlresolvers import get_script_prefix, resolve
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django import forms from django import forms
from rest_framework.compat import urlparse from rest_framework.compat import urlparse
from rest_framework import six
from rest_framework import serializers from rest_framework import serializers
from rest_framework.settings import api_settings from rest_framework.exceptions import ConfigurationError
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): class GenericRelatedField(serializers.WritableField):
""" """
Represents a generic relation foreign key. 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.
It's actually more of a wrapper, that delegates the logic to registered fields / serializers based on some
contenttype framework criteria.
""" """
default_error_messages = { default_error_messages = {
'no_model_match': _('Invalid model - model not available.'), 'no_model_match': _('Invalid model - model not available.'),
'no_match': _('Invalid hyperlink - No URL match'), 'no_url_match': _('Invalid hyperlink - No URL match'),
'incorrect_match': _('Invalid hyperlink - view name not available'), 'incorrect_url_match': _('Invalid hyperlink - view name not available'),
} }
form_field_class = forms.URLField 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) super(GenericRelatedField, self).__init__(*args, **kwargs)
self.serializers = serializers
# Map for option identifying based on a `Model` class (deserialization cycle) for model, serializer in six.iteritems(self.serializers):
self._model_map = dict() # We have to do it, because the serializer can't access a explicit manager through the
# Map for option identifying based on a `view_name` (serialization cycle) # GenericForeignKey field on the model.
self._view_name_map = dict() if hasattr(serializer, 'queryset') and serializer.queryset is None:
serializer.queryset = model._default_manager.all()
# 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): def field_to_native(self, obj, field_name):
""" """
Identifies the option object that is responsible for this `value.__class__` (a model) object and returns Delegates to the `to_native` method of the serializer registered under obj.__class__
its output serializer's `to_native` method.
""" """
value = super(GenericRelatedField, self).field_to_native(obj, field_name) value = super(GenericRelatedField, self).field_to_native(obj, field_name)
serializer = self.determine_deserializer_for_data(value)
# 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. # Necessary because of context, field resolving etc.
serializer.initialize(self.parent, field_name) serializer.initialize(self.parent, field_name)
return serializer.to_native(value) return serializer.to_native(value)
def to_native(self, value): def to_native(self, value):
@ -199,6 +52,24 @@ class GenericRelatedField(serializers.WritableField):
return value return value
def from_native(self, 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 # This excerpt is an exact copy of ``rest_framework.relations.HyperlinkedRelatedField``, Line 363
# From here until ... # From here until ...
@ -217,20 +88,15 @@ class GenericRelatedField(serializers.WritableField):
try: try:
match = resolve(value) match = resolve(value)
except Exception: 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. matched_serializer = None
try: for serializer in six.itervalues(self.serializers):
view_name = match.url_name if serializer.view_name == match.url_name:
option = self._view_name_map[view_name] matched_serializer = serializer
except KeyError:
raise ValidationError(self.error_messages['incorrect_match'])
# Get the serializer responsible for input resolving if matched_serializer is None:
serializer = option.get_input_resolver() raise ValidationError(self.error_messages['incorrect_url_match'])
return matched_serializer
# Necessary because of context, field resolving etc.
serializer.initialize(self.parent, self.source)
return serializer.from_native(value)

View File

@ -1,12 +1,14 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.generic import GenericRelation, GenericForeignKey from django.contrib.contenttypes.generic import GenericRelation, GenericForeignKey
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from rest_framework import serializers from rest_framework import serializers
from rest_framework.compat import patterns, url 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 from rest_framework.reverse import reverse
factory = RequestFactory() factory = RequestFactory()
@ -58,75 +60,19 @@ class Note(models.Model):
return 'Note: %s' % self.text return 'Note: %s' % self.text
class Contact(models.Model): class BookmarkSerializer(serializers.ModelSerializer):
""" class Meta:
A textual note that may have multiple tags attached. model = Bookmark
""" exclude = ('id', )
name = models.TextField()
slug = models.SlugField()
tags = GenericRelation(Tag)
def __unicode__(self):
return 'Contact: %s' % self.name
class TestGenericRelationOptions(TestCase): class NoteSerializer(serializers.ModelSerializer):
class Meta:
def test_default_related_field(self): model = Note
option = GenericRelationOption(Bookmark, 'bookmark-detail') exclude = ('id', )
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): class TestGenericRelatedFieldDeserialization(TestCase):
urls = 'rest_framework.tests.relations_generic' urls = 'rest_framework.tests.relations_generic'
@ -140,10 +86,10 @@ class TestGenericRelatedFieldToNative(TestCase):
def test_relations_as_hyperlinks(self): def test_relations_as_hyperlinks(self):
class TagSerializer(serializers.ModelSerializer): class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField([ tagged_item = GenericRelatedField({
GenericRelationOption(Bookmark, 'bookmark-detail'), Bookmark: serializers.HyperlinkedRelatedField(view_name='bookmark-detail'),
GenericRelationOption(Note, 'note-detail'), Note: serializers.HyperlinkedRelatedField(view_name='note-detail'),
], source='tagged_item') }, source='tagged_item', read_only=True)
class Meta: class Meta:
model = Tag model = Tag
@ -169,10 +115,10 @@ class TestGenericRelatedFieldToNative(TestCase):
def test_relations_as_nested(self): def test_relations_as_nested(self):
class TagSerializer(serializers.ModelSerializer): class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField([ tagged_item = GenericRelatedField({
GenericRelationOption(Bookmark, 'bookmark-detail'), Bookmark: BookmarkSerializer(),
GenericRelationOption(Note, 'note-detail', as_hyperlink=False), Note: NoteSerializer(),
], source='tagged_item') }, source='tagged_item', read_only=True)
class Meta: class Meta:
model = Tag model = Tag
@ -181,16 +127,19 @@ class TestGenericRelatedFieldToNative(TestCase):
serializer = TagSerializer(Tag.objects.all(), many=True) serializer = TagSerializer(Tag.objects.all(), many=True)
expected = [ expected = [
{ {
'tagged_item': '/bookmark/1/', 'tagged_item': {
'url': 'https://www.djangoproject.com/'
},
'tag': 'django' 'tag': 'django'
}, },
{ {
'tagged_item': '/bookmark/1/', 'tagged_item': {
'url': 'https://www.djangoproject.com/'
},
'tag': 'python' 'tag': 'python'
}, },
{ {
'tagged_item': { 'tagged_item': {
'id': 1,
'text': 'Remember the milk', 'text': 'Remember the milk',
}, },
'tag': 'reminder' 'tag': 'reminder'
@ -198,19 +147,12 @@ class TestGenericRelatedFieldToNative(TestCase):
] ]
self.assertEqual(serializer.data, expected) self.assertEqual(serializer.data, expected)
def test_custom_related_field(self): def test_mixed_serializers(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): class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField([ tagged_item = GenericRelatedField({
GenericRelationOption(Bookmark, 'bookmark-detail'), Bookmark: BookmarkSerializer(),
GenericRelationOption(Note, 'note-detail', as_hyperlink=False), Note: serializers.HyperlinkedRelatedField(view_name='note-detail'),
GenericRelationOption(Contact, 'contact-detail', related_field=contact_related_field), }, source='tagged_item', read_only=True)
], source='tagged_item')
class Meta: class Meta:
model = Tag model = Tag
@ -219,80 +161,42 @@ class TestGenericRelatedFieldToNative(TestCase):
serializer = TagSerializer(Tag.objects.all(), many=True) serializer = TagSerializer(Tag.objects.all(), many=True)
expected = [ expected = [
{ {
'tagged_item': '/bookmark/1/', 'tagged_item': {
'url': 'https://www.djangoproject.com/'
},
'tag': 'django' 'tag': 'django'
}, },
{ {
'tagged_item': '/bookmark/1/', 'tagged_item': {
'url': 'https://www.djangoproject.com/'
},
'tag': 'python' 'tag': 'python'
}, },
{ {
'tagged_item': { 'tagged_item': '/note/1/',
'id': 1,
'text': 'Remember the milk',
},
'tag': 'reminder' 'tag': 'reminder'
},
{
'tagged_item': '/contact/lukas-buenger/',
'tag': 'developer'
} }
] ]
self.assertEqual(serializer.data, expected) self.assertEqual(serializer.data, expected)
def test_custom_serializer(self): def test_invalid_model(self):
contact = Contact.objects.create(name='Lukas Buenger', slug='lukas-buenger') # Leaving out the Note model should result in a ValidationError
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): class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField([ tagged_item = GenericRelatedField({
GenericRelationOption(Bookmark, 'bookmark-detail'), Bookmark: BookmarkSerializer(),
GenericRelationOption(Note, 'note-detail', as_hyperlink=False), }, source='tagged_item', read_only=True)
GenericRelationOption(Contact, 'contact-detail', as_hyperlink=False, related_field=contact_related_field,
serializer=ContactSerializer()),
], source='tagged_item')
class Meta: class Meta:
model = Tag model = Tag
exclude = ('id', 'content_type', 'object_id', ) exclude = ('id', 'content_type', 'object_id', )
serializer = TagSerializer(Tag.objects.all(), many=True) serializer = TagSerializer(Tag.objects.all(), many=True)
expected = [
{ def call_data():
'tagged_item': '/bookmark/1/', return serializer.data
'tag': 'django' self.assertRaises(ValidationError, call_data)
},
{
'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)
class TestGenericRelatedFieldFromNative(TestCase): class TestGenericRelatedFieldSerialization(TestCase):
urls = 'rest_framework.tests.relations_generic' urls = 'rest_framework.tests.relations_generic'
@ -302,13 +206,12 @@ class TestGenericRelatedFieldFromNative(TestCase):
Tag.objects.create(tagged_item=self.bookmark, tag='python') Tag.objects.create(tagged_item=self.bookmark, tag='python')
self.note = Note.objects.create(text='Remember the milk') self.note = Note.objects.create(text='Remember the milk')
def test_default(self): def test_hyperlink_serialization(self):
class TagSerializer(serializers.ModelSerializer): class TagSerializer(serializers.ModelSerializer):
tagged_item = GenericRelatedField([ tagged_item = GenericRelatedField({
GenericRelationOption(Bookmark, 'bookmark-detail'), Bookmark: serializers.HyperlinkedRelatedField(view_name='bookmark-detail'),
GenericRelationOption(Note, 'note-detail'), Note: serializers.HyperlinkedRelatedField(view_name='note-detail'),
], source='tagged_item') }, source='tagged_item', read_only=False)
class Meta: class Meta:
model = Tag model = Tag
@ -318,10 +221,82 @@ class TestGenericRelatedFieldFromNative(TestCase):
'tag': 'reminder', 'tag': 'reminder',
'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk}) 'tagged_item': reverse('note-detail', kwargs={'pk': self.note.pk})
}) })
serializer.is_valid() serializer.is_valid()
expected = { expected = {
'tagged_item': '/note/1/', 'tagged_item': '/note/1/',
'tag': 'reminder' 'tag': 'reminder'
} }
self.assertEqual(serializer.data, expected) 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)