summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--AUTHORS1
-rw-r--r--django/contrib/auth/mixins.py110
-rw-r--r--docs/releases/1.9.txt37
-rw-r--r--docs/topics/auth/default.txt161
-rw-r--r--docs/topics/class-based-views/intro.txt23
-rw-r--r--tests/auth_tests/test_mixins.py251
6 files changed, 548 insertions, 35 deletions
diff --git a/AUTHORS b/AUTHORS
index cd71b7b3b3..b25ac1d2f7 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -583,6 +583,7 @@ answer newbie questions, and generally made Django that much better:
Ram Rachum <ram@rachum.com>
Randy Barlow <randy@electronsweatshop.com>
Raphaël Barrois <raphael.barrois@m4x.org>
+ Raphael Michel <mail@raphaelmichel.de>
Raúl Cumplido <raulcumplido@gmail.com>
Remco Wendt <remco.wendt@gmail.com>
Renaud Parent <renaud.parent@gmail.com>
diff --git a/django/contrib/auth/mixins.py b/django/contrib/auth/mixins.py
new file mode 100644
index 0000000000..00fc9cb727
--- /dev/null
+++ b/django/contrib/auth/mixins.py
@@ -0,0 +1,110 @@
+from django.conf import settings
+from django.contrib.auth import REDIRECT_FIELD_NAME
+from django.contrib.auth.views import redirect_to_login
+from django.core.exceptions import ImproperlyConfigured, PermissionDenied
+from django.utils import six
+from django.utils.encoding import force_text
+
+
+class AccessMixin(object):
+ """
+ Abstract CBV mixin that gives access mixins the same customizable
+ functionality.
+ """
+ login_url = None
+ permission_denied_message = ''
+ raise_exception = False
+ redirect_field_name = REDIRECT_FIELD_NAME
+
+ def get_login_url(self):
+ """
+ Override this method to override the login_url attribute.
+ """
+ login_url = self.login_url or settings.LOGIN_URL
+ if not login_url:
+ raise ImproperlyConfigured(
+ '{0} is missing the login_url attribute. Define {0}.login_url, settings.LOGIN_URL, or override '
+ '{0}.get_login_url().'.format(self.__class__.__name__)
+ )
+ return force_text(login_url)
+
+ def get_permission_denied_message(self):
+ """
+ Override this method to override the permission_denied_message attribute.
+ """
+ return self.permission_denied_message
+
+ def get_redirect_field_name(self):
+ """
+ Override this method to override the redirect_field_name attribute.
+ """
+ return self.redirect_field_name
+
+ def handle_no_permission(self):
+ if self.raise_exception:
+ raise PermissionDenied(self.get_permission_denied_message())
+ return redirect_to_login(self.request.get_full_path(), self.get_login_url(), self.get_redirect_field_name())
+
+
+class LoginRequiredMixin(AccessMixin):
+ """
+ CBV mixin which verifies that the current user is authenticated.
+ """
+ def dispatch(self, request, *args, **kwargs):
+ if not request.user.is_authenticated():
+ return self.handle_no_permission()
+ return super(LoginRequiredMixin, self).dispatch(request, *args, **kwargs)
+
+
+class PermissionRequiredMixin(AccessMixin):
+ """
+ CBV mixin which verifies that the current user has all specified
+ permissions.
+ """
+ permission_required = None
+
+ def get_permission_required(self):
+ """
+ Override this method to override the permission_required attribute.
+ Must return an iterable.
+ """
+ if self.permission_required is None:
+ raise ImproperlyConfigured(
+ '{0} is missing the permission_required attribute. Define {0}.permission_required, or override '
+ '{0}.get_permission_required().'.format(self.__class__.__name__)
+ )
+ if isinstance(self.permission_required, six.string_types):
+ perms = (self.permission_required, )
+ else:
+ perms = self.permission_required
+ return perms
+
+ def dispatch(self, request, *args, **kwargs):
+ perms = self.get_permission_required()
+ if not request.user.has_perms(perms):
+ return self.handle_no_permission()
+ return super(PermissionRequiredMixin, self).dispatch(request, *args, **kwargs)
+
+
+class UserPassesTestMixin(AccessMixin):
+ """
+ CBV Mixin that allows you to define a test function which must return True
+ if the current user can access the view.
+ """
+
+ def test_func(self):
+ raise NotImplementedError(
+ '{0} is missing the implementation of the test_func() method.'.format(self.__class__.__name__)
+ )
+
+ def get_test_func(self):
+ """
+ Override this method to use a different test_func method.
+ """
+ return self.test_func
+
+ def dispatch(self, request, *args, **kwargs):
+ user_test_result = self.get_test_func()()
+ if not user_test_result:
+ return self.handle_no_permission()
+ return super(UserPassesTestMixin, self).dispatch(request, *args, **kwargs)
diff --git a/docs/releases/1.9.txt b/docs/releases/1.9.txt
index 63e1335333..2cd2ad018a 100644
--- a/docs/releases/1.9.txt
+++ b/docs/releases/1.9.txt
@@ -65,6 +65,43 @@ the included auth forms for your project, you could set, for example::
See :ref:`password-validation` for more details.
+Permission mixins for class-based views
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Django now ships with the mixins
+:class:`~django.contrib.auth.mixins.AccessMixin`,
+:class:`~django.contrib.auth.mixins.LoginRequiredMixin`,
+:class:`~django.contrib.auth.mixins.PermissionRequiredMixin`, and
+:class:`~django.contrib.auth.mixins.UserPassesTestMixin` to provide the
+functionality of the ``django.contrib.auth.decorators`` for class-based views.
+These mixins have been taken from, or are at least inspired by, the
+`django-braces`_ project.
+
+There are a few differences between Django's and django-braces' implementation,
+though:
+
+* The :attr:`~django.contrib.auth.mixins.AccessMixin.raise_exception` attribute
+ can only be ``True`` or ``False``. Custom exceptions or callables are not
+ supported.
+
+* The :meth:`~django.contrib.auth.mixins.AccessMixin.handle_no_permission`
+ method does not take a ``request`` argument. The current request is available
+ in ``self.request``.
+
+* The custom ``test_func()`` of :class:`~django.contrib.auth.mixins.UserPassesTestMixin`
+ does not take a ``user`` argument. The current user is available in
+ ``self.request.user``.
+
+* The :attr:`permission_required <django.contrib.auth.mixins.PermissionRequiredMixin>`
+ attribute supports a string (defining one permission) or a list/tuple of
+ strings (defining multiple permissions) that need to be fulfilled to grant
+ access.
+
+* The new :attr:`~django.contrib.auth.mixins.AccessMixin.permission_denied_message`
+ attribute allows passing a message to the ``PermissionDenied`` exception.
+
+.. _django-braces: http://django-braces.readthedocs.org/en/latest/index.html
+
Minor features
~~~~~~~~~~~~~~
diff --git a/docs/topics/auth/default.txt b/docs/topics/auth/default.txt
index f04a266cd8..cc8d0abc97 100644
--- a/docs/topics/auth/default.txt
+++ b/docs/topics/auth/default.txt
@@ -425,8 +425,8 @@ login page::
.. currentmodule:: django.contrib.auth.decorators
-The login_required decorator
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+The ``login_required`` decorator
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. function:: login_required([redirect_field_name=REDIRECT_FIELD_NAME, login_url=None])
@@ -500,6 +500,43 @@ The login_required decorator
:func:`django.contrib.admin.views.decorators.staff_member_required`
decorator a useful alternative to ``login_required()``.
+.. currentmodule:: django.contrib.auth.mixins
+
+The ``LoginRequired`` mixin
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+When using :doc:`class-based views </topics/class-based-views/index>`, you can
+achieve the same behavior as with ``login_required`` by using the
+``LoginRequiredMixin``. This mixin should be at the leftmost position in the
+inheritance list.
+
+.. class:: LoginRequiredMixin
+
+ .. versionadded:: 1.9
+
+ If a view is using this mixin, all requests by non-authenticated users will
+ be redirected to the login page or shown an HTTP 403 Forbidden error,
+ depending on the
+ :attr:`~django.contrib.auth.mixins.AccessMixin.raise_exception` parameter.
+
+ You can set any of the parameters of
+ :class:`~django.contrib.auth.mixins.AccessMixin` to customize the handling
+ of unauthorized users::
+
+
+ from django.contrib.auth.mixins import LoginRequiredMixin
+
+ class MyView(LoginRequiredMixin, View):
+ login_url = '/login/'
+ redirect_field_name = 'redirect_to'
+
+.. note::
+
+ Just as the ``login_required`` decorator, this mixin does NOT check the
+ ``is_active`` flag on a user.
+
+.. currentmodule:: django.contrib.auth.decorators
+
Limiting access to logged-in users that pass a test
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -560,8 +597,50 @@ redirects to the login page::
def my_view(request):
...
-The permission_required decorator
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+.. currentmodule:: django.contrib.auth.mixins
+
+.. class:: UserPassesTestMixin
+
+ .. versionadded:: 1.9
+
+ When using :doc:`class-based views </topics/class-based-views/index>`, you
+ can use the ``UserPassesTestMixin`` to do this.
+
+ You have to override the ``test_func()`` method of the class to provide
+ the test that is performed. Furthermore, you can set any of the parameters
+ of :class:`~django.contrib.auth.mixins.AccessMixin` to customize the
+ handling of unauthorized users::
+
+ from django.contrib.auth.mixins import UserPassesTestMixin
+
+ class MyView(UserPassesTestMixin, View):
+
+ def test_func(self):
+ return self.request.user.email.endswith('@example.com')
+
+ .. admonition: Stacking UserPassesTestMixin
+
+ Due to the way ``UserPassesTestMixin`` is implemented, you cannot stack
+ them in your inheritance list. The following does NOT work::
+
+ class TestMixin1(UserPassesTestMixin):
+ def test_func(self):
+ return self.request.user.email.endswith('@example.com')
+
+ class TestMixin2(UserPassesTestMixin):
+ def test_func(self):
+ return self.request.user.username.startswith('django')
+
+ class MyView(TestMixin1, TestMixin2, View):
+ ...
+
+ If ``TestMixin1`` would call ``super()`` and take that result into
+ account, ``TestMixin1`` wouldn't work standalone anymore.
+
+.. currentmodule:: django.contrib.auth.decorators
+
+The ``permission_required`` decorator
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. function:: permission_required(perm, [login_url=None, raise_exception=False])
@@ -583,7 +662,7 @@ The permission_required decorator
The decorator may also take an iterable of permissions.
Note that :func:`~django.contrib.auth.decorators.permission_required()`
- also takes an optional ``login_url`` parameter. Example::
+ also takes an optional ``login_url`` parameter::
from django.contrib.auth.decorators import permission_required
@@ -604,16 +683,74 @@ The permission_required decorator
In older versions, the ``permission`` parameter only worked with
strings, lists, and tuples instead of strings and any iterable.
-.. _applying-permissions-to-generic-views:
+.. currentmodule:: django.contrib.auth.mixins
-Applying permissions to generic views
+The ``PermissionRequiredMixin`` mixin
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-To apply a permission to a :doc:`class-based generic view
-</ref/class-based-views/index>`, decorate the :meth:`View.dispatch
-<django.views.generic.base.View.dispatch>` method on the class. See
-:ref:`decorating-class-based-views` for details. Another approach is to
-:ref:`write a mixin that wraps as_view() <mixins_that_wrap_as_view>`.
+To apply permission checks to :doc:`class-based views
+</ref/class-based-views/index>`, you can use the ``PermissionRequiredMixin``:
+
+.. class:: PermissionRequiredMixin
+
+ .. versionadded:: 1.9
+
+ This mixin, just like the ``permisison_required``
+ decorator, checks whether the user accessing a view has all given
+ permissions. You should specify the permission (or an iterable of
+ permissions) using the ``permission_required`` parameter::
+
+ from django.contrib.auth.mixins import PermissionRequiredMixin
+
+ class MyView(PermissionRequiredMixin, View):
+ permission_required = 'polls.can_vote'
+ # Or multiple of permissions:
+ permission_required = ('polls.can_open', 'polls.can_edit')
+
+ You can set any of the parameters of
+ :class:`~django.contrib.auth.mixins.AccessMixin` to customize the handling
+ of unauthorized users.
+
+Redirecting unauthorized requests in class-based views
+------------------------------------------------------
+
+To ease the handling of access restrictions in :doc:`class-based views
+</ref/class-based-views/index>`, the ``AccessMixin`` can be used to redirect a
+user to the login page or issue an HTTP 403 Forbidden response.
+
+.. class:: AccessMixin
+
+ .. versionadded:: 1.9
+
+ .. attribute:: login_url
+
+ The URL that users who don't pass the test will be redirected to.
+ Defaults to :setting:`settings.LOGIN_URL <LOGIN_URL>`.
+
+ .. attribute:: permission_denied_message
+
+ When ``raise_exception`` is ``True``, this attribute can be used to
+ control the error message passed to the error handler for display to
+ the user. Defaults to an empty string.
+
+ .. attribute:: redirect_field_name
+
+ The name of the query parameter that will contain the URL the user
+ should be redirected to after a successful login. If you set this to
+ ``None``, a query parameter won't be added. Defaults to ``"next"``.
+
+ .. attribute:: raise_exception
+
+ If this attribute is set to ``True``, a
+ :class:`~django.core.exceptions.PermissionDenied` exception will be
+ raised instead of the redirect. Defaults to ``False``.
+
+ .. method:: handle_no_permission()
+
+ Depending on the value of ``raise_exception``, the method either raises
+ a :exc:`~django.core.exceptions.PermissionDenied` exception or
+ redirects the user to the ``login_url``, optionally including the
+ ``redirect_field_name`` if it is set.
.. _session-invalidation-on-password-change:
diff --git a/docs/topics/class-based-views/intro.txt b/docs/topics/class-based-views/intro.txt
index 6c1ec9233e..6724bec0da 100644
--- a/docs/topics/class-based-views/intro.txt
+++ b/docs/topics/class-based-views/intro.txt
@@ -173,29 +173,6 @@ that inherits from ``View`` - for example, trying to use a form at the top of a
list and combining :class:`~django.views.generic.edit.ProcessFormView` and
:class:`~django.views.generic.list.ListView` - won't work as expected.
-.. _mixins_that_wrap_as_view:
-
-Mixins that wrap ``as_view()``
-------------------------------
-
-One way to apply common behavior to many classes is to write a mixin that wraps
-the :meth:`~django.views.generic.base.View.as_view()` method.
-
-For example, if you have many generic views that should be decorated with
-:func:`~django.contrib.auth.decorators.login_required` you could implement a
-mixin like this::
-
- from django.contrib.auth.decorators import login_required
-
- class LoginRequiredMixin(object):
- @classmethod
- def as_view(cls, **initkwargs):
- view = super(LoginRequiredMixin, cls).as_view(**initkwargs)
- return login_required(view)
-
- class MyView(LoginRequiredMixin, ...):
- # this is a generic view
- ...
Handling forms with class-based views
=====================================
diff --git a/tests/auth_tests/test_mixins.py b/tests/auth_tests/test_mixins.py
new file mode 100644
index 0000000000..c04715dc8f
--- /dev/null
+++ b/tests/auth_tests/test_mixins.py
@@ -0,0 +1,251 @@
+from django.contrib.auth import models
+from django.contrib.auth.mixins import (
+ LoginRequiredMixin, PermissionRequiredMixin, UserPassesTestMixin,
+)
+from django.contrib.auth.models import AnonymousUser
+from django.core.exceptions import PermissionDenied
+from django.http import HttpResponse
+from django.test import RequestFactory, TestCase
+from django.views.generic import View
+
+
+class AlwaysTrueMixin(UserPassesTestMixin):
+
+ def test_func(self):
+ return True
+
+
+class AlwaysFalseMixin(UserPassesTestMixin):
+
+ def test_func(self):
+ return False
+
+
+class EmptyResponseView(View):
+ def get(self, request, *args, **kwargs):
+ return HttpResponse()
+
+
+class AlwaysTrueView(AlwaysTrueMixin, EmptyResponseView):
+ pass
+
+
+class AlwaysFalseView(AlwaysFalseMixin, EmptyResponseView):
+ pass
+
+
+class StackedMixinsView1(LoginRequiredMixin, PermissionRequiredMixin, EmptyResponseView):
+ permission_required = ['auth.add_customuser', 'auth.change_customuser']
+ raise_exception = True
+
+
+class StackedMixinsView2(PermissionRequiredMixin, LoginRequiredMixin, EmptyResponseView):
+ permission_required = ['auth.add_customuser', 'auth.change_customuser']
+ raise_exception = True
+
+
+class AccessMixinTests(TestCase):
+
+ factory = RequestFactory()
+
+ def test_stacked_mixins_success(self):
+ user = models.User.objects.create(username='joe', password='qwerty')
+ perms = models.Permission.objects.filter(codename__in=('add_customuser', 'change_customuser'))
+ user.user_permissions.add(*perms)
+ request = self.factory.get('/rand')
+ request.user = user
+
+ view = StackedMixinsView1.as_view()
+ response = view(request)
+ self.assertEqual(response.status_code, 200)
+
+ view = StackedMixinsView2.as_view()
+ response = view(request)
+ self.assertEqual(response.status_code, 200)
+
+ def test_stacked_mixins_missing_permission(self):
+ user = models.User.objects.create(username='joe', password='qwerty')
+ perms = models.Permission.objects.filter(codename__in=('add_customuser',))
+ user.user_permissions.add(*perms)
+ request = self.factory.get('/rand')
+ request.user = user
+
+ view = StackedMixinsView1.as_view()
+ with self.assertRaises(PermissionDenied):
+ view(request)
+
+ view = StackedMixinsView2.as_view()
+ with self.assertRaises(PermissionDenied):
+ view(request)
+
+ def test_stacked_mixins_not_logged_in(self):
+ user = models.User.objects.create(username='joe', password='qwerty')
+ user.is_authenticated = lambda: False
+ perms = models.Permission.objects.filter(codename__in=('add_customuser', 'change_customuser'))
+ user.user_permissions.add(*perms)
+ request = self.factory.get('/rand')
+ request.user = user
+
+ view = StackedMixinsView1.as_view()
+ with self.assertRaises(PermissionDenied):
+ view(request)
+
+ view = StackedMixinsView2.as_view()
+ with self.assertRaises(PermissionDenied):
+ view(request)
+
+
+class UserPassesTestTests(TestCase):
+
+ factory = RequestFactory()
+
+ def _test_redirect(self, view=None, url='/accounts/login/?next=/rand'):
+ if not view:
+ view = AlwaysFalseView.as_view()
+ request = self.factory.get('/rand')
+ request.user = AnonymousUser()
+ response = view(request)
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, url)
+
+ def test_default(self):
+ self._test_redirect()
+
+ def test_custom_redirect_url(self):
+ class AView(AlwaysFalseView):
+ login_url = '/login/'
+
+ self._test_redirect(AView.as_view(), '/login/?next=/rand')
+
+ def test_custom_redirect_parameter(self):
+ class AView(AlwaysFalseView):
+ redirect_field_name = 'goto'
+
+ self._test_redirect(AView.as_view(), '/accounts/login/?goto=/rand')
+
+ def test_no_redirect_parameter(self):
+ class AView(AlwaysFalseView):
+ redirect_field_name = None
+
+ self._test_redirect(AView.as_view(), '/accounts/login/')
+
+ def test_raise_exception(self):
+ class AView(AlwaysFalseView):
+ raise_exception = True
+
+ request = self.factory.get('/rand')
+ request.user = AnonymousUser()
+ self.assertRaises(PermissionDenied, AView.as_view(), request)
+
+ def test_raise_exception_custom_message(self):
+ msg = "You don't have access here"
+
+ class AView(AlwaysFalseView):
+ raise_exception = True
+ permission_denied_message = msg
+
+ request = self.factory.get('/rand')
+ request.user = AnonymousUser()
+ view = AView.as_view()
+ with self.assertRaises(PermissionDenied) as cm:
+ view(request)
+ self.assertEqual(cm.exception.args[0], msg)
+
+ def test_raise_exception_custom_message_function(self):
+ msg = "You don't have access here"
+
+ class AView(AlwaysFalseView):
+ raise_exception = True
+
+ def get_permission_denied_message(self):
+ return msg
+
+ request = self.factory.get('/rand')
+ request.user = AnonymousUser()
+ view = AView.as_view()
+ with self.assertRaises(PermissionDenied) as cm:
+ view(request)
+ self.assertEqual(cm.exception.args[0], msg)
+
+ def test_user_passes(self):
+ view = AlwaysTrueView.as_view()
+ request = self.factory.get('/rand')
+ request.user = AnonymousUser()
+ response = view(request)
+ self.assertEqual(response.status_code, 200)
+
+
+class LoginRequiredMixinTests(TestCase):
+
+ factory = RequestFactory()
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = models.User.objects.create(username='joe', password='qwerty')
+
+ def test_login_required(self):
+ """
+ Check that login_required works on a simple view wrapped in a
+ login_required decorator.
+ """
+ class AView(LoginRequiredMixin, EmptyResponseView):
+ pass
+
+ view = AView.as_view()
+
+ request = self.factory.get('/rand')
+ request.user = AnonymousUser()
+ response = view(request)
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual('/accounts/login/?next=/rand', response.url)
+ request = self.factory.get('/rand')
+ request.user = self.user
+ response = view(request)
+ self.assertEqual(response.status_code, 200)
+
+
+class PermissionsRequiredMixinTests(TestCase):
+
+ factory = RequestFactory()
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = models.User.objects.create(username='joe', password='qwerty')
+ perms = models.Permission.objects.filter(codename__in=('add_customuser', 'change_customuser'))
+ cls.user.user_permissions.add(*perms)
+
+ def test_many_permissions_pass(self):
+ class AView(PermissionRequiredMixin, EmptyResponseView):
+ permission_required = ['auth.add_customuser', 'auth.change_customuser']
+
+ request = self.factory.get('/rand')
+ request.user = self.user
+ resp = AView.as_view()(request)
+ self.assertEqual(resp.status_code, 200)
+
+ def test_single_permission_pass(self):
+ class AView(PermissionRequiredMixin, EmptyResponseView):
+ permission_required = 'auth.add_customuser'
+
+ request = self.factory.get('/rand')
+ request.user = self.user
+ resp = AView.as_view()(request)
+ self.assertEqual(resp.status_code, 200)
+
+ def test_permissioned_denied_redirect(self):
+ class AView(PermissionRequiredMixin, EmptyResponseView):
+ permission_required = ['auth.add_customuser', 'auth.change_customuser', 'non-existent-permission']
+
+ request = self.factory.get('/rand')
+ request.user = self.user
+ resp = AView.as_view()(request)
+ self.assertEqual(resp.status_code, 302)
+
+ def test_permissioned_denied_exception_raised(self):
+ class AView(PermissionRequiredMixin, EmptyResponseView):
+ permission_required = ['auth.add_customuser', 'auth.change_customuser', 'non-existent-permission']
+ raise_exception = True
+
+ request = self.factory.get('/rand')
+ request.user = self.user
+ self.assertRaises(PermissionDenied, AView.as_view(), request)