diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 9cb548a51..f8929ab2d 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -35,6 +35,11 @@ from rest_framework.settings import api_settings from rest_framework.relations import * from rest_framework.fields import * +# nested mode constants +BATCH_ADD = 1 +BATCH_UPDATE = 2 +BATCH_DELETE = 4 + def _resolve_model(obj): """ @@ -179,17 +184,35 @@ class BaseSerializer(WritableField): _options_class = SerializerOptions _dict_class = SortedDictWithMetadata + batch_mode = BATCH_UPDATE def __init__(self, instance=None, data=None, files=None, context=None, partial=False, many=None, - allow_add_remove=False, **kwargs): + batch_mode=None, **kwargs): + allow_add_remove = kwargs.pop('allow_add_remove', None) super(BaseSerializer, self).__init__(**kwargs) self.opts = self._options_class(self.Meta) self.parent = None self.root = None self.partial = partial self.many = many - self.allow_add_remove = allow_add_remove + + # Handle allow_add_remove depreaction + if hasattr(self, 'allow_add_remove'): + # If we already have allow_add_remove, it's a class argument. + # By reassigning it we'll trigger the batch_mode configuration + warnings.warn('The `allow_add_remove` keyword argument is deprecated. ' + 'Use the `batch_mode` keyword argument instead.', + DeprecationWarning, stacklevel=2) + self.allow_add_remove = self.allow_add_remove + if allow_add_remove: + warnings.warn('The `allow_add_remove` keyword argument is deprecated. ' + 'Use the `batch_mode` keyword argument instead.', + DeprecationWarning, stacklevel=2) + self.allow_add_remove = allow_add_remove + + if batch_mode: + self.batch_mode = batch_mode self.context = context or {} @@ -205,9 +228,23 @@ class BaseSerializer(WritableField): if many and instance is not None and not hasattr(instance, '__iter__'): raise ValueError('instance should be a queryset or other iterable with many=True') - if allow_add_remove and not many: + if 'allow_add_remove' in kwargs and not many: raise ValueError('allow_add_remove should only be used for bulk updates, but you have not set many=True') + # Compatibility between allow_add_remove and batch_mode + def set_allow_add_remove(self, value): + self.batch_mode = BATCH_UPDATE + if value: + self.batch_mode = BATCH_ADD | BATCH_UPDATE | BATCH_DELETE + + def get_allow_add_remove(self): + if self.batch_mode == BATCH_ADD | BATCH_UPDATE | BATCH_DELETE: + return True + if self.batch_mode == BATCH_UPDATE: + return False + + allow_add_remove = property(get_allow_add_remove, set_allow_add_remove) + ##### # Methods to determine which fields to use when (de)serializing objects. @@ -464,7 +501,8 @@ class BaseSerializer(WritableField): 'context': self.context, 'partial': self.partial, 'many': self.many, - 'allow_add_remove': self.allow_add_remove + 'allow_add_remove': self.allow_add_remove, + 'batch_mode': self.batch_mode } serializer = self.__class__(**kwargs) @@ -526,15 +564,19 @@ class BaseSerializer(WritableField): # Determine which object we're updating identity = self.get_identity(item) self.object = identity_to_objects.pop(identity, None) - if self.object is None and not self.allow_add_remove: + if self.object is None and not (self.batch_mode & BATCH_ADD): ret.append(None) errors.append({'non_field_errors': ['Cannot create a new item, only existing items may be updated.']}) continue + if self.object is not None and not (self.batch_mode & BATCH_UPDATE): + ret.append(None) + errors.append({'non_field_errors': ['Cannot update an item.']}) + continue ret.append(self.from_native(item, None)) errors.append(self._errors) - if update and self.allow_add_remove: + if update and (self.batch_mode & BATCH_DELETE): ret._deleted = identity_to_objects.values() self._errors = any(errors) and errors or [] diff --git a/rest_framework/tests/test_serializer_nested.py b/rest_framework/tests/test_serializer_nested.py index 6d69ffbd0..c8f0e67dd 100644 --- a/rest_framework/tests/test_serializer_nested.py +++ b/rest_framework/tests/test_serializer_nested.py @@ -6,9 +6,30 @@ Doesn't cover model serializers. from __future__ import unicode_literals from django.test import TestCase from rest_framework import serializers +from rest_framework.serializers import BATCH_ADD, BATCH_UPDATE, BATCH_DELETE from . import models +class BlogPostCommentSerializer(serializers.ModelSerializer): + class Meta: + model = models.BlogPostComment + fields = ('id', 'text') + + +class BlogPostSerializer(serializers.ModelSerializer): + comments = BlogPostCommentSerializer(many=True, source='blogpostcomment_set') + class Meta: + model = models.BlogPost + fields = ('id', 'title', 'comments') + + +class PersonSerializer(serializers.ModelSerializer): + posts = BlogPostSerializer(many=True, source='blogpost_set') + class Meta: + model = models.Person + fields = ('id', 'name', 'age', 'posts') + + class WritableNestedSerializerBasicTests(TestCase): """ Tests for deserializing nested entities. @@ -315,33 +336,273 @@ class ForeignKeyNestedSerializerUpdateTests(TestCase): class NestedModelSerializerUpdateTests(TestCase): - def test_second_nested_level(self): + def test_allows_second_nesting_level(self): + """ + Make sure we can span relations for nested representations + """ john = models.Person.objects.create(name="john") post = john.blogpost_set.create(title="Test blog post") post.blogpostcomment_set.create(text="I hate this blog post") post.blogpostcomment_set.create(text="I love this blog post") - class BlogPostCommentSerializer(serializers.ModelSerializer): - class Meta: - model = models.BlogPostComment - - class BlogPostSerializer(serializers.ModelSerializer): - comments = BlogPostCommentSerializer(many=True, source='blogpostcomment_set') - class Meta: - model = models.BlogPost - fields = ('id', 'title', 'comments') - - class PersonSerializer(serializers.ModelSerializer): - posts = BlogPostSerializer(many=True, source='blogpost_set') - class Meta: - model = models.Person - fields = ('id', 'name', 'age', 'posts') - serialize = PersonSerializer(instance=john) deserialize = PersonSerializer(data=serialize.data, instance=john) - self.assertTrue(deserialize.is_valid()) + self.assertTrue(deserialize.is_valid(), deserialize.errors) result = deserialize.object result.save() self.assertEqual(result.id, john.id) + self.assertEqual( + [i.id for i in result.blogpost_set.all()], + [i.id for i in john.blogpost_set.all()]) + + +class NestedModelSerializerCreationsTests(TestCase): + def test_works_in_batch_add_mode(self): + """ + Create nested while being in BATCH_ADD mode works. + """ + class LocalSerializer(BlogPostSerializer): + comments = BlogPostCommentSerializer(many=True, + source='blogpostcomment_set', batch_mode=BATCH_ADD) + + post = models.BlogPost(title='Test blog post') + post.save() + data = { + 'id': post.id, + 'title': 'Test blog post', + 'comments': [{ + 'text': 'I hate this blog post', + }, { + 'text': 'I love this blog post', + }], + } + + serializer = LocalSerializer(data=data, instance=post) + self.assertTrue(serializer.is_valid(), serializer.errors) + post = serializer.save() + self.assertTrue(post.id) + self.assertEqual(post.blogpostcomment_set.count(), 2) + + def test_fails_in_batch_update_mode(self): + """ + Create nested while being in BATCH_UPDATE mode fails. + """ + post = models.BlogPost(title='Test blog post') + post.save() + data = { + 'id': post.id, + 'title': 'Test blog post', + 'comments': [{ + 'text': 'I hate this blog post', + }, { + 'text': 'I love this blog post', + }], + } + + serializer = BlogPostSerializer(data=data, instance=post) + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors, { + 'comments': [{ + 'non_field_errors': ['Cannot create a new item, only existing items may be updated.'] + }, { + 'non_field_errors': ['Cannot create a new item, only existing items may be updated.'] + }] + }) + + def test_fails_in_batch_delete_mode(self): + """ + Create nested while being in BATCH_DELETE mode fails. + """ + class LocalSerializer(BlogPostSerializer): + comments = BlogPostCommentSerializer(many=True, + source='blogpostcomment_set', batch_mode=BATCH_DELETE) + + post = models.BlogPost(title='Test blog post') + post.save() + data = { + 'id': post.id, + 'title': 'Test blog post', + 'comments': [{ + 'text': 'I hate this blog post', + }, { + 'text': 'I love this blog post', + }], + } + + serializer = LocalSerializer(data=data, instance=post) + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors, { + 'comments': [{ + 'non_field_errors': ['Cannot create a new item, only existing items may be updated.'] + }, { + 'non_field_errors': ['Cannot create a new item, only existing items may be updated.'] + }] + }) + + +class NestedModelSerializerUpdatesTests(TestCase): + def test_fails_in_batch_add_mode(self): + """ + Update nested while being in BATCH_ADD mode fails. + """ + class LocalSerializer(BlogPostSerializer): + comments = BlogPostCommentSerializer(many=True, + source='blogpostcomment_set', batch_mode=BATCH_ADD) + + post = models.BlogPost(title='Test blog post') + post.save() + comment1 = post.blogpostcomment_set.create(text="I hate this blog post") + comment2 = post.blogpostcomment_set.create(text="I love this blog post") + data = { + 'id': post.id, + 'title': 'Test blog post', + 'comments': [{ + 'id': comment1.id, + 'text': 'I hate this blog post :p', + }, { + 'id': comment2.id, + 'text': 'I love this blog post :p', + }], + } + + serializer = LocalSerializer(data=data, instance=post) + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors, { + 'comments': [{ + 'non_field_errors': ['Cannot update an item.'] + }, { + 'non_field_errors': ['Cannot update an item.'] + }] + }) + + def test_fails_in_batch_update_mode(self): + """ + Update nested while being in BATCH_UPDATE mode works. + """ + post = models.BlogPost(title='Test blog post') + post.save() + comment1 = post.blogpostcomment_set.create(text="I hate this blog post") + comment2 = post.blogpostcomment_set.create(text="I love this blog post") + data = { + 'id': post.id, + 'title': 'Test blog post', + 'comments': [{ + 'id': comment1.id, + 'text': 'I hate this blog post :p', + }, { + 'id': comment2.id, + 'text': 'I love this blog post :p', + }], + } + + serializer = BlogPostSerializer(data=data, instance=post) + self.assertTrue(serializer.is_valid(), serializer.errors) + post = serializer.save() + self.assertTrue(post.id) + self.assertEqual(post.blogpostcomment_set.count(), 2) + self.assertEqual( + set(i['text'] for i in data['comments']), + set(i.text for i in post.blogpostcomment_set.all().order_by('id')) + ) + + def test_fails_in_batch_delete_mode(self): + """ + Update nested while being in BATCH_DELETE mode fails. + """ + class LocalSerializer(BlogPostSerializer): + comments = BlogPostCommentSerializer(many=True, + source='blogpostcomment_set', batch_mode=BATCH_DELETE) + + post = models.BlogPost(title='Test blog post') + post.save() + comment1 = post.blogpostcomment_set.create(text="I hate this blog post") + comment2 = post.blogpostcomment_set.create(text="I love this blog post") + data = { + 'id': post.id, + 'title': 'Test blog post', + 'comments': [{ + 'id': comment1.id, + 'text': 'I hate this blog post :p', + }, { + 'id': comment2.id, + 'text': 'I love this blog post :p', + }], + } + + serializer = LocalSerializer(data=data, instance=post) + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.errors, { + 'comments': [{ + 'non_field_errors': ['Cannot update an item.'] + }, { + 'non_field_errors': ['Cannot update an item.'] + }] + }) + +class NestedModelSerializerDeletesTests(TestCase): + def test_fails_in_batch_add_mode(self): + """ + Delete nested while being in BATCH_ADD mode doesn't delete. + """ + class LocalSerializer(BlogPostSerializer): + comments = BlogPostCommentSerializer(many=True, + source='blogpostcomment_set', batch_mode=BATCH_ADD) + + post = models.BlogPost(title='Test blog post') + post.save() + post.blogpostcomment_set.create(text="I hate this blog post") + post.blogpostcomment_set.create(text="I love this blog post") + data = { + 'id': post.id, + 'title': 'Test blog post', + 'comments': [], + } + + serializer = LocalSerializer(data=data, instance=post) + self.assertTrue(serializer.is_valid()) + post = serializer.save() + self.assertEqual(post.blogpostcomment_set.count(), 2) + + def test_fails_in_batch_update_mode(self): + """ + Delete nested while being in BATCH_UPDATE mode doesn't delete. + """ + post = models.BlogPost(title='Test blog post') + post.save() + post.blogpostcomment_set.create(text="I hate this blog post") + post.blogpostcomment_set.create(text="I love this blog post") + data = { + 'id': post.id, + 'title': 'Test blog post', + 'comments': [], + } + + serializer = BlogPostSerializer(data=data, instance=post) + self.assertTrue(serializer.is_valid(), serializer.errors) + post = serializer.save() + self.assertEqual(post.blogpostcomment_set.count(), 2) + + def test_fails_in_batch_delete_mode(self): + """ + Delete nested while being in BATCH_DELETE mode works. + """ + class LocalSerializer(BlogPostSerializer): + comments = BlogPostCommentSerializer(many=True, + source='blogpostcomment_set', batch_mode=BATCH_DELETE) + + post = models.BlogPost(title='Test blog post') + post.save() + post.blogpostcomment_set.create(text="I hate this blog post") + post.blogpostcomment_set.create(text="I love this blog post") + data = { + 'id': post.id, + 'title': 'Test blog post', + 'comments': [], + } + + serializer = LocalSerializer(data=data, instance=post) + self.assertTrue(serializer.is_valid(), serializer.errors) + post = serializer.save() + self.assertEqual(post.blogpostcomment_set.count(), 0)