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)