From 4a9dcfa76089143bbeb5cd43fa3a406365d89e96 Mon Sep 17 00:00:00 2001 From: bwreilly Date: Fri, 6 Sep 2013 11:01:31 -0500 Subject: added guardian as optional requirement, stubbed out object-level permission class --- rest_framework/compat.py | 6 ++++++ rest_framework/permissions.py | 7 ++++++- rest_framework/tests/test_permissions.py | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) (limited to 'rest_framework') 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/permissions.py b/rest_framework/permissions.py index 1036663e..6d213ba1 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -7,7 +7,7 @@ import warnings SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] -from rest_framework.compat import oauth2_provider_scope, oauth2_constants +from rest_framework.compat import oauth2_provider_scope, oauth2_constants, guardian class BasePermission(object): @@ -151,6 +151,11 @@ class DjangoModelPermissionsOrAnonReadOnly(DjangoModelPermissions): authenticated_users_only = False +class DjangoObjectLevelModelPermissions(DjangoModelPermissions): + def __init__(self): + assert guardian, 'Using DjangoObjectLevelModelPermissions, but guardian is not installed' + + class TokenHasReadWriteScope(BasePermission): """ The request is authenticated as a user and the token used has the right scope diff --git a/rest_framework/tests/test_permissions.py b/rest_framework/tests/test_permissions.py index e2cca380..d1171cce 100644 --- a/rest_framework/tests/test_permissions.py +++ b/rest_framework/tests/test_permissions.py @@ -4,6 +4,7 @@ from django.db import models from django.test import TestCase from rest_framework import generics, status, permissions, authentication, HTTP_HEADER_ENCODING from rest_framework.test import APIRequestFactory +from rest_framework.compat import guardian import base64 factory = APIRequestFactory() -- cgit v1.2.3 From b07de86ad372a185c05c1dd77ccd7bee3801996e Mon Sep 17 00:00:00 2001 From: bwreilly Date: Fri, 6 Sep 2013 12:35:06 -0500 Subject: some properly failing tests, set up for standard permissions --- rest_framework/permissions.py | 2 +- rest_framework/runtests/settings.py | 11 ++++ rest_framework/tests/test_permissions.py | 109 ++++++++++++++++++------------- 3 files changed, 76 insertions(+), 46 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index 6d213ba1..b67be414 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -153,7 +153,7 @@ class DjangoModelPermissionsOrAnonReadOnly(DjangoModelPermissions): class DjangoObjectLevelModelPermissions(DjangoModelPermissions): def __init__(self): - assert guardian, 'Using DjangoObjectLevelModelPermissions, but guardian is not installed' + assert guardian, 'Using DjangoObjectLevelModelPermissions, but django-guardian is not installed' class TokenHasReadWriteScope(BasePermission): diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py index b3702d0b..6750376f 100644 --- a/rest_framework/runtests/settings.py +++ b/rest_framework/runtests/settings.py @@ -123,6 +123,17 @@ else: 'provider.oauth2', ) +# guardian is optional +try: + import guardian +except ImportError: + pass +else: + ANONYMOUS_USER_ID = -1 + 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 d1171cce..dcdb4eea 100644 --- a/rest_framework/tests/test_permissions.py +++ b/rest_framework/tests/test_permissions.py @@ -3,17 +3,14 @@ from django.contrib.auth.models import User, Permission from django.db import models from django.test import TestCase from rest_framework import generics, status, permissions, authentication, HTTP_HEADER_ENCODING -from rest_framework.test import APIRequestFactory from rest_framework.compat import guardian +from rest_framework.test import APIRequestFactory +from rest_framework.tests.models import BasicModel +from rest_framework.settings import api_settings import base64 factory = APIRequestFactory() - -class BasicModel(models.Model): - text = models.CharField(max_length=100) - - class RootView(generics.ListCreateAPIView): model = BasicModel authentication_classes = [authentication.BasicAuthentication] @@ -145,45 +142,67 @@ class ModelPermissionsIntegrationTests(TestCase): self.assertEqual(list(response.data['actions'].keys()), ['PUT']) -class OwnerModel(models.Model): - text = models.CharField(max_length=100) - owner = models.ForeignKey(User) +class BasicPermModel(BasicModel): + class Meta: + app_label = 'tests' + permissions = ( + ('read_basicpermmodel', "Can view basic perm model"), + # add, change, delete built in to django + ) -class IsOwnerPermission(permissions.BasePermission): - def has_object_permission(self, request, view, obj): - return request.user == obj.owner - - -class OwnerInstanceView(generics.RetrieveUpdateDestroyAPIView): - model = OwnerModel +class ObjectPermissionInstanceView(generics.RetrieveUpdateDestroyAPIView): + model = BasicModel authentication_classes = [authentication.BasicAuthentication] - permission_classes = [IsOwnerPermission] - - -owner_instance_view = OwnerInstanceView.as_view() - - -class ObjectPermissionsIntegrationTests(TestCase): - """ - Integration tests for the object level permissions API. - """ - - def setUp(self): - User.objects.create_user('not_owner', 'not_owner@example.com', 'password') - user = User.objects.create_user('owner', 'owner@example.com', 'password') - - self.not_owner_credentials = basic_auth_header('not_owner', 'password') - self.owner_credentials = basic_auth_header('owner', 'password') - - OwnerModel(text='foo', owner=user).save() - - 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_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) + permission_classes = [permissions.DjangoObjectLevelModelPermissions] + + +object_permissions_view = ObjectPermissionInstanceView.as_view() + +if guardian: + class ObjectPermissionsIntegrationTests(TestCase): + """ + Integration tests for the object level permissions API. + """ + + def setUp(self): + # create users + User.objects.create_user('no_permission', 'no_permission@example.com', 'password') + reader = User.objects.create_user('reader', 'reader@example.com', 'password') + writer = User.objects.create_user('writer', 'writer@example.com', 'password') + full_access = User.objects.create_user('full_access', 'full_access@example.com', 'password') + + model = BasicPermModel.objects.create(text='foo') + + # assign permissions appropriately + from guardian.shortcuts import assign_perm + + read = "read_basicpermmodel" + write = "change_basicpermmodel" + delete = "delete_basicpermmodel" + app_label = 'tests.' + # model level permissions + assign_perm(app_label + delete, full_access, obj=model) + (assign_perm(app_label + write, user, obj=model) for user in (writer, full_access)) + (assign_perm(app_label + read, user, obj=model) for user in (reader, writer, full_access)) + + # object level permissions + assign_perm(delete, full_access, obj=model) + (assign_perm(write, user, obj=model) for user in (writer, full_access)) + (assign_perm(read, user, obj=model) for user in (reader, writer, full_access)) + + self.no_permission_credentials = basic_auth_header('no_permission', 'password') + self.reader_credentials = basic_auth_header('reader', 'password') + self.writer_credentials = basic_auth_header('writer', 'password') + self.full_access_credentials = basic_auth_header('full_access', 'password') + + + def test_has_delete_permissions(self): + request = factory.delete('/1', HTTP_AUTHORIZATION=self.full_access_credentials) + response = object_permissions_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_no_delete_permissions(self): + request = factory.delete('/1', HTTP_AUTHORIZATION=self.writer_credentials) + response = object_permissions_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) -- cgit v1.2.3 From 57d6b5fb7c2652bb4c68edd1bcc95be736b06b31 Mon Sep 17 00:00:00 2001 From: bwreilly Date: Sat, 7 Sep 2013 23:16:43 -0500 Subject: necessary test settings for guardian --- rest_framework/runtests/settings.py | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'rest_framework') diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py index 6750376f..be721658 100644 --- a/rest_framework/runtests/settings.py +++ b/rest_framework/runtests/settings.py @@ -130,6 +130,10 @@ except ImportError: pass else: ANONYMOUS_USER_ID = -1 + AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', # default + 'guardian.backends.ObjectPermissionBackend', + ) INSTALLED_APPS += ( 'guardian', ) -- cgit v1.2.3 From 118645e4806effaa35726012a983676b2c55b6dd Mon Sep 17 00:00:00 2001 From: bwreilly Date: Sat, 7 Sep 2013 23:18:52 -0500 Subject: first pass at object level permissions and tests --- rest_framework/permissions.py | 46 ++++++++++ rest_framework/tests/test_permissions.py | 146 +++++++++++++++++++++++-------- 2 files changed, 156 insertions(+), 36 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index b67be414..2d8a30e9 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, guardian @@ -152,9 +153,54 @@ class DjangoModelPermissionsOrAnonReadOnly(DjangoModelPermissions): class DjangoObjectLevelModelPermissions(DjangoModelPermissions): + """ + Basic object level permissions utilizing django-guardian. + """ + def __init__(self): assert guardian, 'Using DjangoObjectLevelModelPermissions, but django-guardian is not installed' + action_perm_map = { + 'list': 'read', + 'retrieve': 'read', + 'create': 'add', + 'partial_update': 'change', + 'update': 'change', + 'destroy': 'delete', + } + + def _get_names(self, view): + 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 + if not model_cls: # no model, no model based permissions + return None + model_name = model_cls._meta.module_name + return model_name + + def has_permission(self, request, view): + if view.action == 'list': + user = request.user + queryset = view.get_queryset() + model_name = self._get_names(view) + view.queryset = guardian.shortcuts.get_objects_for_user(user, 'read_' + model_name, queryset) #TODO: move to filter + return super(DjangoObjectLevelModelPermissions, self).has_permission(request, view) + + def has_object_permission(self, request, view, obj): + user = request.user + model_name = self._get_names(view) + action = self.action_perm_map.get(view.action) + + assert action, "Tried to determine object permissions but no action specified in view" + + perm = "{action}_{model_name}".format(action=action, model_name=model_name) + check = user.has_perm(perm, obj) + if not check: + raise Http404 + return user.has_perm(perm, obj) + class TokenHasReadWriteScope(BasePermission): """ diff --git a/rest_framework/tests/test_permissions.py b/rest_framework/tests/test_permissions.py index dcdb4eea..d64ab04e 100644 --- a/rest_framework/tests/test_permissions.py +++ b/rest_framework/tests/test_permissions.py @@ -1,12 +1,11 @@ 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 rest_framework import generics, status, permissions, authentication, HTTP_HEADER_ENCODING from rest_framework.compat import guardian from rest_framework.test import APIRequestFactory from rest_framework.tests.models import BasicModel -from rest_framework.settings import api_settings import base64 factory = APIRequestFactory() @@ -142,67 +141,142 @@ class ModelPermissionsIntegrationTests(TestCase): self.assertEqual(list(response.data['actions'].keys()), ['PUT']) -class BasicPermModel(BasicModel): +class BasicPermModel(models.Model): + text = models.CharField(max_length=100) class Meta: app_label = 'tests' permissions = ( - ('read_basicpermmodel', "Can view basic perm model"), + ('read_basicpermmodel', 'Can view basic perm model'), # add, change, delete built in to django ) class ObjectPermissionInstanceView(generics.RetrieveUpdateDestroyAPIView): - model = BasicModel + model = BasicPermModel authentication_classes = [authentication.BasicAuthentication] permission_classes = [permissions.DjangoObjectLevelModelPermissions] - object_permissions_view = ObjectPermissionInstanceView.as_view() +class ObjectPermissionListView(generics.ListAPIView): + model = BasicPermModel + authentication_classes = [authentication.BasicAuthentication] + permission_classes = [permissions.DjangoObjectLevelModelPermissions] + +object_permissions_list_view = ObjectPermissionListView.as_view() + if guardian: + from guardian.shortcuts import assign_perm + class ObjectPermissionsIntegrationTests(TestCase): """ Integration tests for the object level permissions API. """ + @classmethod + def setUpClass(cls): + # 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 = { + 'read': f('read', 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): - # create users - User.objects.create_user('no_permission', 'no_permission@example.com', 'password') - reader = User.objects.create_user('reader', 'reader@example.com', 'password') - writer = User.objects.create_user('writer', 'writer@example.com', 'password') - full_access = User.objects.create_user('full_access', 'full_access@example.com', 'password') - - model = BasicPermModel.objects.create(text='foo') + perms = self.perms + users = self.users - # assign permissions appropriately - from guardian.shortcuts import assign_perm + # appropriate object level permissions + readers = Group.objects.create(name='readers') + writers = Group.objects.create(name='writers') + deleters = Group.objects.create(name='deleters') - read = "read_basicpermmodel" - write = "change_basicpermmodel" - delete = "delete_basicpermmodel" - app_label = 'tests.' - # model level permissions - assign_perm(app_label + delete, full_access, obj=model) - (assign_perm(app_label + write, user, obj=model) for user in (writer, full_access)) - (assign_perm(app_label + read, user, obj=model) for user in (reader, writer, full_access)) + model = BasicPermModel.objects.create(text='foo') + + assign_perm(perms['read'], 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']) + object_permissions_view.cls.action = 'destroy' + response = object_permissions_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - # object level permissions - assign_perm(delete, full_access, obj=model) - (assign_perm(write, user, obj=model) for user in (writer, full_access)) - (assign_perm(read, user, obj=model) for user in (reader, writer, full_access)) + def test_cannot_delete_permissions(self): + request = factory.delete('/1', HTTP_AUTHORIZATION=self.credentials['readonly']) + object_permissions_view.cls.action = 'destroy' + response = object_permissions_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.no_permission_credentials = basic_auth_header('no_permission', 'password') - self.reader_credentials = basic_auth_header('reader', 'password') - self.writer_credentials = basic_auth_header('writer', 'password') - self.full_access_credentials = basic_auth_header('full_access', 'password') + # Update + def test_can_update_permissions(self): + request = factory.patch('/1', {'text': 'foobar'}, format='json', + HTTP_AUTHORIZATION=self.credentials['writeonly']) + object_permissions_view.cls.action = 'partial_update' + 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']) + object_permissions_view.cls.action = 'partial_update' + response = object_permissions_view(request, pk='1') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_has_delete_permissions(self): - request = factory.delete('/1', HTTP_AUTHORIZATION=self.full_access_credentials) + # Read + def test_can_read_permissions(self): + request = factory.get('/1', HTTP_AUTHORIZATION=self.credentials['readonly']) + object_permissions_view.cls.action = 'retrieve' response = object_permissions_view(request, pk='1') - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_no_delete_permissions(self): - request = factory.delete('/1', HTTP_AUTHORIZATION=self.writer_credentials) + def test_cannot_read_permissions(self): + request = factory.get('/1', HTTP_AUTHORIZATION=self.credentials['writeonly']) + object_permissions_view.cls.action = 'retrieve' response = object_permissions_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # Read list + def test_can_read_list_permissions(self): + request = factory.get('/', HTTP_AUTHORIZATION=self.credentials['readonly']) + object_permissions_list_view.cls.action = 'list' + 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.action = 'list' + response = object_permissions_list_view(request) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, []) \ No newline at end of file -- cgit v1.2.3 From 9ff0f6d3bff3c1d02d2ccaf4f1500e25cb97620d Mon Sep 17 00:00:00 2001 From: bwreilly Date: Sat, 7 Sep 2013 23:48:03 -0500 Subject: switch to a dedicated filter for read list object permissions --- rest_framework/filters.py | 18 +++++++++++++++++- rest_framework/permissions.py | 13 ++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 4079e1bd..6d46ad23 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 @@ -23,6 +23,22 @@ class BaseFilterBackend(object): raise NotImplementedError(".filter_queryset() must be overridden.") +class ObjectPermissionReaderFilter(BaseFilterBackend): + """ + A filter backend that limits results to those where the requesting user + has read object level permissions. + """ + def __init__(self): + assert guardian, 'Using ObjectPermissionReaderFilter, but django-guardian is not installed' + + def filter_queryset(self, request, queryset, view): + user = request.user + model_cls = queryset.model + model_name = model_cls._meta.module_name + permission = 'read_' + model_name + return guardian.shortcuts.get_objects_for_user(user, permission, queryset) + + class DjangoFilterBackend(BaseFilterBackend): """ A filter backend that uses django-filter. diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index 2d8a30e9..0d5e0e78 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -9,6 +9,7 @@ SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] from django.http import Http404 from rest_framework.compat import oauth2_provider_scope, oauth2_constants, guardian +from rest_framework.filters import ObjectPermissionReaderFilter class BasePermission(object): @@ -169,7 +170,7 @@ class DjangoObjectLevelModelPermissions(DjangoModelPermissions): 'destroy': 'delete', } - def _get_names(self, view): + def _get_model_name(self, view): model_cls = getattr(view, 'model', None) queryset = getattr(view, 'queryset', None) @@ -182,19 +183,17 @@ class DjangoObjectLevelModelPermissions(DjangoModelPermissions): def has_permission(self, request, view): if view.action == 'list': - user = request.user queryset = view.get_queryset() - model_name = self._get_names(view) - view.queryset = guardian.shortcuts.get_objects_for_user(user, 'read_' + model_name, queryset) #TODO: move to filter + view.queryset = ObjectPermissionReaderFilter().filter_queryset(request, queryset, view) return super(DjangoObjectLevelModelPermissions, self).has_permission(request, view) def has_object_permission(self, request, view, obj): - user = request.user - model_name = self._get_names(view) action = self.action_perm_map.get(view.action) - assert action, "Tried to determine object permissions but no action specified in view" + user = request.user + model_name = self._get_model_name(view) + perm = "{action}_{model_name}".format(action=action, model_name=model_name) check = user.has_perm(perm, obj) if not check: -- cgit v1.2.3 From 0183c69538de7b6dc4e9b0602fc364e789e0cab6 Mon Sep 17 00:00:00 2001 From: bwreilly Date: Mon, 9 Sep 2013 08:39:09 -0700 Subject: removed unnecessary guardian req and view.action parsing --- rest_framework/permissions.py | 52 ++++++++++++++------------------ rest_framework/tests/test_permissions.py | 11 ++----- 2 files changed, 26 insertions(+), 37 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index 0d5e0e78..61a33bdd 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -8,8 +8,7 @@ import warnings SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] from django.http import Http404 -from rest_framework.compat import oauth2_provider_scope, oauth2_constants, guardian -from rest_framework.filters import ObjectPermissionReaderFilter +from rest_framework.compat import oauth2_provider_scope, oauth2_constants class BasePermission(object): @@ -158,47 +157,42 @@ class DjangoObjectLevelModelPermissions(DjangoModelPermissions): Basic object level permissions utilizing django-guardian. """ - def __init__(self): - assert guardian, 'Using DjangoObjectLevelModelPermissions, but django-guardian is not installed' - - action_perm_map = { - 'list': 'read', - 'retrieve': 'read', - 'create': 'add', - 'partial_update': 'change', - 'update': 'change', - 'destroy': 'delete', + actions_map = { + 'GET': ['read_%(model_name)s'], + 'OPTIONS': ['read_%(model_name)s'], + 'HEAD': ['read_%(model_name)s'], + 'POST': ['add_%(model_name)s'], + 'PUT': ['change_%(model_name)s'], + 'PATCH': ['change_%(model_name)s'], + 'DELETE': ['delete_%(model_name)s'], } - def _get_model_name(self, view): - 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 - if not model_cls: # no model, no model based permissions - return None - model_name = model_cls._meta.module_name - return model_name + def get_required_object_permissions(self, method, model_cls): + kwargs = { + 'model_name': model_cls._meta.module_name + } + return [perm % kwargs for perm in self.actions_map[method]] def has_permission(self, request, view): - if view.action == 'list': + if getattr(view, 'action', None) == 'list': queryset = view.get_queryset() view.queryset = ObjectPermissionReaderFilter().filter_queryset(request, queryset, view) return super(DjangoObjectLevelModelPermissions, self).has_permission(request, view) def has_object_permission(self, request, view, obj): - action = self.action_perm_map.get(view.action) - assert action, "Tried to determine object permissions but no action specified in view" + 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 - model_name = self._get_model_name(view) - perm = "{action}_{model_name}".format(action=action, model_name=model_name) - check = user.has_perm(perm, obj) + check = user.has_perms(perms, obj) if not check: raise Http404 - return user.has_perm(perm, obj) + return user.has_perms(perms, obj) class TokenHasReadWriteScope(BasePermission): diff --git a/rest_framework/tests/test_permissions.py b/rest_framework/tests/test_permissions.py index d64ab04e..2d866cd0 100644 --- a/rest_framework/tests/test_permissions.py +++ b/rest_framework/tests/test_permissions.py @@ -4,6 +4,7 @@ from django.db import models from django.test import TestCase from rest_framework import generics, status, permissions, authentication, HTTP_HEADER_ENCODING from rest_framework.compat import guardian +from rest_framework.filters import ObjectPermissionReaderFilter from rest_framework.test import APIRequestFactory from rest_framework.tests.models import BasicModel import base64 @@ -227,13 +228,11 @@ if guardian: # Delete def test_can_delete_permissions(self): request = factory.delete('/1', HTTP_AUTHORIZATION=self.credentials['deleteonly']) - object_permissions_view.cls.action = 'destroy' response = object_permissions_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) def test_cannot_delete_permissions(self): request = factory.delete('/1', HTTP_AUTHORIZATION=self.credentials['readonly']) - object_permissions_view.cls.action = 'destroy' response = object_permissions_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) @@ -241,7 +240,6 @@ if guardian: def test_can_update_permissions(self): request = factory.patch('/1', {'text': 'foobar'}, format='json', HTTP_AUTHORIZATION=self.credentials['writeonly']) - object_permissions_view.cls.action = 'partial_update' response = object_permissions_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data.get('text'), 'foobar') @@ -249,34 +247,31 @@ if guardian: def test_cannot_update_permissions(self): request = factory.patch('/1', {'text': 'foobar'}, format='json', HTTP_AUTHORIZATION=self.credentials['deleteonly']) - object_permissions_view.cls.action = 'partial_update' response = object_permissions_view(request, pk='1') 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']) - object_permissions_view.cls.action = 'retrieve' response = object_permissions_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) def test_cannot_read_permissions(self): request = factory.get('/1', HTTP_AUTHORIZATION=self.credentials['writeonly']) - object_permissions_view.cls.action = 'retrieve' response = object_permissions_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) # Read list def test_can_read_list_permissions(self): request = factory.get('/', HTTP_AUTHORIZATION=self.credentials['readonly']) - object_permissions_list_view.cls.action = 'list' + object_permissions_list_view.cls.filter_backends = (ObjectPermissionReaderFilter,) 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.action = 'list' + object_permissions_list_view.cls.filter_backends = (ObjectPermissionReaderFilter,) response = object_permissions_list_view(request) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertListEqual(response.data, []) \ No newline at end of file -- cgit v1.2.3 From 23fc9dd53fcd9cc25e2c77e5ffae395f04d4990d Mon Sep 17 00:00:00 2001 From: bwreilly Date: Mon, 9 Sep 2013 09:32:29 -0700 Subject: better doc for object permissions, drop redundant has_permission call --- rest_framework/permissions.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index 61a33bdd..70bf9c61 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -154,7 +154,14 @@ class DjangoModelPermissionsOrAnonReadOnly(DjangoModelPermissions): class DjangoObjectLevelModelPermissions(DjangoModelPermissions): """ - Basic object level permissions utilizing django-guardian. + The request is authenticated using `django.contrib.auth` permissions. + See: https://docs.djangoproject.com/en/dev/topics/auth/#permissions + + 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. """ actions_map = { @@ -173,12 +180,6 @@ class DjangoObjectLevelModelPermissions(DjangoModelPermissions): } return [perm % kwargs for perm in self.actions_map[method]] - def has_permission(self, request, view): - if getattr(view, 'action', None) == 'list': - queryset = view.get_queryset() - view.queryset = ObjectPermissionReaderFilter().filter_queryset(request, queryset, view) - return super(DjangoObjectLevelModelPermissions, self).has_permission(request, view) - def has_object_permission(self, request, view, obj): model_cls = getattr(view, 'model', None) queryset = getattr(view, 'queryset', None) -- cgit v1.2.3 From 222c1d1122b13940fa6072a1dfb89b25491ee6fb Mon Sep 17 00:00:00 2001 From: MichaƂ Ociepka Date: Tue, 10 Sep 2013 12:02:14 +0200 Subject: Add order_by to the AutoFilterSet `AutoFilterSet` should contains `order_by` set to all by default.--- rest_framework/filters.py | 1 + 1 file changed, 1 insertion(+) (limited to 'rest_framework') diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 4079e1bd..1e58d173 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -53,6 +53,7 @@ class DjangoFilterBackend(BaseFilterBackend): class Meta: model = queryset.model fields = filter_fields + order_by = True return AutoFilterSet return None -- cgit v1.2.3 From 5970baa20112921217ae4f2c2a9f175df25922db Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 10 Sep 2013 21:00:13 +0100 Subject: Tweaks and docs to object-level model permissions. --- rest_framework/filters.py | 37 +++-- rest_framework/permissions.py | 47 ++++-- rest_framework/tests/test_permissions.py | 249 +++++++++++++++++-------------- 3 files changed, 188 insertions(+), 145 deletions(-) (limited to 'rest_framework') diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 6d46ad23..1693bcd2 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -23,22 +23,6 @@ class BaseFilterBackend(object): raise NotImplementedError(".filter_queryset() must be overridden.") -class ObjectPermissionReaderFilter(BaseFilterBackend): - """ - A filter backend that limits results to those where the requesting user - has read object level permissions. - """ - def __init__(self): - assert guardian, 'Using ObjectPermissionReaderFilter, but django-guardian is not installed' - - def filter_queryset(self, request, queryset, view): - user = request.user - model_cls = queryset.model - model_name = model_cls._meta.module_name - permission = 'read_' + model_name - return guardian.shortcuts.get_objects_for_user(user, permission, queryset) - - class DjangoFilterBackend(BaseFilterBackend): """ A filter backend that uses django-filter. @@ -156,3 +140,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 70bf9c61..53184798 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -152,10 +152,10 @@ class DjangoModelPermissionsOrAnonReadOnly(DjangoModelPermissions): authenticated_users_only = False -class DjangoObjectLevelModelPermissions(DjangoModelPermissions): +class DjangoObjectPermissions(DjangoModelPermissions): """ - The request is authenticated using `django.contrib.auth` permissions. - See: https://docs.djangoproject.com/en/dev/topics/auth/#permissions + 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. @@ -164,21 +164,22 @@ class DjangoObjectLevelModelPermissions(DjangoModelPermissions): provide a `.model` or `.queryset` attribute. """ - actions_map = { - 'GET': ['read_%(model_name)s'], - 'OPTIONS': ['read_%(model_name)s'], - 'HEAD': ['read_%(model_name)s'], - 'POST': ['add_%(model_name)s'], - 'PUT': ['change_%(model_name)s'], - 'PATCH': ['change_%(model_name)s'], - 'DELETE': ['delete_%(model_name)s'], + 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.actions_map[method]] + return [perm % kwargs for perm in self.perms_map[method]] def has_object_permission(self, request, view, obj): model_cls = getattr(view, 'model', None) @@ -190,10 +191,24 @@ class DjangoObjectLevelModelPermissions(DjangoModelPermissions): perms = self.get_required_object_permissions(request.method, model_cls) user = request.user - check = user.has_perms(perms, obj) - if not check: - raise Http404 - return user.has_perms(perms, obj) + 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): diff --git a/rest_framework/tests/test_permissions.py b/rest_framework/tests/test_permissions.py index 2d866cd0..d08124f4 100644 --- a/rest_framework/tests/test_permissions.py +++ b/rest_framework/tests/test_permissions.py @@ -2,9 +2,10 @@ from __future__ import unicode_literals 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 ObjectPermissionReaderFilter +from rest_framework.filters import DjangoObjectPermissionsFilter from rest_framework.test import APIRequestFactory from rest_framework.tests.models import BasicModel import base64 @@ -148,130 +149,152 @@ class BasicPermModel(models.Model): class Meta: app_label = 'tests' permissions = ( - ('read_basicpermmodel', 'Can view basic perm model'), + ('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 = [permissions.DjangoObjectLevelModelPermissions] + permission_classes = [ViewObjectPermissions] object_permissions_view = ObjectPermissionInstanceView.as_view() + class ObjectPermissionListView(generics.ListAPIView): model = BasicPermModel authentication_classes = [authentication.BasicAuthentication] - permission_classes = [permissions.DjangoObjectLevelModelPermissions] + permission_classes = [ViewObjectPermissions] object_permissions_list_view = ObjectPermissionListView.as_view() -if guardian: - from guardian.shortcuts import assign_perm - - class ObjectPermissionsIntegrationTests(TestCase): - """ - Integration tests for the object level permissions API. - """ - @classmethod - def setUpClass(cls): - # 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 = { - 'read': f('read', 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): - 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['read'], 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) - - 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_404_NOT_FOUND) - - # 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) - - # 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_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) - - # Read list - def test_can_read_list_permissions(self): - request = factory.get('/', HTTP_AUTHORIZATION=self.credentials['readonly']) - object_permissions_list_view.cls.filter_backends = (ObjectPermissionReaderFilter,) - 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 = (ObjectPermissionReaderFilter,) - response = object_permissions_list_view(request) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertListEqual(response.data, []) \ No newline at end of file + +@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): + 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) + + 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) + + # 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_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) + + # 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, []) -- cgit v1.2.3 From 195790e60b117eff421eb8f04a9f9f3527e797b8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 11 Sep 2013 09:09:30 +0100 Subject: Version 2.3.8 --- rest_framework/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'rest_framework') 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 -- cgit v1.2.3