Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8a4baf04d | ||
|
|
5b0400dfaa | ||
|
|
7923eeb0ee | ||
|
|
f09f9e5249 | ||
|
|
031ec6682c | ||
|
|
4dacbdf283 | ||
|
|
bc9601b3d3 | ||
|
|
d57b726d34 | ||
|
|
d25e733c85 | ||
|
|
27091f5b49 | ||
|
|
8d48887f5c | ||
|
|
fa6a841746 | ||
|
|
0ee50d9866 | ||
|
|
7d8908daab | ||
|
|
4a1b83ad1e | ||
|
|
33a00c1e39 | ||
|
|
89674ec375 | ||
|
|
e655ed7a59 |
@@ -1,6 +1,7 @@
|
||||
language: python
|
||||
sudo: false
|
||||
cache:
|
||||
- apt
|
||||
- pip
|
||||
services:
|
||||
- memcached
|
||||
@@ -8,6 +9,12 @@ python:
|
||||
- "2.7"
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- python3-enchant
|
||||
- python2-enchant
|
||||
- graphviz
|
||||
env:
|
||||
global:
|
||||
- DISPLAY=:99.0
|
||||
@@ -31,6 +38,7 @@ script:
|
||||
- isort --check-only --recursive --diff .
|
||||
- flake8 --jobs=2 .
|
||||
- pep257 --explain --source --count django_select2
|
||||
- (cd docs; make spelling)
|
||||
- coverage run --source=django_select2 -m py.test
|
||||
after_success:
|
||||
- coveralls
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
Changelog Summary
|
||||
=================
|
||||
|
||||
### v5.8.0
|
||||
* Changed signature of `render` and `render_choices` to satisfy Django 1.10 changes.
|
||||
* Changed widgets' inheritance tree to be more consistent.
|
||||
|
||||
### v5.7.1
|
||||
* Fixes pickle bug of lazy object
|
||||
|
||||
### v5.7.0
|
||||
* Security fix that allows a `field_id` to only be used for the intended JSON endpoint.
|
||||
|
||||
Prior to that change you could use any `field_id` on any select2 JSON endpoint.
|
||||
Even if the id was intended to be used on a private endpoint if could be used on
|
||||
the default one and therefore leak sensitive data.
|
||||
|
||||
* Breaking change on how `Heavy` widgets are being cached.
|
||||
|
||||
Heavy widgets used to add themselves to the cache. Now they add a dictionary to
|
||||
the cache containing themselves and the target url.
|
||||
|
||||
```python
|
||||
{
|
||||
'widget': self,
|
||||
'url': self.get_url(),
|
||||
}
|
||||
```
|
||||
|
||||
### v5.6.0
|
||||
* Added `label_from_instance` method for model widgets to define custom option labels.
|
||||
|
||||
### v5.5.0
|
||||
* Added settings to delivery static assets from different source.
|
||||
|
||||
### v5.4.2
|
||||
* Fixed initial data not being shown for heavy widgets.
|
||||
|
||||
|
||||
@@ -9,4 +9,4 @@ The app includes Select2 driven Django Widgets and Form Fields.
|
||||
|
||||
"""
|
||||
|
||||
__version__ = "5.4.3"
|
||||
__version__ = "5.8.1"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Shared memory across multiple machines to the heavy ajax lookups.
|
||||
Shared memory across multiple machines to the heavy AJAX lookups.
|
||||
|
||||
Select2 uses django.core.cache_ to share fields across
|
||||
multiple threads and even machines.
|
||||
|
||||
Select2 uses the cabhe backend defind in the setting
|
||||
Select2 uses the cache backend defined in the setting
|
||||
``SELECT2_CACHE_BACKEND`` [default=``default``].
|
||||
|
||||
It is advised to always setup a separate cache server for Select2.
|
||||
|
||||
@@ -48,5 +48,31 @@ class Select2Conf(AppConf):
|
||||
It has set `select2_` as a default value, which you can change if needed.
|
||||
"""
|
||||
|
||||
JS = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/js/select2.min.js'
|
||||
"""
|
||||
The URI for the Select2 JS file. By default this points to the Cloudflare CDN.
|
||||
|
||||
If you want to select the version of the JS library used, or want to serve it from
|
||||
the local 'static' resources, add a line to your settings.py like so::
|
||||
|
||||
SELECT2_JS = 'assets/js/select2.min.js'
|
||||
|
||||
.. tip:: Change this setting to a local asset in your development environment to
|
||||
develop without an Internet connection.
|
||||
"""
|
||||
|
||||
CSS = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/css/select2.min.css'
|
||||
"""
|
||||
The URI for the Select2 CSS file. By default this points to the Cloudflare CDN.
|
||||
|
||||
If you want to select the version of the library used, or want to serve it from
|
||||
the local 'static' resources, add a line to your settings.py like so::
|
||||
|
||||
SELECT2_CSS = 'assets/css/select2.css'
|
||||
|
||||
.. tip:: Change this setting to a local asset in your development environment to
|
||||
develop without an Internet connection.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
prefix = 'SELECT2'
|
||||
|
||||
+98
-52
@@ -50,10 +50,11 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from functools import reduce
|
||||
from itertools import chain
|
||||
from pickle import PicklingError
|
||||
|
||||
from django import forms
|
||||
from django.core import signing
|
||||
from django.core.urlresolvers import reverse_lazy
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db.models import Q
|
||||
from django.forms.models import ModelChoiceIterator
|
||||
from django.utils.encoding import force_text
|
||||
@@ -87,10 +88,10 @@ class Select2Mixin(object):
|
||||
attrs['class'] = 'django-select2'
|
||||
return attrs
|
||||
|
||||
def render_options(self, choices, selected_choices):
|
||||
def render_options(self, *args, **kwargs):
|
||||
"""Render options including an empty one, if the field is not required."""
|
||||
output = '<option></option>' if not self.is_required else ''
|
||||
output += super(Select2Mixin, self).render_options(choices, selected_choices)
|
||||
output += super(Select2Mixin, self).render_options(*args, **kwargs)
|
||||
return output
|
||||
|
||||
def _get_media(self):
|
||||
@@ -101,9 +102,8 @@ class Select2Mixin(object):
|
||||
https://docs.djangoproject.com/en/1.8/topics/forms/media/#media-as-a-dynamic-property
|
||||
"""
|
||||
return forms.Media(
|
||||
js=('//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/js/select2.min.js',
|
||||
'django_select2/django_select2.js'),
|
||||
css={'screen': ('//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/css/select2.min.css',)}
|
||||
js=(settings.SELECT2_JS, 'django_select2/django_select2.js'),
|
||||
css={'screen': (settings.SELECT2_CSS,)}
|
||||
)
|
||||
|
||||
media = property(_get_media)
|
||||
@@ -171,18 +171,17 @@ class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple):
|
||||
pass
|
||||
|
||||
|
||||
class HeavySelect2Mixin(Select2Mixin):
|
||||
"""Mixin that adds select2's ajax options and registers itself on django's cache."""
|
||||
class HeavySelect2Mixin(object):
|
||||
"""Mixin that adds select2's AJAX options and registers itself on Django's cache."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
Return HeavySelect2Mixin.
|
||||
|
||||
:param data_view: url pattern name
|
||||
:type data_view: str
|
||||
:param data_url: url
|
||||
:type data_url: str
|
||||
:return:
|
||||
Args:
|
||||
data_view (str): URL pattern name
|
||||
data_url (str): URL
|
||||
|
||||
"""
|
||||
self.data_view = kwargs.pop('data_view', None)
|
||||
self.data_url = kwargs.pop('data_url', None)
|
||||
@@ -192,13 +191,13 @@ class HeavySelect2Mixin(Select2Mixin):
|
||||
super(HeavySelect2Mixin, self).__init__(**kwargs)
|
||||
|
||||
def get_url(self):
|
||||
"""Return url from instance or by reversing :attr:`.data_view`."""
|
||||
"""Return URL from instance or by reversing :attr:`.data_view`."""
|
||||
if self.data_url:
|
||||
return self.data_url
|
||||
return reverse_lazy(self.data_view)
|
||||
return reverse(self.data_view)
|
||||
|
||||
def build_attrs(self, extra_attrs=None, **kwargs):
|
||||
"""Set select2's ajax attributes."""
|
||||
"""Set select2's AJAX attributes."""
|
||||
attrs = super(HeavySelect2Mixin, self).build_attrs(extra_attrs=extra_attrs, **kwargs)
|
||||
|
||||
# encrypt instance Id
|
||||
@@ -213,9 +212,9 @@ class HeavySelect2Mixin(Select2Mixin):
|
||||
attrs['class'] += ' django-select2-heavy'
|
||||
return attrs
|
||||
|
||||
def render(self, name, value, attrs=None, choices=()):
|
||||
def render(self, *args, **kwargs):
|
||||
"""Render widget and register it in Django's cache."""
|
||||
output = super(HeavySelect2Mixin, self).render(name, value, attrs=attrs, choices=choices)
|
||||
output = super(HeavySelect2Mixin, self).render(*args, **kwargs)
|
||||
self.set_to_cache()
|
||||
return output
|
||||
|
||||
@@ -223,12 +222,30 @@ class HeavySelect2Mixin(Select2Mixin):
|
||||
return "%s%s" % (settings.SELECT2_CACHE_PREFIX, id(self))
|
||||
|
||||
def set_to_cache(self):
|
||||
"""Add widget object to Djnago's cache."""
|
||||
cache.set(self._get_cache_key(), self)
|
||||
"""
|
||||
Add widget object to Django's cache.
|
||||
|
||||
def render_options(self, choices, selected_choices):
|
||||
You may need to overwrite this method, to pickle all information
|
||||
that is required to serve your JSON response view.
|
||||
"""
|
||||
try:
|
||||
cache.set(self._get_cache_key(), {
|
||||
'widget': self,
|
||||
'url': self.get_url(),
|
||||
})
|
||||
except (PicklingError, AttributeError):
|
||||
msg = "You need to overwrite \"set_to_cache\" or ensure that %s is serialisable."
|
||||
raise NotImplementedError(msg % self.__class__.__name__)
|
||||
|
||||
def render_options(self, *args):
|
||||
"""Render only selected options."""
|
||||
choices = chain(choices, self.choices)
|
||||
try:
|
||||
selected_choices, = args
|
||||
except ValueError: # Signature contained `choices` prior to Django 1.10
|
||||
choices, selected_choices = args
|
||||
choices = chain(self.choices, choices)
|
||||
else:
|
||||
choices = self.choices
|
||||
output = ['<option></option>' if not self.is_required else '']
|
||||
choices = {(k, v) for k, v in choices if k in selected_choices}
|
||||
selected_choices = {force_text(v) for v in selected_choices}
|
||||
@@ -237,7 +254,7 @@ class HeavySelect2Mixin(Select2Mixin):
|
||||
return '\n'.join(output)
|
||||
|
||||
|
||||
class HeavySelect2Widget(HeavySelect2Mixin, forms.Select):
|
||||
class HeavySelect2Widget(HeavySelect2Mixin, Select2Widget):
|
||||
"""
|
||||
Select2 widget with AJAX support that registers itself to Django's Cache.
|
||||
|
||||
@@ -260,13 +277,13 @@ class HeavySelect2Widget(HeavySelect2Mixin, forms.Select):
|
||||
pass
|
||||
|
||||
|
||||
class HeavySelect2MultipleWidget(HeavySelect2Mixin, forms.SelectMultiple):
|
||||
class HeavySelect2MultipleWidget(HeavySelect2Mixin, Select2MultipleWidget):
|
||||
"""Select2 multi select widget similar to :class:`.HeavySelect2Widget`."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class HeavySelect2TagWidget(Select2TagMixin, HeavySelect2MultipleWidget):
|
||||
class HeavySelect2TagWidget(HeavySelect2Mixin, Select2TagWidget):
|
||||
"""Select2 tag widget."""
|
||||
|
||||
pass
|
||||
@@ -282,7 +299,7 @@ class ModelSelect2Mixin(object):
|
||||
queryset = None
|
||||
search_fields = []
|
||||
"""
|
||||
Model lookups that are used to filter the queryset.
|
||||
Model lookups that are used to filter the QuerySet.
|
||||
|
||||
Example::
|
||||
|
||||
@@ -299,14 +316,12 @@ class ModelSelect2Mixin(object):
|
||||
"""
|
||||
Overwrite class parameters if passed as keyword arguments.
|
||||
|
||||
:param model: model to select choices from
|
||||
:type model: django.db.models.Model
|
||||
:param queryset: queryset to select choices from
|
||||
:type queryset: django.db.models.query.QuerySet
|
||||
:param search_fields: list of model lookup strings
|
||||
:type search_fields: list
|
||||
:param max_results: max. JsonResponse view page size
|
||||
:type max_results: int
|
||||
Args:
|
||||
model (django.db.models.Model): Model to select choices from.
|
||||
queryset (django.db.models.QuerySet): QuerySet to select choices from.
|
||||
search_fields (list): List of model lookup strings.
|
||||
max_results (int): Max. JsonResponse view page size.
|
||||
|
||||
"""
|
||||
self.model = kwargs.pop('model', self.model)
|
||||
self.queryset = kwargs.pop('queryset', self.queryset)
|
||||
@@ -318,9 +333,9 @@ class ModelSelect2Mixin(object):
|
||||
|
||||
def set_to_cache(self):
|
||||
"""
|
||||
Add widget's attributes to Djnago's cache.
|
||||
Add widget's attributes to Django's cache.
|
||||
|
||||
Split the queryset, to not pickle the result set.
|
||||
Split the QuerySet, to not pickle the result set.
|
||||
"""
|
||||
queryset = self.get_queryset()
|
||||
cache.set(self._get_cache_key(), {
|
||||
@@ -332,16 +347,19 @@ class ModelSelect2Mixin(object):
|
||||
'cls': self.__class__,
|
||||
'search_fields': self.search_fields,
|
||||
'max_results': self.max_results,
|
||||
'url': self.get_url(),
|
||||
})
|
||||
|
||||
def filter_queryset(self, term, queryset=None):
|
||||
"""
|
||||
Return queryset filtered by search_fields matching the passed term.
|
||||
Return QuerySet filtered by search_fields matching the passed term.
|
||||
|
||||
Args:
|
||||
term (str): Search term
|
||||
|
||||
Returns:
|
||||
QuerySet: Filtered QuerySet
|
||||
|
||||
:param term: Search term
|
||||
:type term: str
|
||||
:return: Filtered queryset
|
||||
:rtype: :class:`.django.db.models.QuerySet`
|
||||
"""
|
||||
if queryset is None:
|
||||
queryset = self.get_queryset()
|
||||
@@ -356,10 +374,11 @@ class ModelSelect2Mixin(object):
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Return queryset based on :attr:`.queryset` or :attr:`.model`.
|
||||
Return QuerySet based on :attr:`.queryset` or :attr:`.model`.
|
||||
|
||||
Returns:
|
||||
QuerySet: QuerySet of available choices.
|
||||
|
||||
:return: queryset of available choices
|
||||
:rtype: :class:`.django.db.models.QuerySet`
|
||||
"""
|
||||
if self.queryset is not None:
|
||||
queryset = self.queryset
|
||||
@@ -381,24 +400,51 @@ class ModelSelect2Mixin(object):
|
||||
return self.search_fields
|
||||
raise NotImplementedError('%s, must implement "search_fields".' % self.__class__.__name__)
|
||||
|
||||
def render_options(self, choices, selected_choices):
|
||||
"""Render only selected options and set queryset from :class:`ModelChoicesIterator`."""
|
||||
def render_options(self, *args):
|
||||
"""Render only selected options and set QuerySet from :class:`ModelChoicesIterator`."""
|
||||
try:
|
||||
selected_choices, = args
|
||||
except ValueError:
|
||||
choices, selected_choices = args
|
||||
choices = chain(self.choices, choices)
|
||||
else:
|
||||
choices = self.choices
|
||||
output = ['<option></option>' if not self.is_required else '']
|
||||
if isinstance(self.choices, ModelChoiceIterator):
|
||||
if not self.queryset:
|
||||
self.queryset = self.choices.queryset
|
||||
selected_choices = {c for c in selected_choices
|
||||
if c not in self.choices.field.empty_values}
|
||||
choices = {self.choices.choice(obj)
|
||||
choices = {(obj.pk, self.label_from_instance(obj))
|
||||
for obj in self.choices.queryset.filter(pk__in=selected_choices)}
|
||||
else:
|
||||
choices = chain(choices, self.choices)
|
||||
choices = {(k, v) for k, v in choices if k in selected_choices}
|
||||
selected_choices = {force_text(v) for v in selected_choices}
|
||||
for option_value, option_label in choices:
|
||||
output.append(self.render_option(selected_choices, option_value, option_label))
|
||||
return '\n'.join(output)
|
||||
|
||||
def label_from_instance(self, obj):
|
||||
"""
|
||||
Return option label representation from instance.
|
||||
|
||||
Can be overridden to change the representation of each choice.
|
||||
|
||||
Example usage::
|
||||
|
||||
class MyWidget(ModelSelect2Widget):
|
||||
def label_from_instance(obj):
|
||||
return force_text(obj.title).upper()
|
||||
|
||||
Args:
|
||||
obj (django.db.models.Model): Instance of Django Model.
|
||||
|
||||
Returns:
|
||||
str: Option label.
|
||||
|
||||
"""
|
||||
return force_text(obj)
|
||||
|
||||
|
||||
class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget):
|
||||
"""
|
||||
@@ -430,8 +476,8 @@ class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget):
|
||||
)
|
||||
|
||||
.. tip:: The ModelSelect2(Multiple)Widget will try
|
||||
to get the queryset from the fields choices.
|
||||
Therefore you don't need to define a queryset,
|
||||
to get the QuerySet from the fields choices.
|
||||
Therefore you don't need to define a QuerySet,
|
||||
if you just drop in the widget for a ForeignKey field.
|
||||
"""
|
||||
|
||||
@@ -448,13 +494,13 @@ class ModelSelect2MultipleWidget(ModelSelect2Mixin, HeavySelect2MultipleWidget):
|
||||
pass
|
||||
|
||||
|
||||
class ModelSelect2TagWidget(Select2TagMixin, ModelSelect2MultipleWidget):
|
||||
class ModelSelect2TagWidget(ModelSelect2Mixin, HeavySelect2TagWidget):
|
||||
"""
|
||||
Select2 model widget with tag support.
|
||||
|
||||
This it not a simple drop in widget.
|
||||
It requires to implement you own :func:`.value_from_datadict`
|
||||
that adds missing tags to you queryset.
|
||||
that adds missing tags to you QuerySet.
|
||||
|
||||
Example::
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Django-Select2 url config.
|
||||
Django-Select2 URL configuration.
|
||||
|
||||
Add `django_select` to your urlconf **if** you use any 'Model' fields::
|
||||
Add `django_select` to your ``urlconf`` **if** you use any 'Model' fields::
|
||||
|
||||
url(r'^select2/', include('django_select2.urls')),
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import absolute_import, unicode_literals
|
||||
from django.core import signing
|
||||
from django.core.signing import BadSignature
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.utils.encoding import smart_text
|
||||
from django.views.generic.list import BaseListView
|
||||
|
||||
from .cache import cache
|
||||
@@ -43,7 +42,7 @@ class AutoResponseView(BaseListView):
|
||||
return JsonResponse({
|
||||
'results': [
|
||||
{
|
||||
'text': smart_text(obj),
|
||||
'text': self.widget.label_from_instance(obj),
|
||||
'id': obj.pk,
|
||||
}
|
||||
for obj in context['object_list']
|
||||
@@ -52,7 +51,7 @@ class AutoResponseView(BaseListView):
|
||||
})
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get queryset from cached widget."""
|
||||
"""Get QuerySet from cached widget."""
|
||||
return self.widget.filter_queryset(self.term, self.queryset)
|
||||
|
||||
def get_paginate_by(self, queryset):
|
||||
@@ -63,10 +62,12 @@ class AutoResponseView(BaseListView):
|
||||
"""
|
||||
Get and return widget from cache.
|
||||
|
||||
Raises a 404 if the widget can not be found or no id is provided.
|
||||
Raises:
|
||||
Http404: If if the widget can not be found or no id is provided.
|
||||
|
||||
Returns:
|
||||
ModelSelect2Mixin: Widget from cache.
|
||||
|
||||
:raises: Http404
|
||||
:return: ModelSelect2Mixin
|
||||
"""
|
||||
field_id = self.kwargs.get('field_id', self.request.GET.get('field_id', None))
|
||||
if not field_id:
|
||||
@@ -80,6 +81,8 @@ class AutoResponseView(BaseListView):
|
||||
widget_dict = cache.get(cache_key)
|
||||
if widget_dict is None:
|
||||
raise Http404('field_id not found')
|
||||
if widget_dict.pop('url') != self.request.path:
|
||||
raise Http404('field_id was issued for the view.')
|
||||
qs, qs.query = widget_dict.pop('queryset')
|
||||
self.queryset = qs.all()
|
||||
widget_dict['queryset'] = self.queryset
|
||||
|
||||
@@ -46,6 +46,11 @@ html:
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
||||
spelling:
|
||||
$(SPHINXBUILD) -b spelling -W $(ALLSPHINXOPTS) $(BUILDDIR)/spelling
|
||||
@echo
|
||||
@echo "Spell check finished. The results are in $(BUILDDIR)/spelling."
|
||||
|
||||
dirhtml:
|
||||
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
|
||||
@echo
|
||||
|
||||
+13
-1
@@ -41,7 +41,14 @@ sys.path.insert(0, os.path.abspath('..'))
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be extensions
|
||||
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
||||
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.inheritance_diagram', 'sphinx.ext.intersphinx', 'sphinx.ext.viewcode']
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.napoleon',
|
||||
'sphinx.ext.inheritance_diagram',
|
||||
'sphinx.ext.intersphinx',
|
||||
'sphinx.ext.viewcode',
|
||||
'sphinxcontrib.spelling',
|
||||
]
|
||||
|
||||
intersphinx_mapping = {
|
||||
'python': ('http://docs.python.org/3.5', None),
|
||||
@@ -49,6 +56,11 @@ intersphinx_mapping = {
|
||||
'https://docs.djangoproject.com/en/dev/_objects/'),
|
||||
}
|
||||
|
||||
# spell check
|
||||
spelling_word_list_filename = 'spelling_wordlist.txt'
|
||||
spelling_show_suggestions = True
|
||||
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
|
||||
+27
-1
@@ -49,7 +49,7 @@ DjangoSelect2 handles the initialization of select2 fields automatically. Just i
|
||||
``{{ form.media.js }}`` in your template before the closing ``body`` tag. That's it!
|
||||
|
||||
If you insert forms after page load or if you want to handle the initialization
|
||||
yourself, DjangoSelect2 provides a jQuery-Plugin. It will handle both normal and
|
||||
yourself, DjangoSelect2 provides a jQuery plugin. It will handle both normal and
|
||||
heavy fields. Simply call ``djangoSelect2(options)`` on your select fields.::
|
||||
|
||||
$('.django-select2').djangoSelect2();
|
||||
@@ -58,3 +58,29 @@ heavy fields. Simply call ``djangoSelect2(options)`` on your select fields.::
|
||||
You can pass see `Select2 options <https://select2.github.io/options.html>`_ if needed::
|
||||
|
||||
$('.django-select2').djangoSelect2({placeholder: 'Select an option'});
|
||||
|
||||
Security & Authentication
|
||||
-------------------------
|
||||
|
||||
Security is important. Therefore make sure to read and understand what
|
||||
the security measures in place and their limitations.
|
||||
|
||||
Set up a separate cache. If you have a public form that uses a model widget
|
||||
make sure to setup a separate cache database for Select2. An attacker
|
||||
could constantly reload your site and fill up the select2 cache.
|
||||
Having a separate cache allows you to limit the effect to select2 only.
|
||||
|
||||
You might want to add a secure select2 JSON endpoint for data you don't
|
||||
want to be accessible to the general public. Doing so is easy::
|
||||
|
||||
class UserSelect2View(LoginRequiredMixin, AutoResponseView):
|
||||
pass
|
||||
|
||||
class UserSelect2WidgetMixin(object):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['data_view'] = 'user-select2-view'
|
||||
super(UserSelect2WidgetMixin, self).__init__(*args, **kwargs)
|
||||
|
||||
class MySecretWidget(UserSelect2WidgetMixin, Select2ModelWidget):
|
||||
model = MySecretModel
|
||||
search_fields = ['title__icontains']
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
Django >= 1.4.1
|
||||
Django >= 1.8
|
||||
sphinxcontrib-spelling
|
||||
pyenchant
|
||||
|
||||
@@ -18,7 +18,8 @@ Installation
|
||||
2. Add ``django_select2`` to your ``INSTALLED_APPS`` in your project settings.
|
||||
|
||||
|
||||
3. Add ``django_select`` to your urlconf **if** you use any 'Auto' fields::
|
||||
3. Add ``django_select`` to your ``urlconf`` **if** you use any
|
||||
:class:`ModelWidgets <.django_select2.forms.ModelSelect2Mixin>`::
|
||||
|
||||
url(r'^select2/', include('django_select2.urls')),
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
jQuery
|
||||
Django
|
||||
mixin
|
||||
backend
|
||||
redis
|
||||
memcached
|
||||
AJAX
|
||||
Cloudflare
|
||||
lookup
|
||||
QuerySet
|
||||
pre
|
||||
py
|
||||
lookups
|
||||
functionalities
|
||||
plugin
|
||||
multi
|
||||
Indices
|
||||
@@ -7,3 +7,6 @@ pep257
|
||||
pytest
|
||||
pytest-django
|
||||
selenium
|
||||
sphinx
|
||||
sphinxcontrib-spelling
|
||||
pyenchant
|
||||
+18
-6
@@ -4,16 +4,28 @@
|
||||
#
|
||||
# pip-compile requirements_dev.in
|
||||
#
|
||||
alabaster==0.7.7 # via sphinx
|
||||
babel==2.2.0 # via sphinx
|
||||
django-appconf==1.0.1
|
||||
flake8==2.5.0
|
||||
docutils==0.12 # via sphinx
|
||||
flake8==2.5.2
|
||||
isort==4.2.2
|
||||
mccabe==0.3.1
|
||||
Jinja2==2.8
|
||||
MarkupSafe==0.23
|
||||
mccabe==0.4.0
|
||||
pep257==0.7.0
|
||||
pep8-naming==0.3.3
|
||||
pep8==1.5.7 # via flake8
|
||||
pep8==1.7.0 # via flake8
|
||||
py==1.4.31 # via pytest
|
||||
pyenchant==1.6.6
|
||||
pyflakes==1.0.0 # via flake8
|
||||
Pygments==2.1
|
||||
pytest-django==2.9.1
|
||||
pytest==2.8.3
|
||||
selenium==2.48.0
|
||||
six==1.10.0 # via django-appconf
|
||||
pytest==2.8.7
|
||||
pytz==2015.7 # via babel
|
||||
selenium==2.50.1
|
||||
six==1.10.0 # via django-appconf, sphinx, sphinxcontrib-spelling
|
||||
snowballstemmer==1.2.1 # via sphinx
|
||||
sphinx-rtd-theme==0.1.9 # via sphinx
|
||||
sphinx==1.3.5
|
||||
sphinxcontrib-spelling==2.1.2
|
||||
|
||||
@@ -17,5 +17,6 @@ atomic = true
|
||||
multi_line_output = 5
|
||||
line_length = 79
|
||||
skip = manage.py,docs
|
||||
known_first_party = django_select2
|
||||
known_first_party = django_select2, tests
|
||||
known_third_party = django
|
||||
combine_as_imports = true
|
||||
|
||||
+70
-5
@@ -83,6 +83,28 @@ class TestSelect2Mixin(object):
|
||||
assert widget.get_url() == '/foo/bar'
|
||||
|
||||
|
||||
class TestSelect2MixinSettings(object):
|
||||
def test_default_media(self):
|
||||
sut = Select2Widget()
|
||||
result = sut.media.render()
|
||||
assert '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/js/select2.min.js' in result
|
||||
assert '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/css/select2.min.css' in result
|
||||
assert 'django_select2/django_select2.js' in result
|
||||
|
||||
def test_js_setting(self, settings):
|
||||
settings.SELECT2_JS = 'alternate.js'
|
||||
sut = Select2Widget()
|
||||
result = sut.media.render()
|
||||
assert 'alternate.js' in result
|
||||
assert 'django_select2/django_select2.js' in result
|
||||
|
||||
def test_css_setting(self, settings):
|
||||
settings.SELECT2_CSS = 'alternate.css'
|
||||
sut = Select2Widget()
|
||||
result = sut.media.render()
|
||||
assert 'alternate.css' in result
|
||||
|
||||
|
||||
class TestHeavySelect2Mixin(TestSelect2Mixin):
|
||||
url = reverse('heavy_select2_widget')
|
||||
form = forms.HeavySelect2WidgetForm(initial={'primary_genre': 1})
|
||||
@@ -101,14 +123,13 @@ class TestHeavySelect2Mixin(TestSelect2Mixin):
|
||||
not_required_field = self.form.fields['primary_genre']
|
||||
assert not_required_field.required is False
|
||||
assert '<option value="1" selected="selected">One</option>' in \
|
||||
not_required_field.widget.render('primary_genre', 1, choices=NUMBER_CHOICES), \
|
||||
not_required_field.widget.render('primary_genre', 1, choices=NUMBER_CHOICES)
|
||||
not_required_field.widget.render('primary_genre', 1), \
|
||||
not_required_field.widget.render('primary_genre', 1)
|
||||
|
||||
def test_many_selected_option(self, db, genres):
|
||||
field = HeavySelect2MultipleWidgetForm().fields['genres']
|
||||
widget_output = field.widget.render(
|
||||
'genres', [1, 2],
|
||||
choices=NUMBER_CHOICES)
|
||||
field.widget.choices = NUMBER_CHOICES
|
||||
widget_output = field.widget.render('genres', [1, 2])
|
||||
selected_option = '<option value="{pk}" selected="selected">{value}</option>'.format(pk=1, value='One')
|
||||
selected_option2 = '<option value="{pk}" selected="selected">{value}</option>'.format(pk=2, value='Two')
|
||||
|
||||
@@ -132,6 +153,20 @@ class TestHeavySelect2Mixin(TestSelect2Mixin):
|
||||
error = driver.find_element_by_xpath('//body[@JSError]')
|
||||
pytest.fail(error.get_attribute('JSError'))
|
||||
|
||||
def test_get_url(self):
|
||||
widget = self.widget_cls(data_view='heavy_data_1', attrs={'class': 'my-class'})
|
||||
assert isinstance(widget.get_url(), text_type)
|
||||
|
||||
def test_can_not_pickle(self):
|
||||
widget = self.widget_cls(data_view='heavy_data_1', attrs={'class': 'my-class'})
|
||||
|
||||
class NoPickle(object):
|
||||
pass
|
||||
|
||||
widget.no_pickle = NoPickle()
|
||||
with pytest.raises(NotImplementedError):
|
||||
widget.set_to_cache()
|
||||
|
||||
|
||||
class TestModelSelect2Mixin(TestHeavySelect2Mixin):
|
||||
form = forms.AlbumModelSelect2WidgetForm(initial={'primary_genre': 1})
|
||||
@@ -141,6 +176,15 @@ class TestModelSelect2Mixin(TestHeavySelect2Mixin):
|
||||
form = self.form.__class__(initial={'primary_genre': genre.pk})
|
||||
assert text_type(genre) in form.as_p()
|
||||
|
||||
def test_label_from_instance_initial(self, genres):
|
||||
genre = genres[0]
|
||||
genre.title = genre.title.lower()
|
||||
genre.save()
|
||||
|
||||
form = self.form.__class__(initial={'primary_genre': genre.pk})
|
||||
assert genre.title not in form.as_p()
|
||||
assert genre.title.upper() in form.as_p()
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def genres(self, genres):
|
||||
return genres
|
||||
@@ -160,6 +204,23 @@ class TestModelSelect2Mixin(TestHeavySelect2Mixin):
|
||||
assert selected_option in widget_output, widget_output
|
||||
assert unselected_option not in widget_output
|
||||
|
||||
def test_selected_option_label_from_instance(self, db, genres):
|
||||
genre = genres[0]
|
||||
genre.title = genre.title.lower()
|
||||
genre.save()
|
||||
|
||||
field = self.form.fields['primary_genre']
|
||||
widget_output = field.widget.render('primary_genre', genre.pk)
|
||||
|
||||
def get_selected_option(genre):
|
||||
return '<option value="{pk}" selected="selected">{value}</option>'.format(
|
||||
pk=genre.pk, value=force_text(genre))
|
||||
|
||||
assert get_selected_option(genre) not in widget_output
|
||||
genre.title = genre.title.upper()
|
||||
|
||||
assert get_selected_option(genre) in widget_output
|
||||
|
||||
def test_get_queryset(self):
|
||||
widget = ModelSelect2Widget()
|
||||
with pytest.raises(NotImplementedError):
|
||||
@@ -221,6 +282,10 @@ class TestModelSelect2Mixin(TestHeavySelect2Mixin):
|
||||
assert isinstance(cached_widget['queryset'][0], qs.__class__)
|
||||
assert text_type(cached_widget['queryset'][1]) == text_type(qs.query)
|
||||
|
||||
def test_get_url(self):
|
||||
widget = ModelSelect2Widget(queryset=Genre.objects.all(), search_fields=['title__icontains'])
|
||||
assert isinstance(widget.get_url(), text_type)
|
||||
|
||||
|
||||
class TestHeavySelect2TagWidget(TestHeavySelect2Mixin):
|
||||
|
||||
|
||||
+33
-1
@@ -7,8 +7,11 @@ from django.core import signing
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.encoding import smart_text
|
||||
|
||||
from django_select2.cache import cache
|
||||
from django_select2.forms import ModelSelect2Widget
|
||||
from tests.testapp.forms import AlbumModelSelect2WidgetForm
|
||||
from tests.testapp.forms import (
|
||||
AlbumModelSelect2WidgetForm, ArtistCustomTitleWidget
|
||||
)
|
||||
from tests.testapp.models import Genre
|
||||
|
||||
|
||||
@@ -66,3 +69,32 @@ class TestAutoResponseView(object):
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.content.decode('utf-8'))
|
||||
assert data['more'] is False
|
||||
|
||||
def test_label_from_instance(self, artists, client):
|
||||
url = reverse('django_select2-json')
|
||||
|
||||
form = AlbumModelSelect2WidgetForm()
|
||||
form.fields['artist'].widget = ArtistCustomTitleWidget()
|
||||
assert form.as_p()
|
||||
field_id = signing.dumps(id(form.fields['artist'].widget))
|
||||
|
||||
artist = artists[0]
|
||||
response = client.get(url, {'field_id': field_id, 'term': artist.title})
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.content.decode('utf-8'))
|
||||
assert data['results']
|
||||
assert {'id': artist.pk, 'text': smart_text(artist.title.upper())} in data['results']
|
||||
|
||||
def test_url_check(self, client, artists):
|
||||
artist = artists[0]
|
||||
form = AlbumModelSelect2WidgetForm()
|
||||
assert form.as_p()
|
||||
field_id = signing.dumps(id(form.fields['artist'].widget))
|
||||
cache_key = form.fields['artist'].widget._get_cache_key()
|
||||
widget_dict = cache.get(cache_key)
|
||||
widget_dict['url'] = 'yet/another/url'
|
||||
cache.set(cache_key, widget_dict)
|
||||
url = reverse('django_select2-json')
|
||||
response = client.get(url, {'field_id': field_id, 'term': artist.title})
|
||||
assert response.status_code == 404
|
||||
|
||||
+27
-8
@@ -2,6 +2,7 @@
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django import forms
|
||||
from django.utils.encoding import force_text
|
||||
|
||||
from django_select2.forms import (
|
||||
HeavySelect2MultipleWidget, HeavySelect2Widget, ModelSelect2MultipleWidget,
|
||||
@@ -34,6 +35,26 @@ class GenreSelect2TagWidget(TitleSearchFieldMixin, ModelSelect2TagWidget):
|
||||
self.get_queryset().create(title=value)
|
||||
|
||||
|
||||
class ArtistCustomTitleWidget(ModelSelect2Widget):
|
||||
model = models.Artist
|
||||
search_fields = [
|
||||
'title__icontains'
|
||||
]
|
||||
|
||||
def label_from_instance(self, obj):
|
||||
return force_text(obj.title).upper()
|
||||
|
||||
|
||||
class GenreCustomTitleWidget(ModelSelect2Widget):
|
||||
model = models.Genre
|
||||
search_fields = [
|
||||
'title__icontains'
|
||||
]
|
||||
|
||||
def label_from_instance(self, obj):
|
||||
return force_text(obj.title).upper()
|
||||
|
||||
|
||||
class AlbumSelect2WidgetForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = models.Album
|
||||
@@ -69,16 +90,14 @@ class AlbumModelSelect2WidgetForm(forms.ModelForm):
|
||||
'primary_genre',
|
||||
)
|
||||
widgets = {
|
||||
'artist': ModelSelect2Widget(
|
||||
model=models.Artist,
|
||||
search_fields=['title__icontains']
|
||||
),
|
||||
'primary_genre': ModelSelect2Widget(
|
||||
model=models.Genre,
|
||||
search_fields=['title__icontains']
|
||||
),
|
||||
'artist': ArtistCustomTitleWidget(),
|
||||
'primary_genre': GenreCustomTitleWidget(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AlbumModelSelect2WidgetForm, self).__init__(*args, **kwargs)
|
||||
self.fields['primary_genre'].initial = 2
|
||||
|
||||
|
||||
class AlbumModelSelect2MultipleWidgetRequiredForm(forms.ModelForm):
|
||||
class Meta:
|
||||
|
||||
@@ -33,9 +33,12 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||
SITE_ID = 1
|
||||
ROOT_URLCONF = 'tests.testapp.urls'
|
||||
|
||||
TEMPLATE_DIRS = (
|
||||
os.path.join(BASE_DIR, "templates"),
|
||||
)
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'APP_DIRS': True,
|
||||
},
|
||||
]
|
||||
|
||||
SECRET_KEY = '123456'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user