aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPhilip Douglas2013-09-13 10:47:06 +0100
committerPhilip Douglas2013-09-13 10:47:06 +0100
commite5da0ff5e530c8ea0e2cf4dff0723ede6234860e (patch)
tree776ec2dd47b6c8a4aa976d33c740d4b0267c9522
parent272a6abf91c51b44781d27af5352c7e36c8fa91c (diff)
parentea462b7b9b28f425c8c91d10e34532ddbb3c87fa (diff)
downloaddjango-rest-framework-e5da0ff5e530c8ea0e2cf4dff0723ede6234860e.tar.bz2
Merge remote-tracking branch 'upstream/master'
-rw-r--r--.travis.yml1
-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/viewsets.md2
-rw-r--r--docs/index.md2
-rw-r--r--docs/topics/credits.md2
-rw-r--r--docs/topics/release-notes.md5
-rw-r--r--docs/topics/writable-nested-serializers.md47
-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/permissions.py60
-rw-r--r--rest_framework/runtests/settings.py15
-rw-r--r--rest_framework/tests/test_permissions.py170
-rw-r--r--tox.ini8
16 files changed, 375 insertions, 35 deletions
diff --git a/.travis.yml b/.travis.yml
index 6a453241..d12479e9 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 django-filter==0.6 --use-mirrors; fi"
- export PYTHONPATH=.
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/viewsets.md b/docs/api-guide/viewsets.md
index 2e65b7a4..1062cb32 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/credits.md b/docs/topics/credits.md
index 07e2ec47..8269580e 100644
--- a/docs/topics/credits.md
+++ b/docs/topics/credits.md
@@ -167,6 +167,7 @@ The following people have helped make REST framework great.
* Andrey Antukh - [niwibe]
* Mathieu Pillard - [diox]
* Edmond Wong - [edmondwong]
+* Ben Reilly - [bwreilly]
Many thanks to everyone who's contributed to the project.
@@ -370,3 +371,4 @@ You can also contact [@_tomchristie][twitter] directly on twitter.
[niwibe]: https://github.com/niwibe
[diox]: https://github.com/diox
[edmondwong]: https://github.com/edmondwong
+[bwreilly]: https://github.com/bwreilly
diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md
index 1f363310..3b35d9ed 100644
--- a/docs/topics/release-notes.md
+++ b/docs/topics/release-notes.md
@@ -40,8 +40,11 @@ You can determine your currently installed version using `pip freeze`:
## 2.3.x series
-### Master
+### 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.
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/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 6f7447ad..b9d1dae6 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/permissions.py b/rest_framework/permissions.py
index 1036663e..53184798 100644
--- a/rest_framework/permissions.py
+++ b/rest_framework/permissions.py
@@ -7,6 +7,7 @@ import warnings
SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS']
+from django.http import Http404
from rest_framework.compat import oauth2_provider_scope, oauth2_constants
@@ -151,6 +152,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/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/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/tox.ini b/tox.ini
index aa97fd35..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,6 +97,7 @@ deps = django==1.3.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.3]
basepython = python2.6
@@ -100,3 +107,4 @@ deps = django==1.3.5
django-oauth-plus==2.0
oauth2==1.5.211
django-oauth2-provider==0.2.3
+ django-guardian==1.1.1