Commits

Mark Lavin committed 45543bd Merge

Merge in results-refactor branch.

Comments (0)

Files changed (13)

 The following people who have contributed to django-selectable:
 
 Colin Copeland
-David Ray
+Sławomir Ehlert
 Dan Poirier
 Felipe Prenholato
+David Ray
 Rick Testore
 Karen Tracey
 
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+#html_static_path = ['_static']
 
 # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
 # using the given strftime format.

docs/contribute.rst

 Submit an Issue
 --------------------------------------
 
-The issues are also managed on `Bitbucket <https://bitbucket.org/mlavin/django-selectable/issues>`_.
+The issues are also managed on `Bitbucket issue page <https://bitbucket.org/mlavin/django-selectable/issues>`_.
 If you think you've found a bug it's helpful if you indicate the version of django-selectable
 you are using the ticket version flag. If you think your bug is javascript related it is
 also helpful to know the version of jQuery, jQuery UI, and the browser you are using.
 
 django-selectable uses a registration pattern similar to the Django admin.
 Lookups should be defined in a `lookups.py` in your application's module. Once defined
-you must register in with django-selectable. All lookups must extend from 
+you must register in with django-selectable. All lookups must extend from
 ``selectable.base.LookupBase`` which defines the API for every lookup.
 
     .. code-block:: python
     :return: A string representation of the item to be shown in the search results.
         The label can include HTML. For changing the label format on the client side
         see :ref:`Advanced Label Formats <advanced-label-formats>`.
-    
+
 
 .. py:method:: LookupBase.get_item_id(item)
 
     add additional keys you should add them here.
 
     The results of ``get_item_label`` is conditionally escaped to prevent
-    Cross Site Scripting (XSS) similar to the templating language. 
+    Cross Site Scripting (XSS) similar to the templating language.
     If you know that the content is safe and you want to use these methods
     to include HTML should mark the content as safe with ``django.utils.safestring.mark_safe``
     inside the ``get_item_label`` method.
     :param item: An item from the search results.
     :return: A dictionary of information for this item to be sent back to the client.
 
-.. py:method:: LookupBase.paginate_results(request, results, limit)
+There are also some additional methods that you could want to use/override. These
+are for more advanced use cases such as using the lookups with JS libraries other
+than jQuery UI. Most users will not need to override these methods.
+
+.. _lookup-format-results:
+
+.. py:method:: LookupBase.format_results(self, raw_data, options)
+
+    Returns a python structure that later gets serialized. This makes a call to
+    :ref:`paginate_results<lookup-paginate-results>` prior to calling
+    :ref:`format_item<lookup-format-item>` on each item in the current page.
+
+    :param raw_data: The set of all matched results.
+    :param options: Dictionary of ``cleaned_data`` from the lookup form class.
+    :return: A dictionary with two keys ``meta`` and ``data``.
+        The value of ``data`` is an iterable extracted from page_data.
+        The value of ``meta`` is a dictionary. This is a copy of options with one additional element
+        ``more`` which is a translatable "Show more" string
+        (useful for indicating more results on the javascript side).
+
+.. _lookup-paginate-results:
+
+.. py:method:: LookupBase.paginate_results(results, options)
 
     If :ref:`SELECTABLE_MAX_LIMIT` is defined or ``limit`` is passed in request.GET
     then ``paginate_results`` will return the current page using Django's
-    built in pagination. See the Django docs on `pagination <https://docs.djangoproject.com/en/1.3/topics/pagination/>`_
+    built in pagination. See the Django docs on
+    `pagination <https://docs.djangoproject.com/en/1.3/topics/pagination/>`_
     for more info.
 
-    :param request: The current request object.
     :param results: The set of all matched results.
-    :param limit: The number of results per page.
+    :param options: Dictionary of ``cleaned_data`` from the lookup form class.
     :return: The current `Page object <https://docs.djangoproject.com/en/1.3/topics/pagination/#page-objects>`_
         of results.
 
+.. _lookup-serialize-results:
+
+.. py:method:: LookupBase.serialize_results(self, results)
+
+    Returns serialized results for sending via http. You may choose to override
+    this if you are making use of 
+
+    :param results: a python structure to be serialized e.g. the one returned by :ref:`format_results<lookup-format-results>`
+    :returns: JSON string.
+
 
 .. _ModelLookup:
 
     .. literalinclude:: ../example/core/lookups.py
         :pyobject: FruitLookup
 
-The syntax for ``search_fields`` is the same as the Django 
-`field lookup syntax <http://docs.djangoproject.com/en/1.3/ref/models/querysets/#field-lookups>`_. 
+The syntax for ``search_fields`` is the same as the Django
+`field lookup syntax <http://docs.djangoproject.com/en/1.3/ref/models/querysets/#field-lookups>`_.
 Each of these lookups are combined as OR so any one of them matching will return a
 result. You may optionally define a third class attribute ``filters`` which is a dictionary of
 filters to be applied to the model queryset. The keys should be a string defining a field lookup
 User Lookup Example
 --------------------------------------
 
-Below is a larger model lookup example using multiple search fields, filters 
-and display options for the `auth.User <https://docs.djangoproject.com/en/1.3/topics/auth/#users>`_ 
+Below is a larger model lookup example using multiple search fields, filters
+and display options for the `auth.User <https://docs.djangoproject.com/en/1.3/topics/auth/#users>`_
 model.
 
     .. code-block:: python
 ajax_required
 ______________________________________
 
-The django-selectable javascript will always request the lookup data via 
+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.

docs/releases.rst

 
 - ``get_item_value`` and ``get_item_id`` are no longer marked as safe by default.
 - Removed AutoComboboxSelectField and AutoComboboxSelectMultipleField. These were deprecated in 0.5.
-- Dropping Python 2.5 support
-- Dropping Django 1.2 support
+- Dropping official Python 2.5 support.
+- Dropping official Django 1.2 support.
+- ``paginate_results`` signature changed as part of the lookup refactor.
+- ``SELECTABLE_MAX_LIMIT`` can no longer be ``None``.
 
 
 v0.5.2 (Released 2012-06-27)

docs/settings.rst

 query parameter and validated against this value to ensure the client cannot manipulate
 the query string to retrive more values.
 
-You may disable this global maximum by setting
+Default: ``25``
 
-    .. code-block:: python
 
-        SELECTABLE_MAX_LIMIT = None
+.. versionadded:: 0.6
 
-Default: ``25``
+.. _SELECTABLE_ESCAPED_KEYS:
+
+SELECTABLE_ESCAPED_KEYS
+--------------------------------------
+
+The ``LookupBase.format_item`` will conditionally escape result keys based on this
+setting. The label is escaped by default to prevent a XSS flaw when using the
+jQuery UI autocomplete. If you are using the lookup responses for a different
+autocomplete plugin then you may need to esacpe more keys by default.
+
+Default: ``('label', )``
+
+.. note::
+    You probably don't want to include ``id`` in this setting.
 
 
 .. _javascript-options:

selectable/base.py

 )
 
 
+class JsonResponse(HttpResponse):
+    "HttpResponse subclass for returning JSON data."
+
+    def __init__(self, *args, **kwargs):
+        kwargs['content_type'] = 'application/json'
+        super(JsonResponse, self).__init__(*args, **kwargs)
+
+
 class LookupBase(object):
     "Base class for all django-selectable lookups."
 
     form = BaseLookupForm
+    response = JsonResponse
 
     def _name(cls):
         app_name = cls.__module__.split('.')[-2].lower()
         raise NotImplemented()
 
     def format_item(self, item):
-        return {
+        "Construct result dictionary for the match item."
+        result = {
             'id': self.get_item_id(item),
             'value': self.get_item_value(item),
-            'label': conditional_escape(self.get_item_label(item))
+            'label': self.get_item_label(item),
         }
+        for key in settings.SELECTABLE_ESCAPED_KEYS:
+            if key in result:
+                result[key] = conditional_escape(result[key])
+        return result
 
-    def paginate_results(self, request, results, limit):
-        paginator = Paginator(results, limit)
-        try:
-            page = int(request.GET.get('page', '1'))
-        except ValueError:
-            page = 1
+    def paginate_results(self, results, options):
+        "Return a django.core.paginator.Page of results."
+        limit = options.get('limit', settings.SELECTABLE_MAX_LIMIT)
+        paginator = Paginator(results, limit)        
+        page = options.get('page', 1)
         try:
             results = paginator.page(page)
         except (EmptyPage, InvalidPage):
         return results
 
     def results(self, request):
-        data = []
+        "Match results to given term and return the serialized HttpResponse."
+        results = {}
         form = self.form(request.GET)
         if form.is_valid():
-            term = form.cleaned_data.get('term', '')
-            limit = form.cleaned_data.get('limit', None)
+            options = form.cleaned_data
+            term = options.get('term', '')
             raw_data = self.get_query(request, term)
-            page_data = None      
-            if limit:
-                page_data = self.paginate_results(request, raw_data, limit)
-                raw_data = page_data.object_list
-            for item in raw_data:
-                data.append(self.format_item(item))
-            if page_data and hasattr(page_data, 'has_next') and page_data.has_next():
-                data.append({
-                    'id': '',
-                    'value': '',
-                    'label': _('Show more results'),
-                    'page': page_data.next_page_number()
-                })        
-        content = json.dumps(data, cls=DjangoJSONEncoder, ensure_ascii=False)
-        return HttpResponse(content, content_type='application/json')    
+            results = self.format_results(raw_data, options)
+        content = self.serialize_results(results)
+        return self.response(content)
+
+    def format_results(self, raw_data, options):
+        '''
+        Returns a python structure that later gets serialized.
+        raw_data
+            full list of objects matching the search term
+        options
+            a dictionary of the given options
+        '''
+        page_data = self.paginate_results(raw_data, options)
+        results = {}
+        meta = options.copy()
+        meta['more'] = _('Show more results')
+        if page_data and page_data.has_next():
+            meta['next_page'] = page_data.next_page_number()
+        if page_data and page_data.has_previous():
+            meta['prev_page'] = page_data.next_page_number()
+        results['data'] = map(self.format_item, page_data.object_list)
+        results['meta'] = meta
+        return results
+
+    def serialize_results(self, results):
+        "Returns serialized results for sending via http."
+        return json.dumps(results, cls=DjangoJSONEncoder, ensure_ascii=False)
 
 
 class ModelLookup(LookupBase):

selectable/forms/base.py

 class BaseLookupForm(forms.Form):
     term = forms.CharField(required=False)
     limit = forms.IntegerField(required=False, min_value=1)
+    page = forms.IntegerField(required=False, min_value=1)
 
     def clean_limit(self):
         "Ensure given limit is less than default if defined"
-        DEFAULT_LIMIT = getattr(settings, 'SELECTABLE_MAX_LIMIT', 25)
         limit = self.cleaned_data.get('limit', None)
-        if DEFAULT_LIMIT and (not limit or limit > DEFAULT_LIMIT):
-            limit = DEFAULT_LIMIT
+        if (settings.SELECTABLE_MAX_LIMIT is not None and 
+            (not limit or limit > settings.SELECTABLE_MAX_LIMIT)):
+            limit = settings.SELECTABLE_MAX_LIMIT
         return limit
-            
+
+    def clean_page(self):
+        "Return the first page if no page or invalid number is given."
+        return self.cleaned_data.get('page', 1) or 1

selectable/forms/widgets.py

 )
 
 
-MEDIA_URL = settings.MEDIA_URL
-STATIC_URL = getattr(settings, 'STATIC_URL', u'')
-MEDIA_PREFIX = u'%sselectable/' % (STATIC_URL or MEDIA_URL)
+STATIC_PREFIX = u'%sselectable/' % settings.STATIC_URL
 
 
 class SelectableMediaMixin(object):
 
     class Media(object):
         css = {
-            'all': (u'%scss/dj.selectable.css' % MEDIA_PREFIX, )
+            'all': (u'%scss/dj.selectable.css' % STATIC_PREFIX, )
         }
-        js = (u'%sjs/jquery.dj.selectable.js' % MEDIA_PREFIX, )
+        js = (u'%sjs/jquery.dj.selectable.js' % STATIC_PREFIX, )
 
 
 class AutoCompleteWidget(forms.TextInput, SelectableMediaMixin):

selectable/models.py

-from django.db import models
+from django.conf import settings
 
-# Create your models here.
+# Set default settings
+if not hasattr(settings, 'SELECTABLE_MAX_LIMIT'):
+    settings.SELECTABLE_MAX_LIMIT = 25
+
+if not hasattr(settings, 'SELECTABLE_ESCAPED_KEYS'):
+    settings.SELECTABLE_ESCAPED_KEYS = ('label', )

selectable/static/selectable/js/jquery.dj.selectable.js

                 if (page) {
                     query.page = page;
                 }
-				$.getJSON(url, query, response);
+                function unwrapResponse(data) {
+                    var results = data.data;
+                    var meta = data.meta;
+                    if (meta.next_page && meta.more) {
+                        results.push({
+                            id: '',
+                            value: '',
+                            label: meta.more,
+                            page: meta.next_page
+                        });
+                    }
+                    return response(results);
+                }   
+				$.getJSON(url, query, unwrapResponse);
             }
             // Create base auto-complete lookup
             $(input).autocomplete({

selectable/tests/views.py

 
 
 class SelectableViewTest(PatchSettingsMixin, BaseSelectableTestCase):
-    
+
     def setUp(self):
         super(SelectableViewTest, self).setUp()
         self.url = ThingLookup.url()
     def test_response_keys(self):
         response = self.client.get(self.url)
         data = json.loads(response.content)
-        for result in data:
+        for result in data.get('data'):
             self.assertTrue('id' in result)
             self.assertTrue('value' in result)
             self.assertTrue('label' in result)
         data = {'term': self.thing.name}
         response = self.client.get(self.url, data)
         data = json.loads(response.content)
-        self.assertEqual(len(data), 1)
+        self.assertEqual(len(data), 2)
+        self.assertEqual(len(data.get('data')), 1)
 
     def test_unknown_lookup(self):
         unknown_url = reverse('selectable-lookup', args=["XXXXXXX"])
             self.create_thing(data={'name': 'Thing%s' % i})
         response = self.client.get(self.url)
         data = json.loads(response.content)
-        self.assertEqual(len(data), settings.SELECTABLE_MAX_LIMIT + 1)
-        last_item = data[-1]
-        self.assertTrue('page' in last_item)
+        self.assertEqual(len(data.get('data')), settings.SELECTABLE_MAX_LIMIT)
+        meta = data.get('meta')
+        self.assertTrue('next_page' in meta)
 
     def test_get_next_page(self):
         for i in range(settings.SELECTABLE_MAX_LIMIT * 2):
         data = {'term': 'Thing', 'page': 2}
         response = self.client.get(self.url, data)
         data = json.loads(response.content)
-        self.assertEqual(len(data), settings.SELECTABLE_MAX_LIMIT)
+        self.assertEqual(len(data.get('data')), settings.SELECTABLE_MAX_LIMIT)
         # No next page
-        last_item = data[-1]
-        self.assertFalse('page' in last_item)
+        meta = data.get('meta')
+        self.assertFalse('next_page' in meta)
 
     def test_request_more_than_max(self):
         for i in range(settings.SELECTABLE_MAX_LIMIT):
         data = {'term': '', 'limit': settings.SELECTABLE_MAX_LIMIT * 2}
         response = self.client.get(self.url)
         data = json.loads(response.content)
-        self.assertEqual(len(data), settings.SELECTABLE_MAX_LIMIT + 1)
+        self.assertEqual(len(data.get('data')), settings.SELECTABLE_MAX_LIMIT)
 
     def test_request_less_than_max(self):
         for i in range(settings.SELECTABLE_MAX_LIMIT):
         data = {'term': '', 'limit': new_limit}
         response = self.client.get(self.url, data)
         data = json.loads(response.content)
-        self.assertEqual(len(data), new_limit + 1)
-
+        self.assertEqual(len(data.get('data')), new_limit)
 downloadcache = {toxworkdir}/_download/
 envlist = py26-1.4.X,py26-1.3.X,docs
 
+[testenv]
+commands = {envpython} runtests.py
+
 [testenv:py26-1.4.X]
-commands = {envpython} runtests.py
+basepython = python2.6
 deps = django>=1.4
     mock
 
     mock
 
 [testenv:docs]
-basepython = python
+basepython = python2.6
 deps =
     Sphinx
     Django==1.3.1