aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorTom Christie2013-09-25 09:44:26 +0100
committerTom Christie2013-09-25 09:44:26 +0100
commit21cd6386593aea0b122abec1c5cc3bd5c544aa87 (patch)
treeb7d197c9c04f56448bee36c4789c93c66fb541a8
parent9a5b2eefa92dede844ab94d049093e91ac98af5b (diff)
parente8c6cd5622f62fcf2d4cf2b28b504fe5ff5228f9 (diff)
downloaddjango-rest-framework-21cd6386593aea0b122abec1c5cc3bd5c544aa87.tar.bz2
Merge master
-rw-r--r--.travis.yml1
-rwxr-xr-xdocs/api-guide/authentication.md2
-rw-r--r--docs/api-guide/exceptions.md47
-rw-r--r--docs/api-guide/filtering.md46
-rwxr-xr-xdocs/api-guide/generic-views.md2
-rw-r--r--docs/api-guide/permissions.md18
-rw-r--r--docs/api-guide/relations.md2
-rw-r--r--docs/api-guide/serializers.md2
-rw-r--r--docs/api-guide/settings.md16
-rw-r--r--docs/api-guide/viewsets.md2
-rw-r--r--docs/index.md2
-rw-r--r--docs/topics/browsable-api.md3
-rw-r--r--docs/topics/credits.md6
-rw-r--r--docs/topics/release-notes.md14
-rw-r--r--docs/topics/writable-nested-serializers.md47
-rw-r--r--docs/tutorial/6-viewsets-and-routers.md2
-rw-r--r--docs/tutorial/quickstart.md2
-rw-r--r--rest_framework/__init__.py2
-rw-r--r--rest_framework/compat.py6
-rw-r--r--rest_framework/filters.py24
-rw-r--r--rest_framework/generics.py11
-rw-r--r--rest_framework/permissions.py60
-rw-r--r--rest_framework/relations.py2
-rw-r--r--rest_framework/renderers.py58
-rw-r--r--rest_framework/runtests/settings.py15
-rw-r--r--rest_framework/settings.py4
-rw-r--r--rest_framework/templates/rest_framework/base.html2
-rw-r--r--rest_framework/tests/test_filters.py14
-rw-r--r--rest_framework/tests/test_generics.py42
-rw-r--r--rest_framework/tests/test_pagination.py2
-rw-r--r--rest_framework/tests/test_permissions.py170
-rw-r--r--rest_framework/tests/test_relations_pk.py9
-rw-r--r--rest_framework/tests/test_views.py41
-rw-r--r--rest_framework/utils/encoders.py2
-rw-r--r--rest_framework/views.py2
-rw-r--r--tox.ini10
36 files changed, 604 insertions, 86 deletions
diff --git a/.travis.yml b/.travis.yml
index f8640db2..7ebe715a 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -18,6 +18,7 @@ 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 django-oauth-plus==2.0 --use-mirrors; fi"
- "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.4 --use-mirrors; fi"
+ - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-guardian==1.1.1 --use-mirrors; fi"
- "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi"
- "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install six --use-mirrors; fi"
- "if [[ ${DJANGO::11} != 'django==1.3' ]]; then pip install django-filter==0.6 --use-mirrors; fi"
diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md
index f30b16ed..7caeac1e 100755
--- a/docs/api-guide/authentication.md
+++ b/docs/api-guide/authentication.md
@@ -404,4 +404,4 @@ The [Django OAuth2 Consumer][doac] library from [Rediker Software][rediker] is a
[oauthlib]: https://github.com/idan/oauthlib
[doac]: https://github.com/Rediker-Software/doac
[rediker]: https://github.com/Rediker-Software
-[doac-rest-framework]: https://github.com/Rediker-Software/doac/blob/master/docs/markdown/integrations.md#
+[doac-rest-framework]: https://github.com/Rediker-Software/doac/blob/master/docs/integrations.md#
diff --git a/docs/api-guide/exceptions.md b/docs/api-guide/exceptions.md
index 8b3e50f1..0c48783a 100644
--- a/docs/api-guide/exceptions.md
+++ b/docs/api-guide/exceptions.md
@@ -28,11 +28,54 @@ For example, the following request:
Might receive an error response indicating that the `DELETE` method is not allowed on that resource:
HTTP/1.1 405 Method Not Allowed
- Content-Type: application/json; charset=utf-8
+ Content-Type: application/json
Content-Length: 42
-
+
{"detail": "Method 'DELETE' not allowed."}
+## Custom exception handling
+
+You can implement custom exception handling by creating a handler function that converts exceptions raised in your API views into response objects. This allows you to control the style of error responses used by your API.
+
+The function must take a single argument, which is the exception to be handled, and should either return a `Response` object, or return `None` if the exception cannot be handled. If the handler returns `None` then the exception will be re-raised and Django will return a standard HTTP 500 'server error' response.
+
+For example, you might want to ensure that all error responses include the HTTP status code in the body of the response, like so:
+
+ HTTP/1.1 405 Method Not Allowed
+ Content-Type: application/json
+ Content-Length: 62
+
+ {"status_code": 405, "detail": "Method 'DELETE' not allowed."}
+
+In order to alter the style of the response, you could write the following custom exception handler:
+
+ from rest_framework.views import exception_handler
+
+ def custom_exception_handler(exc):
+ # Call REST framework's default exception handler first,
+ # to get the standard error response.
+ response = exception_handler(exc)
+
+ # Now add the HTTP status code to the response.
+ if response is not None:
+ response.data['status_code'] = response.status_code
+
+ return response
+
+The exception handler must also be configured in your settings, using the `EXCEPTION_HANDLER` setting key. For example:
+
+ REST_FRAMEWORK = {
+ 'EXCEPTION_HANDLER': 'my_project.my_app.utils.custom_exception_handler'
+ }
+
+If not specified, the `'EXCEPTION_HANDLER'` setting defaults to the standard exception handler provided by REST framework:
+
+ REST_FRAMEWORK = {
+ 'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler'
+ }
+
+Note that the exception handler will only be called for responses generated by raised exceptions. It will not be used for any responses returned directly by the view, such as the `HTTP_400_BAD_REQUEST` responses that are returned by the generic views when serializer validation fails.
+
---
# API Reference
diff --git a/docs/api-guide/filtering.md b/docs/api-guide/filtering.md
index 649462da..859e8d52 100644
--- a/docs/api-guide/filtering.md
+++ b/docs/api-guide/filtering.md
@@ -257,6 +257,49 @@ The `ordering` attribute may be either a string or a list/tuple of strings.
---
+## DjangoObjectPermissionsFilter
+
+The `DjangoObjectPermissionsFilter` is intended to be used together with the [`django-guardian`][guardian] package, with custom `'view'` permissions added. The filter will ensure that querysets only returns objects for which the user has the appropriate view permission.
+
+This filter class must be used with views that provide either a `queryset` or a `model` attribute.
+
+If you're using `DjangoObjectPermissionsFilter`, you'll probably also want to add an appropriate object permissions class, to ensure that users can only operate on instances if they have the appropriate object permissions. The easiest way to do this is to subclass `DjangoObjectPermissions` and add `'view'` permissions to the `perms_map` attribute.
+
+A complete example using both `DjangoObjectPermissionsFilter` and `DjangoObjectPermissions` might look something like this.
+
+**permissions.py**:
+
+ class CustomObjectPermissions(permissions.DjangoObjectPermissions):
+ """
+ Similar to `DjangoObjectPermissions`, but adding 'view' permissions.
+ """
+ perms_map = {
+ 'GET': ['%(app_label)s.view_%(model_name)s'],
+ 'OPTIONS': ['%(app_label)s.view_%(model_name)s'],
+ 'HEAD': ['%(app_label)s.view_%(model_name)s'],
+ 'POST': ['%(app_label)s.add_%(model_name)s'],
+ 'PUT': ['%(app_label)s.change_%(model_name)s'],
+ 'PATCH': ['%(app_label)s.change_%(model_name)s'],
+ 'DELETE': ['%(app_label)s.delete_%(model_name)s'],
+ }
+
+**views.py**:
+
+ class EventViewSet(viewsets.ModelViewSet):
+ """
+ Viewset that only lists events if user has 'view' permissions, and only
+ allows operations on individual events if user has appropriate 'view', 'add',
+ 'change' or 'delete' permissions.
+ """
+ queryset = Event.objects.all()
+ serializer = EventSerializer
+ filter_backends = (filters.DjangoObjectPermissionsFilter,)
+ permission_classes = (myapp.permissions.CustomObjectPermissions,)
+
+For more information on adding `'view'` permissions for models, see the [relevant section][view-permissions] of the `django-guardian` documentation, and [this blogpost][view-permissions-blogpost].
+
+---
+
# Custom generic filtering
You can also provide your own generic filtering backend, or write an installable app for other developers to use.
@@ -281,5 +324,8 @@ We could achieve the same behavior by overriding `get_queryset()` on the views,
[cite]: https://docs.djangoproject.com/en/dev/topics/db/queries/#retrieving-specific-objects-with-filters
[django-filter]: https://github.com/alex/django-filter
[django-filter-docs]: https://django-filter.readthedocs.org/en/latest/index.html
+[guardian]: http://pythonhosted.org/django-guardian/
+[view-permissions]: http://pythonhosted.org/django-guardian/userguide/assign.html
+[view-permissions-blogpost]: http://blog.nyaruka.com/adding-a-view-permission-to-django-models
[nullbooleanselect]: https://github.com/django/django/blob/master/django/forms/widgets.py
[search-django-admin]: https://docs.djangoproject.com/en/dev/ref/contrib/admin/#django.contrib.admin.ModelAdmin.search_fields
diff --git a/docs/api-guide/generic-views.md b/docs/api-guide/generic-views.md
index 7185b6b6..dc0076df 100755
--- a/docs/api-guide/generic-views.md
+++ b/docs/api-guide/generic-views.md
@@ -69,7 +69,7 @@ The following attributes control the basic view behavior.
**Shortcuts**:
-* `model` - This shortcut may be used instead of setting either (or both) of the `queryset`/`serializer_class` attributes, although using the explicit style is generally preferred. If used instead of `serializer_class`, then then `DEFAULT_MODEL_SERIALIZER_CLASS` setting will determine the base serializer class.
+* `model` - This shortcut may be used instead of setting either (or both) of the `queryset`/`serializer_class` attributes, although using the explicit style is generally preferred. If used instead of `serializer_class`, then then `DEFAULT_MODEL_SERIALIZER_CLASS` setting will determine the base serializer class. Note that `model` is only ever used for generating a default queryset or serializer class - the `queryset` and `serializer_class` attributes are always preferred if provided.
**Pagination**:
diff --git a/docs/api-guide/permissions.md b/docs/api-guide/permissions.md
index a7bf1555..871de84e 100644
--- a/docs/api-guide/permissions.md
+++ b/docs/api-guide/permissions.md
@@ -120,7 +120,21 @@ To use custom model permissions, override `DjangoModelPermissions` and set the `
## DjangoModelPermissionsOrAnonReadOnly
-Similar to `DjangoModelPermissions`, but also allows unauthenticated users to have read-only access to the API.
+Similar to `DjangoModelPermissions`, but also allows unauthenticated users to have read-only access to the API.
+
+## DjangoObjectPermissions
+
+This permission class ties into Django's standard [object permissions framework][objectpermissions] that allows per-object permissions on models. In order to use this permission class, you'll also need to add a permission backend that supports object-level permissions, such as [django-guardian][guardian].
+
+When applied to a view that has a `.model` property, authorization will only be granted if the user *is authenticated* and has the *relevant per-object permissions* and *relevant model permissions* assigned.
+
+* `POST` requests require the user to have the `add` permission on the model instance.
+* `PUT` and `PATCH` requests require the user to have the `change` permission on the model instance.
+* `DELETE` requests require the user to have the `delete` permission on the model instance.
+
+Note that `DjangoObjectPermissions` **does not** require the `django-guardian` package, and should support other object-level backends equally well.
+
+As with `DjangoModelPermissions` you can use custom model permissions by overriding `DjangoModelPermissions` and setting the `.perms_map` property. Refer to the source code for details. Note that if you add a custom `view` permission for `GET`, `HEAD` and `OPTIONS` requests, you'll probably also want to consider adding the `DjangoObjectPermissionsFilter` class to ensure that list endpoints only return results including objects for which the user has appropriate view permissions.
## TokenHasReadWriteScope
@@ -220,7 +234,9 @@ The [Composed Permissions][composed-permissions] package provides a simple way t
[authentication]: authentication.md
[throttling]: throttling.md
[contribauth]: https://docs.djangoproject.com/en/1.0/topics/auth/#permissions
+[objectpermissions]: https://docs.djangoproject.com/en/dev/topics/auth/customizing/#handling-object-permissions
[guardian]: https://github.com/lukaszb/django-guardian
+[get_objects_for_user]: http://pythonhosted.org/django-guardian/api/guardian.shortcuts.html#get-objects-for-user
[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
diff --git a/docs/api-guide/relations.md b/docs/api-guide/relations.md
index 15ba9a3a..5ec4b22f 100644
--- a/docs/api-guide/relations.md
+++ b/docs/api-guide/relations.md
@@ -421,7 +421,7 @@ For example, if all your object URLs used both a account and a slug in the the U
def get_object(self, queryset, view_name, view_args, view_kwargs):
account = view_kwargs['account']
slug = view_kwargs['slug']
- return queryset.get(account=account, slug=sug)
+ return queryset.get(account=account, slug=slug)
---
diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md
index 5d7e2ac8..a3cd1d6a 100644
--- a/docs/api-guide/serializers.md
+++ b/docs/api-guide/serializers.md
@@ -250,7 +250,7 @@ This allows you to write views that update or create multiple items when a `PUT`
serializer = BookSerializer(queryset, data=data, many=True)
serializer.is_valid()
# True
- serialize.save() # `.save()` will be called on each updated or newly created instance.
+ serializer.save() # `.save()` will be called on each updated or newly created instance.
By default bulk updates will be limited to updating instances that already exist in the provided queryset.
diff --git a/docs/api-guide/settings.md b/docs/api-guide/settings.md
index 542e8c5f..13f96f9a 100644
--- a/docs/api-guide/settings.md
+++ b/docs/api-guide/settings.md
@@ -25,7 +25,7 @@ If you need to access the values of REST framework's API settings in your projec
you should use the `api_settings` object. For example.
from rest_framework.settings import api_settings
-
+
print api_settings.DEFAULT_AUTHENTICATION_CLASSES
The `api_settings` object will check for any user-defined settings, and otherwise fall back to the default values. Any setting that uses string import paths to refer to a class will automatically import and return the referenced class, instead of the string literal.
@@ -339,6 +339,20 @@ Default: `'rest_framework.views.get_view_description'`
## Miscellaneous settings
+#### EXCEPTION_HANDLER
+
+A string representing the function that should be used when returning a response for any given exception. If the function returns `None`, a 500 error will be raised.
+
+This setting can be changed to support error responses other than the default `{"detail": "Failure..."}` responses. For example, you can use it to provide API responses like `{"errors": [{"message": "Failure...", "code": ""} ...]}`.
+
+This should be a function with the following signature:
+
+ exception_handler(exc)
+
+* `exc`: The exception.
+
+Default: `'rest_framework.views.exception_handler'`
+
#### FORMAT_SUFFIX_KWARG
The name of a parameter in the URL conf that may be used to provide a format suffix.
diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md
index 6498e177..a5359e99 100644
--- a/docs/api-guide/viewsets.md
+++ b/docs/api-guide/viewsets.md
@@ -23,7 +23,7 @@ Let's define a simple viewset that can be used to list or retrieve all the users
from django.shortcuts import get_object_or_404
from myapps.serializers import UserSerializer
from rest_framework import viewsets
- from rest_framewor.responses import Response
+ from rest_framework.response import Response
class UserViewSet(viewsets.ViewSet):
"""
diff --git a/docs/index.md b/docs/index.md
index e0a2e911..bb2129f6 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -42,6 +42,7 @@ The following packages are optional:
* [django-filter][django-filter] (0.5.4+) - Filtering support.
* [django-oauth-plus][django-oauth-plus] (2.0+) and [oauth2][oauth2] (1.5.211+) - OAuth 1.0a support.
* [django-oauth2-provider][django-oauth2-provider] (0.2.3+) - OAuth 2.0 support.
+* [django-guardian][django-guardian] (1.1.1+) - Object level permissions support.
**Note**: The `oauth2` Python package is badly misnamed, and actually provides OAuth 1.0a support. Also note that packages required for both OAuth 1.0a, and OAuth 2.0 are not yet Python 3 compatible.
@@ -250,6 +251,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
[oauth2]: https://github.com/simplegeo/python-oauth2
[django-oauth-plus]: https://bitbucket.org/david/django-oauth-plus/wiki/Home
[django-oauth2-provider]: https://github.com/caffeinehit/django-oauth2-provider
+[django-guardian]: https://github.com/lukaszb/django-guardian
[0.4]: https://github.com/tomchristie/django-rest-framework/tree/0.4.X
[image]: img/quickstart.png
[index]: .
diff --git a/docs/topics/browsable-api.md b/docs/topics/browsable-api.md
index b2c78f3c..e32db695 100644
--- a/docs/topics/browsable-api.md
+++ b/docs/topics/browsable-api.md
@@ -115,6 +115,7 @@ The context that's available to the template:
* `name` : The name of the resource
* `post_form` : A form instance for use by the POST form (if allowed)
* `put_form` : A form instance for use by the PUT form (if allowed)
+* `display_edit_forms` : A boolean indicating whether or not POST, PUT and PATCH forms will be displayed
* `request` : The request object
* `response` : The response object
* `version` : The version of Django REST Framework
@@ -122,6 +123,8 @@ The context that's available to the template:
* `FORMAT_PARAM` : The view can accept a format override
* `METHOD_PARAM` : The view can accept a method override
+You can override the `BrowsableAPIRenderer.get_context()` method to customise the context that gets passed to the template.
+
#### Not using base.html
For more advanced customization, such as not having a Bootstrap basis or tighter integration with the rest of your site, you can simply choose not to have `api.html` extend `base.html`. Then the page content and capabilities are entirely up to you.
diff --git a/docs/topics/credits.md b/docs/topics/credits.md
index b2d3d5d2..4483f170 100644
--- a/docs/topics/credits.md
+++ b/docs/topics/credits.md
@@ -166,6 +166,9 @@ The following people have helped make REST framework great.
* Alexander Akhmetov - [alexander-akhmetov]
* Andrey Antukh - [niwibe]
* Mathieu Pillard - [diox]
+* Edmond Wong - [edmondwong]
+* Ben Reilly - [bwreilly]
+* Tai Lee - [mrmachine]
Many thanks to everyone who's contributed to the project.
@@ -368,3 +371,6 @@ You can also contact [@_tomchristie][twitter] directly on twitter.
[alexander-akhmetov]: https://github.com/alexander-akhmetov
[niwibe]: https://github.com/niwibe
[diox]: https://github.com/diox
+[edmondwong]: https://github.com/edmondwong
+[bwreilly]: https://github.com/bwreilly
+[mrmachine]: https://github.com/mrmachine
diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md
index 5e3aa2f0..a3f3ed3c 100644
--- a/docs/topics/release-notes.md
+++ b/docs/topics/release-notes.md
@@ -48,6 +48,20 @@ You can determine your currently installed version using `pip freeze`:
* Added `MAX_PAGINATE_BY` setting and `max_paginate_by` generic view attribute.
* Added `cache` attribute to throttles to allow overriding of default cache.
* Bugfix: `?page_size=0` query parameter now falls back to default page size for view, instead of always turning pagination off.
+* Added JSON renderer support for numpy scalars.
+* Added `get_context` hook in `BrowsableAPIRenderer`.
+
+### 2.3.8
+
+**Date**: 11th September 2013
+
+* Added `DjangoObjectPermissions`, and `DjangoObjectPermissionsFilter`.
+* Support customizable exception handling, using the `EXCEPTION_HANDLER` setting.
+* Support customizable view name and description functions, using the `VIEW_NAME_FUNCTION` and `VIEW_DESCRIPTION_FUNCTION` settings.
+* Added `MAX_PAGINATE_BY` setting and `max_paginate_by` generic view attribute.
+* Added `cache` attribute to throttles to allow overriding of default cache.
+* 'Raw data' tab in browsable API now contains pre-populated data.
+* 'Raw data' and 'HTML form' tab preference in browseable API now saved between page views.
* Bugfix: `required=True` argument fixed for boolean serializer fields.
* Bugfix: `client.force_authenticate(None)` should also clear session info if it exists.
* Bugfix: Client sending emptry string instead of file now clears `FileField`.
diff --git a/docs/topics/writable-nested-serializers.md b/docs/topics/writable-nested-serializers.md
new file mode 100644
index 00000000..66ea7815
--- /dev/null
+++ b/docs/topics/writable-nested-serializers.md
@@ -0,0 +1,47 @@
+> To save HTTP requests, it may be convenient to send related documents along with the request.
+>
+> — [JSON API specification for Ember Data][cite].
+
+# Writable nested serializers
+
+Although flat data structures serve to properly delineate between the individual entities in your service, there are cases where it may be more appropriate or convenient to use nested data structures.
+
+Nested data structures are easy enough to work with if they're read-only - simply nest your serializer classes and you're good to go. However, there are a few more subtleties to using writable nested serializers, due to the dependancies between the various model instances, and the need to save or delete multiple instances in a single action.
+
+## One-to-many data structures
+
+*Example of a **read-only** nested serializer. Nothing complex to worry about here.*
+
+ class ToDoItemSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ToDoItem
+ fields = ('text', 'is_completed')
+
+ class ToDoListSerializer(serializers.ModelSerializer):
+ items = ToDoItemSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = ToDoList
+ fields = ('title', 'items')
+
+Some example output from our serializer.
+
+ {
+ 'title': 'Leaving party preperations',
+ 'items': {
+ {'text': 'Compile playlist', 'is_completed': True},
+ {'text': 'Send invites', 'is_completed': False},
+ {'text': 'Clean house', 'is_completed': False}
+ }
+ }
+
+Let's take a look at updating our nested one-to-many data structure.
+
+### Validation errors
+
+### Adding and removing items
+
+### Making PATCH requests
+
+
+[cite]: http://jsonapi.org/format/#url-based-json-api \ No newline at end of file
diff --git a/docs/tutorial/6-viewsets-and-routers.md b/docs/tutorial/6-viewsets-and-routers.md
index 29c53162..3b9fd7d4 100644
--- a/docs/tutorial/6-viewsets-and-routers.md
+++ b/docs/tutorial/6-viewsets-and-routers.md
@@ -61,6 +61,7 @@ To see what's going on under the hood let's first explicitly create a set of vie
In the `urls.py` file we bind our `ViewSet` classes into a set of concrete views.
from snippets.views import SnippetViewSet, UserViewSet
+ from rest_framework import renderers
snippet_list = SnippetViewSet.as_view({
'get': 'list',
@@ -101,6 +102,7 @@ Because we're using `ViewSet` classes rather than `View` classes, we actually do
Here's our re-wired `urls.py` file.
+ from django.conf.urls import patterns, url, include
from snippets import views
from rest_framework.routers import DefaultRouter
diff --git a/docs/tutorial/quickstart.md b/docs/tutorial/quickstart.md
index f15e75c0..06eec3c4 100644
--- a/docs/tutorial/quickstart.md
+++ b/docs/tutorial/quickstart.md
@@ -12,7 +12,7 @@ Create a new Django project named `tutorial`, then start a new app called `quick
# Create a virtualenv to isolate our package dependencies locally
virtualenv env
- source env/bin/activate
+ source env/bin/activate # On Windows use `env\Scripts\activate`
# Install Django and Django REST framework into the virtualenv
pip install django
diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py
index 087808e0..2bd2991b 100644
--- a/rest_framework/__init__.py
+++ b/rest_framework/__init__.py
@@ -1,4 +1,4 @@
-__version__ = '2.3.7'
+__version__ = '2.3.8'
VERSION = __version__ # synonym
diff --git a/rest_framework/compat.py b/rest_framework/compat.py
index 66be96a6..1238f043 100644
--- a/rest_framework/compat.py
+++ b/rest_framework/compat.py
@@ -47,6 +47,12 @@ try:
except ImportError:
django_filters = None
+# guardian is optional
+try:
+ import guardian
+except ImportError:
+ guardian = None
+
# cStringIO only if it's available, otherwise StringIO
try:
diff --git a/rest_framework/filters.py b/rest_framework/filters.py
index 4079e1bd..b8fe7f77 100644
--- a/rest_framework/filters.py
+++ b/rest_framework/filters.py
@@ -4,7 +4,7 @@ returned by list views.
"""
from __future__ import unicode_literals
from django.db import models
-from rest_framework.compat import django_filters, six
+from rest_framework.compat import django_filters, six, guardian
from functools import reduce
import operator
@@ -53,6 +53,7 @@ class DjangoFilterBackend(BaseFilterBackend):
class Meta:
model = queryset.model
fields = filter_fields
+ order_by = True
return AutoFilterSet
return None
@@ -140,3 +141,24 @@ class OrderingFilter(BaseFilterBackend):
return queryset.order_by(*ordering)
return queryset
+
+
+class DjangoObjectPermissionsFilter(BaseFilterBackend):
+ """
+ A filter backend that limits results to those where the requesting user
+ has read object level permissions.
+ """
+ def __init__(self):
+ assert guardian, 'Using DjangoObjectPermissionsFilter, but django-guardian is not installed'
+
+ perm_format = '%(app_label)s.view_%(model_name)s'
+
+ def filter_queryset(self, request, queryset, view):
+ user = request.user
+ model_cls = queryset.model
+ kwargs = {
+ 'app_label': model_cls._meta.app_label,
+ 'model_name': model_cls._meta.module_name
+ }
+ permission = self.perm_format % kwargs
+ return guardian.shortcuts.get_objects_for_user(user, permission, queryset)
diff --git a/rest_framework/generics.py b/rest_framework/generics.py
index 851f8474..5fb37db7 100644
--- a/rest_framework/generics.py
+++ b/rest_framework/generics.py
@@ -356,8 +356,15 @@ class GenericAPIView(views.APIView):
self.check_permissions(cloned_request)
# Test object permissions
if method == 'PUT':
- self.get_object()
- except (exceptions.APIException, PermissionDenied, Http404):
+ try:
+ self.get_object()
+ except Http404:
+ # Http404 should be acceptable and the serializer
+ # metadata should be populated. Except this so the
+ # outer "else" clause of the try-except-else block
+ # will be executed.
+ pass
+ except (exceptions.APIException, PermissionDenied):
pass
else:
# If user has appropriate permissions for the view, include
diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py
index 0c7b02ff..14bec42c 100644
--- a/rest_framework/permissions.py
+++ b/rest_framework/permissions.py
@@ -2,6 +2,7 @@
Provides a set of pluggable permission policies.
"""
from __future__ import unicode_literals
+from django.http import Http404
from rest_framework.compat import oauth2_provider_scope, oauth2_constants
SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS']
@@ -141,6 +142,65 @@ class DjangoModelPermissionsOrAnonReadOnly(DjangoModelPermissions):
authenticated_users_only = False
+class DjangoObjectPermissions(DjangoModelPermissions):
+ """
+ The request is authenticated using Django's object-level permissions.
+ It requires an object-permissions-enabled backend, such as Django Guardian.
+
+ It ensures that the user is authenticated, and has the appropriate
+ `add`/`change`/`delete` permissions on the object using .has_perms.
+
+ This permission can only be applied against view classes that
+ provide a `.model` or `.queryset` attribute.
+ """
+
+ perms_map = {
+ 'GET': [],
+ 'OPTIONS': [],
+ 'HEAD': [],
+ 'POST': ['%(app_label)s.add_%(model_name)s'],
+ 'PUT': ['%(app_label)s.change_%(model_name)s'],
+ 'PATCH': ['%(app_label)s.change_%(model_name)s'],
+ 'DELETE': ['%(app_label)s.delete_%(model_name)s'],
+ }
+
+ def get_required_object_permissions(self, method, model_cls):
+ kwargs = {
+ 'app_label': model_cls._meta.app_label,
+ 'model_name': model_cls._meta.module_name
+ }
+ return [perm % kwargs for perm in self.perms_map[method]]
+
+ def has_object_permission(self, request, view, obj):
+ model_cls = getattr(view, 'model', None)
+ queryset = getattr(view, 'queryset', None)
+
+ if model_cls is None and queryset is not None:
+ model_cls = queryset.model
+
+ perms = self.get_required_object_permissions(request.method, model_cls)
+ user = request.user
+
+ if not user.has_perms(perms, obj):
+ # If the user does not have permissions we need to determine if
+ # they have read permissions to see 403, or not, and simply see
+ # a 404 reponse.
+
+ if request.method in ('GET', 'OPTIONS', 'HEAD'):
+ # Read permissions already checked and failed, no need
+ # to make another lookup.
+ raise Http404
+
+ read_perms = self.get_required_object_permissions('GET', model_cls)
+ if not user.has_perms(read_perms, obj):
+ raise Http404
+
+ # Has read permissions.
+ return False
+
+ return True
+
+
class TokenHasReadWriteScope(BasePermission):
"""
The request is authenticated as a user and the token used has the right scope
diff --git a/rest_framework/relations.py b/rest_framework/relations.py
index 417925b5..4785c009 100644
--- a/rest_framework/relations.py
+++ b/rest_framework/relations.py
@@ -256,7 +256,7 @@ class PrimaryKeyRelatedField(RelatedField):
# RelatedObject (reverse relationship)
try:
pk = getattr(obj, self.source or field_name).pk
- except ObjectDoesNotExist:
+ except (ObjectDoesNotExist, AttributeError):
return None
# Forward relationship
diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py
index fca67eee..2ce51e97 100644
--- a/rest_framework/renderers.py
+++ b/rest_framework/renderers.py
@@ -564,67 +564,65 @@ class BrowsableAPIRenderer(BaseRenderer):
def get_breadcrumbs(self, request):
return get_breadcrumbs(request.path)
- def render(self, data, accepted_media_type=None, renderer_context=None):
+ def get_context(self, data, accepted_media_type, renderer_context):
"""
- Render the HTML for the browsable API representation.
+ Returns the context used to render.
"""
- self.accepted_media_type = accepted_media_type or ''
- self.renderer_context = renderer_context or {}
-
view = renderer_context['view']
request = renderer_context['request']
response = renderer_context['response']
renderer = self.get_default_renderer(view)
- content = self.get_content(renderer, data, accepted_media_type, renderer_context)
-
- put_form = self.get_rendered_html_form(view, 'PUT', request)
- post_form = self.get_rendered_html_form(view, 'POST', request)
- patch_form = self.get_rendered_html_form(view, 'PATCH', request)
- delete_form = self.get_rendered_html_form(view, 'DELETE', request)
- options_form = self.get_rendered_html_form(view, 'OPTIONS', request)
raw_data_put_form = self.get_raw_data_form(view, 'PUT', request)
- raw_data_post_form = self.get_raw_data_form(view, 'POST', request)
raw_data_patch_form = self.get_raw_data_form(view, 'PATCH', request)
raw_data_put_or_patch_form = raw_data_put_form or raw_data_patch_form
- name = self.get_name(view)
- description = self.get_description(view)
- breadcrumb_list = self.get_breadcrumbs(request)
-
- template = loader.get_template(self.template)
- context = RequestContext(request, {
- 'content': content,
+ context = {
+ 'content': self.get_content(renderer, data, accepted_media_type, renderer_context),
'view': view,
'request': request,
'response': response,
- 'description': description,
- 'name': name,
+ 'description': self.get_description(view),
+ 'name': self.get_name(view),
'version': VERSION,
- 'breadcrumblist': breadcrumb_list,
+ 'breadcrumblist': self.get_breadcrumbs(request),
'allowed_methods': view.allowed_methods,
'available_formats': [renderer.format for renderer in view.renderer_classes],
- 'put_form': put_form,
- 'post_form': post_form,
- 'patch_form': patch_form,
- 'delete_form': delete_form,
- 'options_form': options_form,
+ 'put_form': self.get_rendered_html_form(view, 'PUT', request),
+ 'post_form': self.get_rendered_html_form(view, 'POST', request),
+ 'patch_form': self.get_rendered_html_form(view, 'PATCH', request),
+ 'delete_form': self.get_rendered_html_form(view, 'DELETE', request),
+ 'options_form': self.get_rendered_html_form(view, 'OPTIONS', request),
'raw_data_put_form': raw_data_put_form,
- 'raw_data_post_form': raw_data_post_form,
+ 'raw_data_post_form': self.get_raw_data_form(view, 'POST', request),
'raw_data_patch_form': raw_data_patch_form,
'raw_data_put_or_patch_form': raw_data_put_or_patch_form,
+ 'display_edit_forms': bool(response.status_code != 403),
+
'api_settings': api_settings
- })
+ }
+ return context
+ def render(self, data, accepted_media_type=None, renderer_context=None):
+ """
+ Render the HTML for the browsable API representation.
+ """
+ self.accepted_media_type = accepted_media_type or ''
+ self.renderer_context = renderer_context or {}
+
+ template = loader.get_template(self.template)
+ context = self.get_context(data, accepted_media_type, renderer_context)
+ context = RequestContext(renderer_context['request'], context)
ret = template.render(context)
# Munge DELETE Response code to allow us to return content
# (Do this *after* we've rendered the template so that we include
# the normal deletion response code in the output)
+ response = renderer_context['response']
if response.status_code == status.HTTP_204_NO_CONTENT:
response.status_code = status.HTTP_200_OK
diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py
index b3702d0b..be721658 100644
--- a/rest_framework/runtests/settings.py
+++ b/rest_framework/runtests/settings.py
@@ -123,6 +123,21 @@ else:
'provider.oauth2',
)
+# guardian is optional
+try:
+ import guardian
+except ImportError:
+ pass
+else:
+ ANONYMOUS_USER_ID = -1
+ AUTHENTICATION_BACKENDS = (
+ 'django.contrib.auth.backends.ModelBackend', # default
+ 'guardian.backends.ObjectPermissionBackend',
+ )
+ INSTALLED_APPS += (
+ 'guardian',
+ )
+
STATIC_URL = '/static/'
PASSWORD_HASHERS = (
diff --git a/rest_framework/settings.py b/rest_framework/settings.py
index 8c084751..8abaf140 100644
--- a/rest_framework/settings.py
+++ b/rest_framework/settings.py
@@ -77,6 +77,9 @@ DEFAULTS = {
'VIEW_NAME_FUNCTION': 'rest_framework.views.get_view_name',
'VIEW_DESCRIPTION_FUNCTION': 'rest_framework.views.get_view_description',
+ # Exception handling
+ 'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler',
+
# Testing
'TEST_REQUEST_RENDERER_CLASSES': (
'rest_framework.renderers.MultiPartRenderer',
@@ -125,6 +128,7 @@ IMPORT_STRINGS = (
'DEFAULT_MODEL_SERIALIZER_CLASS',
'DEFAULT_PAGINATION_SERIALIZER_CLASS',
'DEFAULT_FILTER_BACKENDS',
+ 'EXCEPTION_HANDLER',
'FILTER_BACKEND',
'TEST_REQUEST_RENDERER_CLASSES',
'UNAUTHENTICATED_USER',
diff --git a/rest_framework/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html
index aa90e90c..2776d550 100644
--- a/rest_framework/templates/rest_framework/base.html
+++ b/rest_framework/templates/rest_framework/base.html
@@ -122,7 +122,7 @@
</div>
</div>
- {% if response.status_code != 403 %}
+ {% if display_edit_forms %}
{% if post_form or raw_data_post_form %}
<div {% if post_form %}class="tabbable"{% endif %}>
diff --git a/rest_framework/tests/test_filters.py b/rest_framework/tests/test_filters.py
index c9d9e7ff..379db29d 100644
--- a/rest_framework/tests/test_filters.py
+++ b/rest_framework/tests/test_filters.py
@@ -113,7 +113,7 @@ class IntegrationTestFiltering(CommonFilteringTestCase):
Integration tests for filtered list views.
"""
- @unittest.skipUnless(django_filters, 'django-filters not installed')
+ @unittest.skipUnless(django_filters, 'django-filter not installed')
def test_get_filtered_fields_root_view(self):
"""
GET requests to paginated ListCreateAPIView should return paginated results.
@@ -142,7 +142,7 @@ class IntegrationTestFiltering(CommonFilteringTestCase):
expected_data = [f for f in self.data if f['date'] == search_date]
self.assertEqual(response.data, expected_data)
- @unittest.skipUnless(django_filters, 'django-filters not installed')
+ @unittest.skipUnless(django_filters, 'django-filter not installed')
def test_filter_with_queryset(self):
"""
Regression test for #814.
@@ -157,7 +157,7 @@ class IntegrationTestFiltering(CommonFilteringTestCase):
expected_data = [f for f in self.data if f['decimal'] == search_decimal]
self.assertEqual(response.data, expected_data)
- @unittest.skipUnless(django_filters, 'django-filters not installed')
+ @unittest.skipUnless(django_filters, 'django-filter not installed')
def test_filter_with_get_queryset_only(self):
"""
Regression test for #834.
@@ -168,7 +168,7 @@ class IntegrationTestFiltering(CommonFilteringTestCase):
# Used to raise "issubclass() arg 2 must be a class or tuple of classes"
# here when neither `model' nor `queryset' was specified.
- @unittest.skipUnless(django_filters, 'django-filters not installed')
+ @unittest.skipUnless(django_filters, 'django-filter not installed')
def test_get_filtered_class_root_view(self):
"""
GET requests to filtered ListCreateAPIView that have a filter_class set
@@ -216,7 +216,7 @@ class IntegrationTestFiltering(CommonFilteringTestCase):
f['decimal'] < search_decimal]
self.assertEqual(response.data, expected_data)
- @unittest.skipUnless(django_filters, 'django-filters not installed')
+ @unittest.skipUnless(django_filters, 'django-filter not installed')
def test_incorrectly_configured_filter(self):
"""
An error should be displayed when the filter class is misconfigured.
@@ -226,7 +226,7 @@ class IntegrationTestFiltering(CommonFilteringTestCase):
request = factory.get('/')
self.assertRaises(AssertionError, view, request)
- @unittest.skipUnless(django_filters, 'django-filters not installed')
+ @unittest.skipUnless(django_filters, 'django-filter not installed')
def test_unknown_filter(self):
"""
GET requests with filters that aren't configured should return 200.
@@ -248,7 +248,7 @@ class IntegrationTestDetailFiltering(CommonFilteringTestCase):
def _get_url(self, item):
return reverse('detail-view', kwargs=dict(pk=item.pk))
- @unittest.skipUnless(django_filters, 'django-filters not installed')
+ @unittest.skipUnless(django_filters, 'django-filter not installed')
def test_get_filtered_detail_view(self):
"""
GET requests to filtered RetrieveAPIView that have a filter_class set
diff --git a/rest_framework/tests/test_generics.py b/rest_framework/tests/test_generics.py
index 7a87d389..79cd99ac 100644
--- a/rest_framework/tests/test_generics.py
+++ b/rest_framework/tests/test_generics.py
@@ -272,6 +272,48 @@ class TestInstanceView(TestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, expected)
+ def test_options_before_instance_create(self):
+ """
+ OPTIONS requests to RetrieveUpdateDestroyAPIView should return metadata
+ before the instance has been created
+ """
+ request = factory.options('/999')
+ with self.assertNumQueries(1):
+ response = self.view(request, pk=999).render()
+ expected = {
+ 'parses': [
+ 'application/json',
+ 'application/x-www-form-urlencoded',
+ 'multipart/form-data'
+ ],
+ 'renders': [
+ 'application/json',
+ 'text/html'
+ ],
+ 'name': 'Instance',
+ 'description': 'Example description for OPTIONS.',
+ 'actions': {
+ 'PUT': {
+ 'text': {
+ 'max_length': 100,
+ 'read_only': False,
+ 'required': True,
+ 'type': 'string',
+ 'label': 'Text comes here',
+ 'help_text': 'Text description.'
+ },
+ 'id': {
+ 'read_only': True,
+ 'required': False,
+ 'type': 'integer',
+ 'label': 'ID',
+ },
+ }
+ }
+ }
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data, expected)
+
def test_get_instance_view_incorrect_arg(self):
"""
GET requests with an incorrect pk type, should raise 404, not 500.
diff --git a/rest_framework/tests/test_pagination.py b/rest_framework/tests/test_pagination.py
index 4170d4b6..d6bc7895 100644
--- a/rest_framework/tests/test_pagination.py
+++ b/rest_framework/tests/test_pagination.py
@@ -122,7 +122,7 @@ class IntegrationTestPaginationAndFiltering(TestCase):
for obj in self.objects.all()
]
- @unittest.skipUnless(django_filters, 'django-filters not installed')
+ @unittest.skipUnless(django_filters, 'django-filter not installed')
def test_get_django_filter_paginated_filtered_root_view(self):
"""
GET requests to paginated filtered ListCreateAPIView should return
diff --git a/rest_framework/tests/test_permissions.py b/rest_framework/tests/test_permissions.py
index e2cca380..d08124f4 100644
--- a/rest_framework/tests/test_permissions.py
+++ b/rest_framework/tests/test_permissions.py
@@ -1,18 +1,17 @@
from __future__ import unicode_literals
-from django.contrib.auth.models import User, Permission
+from django.contrib.auth.models import User, Permission, Group
from django.db import models
from django.test import TestCase
+from django.utils import unittest
from rest_framework import generics, status, permissions, authentication, HTTP_HEADER_ENCODING
+from rest_framework.compat import guardian
+from rest_framework.filters import DjangoObjectPermissionsFilter
from rest_framework.test import APIRequestFactory
+from rest_framework.tests.models import BasicModel
import base64
factory = APIRequestFactory()
-
-class BasicModel(models.Model):
- text = models.CharField(max_length=100)
-
-
class RootView(generics.ListCreateAPIView):
model = BasicModel
authentication_classes = [authentication.BasicAuthentication]
@@ -144,45 +143,158 @@ class ModelPermissionsIntegrationTests(TestCase):
self.assertEqual(list(response.data['actions'].keys()), ['PUT'])
-class OwnerModel(models.Model):
+class BasicPermModel(models.Model):
text = models.CharField(max_length=100)
- owner = models.ForeignKey(User)
+ class Meta:
+ app_label = 'tests'
+ permissions = (
+ ('view_basicpermmodel', 'Can view basic perm model'),
+ # add, change, delete built in to django
+ )
+
+# Custom object-level permission, that includes 'view' permissions
+class ViewObjectPermissions(permissions.DjangoObjectPermissions):
+ perms_map = {
+ 'GET': ['%(app_label)s.view_%(model_name)s'],
+ 'OPTIONS': ['%(app_label)s.view_%(model_name)s'],
+ 'HEAD': ['%(app_label)s.view_%(model_name)s'],
+ 'POST': ['%(app_label)s.add_%(model_name)s'],
+ 'PUT': ['%(app_label)s.change_%(model_name)s'],
+ 'PATCH': ['%(app_label)s.change_%(model_name)s'],
+ 'DELETE': ['%(app_label)s.delete_%(model_name)s'],
+ }
+
+
+class ObjectPermissionInstanceView(generics.RetrieveUpdateDestroyAPIView):
+ model = BasicPermModel
+ authentication_classes = [authentication.BasicAuthentication]
+ permission_classes = [ViewObjectPermissions]
-class IsOwnerPermission(permissions.BasePermission):
- def has_object_permission(self, request, view, obj):
- return request.user == obj.owner
+object_permissions_view = ObjectPermissionInstanceView.as_view()
-class OwnerInstanceView(generics.RetrieveUpdateDestroyAPIView):
- model = OwnerModel
+class ObjectPermissionListView(generics.ListAPIView):
+ model = BasicPermModel
authentication_classes = [authentication.BasicAuthentication]
- permission_classes = [IsOwnerPermission]
-
+ permission_classes = [ViewObjectPermissions]
-owner_instance_view = OwnerInstanceView.as_view()
+object_permissions_list_view = ObjectPermissionListView.as_view()
+@unittest.skipUnless(guardian, 'django-guardian not installed')
class ObjectPermissionsIntegrationTests(TestCase):
"""
Integration tests for the object level permissions API.
"""
+ @classmethod
+ def setUpClass(cls):
+ from guardian.shortcuts import assign_perm
+
+ # create users
+ create = User.objects.create_user
+ users = {
+ 'fullaccess': create('fullaccess', 'fullaccess@example.com', 'password'),
+ 'readonly': create('readonly', 'readonly@example.com', 'password'),
+ 'writeonly': create('writeonly', 'writeonly@example.com', 'password'),
+ 'deleteonly': create('deleteonly', 'deleteonly@example.com', 'password'),
+ }
+
+ # give everyone model level permissions, as we are not testing those
+ everyone = Group.objects.create(name='everyone')
+ model_name = BasicPermModel._meta.module_name
+ app_label = BasicPermModel._meta.app_label
+ f = '{0}_{1}'.format
+ perms = {
+ 'view': f('view', model_name),
+ 'change': f('change', model_name),
+ 'delete': f('delete', model_name)
+ }
+ for perm in perms.values():
+ perm = '{0}.{1}'.format(app_label, perm)
+ assign_perm(perm, everyone)
+ everyone.user_set.add(*users.values())
+
+ cls.perms = perms
+ cls.users = users
def setUp(self):
- User.objects.create_user('not_owner', 'not_owner@example.com', 'password')
- user = User.objects.create_user('owner', 'owner@example.com', 'password')
+ from guardian.shortcuts import assign_perm
+ perms = self.perms
+ users = self.users
+
+ # appropriate object level permissions
+ readers = Group.objects.create(name='readers')
+ writers = Group.objects.create(name='writers')
+ deleters = Group.objects.create(name='deleters')
+
+ model = BasicPermModel.objects.create(text='foo')
+
+ assign_perm(perms['view'], readers, model)
+ assign_perm(perms['change'], writers, model)
+ assign_perm(perms['delete'], deleters, model)
+
+ readers.user_set.add(users['fullaccess'], users['readonly'])
+ writers.user_set.add(users['fullaccess'], users['writeonly'])
+ deleters.user_set.add(users['fullaccess'], users['deleteonly'])
+
+ self.credentials = {}
+ for user in users.values():
+ self.credentials[user.username] = basic_auth_header(user.username, 'password')
+
+ # Delete
+ def test_can_delete_permissions(self):
+ request = factory.delete('/1', HTTP_AUTHORIZATION=self.credentials['deleteonly'])
+ response = object_permissions_view(request, pk='1')
+ self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
- self.not_owner_credentials = basic_auth_header('not_owner', 'password')
- self.owner_credentials = basic_auth_header('owner', 'password')
+ def test_cannot_delete_permissions(self):
+ request = factory.delete('/1', HTTP_AUTHORIZATION=self.credentials['readonly'])
+ response = object_permissions_view(request, pk='1')
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
- OwnerModel(text='foo', owner=user).save()
+ # Update
+ def test_can_update_permissions(self):
+ request = factory.patch('/1', {'text': 'foobar'}, format='json',
+ HTTP_AUTHORIZATION=self.credentials['writeonly'])
+ response = object_permissions_view(request, pk='1')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data.get('text'), 'foobar')
+
+ def test_cannot_update_permissions(self):
+ request = factory.patch('/1', {'text': 'foobar'}, format='json',
+ HTTP_AUTHORIZATION=self.credentials['deleteonly'])
+ response = object_permissions_view(request, pk='1')
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+ def test_cannot_update_permissions_non_existing(self):
+ request = factory.patch('/999', {'text': 'foobar'}, format='json',
+ HTTP_AUTHORIZATION=self.credentials['deleteonly'])
+ response = object_permissions_view(request, pk='999')
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+ # Read
+ def test_can_read_permissions(self):
+ request = factory.get('/1', HTTP_AUTHORIZATION=self.credentials['readonly'])
+ response = object_permissions_view(request, pk='1')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
- def test_owner_has_delete_permissions(self):
- request = factory.delete('/1', HTTP_AUTHORIZATION=self.owner_credentials)
- response = owner_instance_view(request, pk='1')
- self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+ def test_cannot_read_permissions(self):
+ request = factory.get('/1', HTTP_AUTHORIZATION=self.credentials['writeonly'])
+ response = object_permissions_view(request, pk='1')
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
- def test_non_owner_does_not_have_delete_permissions(self):
- request = factory.delete('/1', HTTP_AUTHORIZATION=self.not_owner_credentials)
- response = owner_instance_view(request, pk='1')
- self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ # Read list
+ def test_can_read_list_permissions(self):
+ request = factory.get('/', HTTP_AUTHORIZATION=self.credentials['readonly'])
+ object_permissions_list_view.cls.filter_backends = (DjangoObjectPermissionsFilter,)
+ response = object_permissions_list_view(request)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data[0].get('id'), 1)
+
+ def test_cannot_read_list_permissions(self):
+ request = factory.get('/', HTTP_AUTHORIZATION=self.credentials['writeonly'])
+ object_permissions_list_view.cls.filter_backends = (DjangoObjectPermissionsFilter,)
+ response = object_permissions_list_view(request)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertListEqual(response.data, [])
diff --git a/rest_framework/tests/test_relations_pk.py b/rest_framework/tests/test_relations_pk.py
index e2a1b815..3815afdd 100644
--- a/rest_framework/tests/test_relations_pk.py
+++ b/rest_framework/tests/test_relations_pk.py
@@ -283,6 +283,15 @@ class PKForeignKeyTests(TestCase):
self.assertFalse(serializer.is_valid())
self.assertEqual(serializer.errors, {'target': ['This field is required.']})
+ def test_foreign_key_with_empty(self):
+ """
+ Regression test for #1072
+
+ https://github.com/tomchristie/django-rest-framework/issues/1072
+ """
+ serializer = NullableForeignKeySourceSerializer()
+ self.assertEqual(serializer.data['target'], None)
+
class PKNullableForeignKeyTests(TestCase):
def setUp(self):
diff --git a/rest_framework/tests/test_views.py b/rest_framework/tests/test_views.py
index c0bec5ae..65c7e50e 100644
--- a/rest_framework/tests/test_views.py
+++ b/rest_framework/tests/test_views.py
@@ -32,6 +32,16 @@ def basic_view(request):
return {'method': 'PATCH', 'data': request.DATA}
+class ErrorView(APIView):
+ def get(self, request, *args, **kwargs):
+ raise Exception
+
+
+@api_view(['GET'])
+def error_view(request):
+ raise Exception
+
+
def sanitise_json_error(error_dict):
"""
Exact contents of JSON error messages depend on the installed version
@@ -99,3 +109,34 @@ class FunctionBasedViewIntegrationTests(TestCase):
}
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(sanitise_json_error(response.data), expected)
+
+
+class TestCustomExceptionHandler(TestCase):
+ def setUp(self):
+ self.DEFAULT_HANDLER = api_settings.EXCEPTION_HANDLER
+
+ def exception_handler(exc):
+ return Response('Error!', status=status.HTTP_400_BAD_REQUEST)
+
+ api_settings.EXCEPTION_HANDLER = exception_handler
+
+ def tearDown(self):
+ api_settings.EXCEPTION_HANDLER = self.DEFAULT_HANDLER
+
+ def test_class_based_view_exception_handler(self):
+ view = ErrorView.as_view()
+
+ request = factory.get('/', content_type='application/json')
+ response = view(request)
+ expected = 'Error!'
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertEqual(response.data, expected)
+
+ def test_function_based_view_exception_handler(self):
+ view = error_view
+
+ request = factory.get('/', content_type='application/json')
+ response = view(request)
+ expected = 'Error!'
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertEqual(response.data, expected)
diff --git a/rest_framework/utils/encoders.py b/rest_framework/utils/encoders.py
index b26a2085..7efd5417 100644
--- a/rest_framework/utils/encoders.py
+++ b/rest_framework/utils/encoders.py
@@ -42,6 +42,8 @@ class JSONEncoder(json.JSONEncoder):
return str(o.total_seconds())
elif isinstance(o, decimal.Decimal):
return str(o)
+ elif hasattr(o, 'tolist'):
+ return o.tolist()
elif hasattr(o, '__iter__'):
return [i for i in o]
return super(JSONEncoder, self).default(o)
diff --git a/rest_framework/views.py b/rest_framework/views.py
index 4cff0422..853e6461 100644
--- a/rest_framework/views.py
+++ b/rest_framework/views.py
@@ -361,7 +361,7 @@ class APIView(View):
else:
exc.status_code = status.HTTP_403_FORBIDDEN
- response = exception_handler(exc)
+ response = self.settings.EXCEPTION_HANDLER(exc)
if response is None:
raise
diff --git a/tox.ini b/tox.ini
index 6ec400dd..6e3b8e0a 100644
--- a/tox.ini
+++ b/tox.ini
@@ -25,6 +25,7 @@ deps = https://www.djangoproject.com/download/1.6a1/tarball/
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.4
+ django-guardian==1.1.1
[testenv:py2.6-django1.6]
basepython = python2.6
@@ -34,6 +35,7 @@ deps = https://www.djangoproject.com/download/1.6a1/tarball/
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.4
+ django-guardian==1.1.1
[testenv:py3.3-django1.5]
basepython = python3.3
@@ -55,6 +57,7 @@ deps = django==1.5
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
+ django-guardian==1.1.1
[testenv:py2.6-django1.5]
basepython = python2.6
@@ -64,6 +67,7 @@ deps = django==1.5
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
+ django-guardian==1.1.1
[testenv:py2.7-django1.4]
basepython = python2.7
@@ -73,6 +77,7 @@ deps = django==1.4.3
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
+ django-guardian==1.1.1
[testenv:py2.6-django1.4]
basepython = python2.6
@@ -82,6 +87,7 @@ deps = django==1.4.3
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
+ django-guardian==1.1.1
[testenv:py2.7-django1.3]
basepython = python2.7
@@ -91,7 +97,7 @@ deps = django==1.3.5
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
- six
+ django-guardian==1.1.1
[testenv:py2.6-django1.3]
basepython = python2.6
@@ -101,4 +107,4 @@ deps = django==1.3.5
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
- six
+ django-guardian==1.1.1