Commits

Mark Lavin  committed 8f2f5cd Merge

Merge in lookup mixins/decorators feature branch.

  • Participants
  • Parent commits 5ef1c0d, ae77471

Comments (0)

Files changed (10)

File docs/contribute.rst

 --------------------------------------
 
 There are a number of tests in place to test the server side code for this
-project. To run the tests you need Django installed and run::
+project. To run the tests you need Django and `mock <http://www.voidspace.org.uk/python/mock/>`_
+installed and run::
 
     python runtests.py
 

File docs/lookups.rst

 
         registry.register(UserLookup)
 
+
+.. versionadded:: 0.5
+
+.. _LookupDecorators
+
+Lookup Decorators
+--------------------------------------
+
+Registering lookups with django-selectable creates a small API for searching the
+lookup data. While the amount of visible data is small there are times when you want
+to restrict the set of requests which can view the data. For this purpose there are
+lookup decorators. To use them you simply decorate your lookup class.
+
+    .. code-block:: python
+
+        from django.contrib.auth.models import User
+        from selectable.base import ModelLookup
+        from selectable.decorators import login_required
+        from selectable.registry import registry
+
+
+        @login_required
+        class UserLookup(ModelLookup):
+            model = User
+            search_fields = ('username__icontains', )
+            filters = {'is_active': True, }
+
+        registry.register(UserLookup)
+
+.. note::
+
+    The class decorator syntax was introduced in Python 2.6. If you are using
+    django-selectable with Python 2.5 you can still make use of these decorators
+    by applying the without the decorator syntax.
+
+    .. code-block:: python
+
+        class UserLookup(ModelLookup):
+            model = User
+            search_fields = ('username__icontains', )
+            filters = {'is_active': True, }
+
+        UserLookup = login_required(UserLookup)
+
+        registry.register(UserLookup)
+
+Below are the descriptions of the available lookup decorators.
+
+
+ajax_required
+______________________________________
+
+The django-selectable javascript will always request the lookup data via 
+XMLHttpRequest (AJAX) request. This decorator enforces that the lookup can only
+be accessed in this way. If the request is not an AJAX request then it will return
+a 400 Bad Request response.
+
+
+login_required
+______________________________________
+
+This decorator requires the user to be authenticated via ``request.user.is_authenticated``.
+If the user is not authenticated this will return a 401 Unauthorized response.
+``request.user`` is set by the ``django.contrib.auth.middleware.AuthenticationMiddleware``
+which is required for this decorator to work. This middleware is enabled by default.
+
+staff_member_required
+______________________________________
+
+This decorator builds from ``login_required`` and in addition requires that
+``request.user.is_staff`` is ``True``. If the user is not authenticatated this will
+continue to return at 401 response. If the user is authenticated but not a staff member
+then this will return a 403 Forbidden response.

File docs/releases.rst

 _________________
 
 - Template tag to add necessary jQuery and jQuery UI libraries. Thanks to Rick Testore.
+- :ref:`Lookup decorators <LookupDecorators>` for requiring user authentication or staff access to use the lookup .
 
 Backwards Incompatible Changes
 ________________________________

File selectable/base.py

+"Base classes for lookup creation."
+
 import operator
 import re
 
 from django.core.paginator import Paginator, InvalidPage, EmptyPage
 from django.core.urlresolvers import reverse
 from django.core.serializers.json import DjangoJSONEncoder
+from django.http import HttpResponse
 from django.db.models import Q
-from django.http import HttpResponse
 from django.utils import simplejson as json
 from django.utils.encoding import smart_unicode
 from django.utils.translation import ugettext as _
 
 
 class LookupBase(object):
+    "Base class for all django-selectable lookups."
+
     form = BaseLookupForm
 
     def _name(cls):
 
 
 class ModelLookup(LookupBase):
+    "Lookup class for easily defining lookups based on Django models."
+
     model = None
     filters = {}
     search_fields = ()

File selectable/decorators.py

+"Decorators for additional lookup functionality."
+
+from functools import wraps
+
+from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
+
+
+__all__ = (
+    'ajax_required',
+    'login_required',
+    'staff_member_required',
+)
+
+
+def ajax_required(lookup_cls):
+    "Lookup decorator to require AJAX calls to the lookup view."
+
+    func = lookup_cls.results
+
+    @wraps(func)
+    def wrapper(self, request):
+        "Wrapped results function."
+        if not request.is_ajax():
+            return HttpResponseBadRequest()
+        return func(self, request)
+
+    lookup_cls.results = wrapper
+    return lookup_cls
+
+
+def login_required(lookup_cls):
+    "Lookup decorator to require the user to be authenticated."
+
+    func = lookup_cls.results
+
+    @wraps(func)
+    def wrapper(self, request):
+        "Wrapped results function."
+        user = getattr(request, 'user', None)
+        if user is None or not user.is_authenticated():
+            return HttpResponse(status=401) # Unauthorized
+        return func(self, request)
+
+    lookup_cls.results = wrapper
+    return lookup_cls
+
+
+def staff_member_required(lookup_cls):
+    "Lookup decorator to require the user is a staff member."
+    func = lookup_cls.results
+
+    @wraps(func)
+    def wrapper(self, request):
+        "Wrapped results function."
+        user = getattr(request, 'user', None)
+        if user is None or not user.is_authenticated():
+            return HttpResponse(status=401) # Unauthorized
+        elif not user.is_staff:
+            return HttpResponseForbidden()
+        return func(self, request)
+
+    lookup_cls.results = wrapper
+    return lookup_cls

File selectable/tests/__init__.py

 
 
 from selectable.tests.base import *
+from selectable.tests.decorators import *
 from selectable.tests.fields import *
 from selectable.tests.functests import *
 from selectable.tests.forms import *

File selectable/tests/base.py

File contents unchanged.

File selectable/tests/decorators.py

+from mock import Mock
+
+from selectable.decorators import ajax_required, login_required, staff_member_required
+from selectable.tests.base import BaseSelectableTestCase, SimpleModelLookup
+
+
+__all__ = (
+    'AjaxRequiredLookupTestCase',
+    'LoginRequiredLookupTestCase',
+    'StaffRequiredLookupTestCase',
+)
+
+
+class AjaxRequiredLookupTestCase(BaseSelectableTestCase):
+
+    def setUp(self):
+        self.lookup = ajax_required(SimpleModelLookup)()
+
+    def test_ajax_call(self):
+        "Ajax call should yield a successful response."
+        request = Mock()
+        request.is_ajax = lambda: True
+        response = self.lookup.results(request)
+        self.assertTrue(response.status_code, 200)
+
+    def test_non_ajax_call(self):
+        "Non-Ajax call should yield a bad request response."
+        request = Mock()
+        request.is_ajax = lambda: False
+        response = self.lookup.results(request)
+        self.assertEqual(response.status_code, 400)
+
+
+class LoginRequiredLookupTestCase(BaseSelectableTestCase):
+
+    def setUp(self):
+        self.lookup = login_required(SimpleModelLookup)()
+    
+    def test_authenicated_call(self):
+        "Authenicated call should yield a successful response."
+        request = Mock()
+        user = Mock()
+        user.is_authenticated = lambda: True
+        request.user = user
+        response = self.lookup.results(request)
+        self.assertTrue(response.status_code, 200)
+
+    def test_non_authenicated_call(self):
+        "Non-Authenicated call should yield an unauthorized response."
+        request = Mock()
+        user = Mock()
+        user.is_authenticated = lambda: False
+        request.user = user
+        response = self.lookup.results(request)
+        self.assertEqual(response.status_code, 401)
+
+
+class StaffRequiredLookupTestCase(BaseSelectableTestCase):
+
+    def setUp(self):
+        self.lookup = staff_member_required(SimpleModelLookup)()
+
+    def test_staff_member_call(self):
+        "Staff member call should yield a successful response."
+        request = Mock()
+        user = Mock()
+        user.is_authenticated = lambda: True
+        user.is_staff = True
+        request.user = user
+        response = self.lookup.results(request)
+        self.assertTrue(response.status_code, 200)
+
+    def test_authenicated_but_not_staff(self):
+        "Authenicated but non staff call should yield a forbidden response."
+        request = Mock()
+        user = Mock()
+        user.is_authenticated = lambda: True
+        user.is_staff = False
+        request.user = user
+        response = self.lookup.results(request)
+        self.assertTrue(response.status_code, 403)
+
+    def test_non_authenicated_call(self):
+        "Non-Authenicated call should yield an unauthorized response."
+        request = Mock()
+        user = Mock()
+        user.is_authenticated = lambda: False
+        user.is_staff = False
+        request.user = user
+        response = self.lookup.results(request)
+        self.assertEqual(response.status_code, 401)
     ],
     long_description=read_file('README.rst'),
     test_suite="runtests.runtests",
+    tests_require=['mock', ],
     zip_safe=False, # because we're including media that Django needs
 )
 [testenv]
 commands = {envpython} runtests.py
 deps = django>=1.4
+    mock
 
 [testenv:docs]
 basepython = python
 [testenv:py25-1.2.X]
 basepython = python2.5
 deps = django>=1.2,<1.3
+    mock
 
 [testenv:py26-1.2.X]
 basepython = python2.6
 deps = django>=1.2,<1.3
-
+    mock
 
 [testenv:py25-1.3.X]
 basepython = python2.5
 deps = django>=1.3,<1.4
+    mock
 
 [testenv:py26-1.3.X]
 basepython = python2.6
 deps = django>=1.3,<1.4
+    mock