diff --git a/MANIFEST.in b/MANIFEST.in index 00e450866..15c4d0b08 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ recursive-include rest_framework/static *.js *.css *.png -recursive-include rest_framework/templates *.txt *.html +recursive-include rest_framework/templates *.html diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md index 53ea7cbcc..ed9463681 100644 --- a/docs/api-guide/filtering.md +++ b/docs/api-guide/filtering.md @@ -140,6 +140,14 @@ For more details on using filter sets see the [django-filter documentation][djan --- +### Filtering and object lookups + +Note that if a filter backend is configured for a view, then as well as being used to filter list views, it will also be used to filter the querysets used for returning a single object. + +For instance, given the previous example, and a product with an id of `4675`, the following URL would either return the corresponding object, or return a 404 response, depending on if the filtering conditions were met by the given product instance: + + http://example.com/api/products/4675/?category=clothing&max_price=10.00 + ## Overriding the initial queryset Note that you can use both an overridden `.get_queryset()` and generic filtering together, and everything will work as expected. For example, if `Product` had a many-to-many relationship with `User`, named `purchase`, you might want to write a view like this: diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index 6f1f28832..de2cf7d8f 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -93,6 +93,8 @@ To serialize a queryset instead of an object instance, you should pass the `many When deserializing data, you always need to call `is_valid()` before attempting to access the deserialized object. If any validation errors occur, the `.errors` and `.non_field_errors` properties will contain the resulting error messages. +When deserialising a list of items, errors will be returned as a list of tuples. The first item in an error tuple will be the index of the item with the error in the original data; The second item in the tuple will be a dict with the individual errors for that item. + ### Field-level validation You can specify custom field-level validation by adding `.validate_` methods to your `Serializer` subclass. These are analagous to `.clean_` methods on Django forms, but accept slightly different arguments. diff --git a/docs/topics/credits.md b/docs/topics/credits.md index 0899632f2..bdd3e27eb 100644 --- a/docs/topics/credits.md +++ b/docs/topics/credits.md @@ -108,6 +108,7 @@ The following people have helped make REST framework great. * Omer Katz - [thedrow] * Wiliam Souza - [waa] * Jonas Braun - [iekadou] +* Ian Dash - [bitmonkey] * Pierre Dulac - [dulaccc] Many thanks to everyone who's contributed to the project. @@ -251,4 +252,5 @@ You can also contact [@_tomchristie][twitter] directly on twitter. [thedrow]: https://github.com/thedrow [waa]: https://github.com/wiliamsouza [iekadou]: https://github.com/iekadou +[bitmonkey]: https://github.com/bitmonkey [dulaccc]: https://github.com/dulaccc diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 42b1d8da2..eb4d378eb 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -40,6 +40,18 @@ You can determine your currently installed version using `pip freeze`: ## 2.2.x series +### Master + +* Filtering backends are now applied to the querysets for object lookups as well as lists. (Eg you can use a filtering backend to control which objects should 404) +* Deal with error data nicely when deserializing lists of objects. +* Bugfix: Workaround for Django bug causing case where `Authtoken` could be registered for cascade delete from `User` even if not installed. + +### 2.2.3 + +**Date**: 7th March 2013 + +* Bugfix: Fix None values for for `DateField`, `DateTimeField` and `TimeField`. + ### 2.2.2 **Date**: 6th March 2013 diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index 2180c509a..1f5d6e623 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -1,4 +1,4 @@ -__version__ = '2.2.2' +__version__ = '2.2.3' VERSION = __version__ # synonym diff --git a/rest_framework/authtoken/models.py b/rest_framework/authtoken/models.py index 7f5a75a3d..52c45ad11 100644 --- a/rest_framework/authtoken/models.py +++ b/rest_framework/authtoken/models.py @@ -2,6 +2,7 @@ import uuid import hmac from hashlib import sha1 from rest_framework.compat import User +from django.conf import settings from django.db import models @@ -13,6 +14,14 @@ class Token(models.Model): user = models.OneToOneField(User, related_name='auth_token') created = models.DateTimeField(auto_now_add=True) + class Meta: + # Work around for a bug in Django: + # https://code.djangoproject.com/ticket/19422 + # + # Also see corresponding ticket: + # https://github.com/tomchristie/django-rest-framework/issues/705 + abstract = 'rest_framework.authtoken' not in settings.INSTALLED_APPS + def save(self, *args, **kwargs): if not self.key: self.key = self.generate_key() diff --git a/rest_framework/fields.py b/rest_framework/fields.py index fe555ee51..0a199f102 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -534,8 +534,12 @@ class DateField(WritableField): raise ValidationError(msg) def to_native(self, value): + if value is None: + return None + if isinstance(value, datetime.datetime): value = value.date() + if self.format.lower() == ISO_8601: return value.isoformat() return value.strftime(self.format) @@ -599,6 +603,9 @@ class DateTimeField(WritableField): raise ValidationError(msg) def to_native(self, value): + if value is None: + return None + if self.format.lower() == ISO_8601: return value.isoformat() return value.strftime(self.format) @@ -649,8 +656,12 @@ class TimeField(WritableField): raise ValidationError(msg) def to_native(self, value): + if value is None: + return None + if isinstance(value, datetime.datetime): value = value.time() + if self.format.lower() == ISO_8601: return value.isoformat() return value.strftime(self.format) diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 9ae8cf0aa..36ecf9150 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -18,6 +18,16 @@ class GenericAPIView(views.APIView): model = None serializer_class = None model_serializer_class = api_settings.DEFAULT_MODEL_SERIALIZER_CLASS + filter_backend = api_settings.FILTER_BACKEND + + def filter_queryset(self, queryset): + """ + Given a queryset, filter it with whichever filter backend is in use. + """ + if not self.filter_backend: + return queryset + backend = self.filter_backend() + return backend.filter_queryset(self.request, queryset, self) def get_serializer_context(self): """ @@ -81,16 +91,6 @@ class MultipleObjectAPIView(MultipleObjectMixin, GenericAPIView): paginate_by = api_settings.PAGINATE_BY paginate_by_param = api_settings.PAGINATE_BY_PARAM pagination_serializer_class = api_settings.DEFAULT_PAGINATION_SERIALIZER_CLASS - filter_backend = api_settings.FILTER_BACKEND - - def filter_queryset(self, queryset): - """ - Given a queryset, filter it with whichever filter backend is in use. - """ - if not self.filter_backend: - return queryset - backend = self.filter_backend() - return backend.filter_queryset(self.request, queryset, self) def get_pagination_serializer(self, page=None): """ diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py index 97201c4b1..8e4012049 100644 --- a/rest_framework/mixins.py +++ b/rest_framework/mixins.py @@ -97,7 +97,9 @@ class RetrieveModelMixin(object): Should be mixed in with `SingleObjectAPIView`. """ def retrieve(self, request, *args, **kwargs): - self.object = self.get_object() + queryset = self.get_queryset() + filtered_queryset = self.filter_queryset(queryset) + self.object = self.get_object(filtered_queryset) serializer = self.get_serializer(self.object) return Response(serializer.data) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index ba9e9e9cf..106e3f17a 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -7,8 +7,7 @@ from django.core.paginator import Page from django.db import models from django.forms import widgets from django.utils.datastructures import SortedDict -from rest_framework.compat import get_concrete_model -from rest_framework.compat import six +from rest_framework.compat import get_concrete_model, six # Note: We do the following so that users of the framework can use this style: # @@ -285,10 +284,6 @@ class BaseSerializer(Field): """ Deserialize primitives -> objects. """ - if hasattr(data, '__iter__') and not isinstance(data, (dict, six.text_type)): - # TODO: error data when deserializing lists - return [self.from_native(item, None) for item in data] - self._errors = {} if data is not None or files is not None: attrs = self.restore_fields(data, files) @@ -330,7 +325,7 @@ class BaseSerializer(Field): if self.many is not None: many = self.many else: - many = hasattr(obj, '__iter__') and not isinstance(obj, (Page, dict)) + many = hasattr(obj, '__iter__') and not isinstance(obj, (Page, dict, six.text_type)) if many: return [self.to_native(item) for item in obj] @@ -348,19 +343,25 @@ class BaseSerializer(Field): if self.many is not None: many = self.many else: - many = hasattr(data, '__iter__') and not isinstance(data, (Page, dict)) + many = hasattr(data, '__iter__') and not isinstance(data, (Page, dict, six.text_type)) if many: warnings.warn('Implict list/queryset serialization is due to be deprecated. ' 'Use the `many=True` flag when instantiating the serializer.', PendingDeprecationWarning, stacklevel=3) - # TODO: error data when deserializing lists if many: - ret = [self.from_native(item, None) for item in data] - ret = self.from_native(data, files) + ret = [] + errors = [] + for item in data: + ret.append(self.from_native(item, None)) + errors.append(self._errors) + self._errors = any(errors) and errors or [] + else: + ret = self.from_native(data, files) if not self._errors: self.object = ret + return self._errors def is_valid(self): diff --git a/rest_framework/tests/authentication.py b/rest_framework/tests/authentication.py index 9e86881a3..54ab0c948 100644 --- a/rest_framework/tests/authentication.py +++ b/rest_framework/tests/authentication.py @@ -145,7 +145,7 @@ class SessionAuthTests(TestCase): class TokenAuthTests(TestCase): """Token authentication""" - urls = 'rest_framework.tests.authentication' + urls = 'ยง.tests.authentication' def setUp(self): self.csrf_client = Client(enforce_csrf_checks=True) diff --git a/rest_framework/tests/fields.py b/rest_framework/tests/fields.py index 28f18ed89..fd6de7797 100644 --- a/rest_framework/tests/fields.py +++ b/rest_framework/tests/fields.py @@ -171,6 +171,13 @@ class DateFieldTest(TestCase): self.assertEqual('1984 - 07.31', result_1) + def test_to_native_none(self): + """ + Make sure from_native() returns None on None param. + """ + f = serializers.DateField(required=False) + self.assertEqual(None, f.to_native(None)) + class DateTimeFieldTest(TestCase): """ @@ -303,6 +310,13 @@ class DateTimeFieldTest(TestCase): self.assertEqual('1984 - 04:31', result_3) self.assertEqual('1984 - 04:31', result_4) + def test_to_native_none(self): + """ + Make sure from_native() returns None on None param. + """ + f = serializers.DateTimeField(required=False) + self.assertEqual(None, f.to_native(None)) + class TimeFieldTest(TestCase): """ diff --git a/rest_framework/tests/generics.py b/rest_framework/tests/generics.py index f8f2ddaac..f70934016 100644 --- a/rest_framework/tests/generics.py +++ b/rest_framework/tests/generics.py @@ -350,3 +350,78 @@ class TestM2MBrowseableAPI(TestCase): view = ExampleView().as_view() response = view(request).render() self.assertEqual(response.status_code, status.HTTP_200_OK) + + +class InclusiveFilterBackend(object): + def filter_queryset(self, request, queryset, view): + return queryset.filter(text='foo') + + +class ExclusiveFilterBackend(object): + def filter_queryset(self, request, queryset, view): + return queryset.filter(text='other') + + +class TestFilterBackendAppliedToViews(TestCase): + + def setUp(self): + """ + Create 3 BasicModel instances to filter on. + """ + items = ['foo', 'bar', 'baz'] + for item in items: + BasicModel(text=item).save() + self.objects = BasicModel.objects + self.data = [ + {'id': obj.id, 'text': obj.text} + for obj in self.objects.all() + ] + self.root_view = RootView.as_view() + self.instance_view = InstanceView.as_view() + self.original_root_backend = getattr(RootView, 'filter_backend') + self.original_instance_backend = getattr(InstanceView, 'filter_backend') + + def tearDown(self): + setattr(RootView, 'filter_backend', self.original_root_backend) + setattr(InstanceView, 'filter_backend', self.original_instance_backend) + + def test_get_root_view_filters_by_name_with_filter_backend(self): + """ + GET requests to ListCreateAPIView should return filtered list. + """ + setattr(RootView, 'filter_backend', InclusiveFilterBackend) + request = factory.get('/') + response = self.root_view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data, [{'id': 1, 'text': 'foo'}]) + + def test_get_root_view_filters_out_all_models_with_exclusive_filter_backend(self): + """ + GET requests to ListCreateAPIView should return empty list when all models are filtered out. + """ + setattr(RootView, 'filter_backend', ExclusiveFilterBackend) + request = factory.get('/') + response = self.root_view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, []) + + def test_get_instance_view_filters_out_name_with_filter_backend(self): + """ + GET requests to RetrieveUpdateDestroyAPIView should raise 404 when model filtered out. + """ + setattr(InstanceView, 'filter_backend', ExclusiveFilterBackend) + request = factory.get('/1') + response = self.instance_view(request, pk=1).render() + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.data, {'detail': 'Not found'}) + + def test_get_instance_view_will_return_single_object_when_filter_does_not_exclude_it(self): + """ + GET requests to RetrieveUpdateDestroyAPIView should return a single object when not excluded + """ + setattr(InstanceView, 'filter_backend', InclusiveFilterBackend) + request = factory.get('/1') + response = self.instance_view(request, pk=1).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {'id': 1, 'text': 'foo'}) diff --git a/rest_framework/tests/serializer.py b/rest_framework/tests/serializer.py index 510650173..beb372c2b 100644 --- a/rest_framework/tests/serializer.py +++ b/rest_framework/tests/serializer.py @@ -268,7 +268,16 @@ class ValidationTests(TestCase): data = ['i am', 'a', 'list'] serializer = CommentSerializer(self.comment, data=data, many=True) self.assertEqual(serializer.is_valid(), False) - self.assertEqual(serializer.errors, {'non_field_errors': ['Invalid data']}) + self.assertTrue(isinstance(serializer.errors, list)) + + self.assertEqual( + serializer.errors, + [ + {'non_field_errors': ['Invalid data']}, + {'non_field_errors': ['Invalid data']}, + {'non_field_errors': ['Invalid data']} + ] + ) data = 'and i am a string' serializer = CommentSerializer(self.comment, data=data) @@ -1072,3 +1081,32 @@ class NestedSerializerContextTests(TestCase): # This will raise RuntimeError if context doesn't get passed correctly to the nested Serializers AlbumCollectionSerializer(album_collection, context={'context_item': 'album context'}).data + + +class DeserializeListTestCase(TestCase): + + def setUp(self): + self.data = { + 'email': 'nobody@nowhere.com', + 'content': 'This is some test content', + 'created': datetime.datetime(2013, 3, 7), + } + + def test_no_errors(self): + data = [self.data.copy() for x in range(0, 3)] + serializer = CommentSerializer(data=data) + self.assertTrue(serializer.is_valid()) + self.assertTrue(isinstance(serializer.object, list)) + self.assertTrue( + all((isinstance(item, Comment) for item in serializer.object)) + ) + + def test_errors_return_as_list(self): + invalid_item = self.data.copy() + invalid_item['email'] = '' + data = [self.data.copy(), invalid_item, self.data.copy()] + + serializer = CommentSerializer(data=data) + self.assertFalse(serializer.is_valid()) + expected = [{}, {'email': ['This field is required.']}, {}] + self.assertEqual(serializer.errors, expected)