diff options
Diffstat (limited to 'rest_framework')
| -rw-r--r-- | rest_framework/__init__.py | 2 | ||||
| -rw-r--r-- | rest_framework/authtoken/serializers.py | 2 | ||||
| -rw-r--r-- | rest_framework/authtoken/views.py | 5 | ||||
| -rw-r--r-- | rest_framework/compat.py | 10 | ||||
| -rw-r--r-- | rest_framework/fields.py | 28 | ||||
| -rw-r--r-- | rest_framework/renderers.py | 3 | ||||
| -rwxr-xr-x | rest_framework/runtests/runtests.py | 5 | ||||
| -rw-r--r-- | rest_framework/serializers.py | 94 | ||||
| -rw-r--r-- | rest_framework/tests/authentication.py | 16 | ||||
| -rw-r--r-- | rest_framework/tests/hyperlinkedserializers.py | 25 | ||||
| -rw-r--r-- | rest_framework/tests/models.py | 27 | ||||
| -rw-r--r-- | rest_framework/tests/pagination.py | 45 | ||||
| -rw-r--r-- | rest_framework/tests/pk_relations.py | 138 | ||||
| -rw-r--r-- | rest_framework/tests/serializer.py | 180 |
14 files changed, 508 insertions, 72 deletions
diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index 48cebbc5..d61632bc 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -1,3 +1,3 @@ -__version__ = '2.1.6' +__version__ = '2.1.10' VERSION = __version__ # synonym diff --git a/rest_framework/authtoken/serializers.py b/rest_framework/authtoken/serializers.py index a5ed6e6d..60a3740e 100644 --- a/rest_framework/authtoken/serializers.py +++ b/rest_framework/authtoken/serializers.py @@ -1,6 +1,7 @@ from django.contrib.auth import authenticate from rest_framework import serializers + class AuthTokenSerializer(serializers.Serializer): username = serializers.CharField() password = serializers.CharField() @@ -21,4 +22,3 @@ class AuthTokenSerializer(serializers.Serializer): raise serializers.ValidationError('Unable to login with provided credentials.') else: raise serializers.ValidationError('Must include "username" and "password"') - diff --git a/rest_framework/authtoken/views.py b/rest_framework/authtoken/views.py index cfaacbe9..d318c723 100644 --- a/rest_framework/authtoken/views.py +++ b/rest_framework/authtoken/views.py @@ -6,11 +6,12 @@ from rest_framework.response import Response from rest_framework.authtoken.models import Token from rest_framework.authtoken.serializers import AuthTokenSerializer + class ObtainAuthToken(APIView): throttle_classes = () permission_classes = () parser_classes = (parsers.FormParser, parsers.MultiPartParser, parsers.JSONParser,) - renderer_classes = (renderers.JSONRenderer,) + renderer_classes = (renderers.JSONRenderer,) model = Token def post(self, request): @@ -18,7 +19,7 @@ class ObtainAuthToken(APIView): if serializer.is_valid(): token, created = Token.objects.get_or_create(user=serializer.object['user']) return Response({'token': token.key}) - return Response(serializer.errors, status=status.HTTP_401_UNAUTHORIZED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) obtain_auth_token = ObtainAuthToken.as_view() diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 09b76368..d4901437 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -19,6 +19,16 @@ except ImportError: import StringIO +# Try to import PIL in either of the two ways it can end up installed. +try: + from PIL import Image +except ImportError: + try: + import Image + except ImportError: + Image = None + + def get_concrete_model(model_cls): try: return model_cls._meta.concrete_model diff --git a/rest_framework/fields.py b/rest_framework/fields.py index f57dc57f..d3470854 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -32,6 +32,7 @@ def is_simple_callable(obj): class Field(object): + read_only = True creation_counter = 0 empty = '' type_name = None @@ -139,7 +140,7 @@ class WritableField(Field): if required is None: self.required = not(read_only) else: - assert not read_only, "Cannot set required=True and read_only=True" + assert not (read_only and required), "Cannot set required=True and read_only=True" self.required = required messages = {} @@ -275,6 +276,7 @@ class RelatedField(WritableField): def __init__(self, *args, **kwargs): self.queryset = kwargs.pop('queryset', None) + self.null = kwargs.pop('null', False) super(RelatedField, self).__init__(*args, **kwargs) self.read_only = kwargs.pop('read_only', self.default_read_only) @@ -356,7 +358,13 @@ class RelatedField(WritableField): return value = data.get(field_name) - into[(self.source or field_name)] = self.from_native(value) + + if value in (None, '') and not self.null: + raise ValidationError('Value may not be null') + elif value in (None, '') and self.null: + into[(self.source or field_name)] = None + else: + into[(self.source or field_name)] = self.from_native(value) class ManyRelatedMixin(object): @@ -580,7 +588,7 @@ class HyperlinkedRelatedField(RelatedField): except: pass - raise ValidationError('Could not resolve URL for field using view name "%s"', view_name) + raise ValidationError('Could not resolve URL for field using view name "%s"' % view_name) def from_native(self, value): # Convert URL -> model instance pk @@ -679,7 +687,7 @@ class HyperlinkedIdentityField(Field): except: pass - raise ValidationError('Could not resolve URL for field using view name "%s"', view_name) + raise ValidationError('Could not resolve URL for field using view name "%s"' % view_name) ##### Typed Fields ##### @@ -699,9 +707,9 @@ class BooleanField(WritableField): default = False def from_native(self, value): - if value in ('t', 'True', '1'): + if value in ('true', 't', 'True', '1'): return True - if value in ('f', 'False', '0'): + if value in ('false', 'f', 'False', '0'): return False return bool(value) @@ -823,6 +831,7 @@ class EmailField(CharField): class RegexField(CharField): type_name = 'RegexField' + form_field_class = forms.RegexField def __init__(self, regex, max_length=None, min_length=None, *args, **kwargs): super(RegexField, self).__init__(max_length, min_length, *args, **kwargs) @@ -1054,11 +1063,8 @@ class ImageField(FileField): if f is None: return None - # Try to import PIL in either of the two ways it can end up installed. - try: - from PIL import Image - except ImportError: - import Image + from compat import Image + assert Image is not None, 'PIL must be installed for ImageField support' # We need to get a file object for PIL. We might have a path or we might # have to read the data into memory. diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 25a32baa..1220bca1 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -320,6 +320,9 @@ class BrowsableAPIRenderer(BaseRenderer): if getattr(v, 'choices', None) is not None: kwargs['choices'] = v.choices + if getattr(v, 'regex', None) is not None: + kwargs['regex'] = v.regex + if getattr(v, 'widget', None): widget = copy.deepcopy(v.widget) kwargs['widget'] = widget diff --git a/rest_framework/runtests/runtests.py b/rest_framework/runtests/runtests.py index 1bd0a5fc..729ef26a 100755 --- a/rest_framework/runtests/runtests.py +++ b/rest_framework/runtests/runtests.py @@ -5,6 +5,11 @@ # http://code.djangoproject.com/svn/django/trunk/tests/runtests.py import os import sys +""" +Need to fix sys path so following works without specifically messing with PYTHONPATH +python ./rest_framework/runtests/runtests.py +""" +sys.path.append(os.path.join(os.path.dirname(__file__), "../..")) os.environ['DJANGO_SETTINGS_MODULE'] = 'rest_framework.runtests.settings' from django.conf import settings diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 37496be3..ed6835fa 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -22,7 +22,16 @@ class DictWithMetadata(dict): """ A dict-like object, that can have additional properties attached. """ - pass + def __getstate__(self): + """ + Used by pickle (e.g., caching). + Overriden to remove metadata from the dict, since it shouldn't be pickled + and may in some instances be unpickleable. + """ + # return an instance of the first dict in MRO that isn't a DictWithMetadata + for base in self.__class__.__mro__: + if not isinstance(base, DictWithMetadata) and isinstance(base, dict): + return base(self) class SortedDictWithMetadata(SortedDict, DictWithMetadata): @@ -91,7 +100,8 @@ class BaseSerializer(Field): _options_class = SerializerOptions _dict_class = SortedDictWithMetadata # Set to unsorted dict for backwards compatibility with unsorted implementations. - def __init__(self, instance=None, data=None, files=None, context=None, partial=False, **kwargs): + def __init__(self, instance=None, data=None, files=None, + context=None, partial=False, **kwargs): super(BaseSerializer, self).__init__(**kwargs) self.opts = self._options_class(self.Meta) self.parent = None @@ -131,8 +141,6 @@ class BaseSerializer(Field): base_fields = copy.deepcopy(self.base_fields) for key, field in base_fields.items(): ret[key] = field - # Set up the field - field.initialize(parent=self, field_name=key) # Add in the default fields default_fields = self.get_default_fields() @@ -166,6 +174,13 @@ class BaseSerializer(Field): if parent.opts.depth: self.opts.depth = parent.opts.depth - 1 + # We need to call initialize here to ensure any nested + # serializers that will have already called initialize on their + # descendants get updated with *their* parent. + # We could be a bit more smart about this, but it'll do for now. + for key, field in self.fields.items(): + field.initialize(parent=self, field_name=key) + ##### # Methods to convert or revert from objects <--> primitive representations. @@ -184,6 +199,7 @@ class BaseSerializer(Field): ret.fields = {} for field_name, field in self.fields.items(): + field.initialize(parent=self, field_name=field_name) key = self.get_field_key(field_name) value = field.field_to_native(obj, field_name) ret[key] = value @@ -197,6 +213,7 @@ class BaseSerializer(Field): """ reverted_data = {} for field_name, field in self.fields.items(): + field.initialize(parent=self, field_name=field_name) try: field.field_from_native(data, files, field_name, reverted_data) except ValidationError as err: @@ -217,10 +234,18 @@ class BaseSerializer(Field): except ValidationError as err: self._errors[field_name] = self._errors.get(field_name, []) + list(err.messages) - try: - attrs = self.validate(attrs) - except ValidationError as err: - self._errors['non_field_errors'] = err.messages + # If there are already errors, we don't run .validate() because + # field-validation failed and thus `attrs` may not be complete. + # which in turn can cause inconsistent validation errors. + if not self._errors: + try: + attrs = self.validate(attrs) + except ValidationError as err: + if hasattr(err, 'message_dict'): + for field_name, error_messages in err.message_dict.items(): + self._errors[field_name] = self._errors.get(field_name, []) + list(error_messages) + elif hasattr(err, 'messages'): + self._errors['non_field_errors'] = err.messages return attrs @@ -272,10 +297,15 @@ class BaseSerializer(Field): Override default so that we can apply ModelSerializer as a nested field to relationships. """ - obj = getattr(obj, self.source or field_name) - - if is_simple_callable(obj): - obj = obj() + if self.source: + for component in self.source.split('.'): + obj = getattr(obj, component) + if is_simple_callable(obj): + obj = obj() + else: + obj = getattr(obj, field_name) + if is_simple_callable(obj): + obj = value() # If the object has an "all" method, assume it's a relationship if is_simple_callable(getattr(obj, 'all', None)): @@ -364,7 +394,6 @@ class ModelSerializer(Serializer): field = self.get_field(model_field) if field: - field.initialize(parent=self, field_name=model_field.name) ret[model_field.name] = field for field_name in self.opts.read_only_fields: @@ -396,10 +425,14 @@ class ModelSerializer(Serializer): """ # TODO: filter queryset using: # .using(db).complex_filter(self.rel.limit_choices_to) - queryset = model_field.rel.to._default_manager + kwargs = { + 'null': model_field.null, + 'queryset': model_field.rel.to._default_manager + } + if to_many: - return ManyPrimaryKeyRelatedField(queryset=queryset) - return PrimaryKeyRelatedField(queryset=queryset) + return ManyPrimaryKeyRelatedField(**kwargs) + return PrimaryKeyRelatedField(**kwargs) def get_field(self, model_field): """ @@ -424,10 +457,6 @@ class ModelSerializer(Serializer): kwargs['choices'] = model_field.flatchoices return ChoiceField(**kwargs) - max_length = getattr(model_field, 'max_length', None) - if max_length: - kwargs['max_length'] = max_length - if model_field.verbose_name is not None: kwargs['label'] = model_field.verbose_name @@ -457,6 +486,18 @@ class ModelSerializer(Serializer): except KeyError: return ModelField(model_field=model_field, **kwargs) + def get_validation_exclusions(self): + """ + Return a list of field names to exclude from model validation. + """ + cls = self.opts.model + opts = get_concrete_model(cls)._meta + exclusions = [field.name for field in opts.fields + opts.many_to_many] + for field_name, field in self.fields.items(): + if field_name in exclusions and not field.read_only: + exclusions.remove(field_name) + return exclusions + def restore_object(self, attrs, instance=None): """ Restore the model instance. @@ -478,7 +519,14 @@ class ModelSerializer(Serializer): for field in self.opts.model._meta.many_to_many: if field.name in attrs: self.m2m_data[field.name] = attrs.pop(field.name) - return self.opts.model(**attrs) + + instance = self.opts.model(**attrs) + try: + instance.full_clean(exclude=self.get_validation_exclusions()) + except ValidationError, err: + self._errors = err.message_dict + return None + return instance def save(self, save_m2m=True): """ @@ -537,9 +585,9 @@ class HyperlinkedModelSerializer(ModelSerializer): # TODO: filter queryset using: # .using(db).complex_filter(self.rel.limit_choices_to) rel = model_field.rel.to - queryset = rel._default_manager kwargs = { - 'queryset': queryset, + 'null': model_field.null, + 'queryset': rel._default_manager, 'view_name': self._get_default_view_name(rel) } if to_many: diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 802bc6c1..d498ae3e 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -1,4 +1,4 @@ -from django.conf.urls.defaults import patterns, include +from django.conf.urls.defaults import patterns from django.contrib.auth.models import User from django.test import Client, TestCase @@ -27,7 +27,7 @@ MockView.authentication_classes += (TokenAuthentication,) urlpatterns = patterns('', (r'^$', MockView.as_view()), - (r'^auth-token/', 'rest_framework.authtoken.views.obtain_auth_token'), + (r'^auth-token/$', 'rest_framework.authtoken.views.obtain_auth_token'), ) @@ -157,7 +157,7 @@ class TokenAuthTests(TestCase): def test_token_login_json(self): """Ensure token login view using JSON POST works.""" client = Client(enforce_csrf_checks=True) - response = client.post('/auth-token/login/', + response = client.post('/auth-token/', json.dumps({'username': self.username, 'password': self.password}), 'application/json') self.assertEqual(response.status_code, 200) self.assertEqual(json.loads(response.content)['token'], self.key) @@ -165,21 +165,21 @@ class TokenAuthTests(TestCase): def test_token_login_json_bad_creds(self): """Ensure token login view using JSON POST fails if bad credentials are used.""" client = Client(enforce_csrf_checks=True) - response = client.post('/auth-token/login/', + response = client.post('/auth-token/', json.dumps({'username': self.username, 'password': "badpass"}), 'application/json') - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) def test_token_login_json_missing_fields(self): """Ensure token login view using JSON POST fails if missing fields.""" client = Client(enforce_csrf_checks=True) - response = client.post('/auth-token/login/', + response = client.post('/auth-token/', json.dumps({'username': self.username}), 'application/json') - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) def test_token_login_form(self): """Ensure token login view using form POST works.""" client = Client(enforce_csrf_checks=True) - response = client.post('/auth-token/login/', + response = client.post('/auth-token/', {'username': self.username, 'password': self.password}) self.assertEqual(response.status_code, 200) self.assertEqual(json.loads(response.content)['token'], self.key) diff --git a/rest_framework/tests/hyperlinkedserializers.py b/rest_framework/tests/hyperlinkedserializers.py index d7effce7..24bf61bf 100644 --- a/rest_framework/tests/hyperlinkedserializers.py +++ b/rest_framework/tests/hyperlinkedserializers.py @@ -1,6 +1,7 @@ from django.conf.urls.defaults import patterns, url from django.test import TestCase from django.test.client import RequestFactory +from django.utils import simplejson as json from rest_framework import generics, status, serializers from rest_framework.tests.models import Anchor, BasicModel, ManyToManyModel, BlogPost, BlogPostComment, Album, Photo, OptionalRelationModel @@ -54,10 +55,12 @@ class BlogPostCommentListCreate(generics.ListCreateAPIView): model = BlogPostComment serializer_class = BlogPostCommentSerializer + class BlogPostCommentDetail(generics.RetrieveAPIView): model = BlogPostComment serializer_class = BlogPostCommentSerializer + class BlogPostDetail(generics.RetrieveAPIView): model = BlogPost @@ -71,7 +74,7 @@ class AlbumDetail(generics.RetrieveAPIView): model = Album -class OptionalRelationDetail(generics.RetrieveAPIView): +class OptionalRelationDetail(generics.RetrieveUpdateDestroyAPIView): model = OptionalRelationModel model_serializer_class = serializers.HyperlinkedModelSerializer @@ -162,7 +165,7 @@ class TestManyToManyHyperlinkedView(TestCase): GET requests to ListCreateAPIView should return list of objects. """ request = factory.get('/manytomany/') - response = self.list_view(request).render() + response = self.list_view(request) self.assertEquals(response.status_code, status.HTTP_200_OK) self.assertEquals(response.data, self.data) @@ -171,7 +174,7 @@ class TestManyToManyHyperlinkedView(TestCase): GET requests to ListCreateAPIView should return list of objects. """ request = factory.get('/manytomany/1/') - response = self.detail_view(request, pk=1).render() + response = self.detail_view(request, pk=1) self.assertEquals(response.status_code, status.HTTP_200_OK) self.assertEquals(response.data, self.data[0]) @@ -194,7 +197,7 @@ class TestCreateWithForeignKeys(TestCase): } request = factory.post('/comments/', data=data) - response = self.create_view(request).render() + response = self.create_view(request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response['Location'], 'http://testserver/comments/1/') self.assertEqual(self.post.blogpostcomment_set.count(), 1) @@ -219,7 +222,7 @@ class TestCreateWithForeignKeysAndCustomSlug(TestCase): } request = factory.post('/photos/', data=data) - response = self.list_create_view(request).render() + response = self.list_create_view(request) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertNotIn('Location', response, msg='Location should only be included if there is a "url" field on the serializer') self.assertEqual(self.post.photo_set.count(), 1) @@ -244,6 +247,16 @@ class TestOptionalRelationHyperlinkedView(TestCase): for non existing relations. """ request = factory.get('/optionalrelationmodel-detail/1') - response = self.detail_view(request, pk=1).render() + response = self.detail_view(request, pk=1) self.assertEquals(response.status_code, status.HTTP_200_OK) self.assertEquals(response.data, self.data) + + def test_put_detail_view(self): + """ + PUT requests to RetrieveUpdateDestroyAPIView with optional relations + should accept None for non existing relations. + """ + response = self.client.put('/optionalrelation/1/', + data=json.dumps(self.data), + content_type='application/json') + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/rest_framework/tests/models.py b/rest_framework/tests/models.py index a13f1ef3..5cc7eb9a 100644 --- a/rest_framework/tests/models.py +++ b/rest_framework/tests/models.py @@ -125,8 +125,21 @@ class ActionItem(RESTFrameworkModel): # Models for reverse relations +class Person(RESTFrameworkModel): + name = models.CharField(max_length=10) + age = models.IntegerField(null=True, blank=True) + + @property + def info(self): + return { + 'name': self.name, + 'age': self.age, + } + + class BlogPost(RESTFrameworkModel): title = models.CharField(max_length=100) + writer = models.ForeignKey(Person, null=True, blank=True) def get_first_comment(self): return self.blogpostcomment_set.all()[0] @@ -146,21 +159,9 @@ class Photo(RESTFrameworkModel): album = models.ForeignKey(Album) -class Person(RESTFrameworkModel): - name = models.CharField(max_length=10) - age = models.IntegerField(null=True, blank=True) - - @property - def info(self): - return { - 'name': self.name, - 'age': self.age, - } - - # Model for issue #324 class BlankFieldModel(RESTFrameworkModel): - title = models.CharField(max_length=100, blank=True) + title = models.CharField(max_length=100, blank=True, null=True) # Model for issue #380 diff --git a/rest_framework/tests/pagination.py b/rest_framework/tests/pagination.py index 3062007d..81d297a1 100644 --- a/rest_framework/tests/pagination.py +++ b/rest_framework/tests/pagination.py @@ -4,7 +4,7 @@ from django.core.paginator import Paginator from django.test import TestCase from django.test.client import RequestFactory from django.utils import unittest -from rest_framework import generics, status, pagination, filters +from rest_framework import generics, status, pagination, filters, serializers from rest_framework.compat import django_filters from rest_framework.tests.models import BasicModel, FilterableItem @@ -148,6 +148,11 @@ class IntegrationTestPaginationAndFiltering(TestCase): self.assertEquals(response.data['previous'], None) +class PassOnContextPaginationSerializer(pagination.PaginationSerializer): + class Meta: + object_serializer_class = serializers.Serializer + + class UnitTestPagination(TestCase): """ Unit tests for pagination of primitive objects. @@ -172,6 +177,15 @@ class UnitTestPagination(TestCase): self.assertEquals(serializer.data['previous'], '?page=2') self.assertEquals(serializer.data['results'], self.objects[20:]) + def test_context_available_in_result(self): + """ + Ensure context gets passed through to the object serializer. + """ + serializer = PassOnContextPaginationSerializer(self.first_page) + serializer.data + results = serializer.fields[serializer.results_field] + self.assertTrue(serializer.context is results.context) + class TestUnpaginated(TestCase): """ @@ -236,3 +250,32 @@ class TestCustomPaginateByParam(TestCase): response = self.view(request).render() self.assertEquals(response.data['count'], 13) self.assertEquals(response.data['results'], self.data[:5]) + + +class CustomField(serializers.Field): + def to_native(self, value): + if not 'view' in self.context: + raise RuntimeError("context isn't getting passed into custom field") + return "value" + + +class BasicModelSerializer(serializers.Serializer): + text = CustomField() + + +class TestContextPassedToCustomField(TestCase): + def setUp(self): + BasicModel.objects.create(text='ala ma kota') + + def test_with_pagination(self): + class ListView(generics.ListCreateAPIView): + model = BasicModel + serializer_class = BasicModelSerializer + paginate_by = 1 + + self.view = ListView.as_view() + request = factory.get('/') + response = self.view(request).render() + + self.assertEquals(response.status_code, status.HTTP_200_OK) + diff --git a/rest_framework/tests/pk_relations.py b/rest_framework/tests/pk_relations.py index 3dcc76f9..e3360939 100644 --- a/rest_framework/tests/pk_relations.py +++ b/rest_framework/tests/pk_relations.py @@ -49,9 +49,22 @@ class ForeignKeySourceSerializer(serializers.ModelSerializer): model = ForeignKeySource +# Nullable ForeignKey + +class NullableForeignKeySource(models.Model): + name = models.CharField(max_length=100) + target = models.ForeignKey(ForeignKeyTarget, null=True, blank=True, + related_name='nullable_sources') + + +class NullableForeignKeySourceSerializer(serializers.ModelSerializer): + class Meta: + model = NullableForeignKeySource + + # TODO: Add test that .data cannot be accessed prior to .is_valid -class PrimaryKeyManyToManyTests(TestCase): +class PKManyToManyTests(TestCase): def setUp(self): for idx in range(1, 4): target = ManyToManyTarget(name='target-%d' % idx) @@ -117,6 +130,25 @@ class PrimaryKeyManyToManyTests(TestCase): ] self.assertEquals(serializer.data, expected) + def test_many_to_many_create(self): + data = {'id': 4, 'name': u'source-4', 'targets': [1, 3]} + serializer = ManyToManySourceSerializer(data=data) + self.assertTrue(serializer.is_valid()) + obj = serializer.save() + self.assertEquals(serializer.data, data) + self.assertEqual(obj.name, u'source-4') + + # Ensure source 4 is added, and everything else is as expected + queryset = ManyToManySource.objects.all() + serializer = ManyToManySourceSerializer(queryset) + expected = [ + {'id': 1, 'name': u'source-1', 'targets': [1]}, + {'id': 2, 'name': u'source-2', 'targets': [1, 2]}, + {'id': 3, 'name': u'source-3', 'targets': [1, 2, 3]}, + {'id': 4, 'name': u'source-4', 'targets': [1, 3]}, + ] + self.assertEquals(serializer.data, expected) + def test_reverse_many_to_many_create(self): data = {'id': 4, 'name': u'target-4', 'sources': [1, 3]} serializer = ManyToManyTargetSerializer(data=data) @@ -137,7 +169,7 @@ class PrimaryKeyManyToManyTests(TestCase): self.assertEquals(serializer.data, expected) -class PrimaryKeyForeignKeyTests(TestCase): +class PKForeignKeyTests(TestCase): def setUp(self): target = ForeignKeyTarget(name='target-1') target.save() @@ -174,7 +206,7 @@ class PrimaryKeyForeignKeyTests(TestCase): self.assertEquals(serializer.data, data) serializer.save() - # # Ensure source 1 is updated, and everything else is as expected + # Ensure source 1 is updated, and everything else is as expected queryset = ForeignKeySource.objects.all() serializer = ForeignKeySourceSerializer(queryset) expected = [ @@ -184,6 +216,106 @@ class PrimaryKeyForeignKeyTests(TestCase): ] self.assertEquals(serializer.data, expected) + def test_foreign_key_update_with_invalid_null(self): + data = {'id': 1, 'name': u'source-1', 'target': None} + instance = ForeignKeySource.objects.get(pk=1) + serializer = ForeignKeySourceSerializer(instance, data=data) + self.assertFalse(serializer.is_valid()) + self.assertEquals(serializer.errors, {'target': [u'Value may not be null']}) + + +class PKNullableForeignKeyTests(TestCase): + def setUp(self): + target = ForeignKeyTarget(name='target-1') + target.save() + for idx in range(1, 4): + source = NullableForeignKeySource(name='source-%d' % idx, target=target) + source.save() + + def test_foreign_key_create_with_valid_null(self): + data = {'id': 4, 'name': u'source-4', 'target': None} + serializer = NullableForeignKeySourceSerializer(data=data) + self.assertTrue(serializer.is_valid()) + obj = serializer.save() + self.assertEquals(serializer.data, data) + self.assertEqual(obj.name, u'source-4') + + # Ensure source 4 is created, and everything else is as expected + queryset = NullableForeignKeySource.objects.all() + serializer = NullableForeignKeySourceSerializer(queryset) + expected = [ + {'id': 1, 'name': u'source-1', 'target': 1}, + {'id': 2, 'name': u'source-2', 'target': 1}, + {'id': 3, 'name': u'source-3', 'target': 1}, + {'id': 4, 'name': u'source-4', 'target': None} + ] + self.assertEquals(serializer.data, expected) + + def test_foreign_key_create_with_valid_emptystring(self): + """ + The emptystring should be interpreted as null in the context + of relationships. + """ + data = {'id': 4, 'name': u'source-4', 'target': ''} + expected_data = {'id': 4, 'name': u'source-4', 'target': None} + serializer = NullableForeignKeySourceSerializer(data=data) + self.assertTrue(serializer.is_valid()) + obj = serializer.save() + self.assertEquals(serializer.data, expected_data) + self.assertEqual(obj.name, u'source-4') + + # Ensure source 4 is created, and everything else is as expected + queryset = NullableForeignKeySource.objects.all() + serializer = NullableForeignKeySourceSerializer(queryset) + expected = [ + {'id': 1, 'name': u'source-1', 'target': 1}, + {'id': 2, 'name': u'source-2', 'target': 1}, + {'id': 3, 'name': u'source-3', 'target': 1}, + {'id': 4, 'name': u'source-4', 'target': None} + ] + self.assertEquals(serializer.data, expected) + + def test_foreign_key_update_with_valid_null(self): + data = {'id': 1, 'name': u'source-1', 'target': None} + instance = NullableForeignKeySource.objects.get(pk=1) + serializer = NullableForeignKeySourceSerializer(instance, data=data) + self.assertTrue(serializer.is_valid()) + self.assertEquals(serializer.data, data) + serializer.save() + + # Ensure source 1 is updated, and everything else is as expected + queryset = NullableForeignKeySource.objects.all() + serializer = NullableForeignKeySourceSerializer(queryset) + expected = [ + {'id': 1, 'name': u'source-1', 'target': None}, + {'id': 2, 'name': u'source-2', 'target': 1}, + {'id': 3, 'name': u'source-3', 'target': 1} + ] + self.assertEquals(serializer.data, expected) + + def test_foreign_key_update_with_valid_emptystring(self): + """ + The emptystring should be interpreted as null in the context + of relationships. + """ + data = {'id': 1, 'name': u'source-1', 'target': ''} + expected_data = {'id': 1, 'name': u'source-1', 'target': None} + instance = NullableForeignKeySource.objects.get(pk=1) + serializer = NullableForeignKeySourceSerializer(instance, data=data) + self.assertTrue(serializer.is_valid()) + self.assertEquals(serializer.data, expected_data) + serializer.save() + + # Ensure source 1 is updated, and everything else is as expected + queryset = NullableForeignKeySource.objects.all() + serializer = NullableForeignKeySourceSerializer(queryset) + expected = [ + {'id': 1, 'name': u'source-1', 'target': None}, + {'id': 2, 'name': u'source-2', 'target': 1}, + {'id': 3, 'name': u'source-3', 'target': 1} + ] + self.assertEquals(serializer.data, expected) + # reverse foreign keys MUST be read_only # In the general case they do not provide .remove() or .clear() # and cannot be arbitrarily set. diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index 44adf92e..4cfb0aae 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -1,9 +1,10 @@ import datetime +import pickle from django.test import TestCase from rest_framework import serializers, fields -from rest_framework.tests.models import (ActionItem, Anchor, BasicModel, +from rest_framework.tests.models import (Album, ActionItem, Anchor, BasicModel, BlankFieldModel, BlogPost, Book, CallableDefaultValueModel, DefaultValueModel, - ManyToManyModel, Person, ReadOnlyManyToManyModel) + ManyToManyModel, Person, ReadOnlyManyToManyModel, Photo) class SubComment(object): @@ -62,6 +63,13 @@ class PersonSerializer(serializers.ModelSerializer): read_only_fields = ('age',) +class AlbumsSerializer(serializers.ModelSerializer): + + class Meta: + model = Album + fields = ['title'] # lists are also valid options + + class BasicTests(TestCase): def setUp(self): self.comment = Comment( @@ -169,7 +177,7 @@ class ValidationTests(TestCase): 'content': 'x' * 1001, 'created': datetime.datetime(2012, 1, 1) } - self.actionitem = ActionItem('Some to do item', + self.actionitem = ActionItem(title='Some to do item', ) def test_create(self): @@ -277,6 +285,19 @@ class ValidationTests(TestCase): self.assertEquals(serializer.errors, {'info': [u'Ensure this value has at most 12 characters (it has 13).']}) +class ModelValidationTests(TestCase): + def test_validate_unique(self): + """ + Just check if serializers.ModelSerializer handles unique checks via .full_clean() + """ + serializer = AlbumsSerializer(data={'title': 'a'}) + serializer.is_valid() + serializer.save() + second_serializer = AlbumsSerializer(data={'title': 'a'}) + self.assertFalse(second_serializer.is_valid()) + self.assertEqual(second_serializer.errors, {'title': [u'Album with this Title already exists.']}) + + class RegexValidationTest(TestCase): def test_create_failed(self): serializer = BookSerializer(data={'isbn': '1234567890'}) @@ -560,6 +581,47 @@ class ManyRelatedTests(TestCase): self.assertEqual(serializer.data, expected) +class RelatedTraversalTest(TestCase): + def test_nested_traversal(self): + user = Person.objects.create(name="django") + post = BlogPost.objects.create(title="Test blog post", writer=user) + post.blogpostcomment_set.create(text="I love this blog post") + + from rest_framework.tests.models import BlogPostComment + + class PersonSerializer(serializers.ModelSerializer): + class Meta: + model = Person + fields = ("name", "age") + + class BlogPostCommentSerializer(serializers.ModelSerializer): + class Meta: + model = BlogPostComment + fields = ("text", "post_owner") + + text = serializers.CharField() + post_owner = PersonSerializer(source='blog_post.writer') + + class BlogPostSerializer(serializers.Serializer): + title = serializers.CharField() + comments = BlogPostCommentSerializer(source='blogpostcomment_set') + + serializer = BlogPostSerializer(instance=post) + + expected = { + 'title': u'Test blog post', + 'comments': [{ + 'text': u'I love this blog post', + 'post_owner': { + "name": u"django", + "age": None + } + }] + } + + self.assertEqual(serializer.data, expected) + + class SerializerMethodFieldTests(TestCase): def setUp(self): @@ -643,6 +705,118 @@ class BlankFieldTests(TestCase): self.assertEquals(serializer.is_valid(), False) +#test for issue #460 +class SerializerPickleTests(TestCase): + """ + Test pickleability of the output of Serializers + """ + def test_pickle_simple_model_serializer_data(self): + """ + Test simple serializer + """ + pickle.dumps(PersonSerializer(Person(name="Methusela", age=969)).data) + + def test_pickle_inner_serializer(self): + """ + Test pickling a serializer whose resulting .data (a SortedDictWithMetadata) will + have unpickleable meta data--in order to make sure metadata doesn't get pulled into the pickle. + See DictWithMetadata.__getstate__ + """ + class InnerPersonSerializer(serializers.ModelSerializer): + class Meta: + model = Person + fields = ('name', 'age') + pickle.dumps(InnerPersonSerializer(Person(name="Noah", age=950)).data) + + +class DepthTest(TestCase): + def test_implicit_nesting(self): + writer = Person.objects.create(name="django", age=1) + post = BlogPost.objects.create(title="Test blog post", writer=writer) + + class BlogPostSerializer(serializers.ModelSerializer): + class Meta: + model = BlogPost + depth = 1 + + serializer = BlogPostSerializer(instance=post) + expected = {'id': 1, 'title': u'Test blog post', + 'writer': {'id': 1, 'name': u'django', 'age': 1}} + + self.assertEqual(serializer.data, expected) + + def test_explicit_nesting(self): + writer = Person.objects.create(name="django", age=1) + post = BlogPost.objects.create(title="Test blog post", writer=writer) + + class PersonSerializer(serializers.ModelSerializer): + class Meta: + model = Person + + class BlogPostSerializer(serializers.ModelSerializer): + writer = PersonSerializer() + + class Meta: + model = BlogPost + + serializer = BlogPostSerializer(instance=post) + expected = {'id': 1, 'title': u'Test blog post', + 'writer': {'id': 1, 'name': u'django', 'age': 1}} + + self.assertEqual(serializer.data, expected) + + +class NestedSerializerContextTests(TestCase): + + def test_nested_serializer_context(self): + """ + Regression for #497 + + https://github.com/tomchristie/django-rest-framework/issues/497 + """ + class PhotoSerializer(serializers.ModelSerializer): + class Meta: + model = Photo + fields = ("description", "callable") + + callable = serializers.SerializerMethodField('_callable') + + def _callable(self, instance): + if not 'context_item' in self.context: + raise RuntimeError("context isn't getting passed into 2nd level nested serializer") + return "success" + + class AlbumSerializer(serializers.ModelSerializer): + class Meta: + model = Album + fields = ("photo_set", "callable") + + photo_set = PhotoSerializer(source="photo_set") + callable = serializers.SerializerMethodField("_callable") + + def _callable(self, instance): + if not 'context_item' in self.context: + raise RuntimeError("context isn't getting passed into 1st level nested serializer") + return "success" + + class AlbumCollection(object): + albums = None + + class AlbumCollectionSerializer(serializers.Serializer): + albums = AlbumSerializer(source="albums") + + album1 = Album.objects.create(title="album 1") + album2 = Album.objects.create(title="album 2") + Photo.objects.create(description="Bigfoot", album=album1) + Photo.objects.create(description="Unicorn", album=album1) + Photo.objects.create(description="Yeti", album=album2) + Photo.objects.create(description="Sasquatch", album=album2) + album_collection = AlbumCollection() + album_collection.albums = [album1, album2] + + # This will raise RuntimeError if context doesn't get passed correctly to the nested Serializers + AlbumCollectionSerializer(album_collection, context={'context_item': 'album context'}).data + # Test for issue #467 class FieldLabelTest(TestCase): def setUp(self): |
