This commit is contained in:
Tom Christie 2013-03-08 23:00:23 +00:00
commit 5e993f3929
15 changed files with 200 additions and 26 deletions

View File

@ -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

View File

@ -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:

View File

@ -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_<fieldname>` methods to your `Serializer` subclass. These are analagous to `.clean_<fieldname>` methods on Django forms, but accept slightly different arguments.

View File

@ -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

View File

@ -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

View File

@ -1,4 +1,4 @@
__version__ = '2.2.2'
__version__ = '2.2.3'
VERSION = __version__ # synonym

View File

@ -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()

View File

@ -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)

View File

@ -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):
"""

View File

@ -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)

View File

@ -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 = []
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):

View File

@ -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)

View File

@ -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):
"""

View File

@ -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'})

View File

@ -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)