Merge branch 'master' into basic-nested-serialization

This commit is contained in:
Tom Christie 2013-03-15 19:25:12 +00:00
commit 1aedf57f4a
18 changed files with 298 additions and 75 deletions

View File

@ -17,8 +17,8 @@ install:
- "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2==1.5.211 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2==1.5.211 --use-mirrors; fi"
- "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.0 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.0 --use-mirrors; fi"
- "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.3 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.3 --use-mirrors; fi"
- "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi" - "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi"
- "if [[ ${TRAVIS_PYTHON_VERSION::1} == '3' ]]; then pip install https://github.com/alex/django-filter/tarball/master; fi" - "if [[ ${DJANGO::11} != 'django==1.3' ]]; then pip install django-filter==0.6a1 --use-mirrors; fi"
- export PYTHONPATH=. - export PYTHONPATH=.
script: script:

View File

@ -105,6 +105,21 @@ The default behaviour can also be overridden to support custom model permissions
To use custom model permissions, override `DjangoModelPermissions` and set the `.perms_map` property. Refer to the source code for details. To use custom model permissions, override `DjangoModelPermissions` and set the `.perms_map` property. Refer to the source code for details.
## TokenHasReadWriteScope
This permission class is intended for use with either of the `OAuthAuthentication` and `OAuth2Authentication` classes, and ties into the scoping that their backends provide.
Requests with a safe methods of `GET`, `OPTIONS` or `HEAD` will be allowed if the authenticated token has read permission.
Requests for `POST`, `PUT`, `PATCH` and `DELETE` will be allowed if the authenticated token has write permission.
This permission class relies on the implementations of the [django-oauth-plus][django-oauth-plus] and [django-oauth2-provider][django-oauth2-provider] libraries, which both provide limited support for controlling the scope of access tokens:
* `django-oauth-plus`: Tokens are associated with a `Resource` class which has a `name`, `url` and `is_readonly` properties.
* `django-oauth2-provider`: Tokens are associated with a bitwise `scope` attribute, that defaults to providing bitwise values for `read` and/or `write`.
If you require more advanced scoping for your API, such as restricting tokens to accessing a subset of functionality of your API then you will need to provide a custom permission class. See the source of the `django-oauth-plus` or `django-oauth2-provider` package for more details on scoping token access.
--- ---
# Custom permissions # Custom permissions
@ -173,5 +188,7 @@ Also note that the generic views will only check the object-level permissions fo
[throttling]: throttling.md [throttling]: throttling.md
[contribauth]: https://docs.djangoproject.com/en/1.0/topics/auth/#permissions [contribauth]: https://docs.djangoproject.com/en/1.0/topics/auth/#permissions
[guardian]: https://github.com/lukaszb/django-guardian [guardian]: https://github.com/lukaszb/django-guardian
[django-oauth-plus]: http://code.larlet.fr/django-oauth-plus
[django-oauth2-provider]: https://github.com/caffeinehit/django-oauth2-provider
[2.2-announcement]: ../topics/2.2-announcement.md [2.2-announcement]: ../topics/2.2-announcement.md
[filtering]: filtering.md [filtering]: filtering.md

View File

@ -9,11 +9,11 @@
# Django REST framework # Django REST framework
**A toolkit for building well-connected, self-describing Web APIs.** **Web APIs for Django, made easy.**
Django REST framework is a lightweight library that makes it easy to build Web APIs. It is designed as a modular and easy to customize architecture, based on Django's class based views. Django REST framework is a flexible, powerful library that makes it incredibly easy to build Web APIs. It is designed as a modular and easy to customize architecture, based on Django's class based views.
Web APIs built using REST framework are fully self-describing and web browseable - a huge useability win for your developers. It also supports a wide range of media types, authentication and permission policies out of the box. APIs built using REST framework are fully self-describing and web browseable - a huge useability win for your developers. It also supports a wide range of media types, authentication and permission policies out of the box.
If you are considering using REST framework for your API, we recommend reading the [REST framework 2 announcement][rest-framework-2-announcement] which gives a good overview of the framework and it's capabilities. If you are considering using REST framework for your API, we recommend reading the [REST framework 2 announcement][rest-framework-2-announcement] which gives a good overview of the framework and it's capabilities.
@ -75,7 +75,7 @@ Note that the URL path can be whatever you want, but you must include `'rest_fra
## Quickstart ## Quickstart
Can't wait to get started? The [quickstart guide][quickstart] is the fastest way to get up and running with REST framework. Can't wait to get started? The [quickstart guide][quickstart] is the fastest way to get up and running, and building APIs with REST framework.
## Tutorial ## Tutorial

View File

@ -2,11 +2,11 @@
<html lang="en"> <html lang="en">
<head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta charset="utf-8"> <meta charset="utf-8">
<title>Django REST framework</title> <title>{{ title }}</title>
<link href="{{ base_url }}/img/favicon.ico" rel="icon" type="image/x-icon"> <link href="{{ base_url }}/img/favicon.ico" rel="icon" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content=""> <meta name="description" content="{{ description }}">
<meta name="author" content=""> <meta name="author" content="Tom Christie">
<!-- Le styles --> <!-- Le styles -->
<link href="{{ base_url }}/css/prettify.css" rel="stylesheet"> <link href="{{ base_url }}/css/prettify.css" rel="stylesheet">

View File

@ -19,6 +19,21 @@ For example, given the following form:
`request.method` would return `"DELETE"`. `request.method` would return `"DELETE"`.
## HTTP header based method overriding
REST framework also supports method overriding via the semi-standard `X-HTTP-Method-Override` header. This can be useful if you are working with non-form content such as JSON and are working with an older web server and/or hosting provider that doesn't recognise particular HTTP methods such as `PATCH`. For example [Amazon Web Services ELB][aws_elb].
To use it, make a `POST` request, setting the `X-HTTP-Method-Override` header.
For example, making a `PATCH` request via `POST` in jQuery:
$.ajax({
url: '/myresource/',
method: 'POST',
headers: {'X-HTTP-Method-Override': 'PATCH'},
...
});
## Browser based submission of non-form content ## Browser based submission of non-form content
Browser-based submission of content types other than form are supported by Browser-based submission of content types other than form are supported by
@ -62,3 +77,4 @@ as well as how to support content types other than form-encoded data.
[rails]: http://guides.rubyonrails.org/form_helpers.html#how-do-forms-with-put-or-delete-methods-work [rails]: http://guides.rubyonrails.org/form_helpers.html#how-do-forms-with-put-or-delete-methods-work
[html5]: http://www.w3.org/TR/html5-diff/#changes-2010-06-24 [html5]: http://www.w3.org/TR/html5-diff/#changes-2010-06-24
[put_delete]: http://amundsen.com/examples/put-delete-forms/ [put_delete]: http://amundsen.com/examples/put-delete-forms/
[aws_elb]: https://forums.aws.amazon.com/thread.jspa?messageID=400724

View File

@ -111,6 +111,7 @@ The following people have helped make REST framework great.
* Ian Dash - [bitmonkey] * Ian Dash - [bitmonkey]
* Bouke Haarsma - [bouke] * Bouke Haarsma - [bouke]
* Pierre Dulac - [dulaccc] * Pierre Dulac - [dulaccc]
* Dave Kuhn - [kuhnza]
Many thanks to everyone who's contributed to the project. Many thanks to everyone who's contributed to the project.
@ -256,3 +257,4 @@ You can also contact [@_tomchristie][twitter] directly on twitter.
[bitmonkey]: https://github.com/bitmonkey [bitmonkey]: https://github.com/bitmonkey
[bouke]: https://github.com/bouke [bouke]: https://github.com/bouke
[dulaccc]: https://github.com/dulaccc [dulaccc]: https://github.com/dulaccc
[kuhnza]: https://github.com/kuhnza

View File

@ -42,11 +42,19 @@ You can determine your currently installed version using `pip freeze`:
### Master ### Master
* `Serializer.save()` now supports arbitrary keyword args which are passed through to the object `.save()` method. Mixins use `force_insert` and `force_update` where appropriate, resulting in one less database query.
### 2.2.4
**Date**: 13th March 2013
* OAuth 2 support. * OAuth 2 support.
* OAuth 1.0a support. * OAuth 1.0a support.
* Support X-HTTP-Method-Override header.
* 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) * 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. * Deal with error data nicely when deserializing lists of objects.
* Extra override hook to configure `DjangoModelPermissions` for unauthenticated users. * Extra override hook to configure `DjangoModelPermissions` for unauthenticated users.
* Bugfix: Fix regression which caused extra database query on paginated list views.
* Bugfix: Fix pk relationship bug for some types of 1-to-1 relations. * Bugfix: Fix pk relationship bug for some types of 1-to-1 relations.
* Bugfix: Workaround for Django bug causing case where `Authtoken` could be registered for cascade delete from `User` even if not installed. * Bugfix: Workaround for Django bug causing case where `Authtoken` could be registered for cascade delete from `User` even if not installed.

View File

@ -57,24 +57,36 @@ for (dirpath, dirnames, filenames) in os.walk(docs_dir):
toc = '' toc = ''
text = open(path, 'r').read().decode('utf-8') text = open(path, 'r').read().decode('utf-8')
main_title = None
description = 'Django, API, REST'
for line in text.splitlines(): for line in text.splitlines():
if line.startswith('# '): if line.startswith('# '):
title = line[2:].strip() title = line[2:].strip()
template = main_header template = main_header
description = description + ', ' + title
elif line.startswith('## '): elif line.startswith('## '):
title = line[3:].strip() title = line[3:].strip()
template = sub_header template = sub_header
else: else:
continue continue
if not main_title:
main_title = title
anchor = title.lower().replace(' ', '-').replace(':-', '-').replace("'", '').replace('?', '').replace('.', '') anchor = title.lower().replace(' ', '-').replace(':-', '-').replace("'", '').replace('?', '').replace('.', '')
template = template.replace('{{ title }}', title) template = template.replace('{{ title }}', title)
template = template.replace('{{ anchor }}', anchor) template = template.replace('{{ anchor }}', anchor)
toc += template + '\n' toc += template + '\n'
if filename == 'index.md':
main_title = 'Django REST framework - APIs made easy'
else:
main_title = 'Django REST framework - ' + main_title
content = markdown.markdown(text, ['headerid']) content = markdown.markdown(text, ['headerid'])
output = page.replace('{{ content }}', content).replace('{{ toc }}', toc).replace('{{ base_url }}', base_url).replace('{{ suffix }}', suffix).replace('{{ index }}', index) output = page.replace('{{ content }}', content).replace('{{ toc }}', toc).replace('{{ base_url }}', base_url).replace('{{ suffix }}', suffix).replace('{{ index }}', index)
output = output.replace('{{ title }}', main_title)
output = output.replace('{{ description }}', description)
output = output.replace('{{ page_id }}', filename[:-3]) output = output.replace('{{ page_id }}', filename[:-3])
output = re.sub(r'a href="([^"]*)\.md"', r'a href="\1%s"' % suffix, output) output = re.sub(r'a href="([^"]*)\.md"', r'a href="\1%s"' % suffix, output)
output = re.sub(r'<pre><code>:::bash', r'<pre class="prettyprint lang-bsh">', output) output = re.sub(r'<pre><code>:::bash', r'<pre class="prettyprint lang-bsh">', output)

View File

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

View File

@ -26,14 +26,16 @@ def is_simple_callable(obj):
""" """
True if the object is a callable that takes no arguments. True if the object is a callable that takes no arguments.
""" """
try: function = inspect.isfunction(obj)
args, _, _, defaults = inspect.getargspec(obj) method = inspect.ismethod(obj)
except TypeError:
if not (function or method):
return False return False
else:
len_args = len(args) if inspect.isfunction(obj) else len(args) - 1 args, _, _, defaults = inspect.getargspec(obj)
len_defaults = len(defaults) if defaults else 0 len_args = len(args) if function else len(args) - 1
return len_args <= len_defaults len_defaults = len(defaults) if defaults else 0
return len_args <= len_defaults
def get_component(obj, attr_name): def get_component(obj, attr_name):

View File

@ -44,7 +44,7 @@ class CreateModelMixin(object):
if serializer.is_valid(): if serializer.is_valid():
self.pre_save(serializer.object) self.pre_save(serializer.object)
self.object = serializer.save() self.object = serializer.save(force_insert=True)
self.post_save(self.object, created=True) self.post_save(self.object, created=True)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, return Response(serializer.data, status=status.HTTP_201_CREATED,
@ -119,9 +119,11 @@ class UpdateModelMixin(object):
# we have relevant permissions, as if this was a POST request. # we have relevant permissions, as if this was a POST request.
self.check_permissions(clone_request(request, 'POST')) self.check_permissions(clone_request(request, 'POST'))
created = True created = True
save_kwargs = {'force_insert': True}
success_status_code = status.HTTP_201_CREATED success_status_code = status.HTTP_201_CREATED
else: else:
created = False created = False
save_kwargs = {'force_update': True}
success_status_code = status.HTTP_200_OK success_status_code = status.HTTP_200_OK
serializer = self.get_serializer(self.object, data=request.DATA, serializer = self.get_serializer(self.object, data=request.DATA,
@ -129,7 +131,7 @@ class UpdateModelMixin(object):
if serializer.is_valid(): if serializer.is_valid():
self.pre_save(serializer.object) self.pre_save(serializer.object)
self.object = serializer.save() self.object = serializer.save(**save_kwargs)
self.post_save(self.object, created=created) self.post_save(self.object, created=created)
return Response(serializer.data, status=success_status_code) return Response(serializer.data, status=success_status_code)

View File

@ -231,11 +231,17 @@ class Request(object):
""" """
self._content_type = self.META.get('HTTP_CONTENT_TYPE', self._content_type = self.META.get('HTTP_CONTENT_TYPE',
self.META.get('CONTENT_TYPE', '')) self.META.get('CONTENT_TYPE', ''))
self._perform_form_overloading() self._perform_form_overloading()
# if the HTTP method was not overloaded, we take the raw HTTP method
if not _hasattr(self, '_method'): if not _hasattr(self, '_method'):
self._method = self._request.method self._method = self._request.method
if self._method == 'POST':
# Allow X-HTTP-METHOD-OVERRIDE header
self._method = self.META.get('HTTP_X_HTTP_METHOD_OVERRIDE',
self._method)
def _load_stream(self): def _load_stream(self):
""" """
Return the content body of the request, as a stream. Return the content body of the request, as a stream.

View File

@ -437,17 +437,17 @@ class BaseSerializer(WritableField):
return self._data return self._data
def save_object(self, obj): def save_object(self, obj, **kwargs):
obj.save() obj.save(**kwargs)
def save(self): def save(self, **kwargs):
""" """
Save the deserialized object and return it. Save the deserialized object and return it.
""" """
if isinstance(self.object, list): if isinstance(self.object, list):
[self.save_object(item) for item in self.object] [self.save_object(item, **kwargs) for item in self.object]
else: else:
self.save_object(self.object) self.save_object(self.object, **kwargs)
return self.object return self.object
@ -502,8 +502,11 @@ class ModelSerializer(Serializer):
"Serializer class '%s' is missing 'model' Meta option" % self.__class__.__name__ "Serializer class '%s' is missing 'model' Meta option" % self.__class__.__name__
opts = get_concrete_model(cls)._meta opts = get_concrete_model(cls)._meta
pk_field = opts.pk pk_field = opts.pk
# while pk_field.rel:
# pk_field = pk_field.rel.to._meta.pk # If model is a child via multitable inheritance, use parent's pk
while pk_field.rel and pk_field.rel.parent_link:
pk_field = pk_field.rel.to._meta.pk
fields = [pk_field] fields = [pk_field]
fields += [field for field in opts.fields if field.serialize] fields += [field for field in opts.fields if field.serialize]
fields += [field for field in opts.many_to_many if field.serialize] fields += [field for field in opts.many_to_many if field.serialize]
@ -664,11 +667,11 @@ class ModelSerializer(Serializer):
if instance: if instance:
return self.full_clean(instance) return self.full_clean(instance)
def save_object(self, obj): def save_object(self, obj, **kwargs):
""" """
Save the deserialized object and return it. Save the deserialized object and return it.
""" """
obj.save() obj.save(**kwargs)
if getattr(self, 'm2m_data', None): if getattr(self, 'm2m_data', None):
for accessor_name, object_list in self.m2m_data.items(): for accessor_name, object_list in self.m2m_data.items():

View File

@ -60,7 +60,8 @@ class TestRootView(TestCase):
GET requests to ListCreateAPIView should return list of objects. GET requests to ListCreateAPIView should return list of objects.
""" """
request = factory.get('/') request = factory.get('/')
response = self.view(request).render() with self.assertNumQueries(1):
response = self.view(request).render()
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, self.data) self.assertEqual(response.data, self.data)
@ -71,7 +72,8 @@ class TestRootView(TestCase):
content = {'text': 'foobar'} content = {'text': 'foobar'}
request = factory.post('/', json.dumps(content), request = factory.post('/', json.dumps(content),
content_type='application/json') content_type='application/json')
response = self.view(request).render() with self.assertNumQueries(1):
response = self.view(request).render()
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data, {'id': 4, 'text': 'foobar'}) self.assertEqual(response.data, {'id': 4, 'text': 'foobar'})
created = self.objects.get(id=4) created = self.objects.get(id=4)
@ -84,7 +86,8 @@ class TestRootView(TestCase):
content = {'text': 'foobar'} content = {'text': 'foobar'}
request = factory.put('/', json.dumps(content), request = factory.put('/', json.dumps(content),
content_type='application/json') content_type='application/json')
response = self.view(request).render() with self.assertNumQueries(0):
response = self.view(request).render()
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
self.assertEqual(response.data, {"detail": "Method 'PUT' not allowed."}) self.assertEqual(response.data, {"detail": "Method 'PUT' not allowed."})
@ -93,7 +96,8 @@ class TestRootView(TestCase):
DELETE requests to ListCreateAPIView should not be allowed DELETE requests to ListCreateAPIView should not be allowed
""" """
request = factory.delete('/') request = factory.delete('/')
response = self.view(request).render() with self.assertNumQueries(0):
response = self.view(request).render()
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
self.assertEqual(response.data, {"detail": "Method 'DELETE' not allowed."}) self.assertEqual(response.data, {"detail": "Method 'DELETE' not allowed."})
@ -102,7 +106,8 @@ class TestRootView(TestCase):
OPTIONS requests to ListCreateAPIView should return metadata OPTIONS requests to ListCreateAPIView should return metadata
""" """
request = factory.options('/') request = factory.options('/')
response = self.view(request).render() with self.assertNumQueries(0):
response = self.view(request).render()
expected = { expected = {
'parses': [ 'parses': [
'application/json', 'application/json',
@ -126,7 +131,8 @@ class TestRootView(TestCase):
content = {'id': 999, 'text': 'foobar'} content = {'id': 999, 'text': 'foobar'}
request = factory.post('/', json.dumps(content), request = factory.post('/', json.dumps(content),
content_type='application/json') content_type='application/json')
response = self.view(request).render() with self.assertNumQueries(1):
response = self.view(request).render()
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data, {'id': 4, 'text': 'foobar'}) self.assertEqual(response.data, {'id': 4, 'text': 'foobar'})
created = self.objects.get(id=4) created = self.objects.get(id=4)
@ -154,7 +160,8 @@ class TestInstanceView(TestCase):
GET requests to RetrieveUpdateDestroyAPIView should return a single object. GET requests to RetrieveUpdateDestroyAPIView should return a single object.
""" """
request = factory.get('/1') request = factory.get('/1')
response = self.view(request, pk=1).render() with self.assertNumQueries(1):
response = self.view(request, pk=1).render()
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, self.data[0]) self.assertEqual(response.data, self.data[0])
@ -165,7 +172,8 @@ class TestInstanceView(TestCase):
content = {'text': 'foobar'} content = {'text': 'foobar'}
request = factory.post('/', json.dumps(content), request = factory.post('/', json.dumps(content),
content_type='application/json') content_type='application/json')
response = self.view(request).render() with self.assertNumQueries(0):
response = self.view(request).render()
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
self.assertEqual(response.data, {"detail": "Method 'POST' not allowed."}) self.assertEqual(response.data, {"detail": "Method 'POST' not allowed."})
@ -176,7 +184,8 @@ class TestInstanceView(TestCase):
content = {'text': 'foobar'} content = {'text': 'foobar'}
request = factory.put('/1', json.dumps(content), request = factory.put('/1', json.dumps(content),
content_type='application/json') content_type='application/json')
response = self.view(request, pk='1').render() with self.assertNumQueries(2):
response = self.view(request, pk='1').render()
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, {'id': 1, 'text': 'foobar'}) self.assertEqual(response.data, {'id': 1, 'text': 'foobar'})
updated = self.objects.get(id=1) updated = self.objects.get(id=1)
@ -190,7 +199,8 @@ class TestInstanceView(TestCase):
request = factory.patch('/1', json.dumps(content), request = factory.patch('/1', json.dumps(content),
content_type='application/json') content_type='application/json')
response = self.view(request, pk=1).render() with self.assertNumQueries(2):
response = self.view(request, pk=1).render()
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, {'id': 1, 'text': 'foobar'}) self.assertEqual(response.data, {'id': 1, 'text': 'foobar'})
updated = self.objects.get(id=1) updated = self.objects.get(id=1)
@ -201,7 +211,8 @@ class TestInstanceView(TestCase):
DELETE requests to RetrieveUpdateDestroyAPIView should delete an object. DELETE requests to RetrieveUpdateDestroyAPIView should delete an object.
""" """
request = factory.delete('/1') request = factory.delete('/1')
response = self.view(request, pk=1).render() with self.assertNumQueries(2):
response = self.view(request, pk=1).render()
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(response.content, six.b('')) self.assertEqual(response.content, six.b(''))
ids = [obj.id for obj in self.objects.all()] ids = [obj.id for obj in self.objects.all()]
@ -212,7 +223,8 @@ class TestInstanceView(TestCase):
OPTIONS requests to RetrieveUpdateDestroyAPIView should return metadata OPTIONS requests to RetrieveUpdateDestroyAPIView should return metadata
""" """
request = factory.options('/') request = factory.options('/')
response = self.view(request).render() with self.assertNumQueries(0):
response = self.view(request).render()
expected = { expected = {
'parses': [ 'parses': [
'application/json', 'application/json',
@ -236,7 +248,8 @@ class TestInstanceView(TestCase):
content = {'id': 999, 'text': 'foobar'} content = {'id': 999, 'text': 'foobar'}
request = factory.put('/1', json.dumps(content), request = factory.put('/1', json.dumps(content),
content_type='application/json') content_type='application/json')
response = self.view(request, pk=1).render() with self.assertNumQueries(2):
response = self.view(request, pk=1).render()
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, {'id': 1, 'text': 'foobar'}) self.assertEqual(response.data, {'id': 1, 'text': 'foobar'})
updated = self.objects.get(id=1) updated = self.objects.get(id=1)
@ -251,7 +264,8 @@ class TestInstanceView(TestCase):
content = {'text': 'foobar'} content = {'text': 'foobar'}
request = factory.put('/1', json.dumps(content), request = factory.put('/1', json.dumps(content),
content_type='application/json') content_type='application/json')
response = self.view(request, pk=1).render() with self.assertNumQueries(3):
response = self.view(request, pk=1).render()
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data, {'id': 1, 'text': 'foobar'}) self.assertEqual(response.data, {'id': 1, 'text': 'foobar'})
updated = self.objects.get(id=1) updated = self.objects.get(id=1)
@ -263,10 +277,11 @@ class TestInstanceView(TestCase):
at the requested url if it doesn't exist. at the requested url if it doesn't exist.
""" """
content = {'text': 'foobar'} content = {'text': 'foobar'}
# pk fields can not be created on demand, only the database can set th pk for a new object # pk fields can not be created on demand, only the database can set the pk for a new object
request = factory.put('/5', json.dumps(content), request = factory.put('/5', json.dumps(content),
content_type='application/json') content_type='application/json')
response = self.view(request, pk=5).render() with self.assertNumQueries(3):
response = self.view(request, pk=5).render()
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
new_obj = self.objects.get(pk=5) new_obj = self.objects.get(pk=5)
self.assertEqual(new_obj.text, 'foobar') self.assertEqual(new_obj.text, 'foobar')
@ -279,7 +294,8 @@ class TestInstanceView(TestCase):
content = {'text': 'foobar'} content = {'text': 'foobar'}
request = factory.put('/test_slug', json.dumps(content), request = factory.put('/test_slug', json.dumps(content),
content_type='application/json') content_type='application/json')
response = self.slug_based_view(request, slug='test_slug').render() with self.assertNumQueries(2):
response = self.slug_based_view(request, slug='test_slug').render()
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data, {'slug': 'test_slug', 'text': 'foobar'}) self.assertEqual(response.data, {'slug': 'test_slug', 'text': 'foobar'})
new_obj = SlugBasedModel.objects.get(slug='test_slug') new_obj = SlugBasedModel.objects.get(slug='test_slug')

View File

@ -0,0 +1,67 @@
from __future__ import unicode_literals
from django.db import models
from django.test import TestCase
from rest_framework import serializers
from rest_framework.tests.models import RESTFrameworkModel
# Models
class ParentModel(RESTFrameworkModel):
name1 = models.CharField(max_length=100)
class ChildModel(ParentModel):
name2 = models.CharField(max_length=100)
class AssociatedModel(RESTFrameworkModel):
ref = models.OneToOneField(ParentModel, primary_key=True)
name = models.CharField(max_length=100)
# Serializers
class DerivedModelSerializer(serializers.ModelSerializer):
class Meta:
model = ChildModel
class AssociatedModelSerializer(serializers.ModelSerializer):
class Meta:
model = AssociatedModel
# Tests
class IneritedModelSerializationTests(TestCase):
def test_multitable_inherited_model_fields_as_expected(self):
"""
Assert that the parent pointer field is not included in the fields
serialized fields
"""
child = ChildModel(name1='parent name', name2='child name')
serializer = DerivedModelSerializer(child)
self.assertEqual(set(serializer.data.keys()),
set(['name1', 'name2', 'id']))
def test_onetoone_primary_key_model_fields_as_expected(self):
"""
Assert that a model with a onetoone field that is the primary key is
not treated like a derived model
"""
parent = ParentModel(name1='parent name')
associate = AssociatedModel(name='hello', ref=parent)
serializer = AssociatedModelSerializer(associate)
self.assertEqual(set(serializer.data.keys()),
set(['name', 'ref']))
def test_data_is_valid_without_parent_ptr(self):
"""
Assert that the pointer to the parent table is not a required field
for input data
"""
data = {
'name1': 'parent name',
'name2': 'child name',
}
serializer = DerivedModelSerializer(data=data)
self.assertEqual(serializer.is_valid(), True)

View File

@ -1,6 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import datetime import datetime
from decimal import Decimal from decimal import Decimal
import django
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
@ -20,21 +21,6 @@ class RootView(generics.ListCreateAPIView):
paginate_by = 10 paginate_by = 10
if django_filters:
class DecimalFilter(django_filters.FilterSet):
decimal = django_filters.NumberFilter(lookup_type='lt')
class Meta:
model = FilterableItem
fields = ['text', 'decimal', 'date']
class FilterFieldsRootView(generics.ListCreateAPIView):
model = FilterableItem
paginate_by = 10
filter_class = DecimalFilter
filter_backend = filters.DjangoFilterBackend
class DefaultPageSizeKwargView(generics.ListAPIView): class DefaultPageSizeKwargView(generics.ListAPIView):
""" """
View for testing default paginate_by_param usage View for testing default paginate_by_param usage
@ -73,7 +59,9 @@ class IntegrationTestPagination(TestCase):
GET requests to paginated ListCreateAPIView should return paginated results. GET requests to paginated ListCreateAPIView should return paginated results.
""" """
request = factory.get('/') request = factory.get('/')
response = self.view(request).render() # Note: Database queries are a `SELECT COUNT`, and `SELECT <fields>`
with self.assertNumQueries(2):
response = self.view(request).render()
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 26) self.assertEqual(response.data['count'], 26)
self.assertEqual(response.data['results'], self.data[:10]) self.assertEqual(response.data['results'], self.data[:10])
@ -81,7 +69,8 @@ class IntegrationTestPagination(TestCase):
self.assertEqual(response.data['previous'], None) self.assertEqual(response.data['previous'], None)
request = factory.get(response.data['next']) request = factory.get(response.data['next'])
response = self.view(request).render() with self.assertNumQueries(2):
response = self.view(request).render()
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 26) self.assertEqual(response.data['count'], 26)
self.assertEqual(response.data['results'], self.data[10:20]) self.assertEqual(response.data['results'], self.data[10:20])
@ -89,7 +78,8 @@ class IntegrationTestPagination(TestCase):
self.assertNotEqual(response.data['previous'], None) self.assertNotEqual(response.data['previous'], None)
request = factory.get(response.data['next']) request = factory.get(response.data['next'])
response = self.view(request).render() with self.assertNumQueries(2):
response = self.view(request).render()
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 26) self.assertEqual(response.data['count'], 26)
self.assertEqual(response.data['results'], self.data[20:]) self.assertEqual(response.data['results'], self.data[20:])
@ -115,17 +105,44 @@ class IntegrationTestPaginationAndFiltering(TestCase):
{'id': obj.id, 'text': obj.text, 'decimal': obj.decimal, 'date': obj.date.isoformat()} {'id': obj.id, 'text': obj.text, 'decimal': obj.decimal, 'date': obj.date.isoformat()}
for obj in self.objects.all() for obj in self.objects.all()
] ]
self.view = FilterFieldsRootView.as_view()
@unittest.skipUnless(django_filters, 'django-filters not installed') @unittest.skipUnless(django_filters, 'django-filters not installed')
def test_get_paginated_filtered_root_view(self): def test_get_django_filter_paginated_filtered_root_view(self):
""" """
GET requests to paginated filtered ListCreateAPIView should return GET requests to paginated filtered ListCreateAPIView should return
paginated results. The next and previous links should preserve the paginated results. The next and previous links should preserve the
filtered parameters. filtered parameters.
""" """
class DecimalFilter(django_filters.FilterSet):
decimal = django_filters.NumberFilter(lookup_type='lt')
class Meta:
model = FilterableItem
fields = ['text', 'decimal', 'date']
class FilterFieldsRootView(generics.ListCreateAPIView):
model = FilterableItem
paginate_by = 10
filter_class = DecimalFilter
filter_backend = filters.DjangoFilterBackend
view = FilterFieldsRootView.as_view()
EXPECTED_NUM_QUERIES = 2
if django.VERSION < (1, 4):
# On Django 1.3 we need to use django-filter 0.5.4
#
# The filter objects there don't expose a `.count()` method,
# which means we only make a single query *but* it's a single
# query across *all* of the queryset, instead of a COUNT and then
# a SELECT with a LIMIT.
#
# Although this is fewer queries, it's actually a regression.
EXPECTED_NUM_QUERIES = 1
request = factory.get('/?decimal=15.20') request = factory.get('/?decimal=15.20')
response = self.view(request).render() with self.assertNumQueries(EXPECTED_NUM_QUERIES):
response = view(request).render()
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 15) self.assertEqual(response.data['count'], 15)
self.assertEqual(response.data['results'], self.data[:10]) self.assertEqual(response.data['results'], self.data[:10])
@ -133,7 +150,8 @@ class IntegrationTestPaginationAndFiltering(TestCase):
self.assertEqual(response.data['previous'], None) self.assertEqual(response.data['previous'], None)
request = factory.get(response.data['next']) request = factory.get(response.data['next'])
response = self.view(request).render() with self.assertNumQueries(EXPECTED_NUM_QUERIES):
response = view(request).render()
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 15) self.assertEqual(response.data['count'], 15)
self.assertEqual(response.data['results'], self.data[10:15]) self.assertEqual(response.data['results'], self.data[10:15])
@ -141,7 +159,53 @@ class IntegrationTestPaginationAndFiltering(TestCase):
self.assertNotEqual(response.data['previous'], None) self.assertNotEqual(response.data['previous'], None)
request = factory.get(response.data['previous']) request = factory.get(response.data['previous'])
response = self.view(request).render() with self.assertNumQueries(EXPECTED_NUM_QUERIES):
response = view(request).render()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 15)
self.assertEqual(response.data['results'], self.data[:10])
self.assertNotEqual(response.data['next'], None)
self.assertEqual(response.data['previous'], None)
def test_get_basic_paginated_filtered_root_view(self):
"""
Same as `test_get_django_filter_paginated_filtered_root_view`,
except using a custom filter backend instead of the django-filter
backend,
"""
class DecimalFilterBackend(filters.BaseFilterBackend):
def filter_queryset(self, request, queryset, view):
return queryset.filter(decimal__lt=Decimal(request.GET['decimal']))
class BasicFilterFieldsRootView(generics.ListCreateAPIView):
model = FilterableItem
paginate_by = 10
filter_backend = DecimalFilterBackend
view = BasicFilterFieldsRootView.as_view()
request = factory.get('/?decimal=15.20')
with self.assertNumQueries(2):
response = view(request).render()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 15)
self.assertEqual(response.data['results'], self.data[:10])
self.assertNotEqual(response.data['next'], None)
self.assertEqual(response.data['previous'], None)
request = factory.get(response.data['next'])
with self.assertNumQueries(2):
response = view(request).render()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 15)
self.assertEqual(response.data['results'], self.data[10:15])
self.assertEqual(response.data['next'], None)
self.assertNotEqual(response.data['previous'], None)
request = factory.get(response.data['previous'])
with self.assertNumQueries(2):
response = view(request).render()
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 15) self.assertEqual(response.data['count'], 15)
self.assertEqual(response.data['results'], self.data[:10]) self.assertEqual(response.data['results'], self.data[:10])

View File

@ -58,6 +58,14 @@ class TestMethodOverloading(TestCase):
request = Request(factory.post('/', {api_settings.FORM_METHOD_OVERRIDE: 'DELETE'})) request = Request(factory.post('/', {api_settings.FORM_METHOD_OVERRIDE: 'DELETE'}))
self.assertEqual(request.method, 'DELETE') self.assertEqual(request.method, 'DELETE')
def test_x_http_method_override_header(self):
"""
POST requests can also be overloaded to another method by setting
the X-HTTP-Method-Override header.
"""
request = Request(factory.post('/', {'foo': 'bar'}, HTTP_X_HTTP_METHOD_OVERRIDE='DELETE'))
self.assertEqual(request.method, 'DELETE')
class TestContentParsing(TestCase): class TestContentParsing(TestCase):
def test_standard_behaviour_determines_no_content_GET(self): def test_standard_behaviour_determines_no_content_GET(self):

12
tox.ini
View File

@ -8,19 +8,19 @@ commands = {envpython} rest_framework/runtests/runtests.py
[testenv:py3.3-django1.5] [testenv:py3.3-django1.5]
basepython = python3.3 basepython = python3.3
deps = django==1.5 deps = django==1.5
-egit+git://github.com/alex/django-filter.git#egg=django_filter django-filter==0.6a1
defusedxml==0.3 defusedxml==0.3
[testenv:py3.2-django1.5] [testenv:py3.2-django1.5]
basepython = python3.2 basepython = python3.2
deps = django==1.5 deps = django==1.5
-egit+git://github.com/alex/django-filter.git#egg=django_filter django-filter==0.6a1
defusedxml==0.3 defusedxml==0.3
[testenv:py2.7-django1.5] [testenv:py2.7-django1.5]
basepython = python2.7 basepython = python2.7
deps = django==1.5 deps = django==1.5
django-filter==0.5.4 django-filter==0.6a1
defusedxml==0.3 defusedxml==0.3
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
@ -29,7 +29,7 @@ deps = django==1.5
[testenv:py2.6-django1.5] [testenv:py2.6-django1.5]
basepython = python2.6 basepython = python2.6
deps = django==1.5 deps = django==1.5
django-filter==0.5.4 django-filter==0.6a1
defusedxml==0.3 defusedxml==0.3
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
@ -38,7 +38,7 @@ deps = django==1.5
[testenv:py2.7-django1.4] [testenv:py2.7-django1.4]
basepython = python2.7 basepython = python2.7
deps = django==1.4.3 deps = django==1.4.3
django-filter==0.5.4 django-filter==0.6a1
defusedxml==0.3 defusedxml==0.3
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211
@ -47,7 +47,7 @@ deps = django==1.4.3
[testenv:py2.6-django1.4] [testenv:py2.6-django1.4]
basepython = python2.6 basepython = python2.6
deps = django==1.4.3 deps = django==1.4.3
django-filter==0.5.4 django-filter==0.6a1
defusedxml==0.3 defusedxml==0.3
django-oauth-plus==2.0 django-oauth-plus==2.0
oauth2==1.5.211 oauth2==1.5.211