aboutsummaryrefslogtreecommitdiffstats
path: root/rest_framework
diff options
context:
space:
mode:
authorTom Christie2013-09-25 09:44:26 +0100
committerTom Christie2013-09-25 09:44:26 +0100
commit21cd6386593aea0b122abec1c5cc3bd5c544aa87 (patch)
treeb7d197c9c04f56448bee36c4789c93c66fb541a8 /rest_framework
parent9a5b2eefa92dede844ab94d049093e91ac98af5b (diff)
parente8c6cd5622f62fcf2d4cf2b28b504fe5ff5228f9 (diff)
downloaddjango-rest-framework-21cd6386593aea0b122abec1c5cc3bd5c544aa87.tar.bz2
Merge master
Diffstat (limited to 'rest_framework')
-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
18 files changed, 392 insertions, 74 deletions
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