Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46f69f166 | ||
|
|
b88e26bfa2 | ||
|
|
78c54f2c47 | ||
|
|
018701680b | ||
|
|
d4668e3125 | ||
|
|
bc0eab96e0 | ||
|
|
6d2bd1cbd3 | ||
|
|
9d215f6efe | ||
|
|
5741279d0e | ||
|
|
12b0d472b8 | ||
|
|
74d2266817 | ||
|
|
c46fd5a5ec | ||
|
|
ad938c6008 | ||
|
|
2c1ae2df92 | ||
|
|
6260226ae5 | ||
|
|
a5bb222ea8 | ||
|
|
f12e624cb8 | ||
|
|
c0d6fcb663 | ||
|
|
6dc2f3eb19 | ||
|
|
26f2a36dc6 | ||
|
|
d6180e05fc | ||
|
|
67b7e755fe | ||
|
|
4147cac2f8 | ||
|
|
55e481470e | ||
|
|
7297ed7fde | ||
|
|
90be63441b | ||
|
|
d6e056c009 | ||
|
|
a910581f7a | ||
|
|
1931a98240 | ||
|
|
5840622794 | ||
|
|
dc6e87e7ba | ||
|
|
71a836c892 | ||
|
|
735d5931d6 | ||
|
|
9cc0a9d62d | ||
|
|
dfdc626ad3 | ||
|
|
07e8029026 | ||
|
|
1f9d564445 | ||
|
|
5099b8d44d | ||
|
|
8958940c43 | ||
|
|
d6202c4108 | ||
|
|
0d560c08fc | ||
|
|
17dff07690 | ||
|
|
c6d7054e16 | ||
|
|
a89fedbe75 | ||
|
|
0918cae3e7 | ||
|
|
8f6e33845e | ||
|
|
46a50f377b | ||
|
|
895a7e519a | ||
|
|
c235c2e3f0 | ||
|
|
9a658b3b0f | ||
|
|
efb120b47d | ||
|
|
b34642dcef | ||
|
|
8b64b5d4fa | ||
|
|
4e8b2c24a3 | ||
|
|
23e91c8bb0 | ||
|
|
e88c0e059c | ||
|
|
047ee28722 | ||
|
|
6018b60fea | ||
|
|
f1e0752f2d | ||
|
|
172ef79ffd | ||
|
|
36a9ab7bab | ||
|
|
09110a5460 | ||
|
|
ec1c81ad87 | ||
|
|
3673e55195 | ||
|
|
937874127f | ||
|
|
681ced9f15 | ||
|
|
e375a58b4d | ||
|
|
b2609256f5 | ||
|
|
b676e563e2 | ||
|
|
65cfc12be3 | ||
|
|
e6ace9a8bc | ||
|
|
c3a48de1a3 | ||
|
|
8a463752a0 | ||
|
|
c364394450 | ||
|
|
977f2c3e25 | ||
|
|
aa15810d92 | ||
|
|
4a5c13a35f | ||
|
|
584e2e9f89 | ||
|
|
33a7e03acf | ||
|
|
93dab02fd1 | ||
|
|
d364dc9630 | ||
|
|
701d57ef31 | ||
|
|
71c6e1d9c3 | ||
|
|
7c2a898cfa | ||
|
|
6180846345 | ||
|
|
9430a950c2 | ||
|
|
1f9b094b67 | ||
|
|
15dbbf41a7 | ||
|
|
fb3bc19595 | ||
|
|
f03d7d44a8 | ||
|
|
e3c6919bbd | ||
|
|
9f5644b479 | ||
|
|
c797e59584 | ||
|
|
72616f8276 | ||
|
|
99aed19308 | ||
|
|
74bf15ce48 | ||
|
|
57dfd53d55 | ||
|
|
f943fe07f5 | ||
|
|
a6a12d7392 | ||
|
|
f579f4af35 | ||
|
|
0b3abb8f1f | ||
|
|
01c8979100 | ||
|
|
f6ee1f57db | ||
|
|
894a251be3 |
+4
-3
@@ -1,6 +1,3 @@
|
||||
|
||||
testapp/tt.py
|
||||
|
||||
*.pyc
|
||||
|
||||
Django_Select2.egg-info
|
||||
@@ -19,3 +16,7 @@ docs/_build
|
||||
env/
|
||||
venv/
|
||||
.cache/
|
||||
.tox/
|
||||
geckodriver.log
|
||||
ghostdriver.log
|
||||
.coverage
|
||||
|
||||
+57
-29
@@ -1,44 +1,72 @@
|
||||
language: python
|
||||
sudo: false
|
||||
dist: trusty
|
||||
cache:
|
||||
- apt
|
||||
- pip
|
||||
- apt
|
||||
- pip
|
||||
services:
|
||||
- memcached
|
||||
python:
|
||||
- "2.7"
|
||||
- "3.4"
|
||||
- "3.5"
|
||||
- redis
|
||||
addons:
|
||||
chrome: stable
|
||||
apt:
|
||||
packages:
|
||||
- python3-enchant
|
||||
- python2-enchant
|
||||
- graphviz
|
||||
- python3-enchant
|
||||
- python-enchant
|
||||
- graphviz
|
||||
python:
|
||||
- "2.7"
|
||||
- "3.5"
|
||||
- "3.6"
|
||||
env:
|
||||
global:
|
||||
- DISPLAY=:99.0
|
||||
matrix:
|
||||
- DJANGO="Django<1.9,>=1.8"
|
||||
- DJANGO="Django<1.10,>=1.9"
|
||||
- DJANGO="-e git+https://github.com/django/django.git@master#egg=Django"
|
||||
- DJANGO=18
|
||||
- DJANGO=110
|
||||
- DJANGO=111
|
||||
- DJANGO=master
|
||||
- TOXENV=qa
|
||||
- TOXENV=docs
|
||||
matrix:
|
||||
fast_finish: true
|
||||
allow_failures:
|
||||
- env: DJANGO="-e git+https://github.com/django/django.git@master#egg=Django"
|
||||
- env: DJANGO=master
|
||||
exclude:
|
||||
- env: DJANGO=master
|
||||
python: "2.7"
|
||||
- env: TOXENV=qa
|
||||
python: "2.7"
|
||||
- env: TOXENV=qa
|
||||
python: "3.5"
|
||||
- env: TOXENV=docs
|
||||
python: "2.7"
|
||||
- env: TOXENV=docs
|
||||
python: "3.5"
|
||||
install:
|
||||
- pip install --upgrade pip
|
||||
- pip install -r requirements_dev.txt
|
||||
- if [[ $TRAVIS_PYTHON_VERSION == 2* ]] || [[ $TRAVIS_PYTHON_VERSION == pypy ]]; then pip install python-memcached; fi
|
||||
- if [[ $TRAVIS_PYTHON_VERSION == 3* ]] || [[ $TRAVIS_PYTHON_VERSION == pypy3 ]]; then pip install python3-memcached; fi
|
||||
- pip install $DJANGO
|
||||
- pip install coveralls
|
||||
- sh -e /etc/init.d/xvfb start
|
||||
- pip install --upgrade pip tox
|
||||
- pip install -U coveralls
|
||||
before_script:
|
||||
- mkdir bin
|
||||
- export CHROME_DRIVER_VERSION=`curl https://chromedriver.storage.googleapis.com/LATEST_RELEASE`
|
||||
- curl -O https://chromedriver.storage.googleapis.com/${CHROME_DRIVER_VERSION}/chromedriver_linux64.zip
|
||||
- unzip chromedriver_linux64.zip -d bin
|
||||
- PATH=$PATH:$PWD/bin
|
||||
- nvm install --lts
|
||||
- npm install -g standard
|
||||
- |
|
||||
if [[ -z $TOXENV ]]; then
|
||||
export TOXENV=py$(echo $TRAVIS_PYTHON_VERSION | sed -e 's/\.//g')-dj$DJANGO
|
||||
fi
|
||||
- echo $TOXENV
|
||||
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
|
||||
- tox -e $TOXENV
|
||||
- standard
|
||||
after_success:
|
||||
- coveralls
|
||||
- coveralls
|
||||
deploy:
|
||||
provider: pypi
|
||||
user: codingjoe
|
||||
password:
|
||||
secure: fEP9K7y0ZF9fRvQEUN4kgPXQEZvi3Cx3ikUebG2UM/2uhcaUQm0+KpgZ2S+lvOTYcBnNgzPzFsVIZMcVcTxwIKuQDEMq9y2eop2hnisb9KXsIm9qPYSzOnRm74VuiOt4hNOZMe0qVBK2cO3vC9NDXuzdI8A0JynJhthfl4t+kFM=
|
||||
on:
|
||||
tags: true
|
||||
distributions: sdist bdist_wheel
|
||||
repo: applegrew/django-select2
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
Changelog Summary
|
||||
=================
|
||||
|
||||
### v5.10.0
|
||||
* Add support for dependent select fields [321](github.com/applegrew/django-select2/pull/321/).
|
||||
|
||||
### v5.9.0
|
||||
* Add support for Django 1.11 LTS
|
||||
* Drop support for Django 1.9
|
||||
|
||||
### v5.8.10
|
||||
* Fixes tests for Django 1.10+
|
||||
* retain order of choices [299](https://github.com/applegrew/django-select2/pull/299)
|
||||
|
||||
### v5.8.9
|
||||
* Fixes tests. No functional change from 5.8.8.
|
||||
|
||||
### v5.8.8
|
||||
* Upgrade Select2 to version 4.0.3
|
||||
|
||||
### v5.8.7
|
||||
* Fix #285 -- Do not evaluate queryset in `render_options`.
|
||||
|
||||
### v5.8.3
|
||||
* Remove six dependency -- User django.utils.six instead.
|
||||
|
||||
### v5.8.2
|
||||
* Fixes #260 -- Fixes bug in render choices
|
||||
|
||||
|
||||
@@ -3,3 +3,6 @@ recursive-include django_select2/static *.*
|
||||
recursive-exclude django_select2/static .DS_Store
|
||||
exclude requirements_dev.txt
|
||||
prune tests
|
||||
|
||||
[bdist_wheel]
|
||||
universal=1
|
||||
|
||||
@@ -3,7 +3,7 @@ Django-Select2
|
||||
|
||||
[](https://pypi.python.org/pypi/Django-Select2/)
|
||||
[](https://travis-ci.org/applegrew/django-select2)
|
||||
[](https://coveralls.io/r/applegrew/django-select2)
|
||||
[](https://coveralls.io/r/applegrew/django-select2)
|
||||
[](https://raw.githubusercontent.com/applegrew/django-select2/master/LICENSE.txt)
|
||||
[](https://gitter.im/applegrew/django-select2?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
|
||||
@@ -54,7 +54,7 @@ Major changes:
|
||||
## Documentation
|
||||
|
||||
|
||||
Documentation available at http://django-select2.readthedocs.org/.
|
||||
Documentation available at http://django-select2.readthedocs.io/.
|
||||
|
||||
## External Dependencies
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
"""
|
||||
This is a Django_ integration of Select2_.
|
||||
|
||||
The app includes Select2 driven Django Widgets and Form Fields.
|
||||
The application includes Select2 driven Django Widgets and Form Fields.
|
||||
|
||||
.. _Django: https://www.djangoproject.com/
|
||||
.. _Select2: http://ivaynberg.github.com/select2/
|
||||
|
||||
"""
|
||||
|
||||
__version__ = "5.8.2"
|
||||
__version__ = "5.11.0"
|
||||
|
||||
+87
-3
@@ -43,12 +43,12 @@ class Select2Conf(AppConf):
|
||||
"""
|
||||
CACHE_PREFIX = 'select2_'
|
||||
"""
|
||||
If you caching backend doesn't support multiple databases
|
||||
If you caching backend does not support multiple databases
|
||||
you can isolate select2 using the cache prefix setting.
|
||||
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'
|
||||
JS = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/select2.min.js'
|
||||
"""
|
||||
The URI for the Select2 JS file. By default this points to the Cloudflare CDN.
|
||||
|
||||
@@ -61,7 +61,7 @@ class Select2Conf(AppConf):
|
||||
develop without an Internet connection.
|
||||
"""
|
||||
|
||||
CSS = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.0/css/select2.min.css'
|
||||
CSS = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/css/select2.min.css'
|
||||
"""
|
||||
The URI for the Select2 CSS file. By default this points to the Cloudflare CDN.
|
||||
|
||||
@@ -74,5 +74,89 @@ class Select2Conf(AppConf):
|
||||
develop without an Internet connection.
|
||||
"""
|
||||
|
||||
I18N_PATH = '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/i18n'
|
||||
"""
|
||||
The base URI for the Select2 i18n files. 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/i18n'
|
||||
|
||||
.. tip:: Change this setting to a local asset in your development environment to
|
||||
develop without an Internet connection.
|
||||
"""
|
||||
|
||||
I18N_AVAILABLE_LANGUAGES = [
|
||||
'ar',
|
||||
'az',
|
||||
'bg',
|
||||
'ca',
|
||||
'cs',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'en',
|
||||
'es',
|
||||
'et',
|
||||
'eu',
|
||||
'fa',
|
||||
'fi',
|
||||
'fr',
|
||||
'gl',
|
||||
'he',
|
||||
'hi',
|
||||
'hr',
|
||||
'hu',
|
||||
'id',
|
||||
'is',
|
||||
'it',
|
||||
'ja',
|
||||
'km',
|
||||
'ko',
|
||||
'lt',
|
||||
'lv',
|
||||
'mk',
|
||||
'ms',
|
||||
'nb',
|
||||
'nl',
|
||||
'pl',
|
||||
'pt-BR',
|
||||
'pt',
|
||||
'ro',
|
||||
'ru',
|
||||
'sk',
|
||||
'sr-Cyrl',
|
||||
'sr',
|
||||
'sv',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'vi',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
]
|
||||
"""
|
||||
List of available translations.
|
||||
|
||||
List of ISO 639-1 language codes that are supported by Select2.
|
||||
If currently set language code (e.g. using the HTTP ``Accept-Language`` header)
|
||||
is in this list, Django-Select2 will use the language code to create load
|
||||
the proper translation.
|
||||
|
||||
The full path for the language file consists of::
|
||||
|
||||
from django.utils import translations
|
||||
|
||||
full_path = "{i18n_path}/{language_code}.js".format(
|
||||
i18n_path=settings.DJANGO_SELECT2_I18N,
|
||||
language_code=translations.get_language(),
|
||||
)
|
||||
|
||||
``settings.DJANGO_SELECT2_I18N`` refers to :attr:`.I18N_PATH`.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Prefix for all Django-Select2 settings."""
|
||||
|
||||
prefix = 'SELECT2'
|
||||
|
||||
+102
-26
@@ -54,14 +54,20 @@ from pickle import PicklingError
|
||||
|
||||
from django import forms
|
||||
from django.core import signing
|
||||
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
|
||||
from django.utils.six.moves.cPickle import PicklingError as cPicklingError
|
||||
from django.utils.translation import get_language
|
||||
|
||||
from .cache import cache
|
||||
from .conf import settings
|
||||
|
||||
try:
|
||||
from django.urls import reverse
|
||||
except ImportError:
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
|
||||
class Select2Mixin(object):
|
||||
"""
|
||||
@@ -72,9 +78,9 @@ class Select2Mixin(object):
|
||||
form media.
|
||||
"""
|
||||
|
||||
def build_attrs(self, extra_attrs=None, **kwargs):
|
||||
def build_attrs(self, *args, **kwargs):
|
||||
"""Add select2 data attributes."""
|
||||
attrs = super(Select2Mixin, self).build_attrs(extra_attrs=extra_attrs, **kwargs)
|
||||
attrs = super(Select2Mixin, self).build_attrs(*args, **kwargs)
|
||||
if self.is_required:
|
||||
attrs.setdefault('data-allow-clear', 'false')
|
||||
else:
|
||||
@@ -88,9 +94,15 @@ class Select2Mixin(object):
|
||||
attrs['class'] = 'django-select2'
|
||||
return attrs
|
||||
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
"""Add empty option for clearable selects."""
|
||||
if not self.is_required and not self.allow_multiple_selected:
|
||||
self.choices = list(chain([('', '')], self.choices))
|
||||
return super(Select2Mixin, self).optgroups(name, value, attrs=attrs)
|
||||
|
||||
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 = '<option value=""></option>' if not self.is_required and not self.allow_multiple_selected else ''
|
||||
output += super(Select2Mixin, self).render_options(*args, **kwargs)
|
||||
return output
|
||||
|
||||
@@ -101,8 +113,10 @@ class Select2Mixin(object):
|
||||
.. Note:: For more information visit
|
||||
https://docs.djangoproject.com/en/1.8/topics/forms/media/#media-as-a-dynamic-property
|
||||
"""
|
||||
i18n_file = '%s/%s.js' % (settings.SELECT2_I18N_PATH, get_language())
|
||||
i18n_file = (i18n_file,) if get_language() in settings.SELECT2_I18N_AVAILABLE_LANGUAGES else ()
|
||||
return forms.Media(
|
||||
js=(settings.SELECT2_JS, 'django_select2/django_select2.js'),
|
||||
js=(settings.SELECT2_JS,) + i18n_file + ('django_select2/django_select2.js',),
|
||||
css={'screen': (settings.SELECT2_CSS,)}
|
||||
)
|
||||
|
||||
@@ -112,12 +126,12 @@ class Select2Mixin(object):
|
||||
class Select2TagMixin(object):
|
||||
"""Mixin to add select2 tag functionality."""
|
||||
|
||||
def build_attrs(self, extra_attrs=None, **kwargs):
|
||||
def build_attrs(self, *args, **kwargs):
|
||||
"""Add select2's tag attributes."""
|
||||
self.attrs.setdefault('data-minimum-input-length', 1)
|
||||
self.attrs.setdefault('data-tags', 'true')
|
||||
self.attrs.setdefault('data-token-separators', [",", " "])
|
||||
return super(Select2TagMixin, self).build_attrs(extra_attrs, **kwargs)
|
||||
self.attrs.setdefault('data-token-separators', '[",", " "]')
|
||||
return super(Select2TagMixin, self).build_attrs(*args, **kwargs)
|
||||
|
||||
|
||||
class Select2Widget(Select2Mixin, forms.Select):
|
||||
@@ -166,6 +180,12 @@ class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple):
|
||||
values = super(MyWidget, self).value_from_datadict(data, files, name):
|
||||
return ",".join(values)
|
||||
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
values = value[0].split(',') if value[0] else []
|
||||
selected = set(values)
|
||||
subgroup = [self.create_option(name, v, v, selected, i) for i, v in enumerate(values)]
|
||||
return [(None, subgroup, 0)]
|
||||
|
||||
"""
|
||||
|
||||
pass
|
||||
@@ -174,21 +194,37 @@ class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple):
|
||||
class HeavySelect2Mixin(object):
|
||||
"""Mixin that adds select2's AJAX options and registers itself on Django's cache."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
dependent_fields = {}
|
||||
|
||||
def __init__(self, attrs=None, choices=(), **kwargs):
|
||||
"""
|
||||
Return HeavySelect2Mixin.
|
||||
|
||||
Args:
|
||||
data_view (str): URL pattern name
|
||||
data_url (str): URL
|
||||
|
||||
dependent_fields (dict): Dictionary of dependent parent fields.
|
||||
The value of the dependent field will be passed as to :func:`.filter_queryset`.
|
||||
It can be used to further restrict the search results. For example, a city
|
||||
widget could be dependent on a country.
|
||||
Key is a name of a field in a form.
|
||||
Value is a name of a field in a model (used in `queryset`).
|
||||
"""
|
||||
self.choices = choices
|
||||
if attrs is not None:
|
||||
self.attrs = attrs.copy()
|
||||
else:
|
||||
self.attrs = {}
|
||||
|
||||
self.data_view = kwargs.pop('data_view', None)
|
||||
self.data_url = kwargs.pop('data_url', None)
|
||||
|
||||
dependent_fields = kwargs.pop('dependent_fields', None)
|
||||
if dependent_fields is not None:
|
||||
self.dependent_fields = dict(dependent_fields)
|
||||
if not (self.data_view or self.data_url):
|
||||
raise ValueError('You must ether specify "data_view" or "data_url".')
|
||||
self.userGetValTextFuncName = kwargs.pop('userGetValTextFuncName', 'null')
|
||||
super(HeavySelect2Mixin, self).__init__(**kwargs)
|
||||
|
||||
def get_url(self):
|
||||
"""Return URL from instance or by reversing :attr:`.data_view`."""
|
||||
@@ -196,9 +232,9 @@ class HeavySelect2Mixin(object):
|
||||
return self.data_url
|
||||
return reverse(self.data_view)
|
||||
|
||||
def build_attrs(self, extra_attrs=None, **kwargs):
|
||||
def build_attrs(self, *args, **kwargs):
|
||||
"""Set select2's AJAX attributes."""
|
||||
attrs = super(HeavySelect2Mixin, self).build_attrs(extra_attrs=extra_attrs, **kwargs)
|
||||
attrs = super(HeavySelect2Mixin, self).build_attrs(*args, **kwargs)
|
||||
|
||||
# encrypt instance Id
|
||||
self.widget_id = signing.dumps(id(self))
|
||||
@@ -208,6 +244,8 @@ class HeavySelect2Mixin(object):
|
||||
attrs.setdefault('data-ajax--cache', "true")
|
||||
attrs.setdefault('data-ajax--type', "GET")
|
||||
attrs.setdefault('data-minimum-input-length', 2)
|
||||
if self.dependent_fields:
|
||||
attrs.setdefault('data-select2-dependent-fields', " ".join(self.dependent_fields))
|
||||
|
||||
attrs['class'] += ' django-select2-heavy'
|
||||
return attrs
|
||||
@@ -233,7 +271,7 @@ class HeavySelect2Mixin(object):
|
||||
'widget': self,
|
||||
'url': self.get_url(),
|
||||
})
|
||||
except (PicklingError, AttributeError):
|
||||
except (PicklingError, cPicklingError, AttributeError):
|
||||
msg = "You need to overwrite \"set_to_cache\" or ensure that %s is serialisable."
|
||||
raise NotImplementedError(msg % self.__class__.__name__)
|
||||
|
||||
@@ -246,9 +284,9 @@ class HeavySelect2Mixin(object):
|
||||
choices = chain(self.choices, choices)
|
||||
else:
|
||||
choices = self.choices
|
||||
output = ['<option></option>' if not self.is_required else '']
|
||||
output = ['<option value=""></option>' if not self.is_required and not self.allow_multiple_selected else '']
|
||||
selected_choices = {force_text(v) for v in selected_choices}
|
||||
choices = {(k, v) for k, v in choices if force_text(k) in selected_choices}
|
||||
choices = [(k, v) for k, v in choices if force_text(k) 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)
|
||||
@@ -260,14 +298,14 @@ class HeavySelect2Widget(HeavySelect2Mixin, Select2Widget):
|
||||
|
||||
Usage example::
|
||||
|
||||
class MyWidget(HeavySelectWidget):
|
||||
class MyWidget(HeavySelect2Widget):
|
||||
data_view = 'my_view_name'
|
||||
|
||||
or::
|
||||
|
||||
class MyForm(forms.Form):
|
||||
my_field = forms.ChoicesField(
|
||||
widget=HeavySelectWidget(
|
||||
widget=HeavySelect2Widget(
|
||||
data_url='/url/to/json/response'
|
||||
)
|
||||
)
|
||||
@@ -318,7 +356,7 @@ class ModelSelect2Mixin(object):
|
||||
|
||||
Args:
|
||||
model (django.db.models.Model): Model to select choices from.
|
||||
queryset (django.db.models.QuerySet): QuerySet to select choices from.
|
||||
queryset (django.db.models.query.QuerySet): QuerySet to select choices from.
|
||||
search_fields (list): List of model lookup strings.
|
||||
max_results (int): Max. JsonResponse view page size.
|
||||
|
||||
@@ -348,14 +386,19 @@ class ModelSelect2Mixin(object):
|
||||
'search_fields': self.search_fields,
|
||||
'max_results': self.max_results,
|
||||
'url': self.get_url(),
|
||||
'dependent_fields': self.dependent_fields,
|
||||
})
|
||||
|
||||
def filter_queryset(self, term, queryset=None):
|
||||
def filter_queryset(self, term, queryset=None, **dependent_fields):
|
||||
"""
|
||||
Return QuerySet filtered by search_fields matching the passed term.
|
||||
|
||||
Args:
|
||||
term (str): Search term
|
||||
queryset (django.db.models.query.QuerySet): QuerySet to select choices from.
|
||||
**dependent_fields: Dependent fields and their values. If you want to inherit
|
||||
from ModelSelect2Mixin and later call to this method, be sure to pop
|
||||
from kwargs everything if it is not a dependent field.
|
||||
|
||||
Returns:
|
||||
QuerySet: Filtered QuerySet
|
||||
@@ -370,6 +413,9 @@ class ModelSelect2Mixin(object):
|
||||
for t in [t for t in term.split(' ') if not t == '']:
|
||||
select &= reduce(lambda x, y: x | Q(**{y: t}), search_fields,
|
||||
Q(**{search_fields[0]: t}))
|
||||
if dependent_fields:
|
||||
select &= Q(**dependent_fields)
|
||||
|
||||
return queryset.filter(select).distinct()
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -400,8 +446,38 @@ class ModelSelect2Mixin(object):
|
||||
return self.search_fields
|
||||
raise NotImplementedError('%s, must implement "search_fields".' % self.__class__.__name__)
|
||||
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
"""Return only selected options and set QuerySet from `ModelChoicesIterator`."""
|
||||
default = (None, [], 0)
|
||||
groups = [default]
|
||||
has_selected = False
|
||||
selected_choices = {force_text(v) for v in value}
|
||||
if not self.is_required and not self.allow_multiple_selected:
|
||||
default[1].append(self.create_option(name, '', '', False, 0))
|
||||
if not isinstance(self.choices, ModelChoiceIterator):
|
||||
return super(ModelSelect2Mixin, self).optgroups(name, value, attrs=attrs)
|
||||
selected_choices = {
|
||||
c for c in selected_choices
|
||||
if c not in self.choices.field.empty_values
|
||||
}
|
||||
choices = (
|
||||
(obj.pk, self.label_from_instance(obj))
|
||||
for obj in self.choices.queryset.filter(pk__in=selected_choices)
|
||||
)
|
||||
for option_value, option_label in choices:
|
||||
selected = (
|
||||
force_text(option_value) in value and
|
||||
(has_selected is False or self.allow_multiple_selected)
|
||||
)
|
||||
if selected is True and has_selected is False:
|
||||
has_selected = True
|
||||
index = len(default[1])
|
||||
subgroup = default[1]
|
||||
subgroup.append(self.create_option(name, option_value, option_label, selected_choices, index))
|
||||
return groups
|
||||
|
||||
def render_options(self, *args):
|
||||
"""Render only selected options and set QuerySet from :class:`ModelChoicesIterator`."""
|
||||
"""Render only selected options and set QuerySet from :class:`ModelChoiceIterator`."""
|
||||
try:
|
||||
selected_choices, = args
|
||||
except ValueError:
|
||||
@@ -410,16 +486,16 @@ class ModelSelect2Mixin(object):
|
||||
else:
|
||||
choices = self.choices
|
||||
selected_choices = {force_text(v) for v in selected_choices}
|
||||
output = ['<option></option>' if not self.is_required else '']
|
||||
output = ['<option value=""></option>' if not self.is_required and not self.allow_multiple_selected else '']
|
||||
if isinstance(self.choices, ModelChoiceIterator):
|
||||
if not self.queryset:
|
||||
if self.queryset is None:
|
||||
self.queryset = self.choices.queryset
|
||||
selected_choices = {c for c in selected_choices
|
||||
if c not in self.choices.field.empty_values}
|
||||
choices = {(obj.pk, self.label_from_instance(obj))
|
||||
for obj in self.choices.queryset.filter(pk__in=selected_choices)}
|
||||
choices = [(obj.pk, self.label_from_instance(obj))
|
||||
for obj in self.choices.queryset.filter(pk__in=selected_choices)]
|
||||
else:
|
||||
choices = {(k, v) for k, v in choices if force_text(k) in selected_choices}
|
||||
choices = [(k, v) for k, v in choices if force_text(k) 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)
|
||||
|
||||
@@ -1,48 +1,56 @@
|
||||
(function ($) {
|
||||
var init = function ($element, options) {
|
||||
$element.select2(options)
|
||||
}
|
||||
|
||||
var init = function ($element, options) {
|
||||
$element.select2(options);
|
||||
};
|
||||
var initHeavy = function ($element, options) {
|
||||
var settings = $.extend({
|
||||
ajax: {
|
||||
data: function (params) {
|
||||
var result = {
|
||||
term: params.term,
|
||||
page: params.page,
|
||||
field_id: $element.data('field_id')
|
||||
}
|
||||
|
||||
var initHeavy = function ($element, options) {
|
||||
var settings = $.extend({
|
||||
ajax: {
|
||||
data: function (params) {
|
||||
return {
|
||||
term: params.term,
|
||||
page: params.page,
|
||||
field_id: $element.data('field_id')
|
||||
};
|
||||
},
|
||||
processResults: function (data, page) {
|
||||
return {
|
||||
results: data.results,
|
||||
pagination: {
|
||||
more: data.more
|
||||
}
|
||||
};
|
||||
}
|
||||
var dependentFields = $element.data('select2-dependent-fields')
|
||||
if (dependentFields) {
|
||||
dependentFields = dependentFields.trim().split(/\s+/)
|
||||
$.each(dependentFields, function (i, dependentField) {
|
||||
result[dependentField] = $('[name=' + dependentField + ']', $element.closest('form')).val()
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
processResults: function (data, page) {
|
||||
return {
|
||||
results: data.results,
|
||||
pagination: {
|
||||
more: data.more
|
||||
}
|
||||
}, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, options)
|
||||
|
||||
$element.select2(settings);
|
||||
};
|
||||
$element.select2(settings)
|
||||
}
|
||||
|
||||
$.fn.djangoSelect2 = function (options) {
|
||||
var settings = $.extend({}, options);
|
||||
$.each(this, function (i, element) {
|
||||
var $element = $(element);
|
||||
if ($element.hasClass('django-select2-heavy')) {
|
||||
initHeavy($element, settings);
|
||||
} else {
|
||||
init($element, settings);
|
||||
}
|
||||
});
|
||||
return this;
|
||||
};
|
||||
$.fn.djangoSelect2 = function (options) {
|
||||
var settings = $.extend({}, options)
|
||||
$.each(this, function (i, element) {
|
||||
var $element = $(element)
|
||||
if ($element.hasClass('django-select2-heavy')) {
|
||||
initHeavy($element, settings)
|
||||
} else {
|
||||
init($element, settings)
|
||||
}
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
$(function () {
|
||||
$('.django-select2').djangoSelect2();
|
||||
});
|
||||
|
||||
}(this.jQuery));
|
||||
$(function () {
|
||||
$('.django-select2').djangoSelect2()
|
||||
})
|
||||
}(this.jQuery))
|
||||
|
||||
@@ -52,7 +52,12 @@ class AutoResponseView(BaseListView):
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get QuerySet from cached widget."""
|
||||
return self.widget.filter_queryset(self.term, self.queryset)
|
||||
kwargs = {
|
||||
model_field_name: self.request.GET.get(form_field_name)
|
||||
for form_field_name, model_field_name in self.widget.dependent_fields.items()
|
||||
if form_field_name in self.request.GET and self.request.GET.get(form_field_name, '') != ''
|
||||
}
|
||||
return self.widget.filter_queryset(self.term, self.queryset, **kwargs)
|
||||
|
||||
def get_paginate_by(self, queryset):
|
||||
"""Paginate response by size of widget's `max_results` parameter."""
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
Extra
|
||||
=====
|
||||
|
||||
Chained select2
|
||||
---------------
|
||||
|
||||
Suppose you have an address form where a user should choose a Country and a City.
|
||||
When the user selects a country we want to show only cities belonging to that country.
|
||||
So the one selector depends on another one.
|
||||
|
||||
Models
|
||||
``````
|
||||
|
||||
Here are our two models:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class Country(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
|
||||
class City(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
country = models.ForeignKey('Country', related_name="cities")
|
||||
|
||||
|
||||
Customizing a Form
|
||||
``````````````````
|
||||
|
||||
Lets link two widgets via *dependent_fields*.
|
||||
|
||||
.. code-block:: python
|
||||
:emphasize-lines: 15
|
||||
|
||||
class AddressForm(forms.Form):
|
||||
country = forms.ModelChoiceField(
|
||||
queryset=Country.objects.all(),
|
||||
label=u"Country",
|
||||
widget=ModelSelect2Widget(
|
||||
search_fields=['name__icontains'],
|
||||
)
|
||||
)
|
||||
|
||||
city = forms.ModelChoiceField(
|
||||
queryset=City.objects.all(),
|
||||
label=u"City",
|
||||
widget=ModelSelect2Widget(
|
||||
search_fields=['name__icontains'],
|
||||
dependent_fields={'country': 'country'},
|
||||
max_results=500,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Interdependent select2
|
||||
----------------------
|
||||
|
||||
Also you may want not to restrict the user to which field should be selected first.
|
||||
Instead you want to suggest to the user options for any select2 depending of his selection in another one.
|
||||
|
||||
Customize the form in a manner:
|
||||
|
||||
.. code-block:: python
|
||||
:emphasize-lines: 7
|
||||
|
||||
class AddressForm(forms.Form):
|
||||
country = forms.ModelChoiceField(
|
||||
queryset=Country.objects.all(),
|
||||
label=u"Country",
|
||||
widget=ModelSelect2Widget(
|
||||
search_fields=['name__icontains'],
|
||||
dependent_fields={'city': 'cities'},
|
||||
)
|
||||
)
|
||||
|
||||
city = forms.ModelChoiceField(
|
||||
queryset=City.objects.all(),
|
||||
label=u"City",
|
||||
widget=ModelSelect2Widget(
|
||||
search_fields=['name__icontains'],
|
||||
dependent_fields={'country': 'country'},
|
||||
max_results=500,
|
||||
)
|
||||
)
|
||||
|
||||
Take attention to country's dependent_fields. The value of 'city' is 'cities' because of
|
||||
related name used in a filter condition `cities` which differs from widget field name `city`.
|
||||
|
||||
.. caution::
|
||||
Be aware of using interdependent select2 in parent-child relation.
|
||||
When a child is selected, you are restricted to change parent (only one value is available).
|
||||
Probably you should let the user reset the child first to release parent select2.
|
||||
|
||||
|
||||
Multi-dependent select2
|
||||
-----------------------
|
||||
|
||||
Furthermore you may want to filter options on two or more select2 selections (some code is dropped for clarity):
|
||||
|
||||
.. code-block:: python
|
||||
:emphasize-lines: 14
|
||||
|
||||
class SomeForm(forms.Form):
|
||||
field1 = forms.ModelChoiceField(
|
||||
widget=ModelSelect2Widget(
|
||||
)
|
||||
)
|
||||
|
||||
field2 = forms.ModelChoiceField(
|
||||
widget=ModelSelect2Widget(
|
||||
)
|
||||
)
|
||||
|
||||
field3 = forms.ModelChoiceField(
|
||||
widget=ModelSelect2Widget(
|
||||
dependent_fields={'field1': 'field1', 'field2': 'field2'},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ Contents:
|
||||
|
||||
get_started
|
||||
django_select2
|
||||
extra
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
@@ -15,3 +15,4 @@ functionalities
|
||||
plugin
|
||||
multi
|
||||
Indices
|
||||
clearable
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
coverage
|
||||
django-appconf
|
||||
flake8
|
||||
pep8-naming
|
||||
mccabe
|
||||
isort
|
||||
pep257
|
||||
pydocstyle
|
||||
pytest
|
||||
pytest-django
|
||||
selenium
|
||||
sphinx
|
||||
sphinxcontrib-spelling
|
||||
pyenchant
|
||||
pyenchant
|
||||
django-redis
|
||||
@@ -0,0 +1,34 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile
|
||||
# To update, run:
|
||||
#
|
||||
# pip-compile --output-file requirements-dev.txt requirements-dev.in
|
||||
#
|
||||
alabaster==0.7.10 # via sphinx
|
||||
babel==2.4.0 # via sphinx
|
||||
coverage==4.4.1
|
||||
django-appconf==1.0.2
|
||||
django-redis==4.8.0
|
||||
docutils==0.13.1 # via sphinx
|
||||
flake8==3.3.0
|
||||
isort==4.2.15
|
||||
jinja2==2.9.6 # via sphinx
|
||||
markupsafe==1.0 # via jinja2
|
||||
mccabe==0.6.1
|
||||
pydocstyle==2.0.0
|
||||
pep8-naming==0.4.1
|
||||
pep8==1.7.0 # via flake8
|
||||
py==1.4.34 # via pytest
|
||||
pyenchant==1.6.8
|
||||
pyflakes==1.5.0 # via flake8
|
||||
pygments==2.2.0 # via sphinx
|
||||
pytest-django==3.1.2
|
||||
pytest==3.1.3
|
||||
pytz==2017.2 # via babel
|
||||
redis==2.10.5 # via django-redis
|
||||
selenium==3.4.3
|
||||
six==1.10.0 # via django-appconf, sphinx, sphinxcontrib-spelling
|
||||
snowballstemmer==1.2.1 # via sphinx
|
||||
sphinx-rtd-theme==0.2.4 # via sphinx
|
||||
sphinx==1.6.3
|
||||
sphinxcontrib-spelling==2.3.0
|
||||
@@ -1,31 +0,0 @@
|
||||
#
|
||||
# This file is autogenerated by pip-compile
|
||||
# Make changes in requirements_dev.in, then run this to update:
|
||||
#
|
||||
# pip-compile requirements_dev.in
|
||||
#
|
||||
alabaster==0.7.7 # via sphinx
|
||||
babel==2.2.0 # via sphinx
|
||||
django-appconf==1.0.1
|
||||
docutils==0.12 # via sphinx
|
||||
flake8==2.5.2
|
||||
isort==4.2.2
|
||||
Jinja2==2.8
|
||||
MarkupSafe==0.23
|
||||
mccabe==0.4.0
|
||||
pep257==0.7.0
|
||||
pep8-naming==0.3.3
|
||||
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.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
|
||||
@@ -1,5 +1,5 @@
|
||||
[pytest]
|
||||
norecursedirs = env
|
||||
[tool:pytest]
|
||||
norecursedirs = env docs
|
||||
addopts = --tb=short -rxs
|
||||
DJANGO_SETTINGS_MODULE=tests.testapp.settings
|
||||
|
||||
@@ -8,15 +8,17 @@ max-line-length = 120
|
||||
max-complexity = 10
|
||||
statistics = true
|
||||
show-source = true
|
||||
exclude = docs,runtests.py,setup.py,env
|
||||
exclude = docs,runtests.py,setup.py,.tox,env
|
||||
|
||||
[pep257]
|
||||
match = (?!setup).*.py
|
||||
match-dir = (?!tests|env|docs|\.).*
|
||||
|
||||
[isort]
|
||||
atomic = true
|
||||
multi_line_output = 5
|
||||
line_length = 79
|
||||
skip = manage.py,docs
|
||||
skip = manage.py,docs,.tox,env
|
||||
known_first_party = django_select2, tests
|
||||
known_third_party = django
|
||||
combine_as_imports = true
|
||||
|
||||
@@ -44,7 +44,8 @@ setup(
|
||||
"Programming Language :: Python :: 3",
|
||||
"Framework :: Django",
|
||||
"Framework :: Django :: 1.8",
|
||||
"Framework :: Django :: 1.9",
|
||||
"Framework :: Django :: 1.10",
|
||||
"Framework :: Django :: 1.11",
|
||||
],
|
||||
install_requires=[
|
||||
'django-appconf>=0.6.0',
|
||||
|
||||
+30
-17
@@ -1,20 +1,14 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
from __future__ import absolute_import, print_function, unicode_literals
|
||||
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
|
||||
import pytest
|
||||
from django.utils.encoding import force_text
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
|
||||
browsers = {
|
||||
# 'firefox': webdriver.Firefox,
|
||||
# 'chrome': webdriver.Chrome,
|
||||
'phantomjs': webdriver.PhantomJS,
|
||||
}
|
||||
|
||||
|
||||
def random_string(n):
|
||||
return ''.join(
|
||||
@@ -23,20 +17,23 @@ def random_string(n):
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope='session',
|
||||
params=browsers.keys())
|
||||
def driver(request):
|
||||
if 'DISPLAY' not in os.environ:
|
||||
pytest.skip('Test requires display server (export DISPLAY)')
|
||||
def random_name(n):
|
||||
words = ''.join(random.choice(string.ascii_lowercase + ' ') for _ in range(n)).strip().split(' ')
|
||||
return '-'.join([x.capitalize() for x in words])
|
||||
|
||||
|
||||
@pytest.yield_fixture(scope='session')
|
||||
def driver():
|
||||
chrome_options = webdriver.ChromeOptions()
|
||||
chrome_options.add_argument('headless')
|
||||
chrome_options.add_argument('window-size=1200x800')
|
||||
try:
|
||||
b = browsers[request.param]()
|
||||
b = webdriver.Chrome(chrome_options=chrome_options)
|
||||
except WebDriverException as e:
|
||||
pytest.skip(e)
|
||||
pytest.skip(force_text(e))
|
||||
else:
|
||||
b.set_window_size(1200, 800)
|
||||
request.addfinalizer(lambda *args: b.quit())
|
||||
return b
|
||||
yield b
|
||||
b.quit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -54,3 +51,19 @@ def artists(db):
|
||||
return Artist.objects.bulk_create(
|
||||
[Artist(pk=pk, title=random_string(50)) for pk in range(100)]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def countries(db):
|
||||
from .testapp.models import Country
|
||||
return Country.objects.bulk_create(
|
||||
[Country(pk=pk, name=random_name(random.randint(10, 20))) for pk in range(10)]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cities(db, countries):
|
||||
from .testapp.models import City
|
||||
return City.objects.bulk_create(
|
||||
[City(pk=pk, name=random_name(random.randint(5, 15)), country=random.choice(countries)) for pk in range(100)]
|
||||
)
|
||||
|
||||
+143
-18
@@ -3,14 +3,18 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from django.core import signing
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db.models import QuerySet
|
||||
from django.utils import translation
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.six import text_type
|
||||
from selenium.common.exceptions import NoSuchElementException
|
||||
from six import text_type
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions
|
||||
from selenium.webdriver.support.wait import WebDriverWait
|
||||
|
||||
from django_select2.cache import cache
|
||||
from django_select2.forms import (
|
||||
@@ -21,12 +25,18 @@ from tests.testapp import forms
|
||||
from tests.testapp.forms import (
|
||||
NUMBER_CHOICES, HeavySelect2MultipleWidgetForm, TitleModelSelect2Widget
|
||||
)
|
||||
from tests.testapp.models import Genre
|
||||
from tests.testapp.models import City, Country, Genre
|
||||
|
||||
try:
|
||||
from django.urls import reverse
|
||||
except ImportError:
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
|
||||
class TestSelect2Mixin(object):
|
||||
url = reverse('select2_widget')
|
||||
form = forms.AlbumSelect2WidgetForm()
|
||||
multiple_form = forms.AlbumSelect2MultipleWidgetForm()
|
||||
widget_cls = Select2Widget
|
||||
|
||||
def test_initial_data(self, genres):
|
||||
@@ -44,7 +54,7 @@ class TestSelect2Mixin(object):
|
||||
assert required_field.required is True
|
||||
assert 'data-allow-clear="true"' not in required_field.widget.render('artist', None)
|
||||
assert 'data-allow-clear="false"' in required_field.widget.render('artist', None)
|
||||
assert '<option></option>' not in required_field.widget.render('artist', None)
|
||||
assert '<option value=""></option>' not in required_field.widget.render('artist', None)
|
||||
|
||||
not_required_field = self.form.fields['primary_genre']
|
||||
assert not_required_field.required is False
|
||||
@@ -52,7 +62,7 @@ class TestSelect2Mixin(object):
|
||||
assert 'data-allow-clear="false"' not in not_required_field.widget.render('primary_genre',
|
||||
None)
|
||||
assert 'data-placeholder' in not_required_field.widget.render('primary_genre', None)
|
||||
assert '<option></option>' in not_required_field.widget.render('primary_genre', None)
|
||||
assert '<option value=""></option>' in not_required_field.widget.render('primary_genre', None)
|
||||
|
||||
def test_no_js_error(self, db, live_server, driver):
|
||||
driver.get(live_server + self.url)
|
||||
@@ -82,13 +92,46 @@ class TestSelect2Mixin(object):
|
||||
widget = HeavySelect2Widget(data_url='/foo/bar')
|
||||
assert widget.get_url() == '/foo/bar'
|
||||
|
||||
def test_empty_option(self, db):
|
||||
# Empty options is only required for single selects
|
||||
# https://select2.github.io/options.html#allowClear
|
||||
single_select = self.form.fields['primary_genre']
|
||||
assert single_select.required is False
|
||||
assert '<option value=""></option>' in single_select.widget.render('primary_genre', None)
|
||||
|
||||
multiple_select = self.multiple_form.fields['featured_artists']
|
||||
assert multiple_select.required is False
|
||||
assert multiple_select.widget.allow_multiple_selected
|
||||
assert '<option value=""></option>' not in multiple_select.widget.render('featured_artists', None)
|
||||
|
||||
def test_i18n(self):
|
||||
translation.activate('de')
|
||||
assert Select2Widget().media._js == [
|
||||
'//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/select2.min.js',
|
||||
'//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/i18n/de.js',
|
||||
'django_select2/django_select2.js'
|
||||
]
|
||||
|
||||
translation.activate('en')
|
||||
assert Select2Widget().media._js == [
|
||||
'//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/select2.min.js',
|
||||
'//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/i18n/en.js',
|
||||
'django_select2/django_select2.js'
|
||||
]
|
||||
|
||||
translation.activate('00')
|
||||
assert Select2Widget().media._js == [
|
||||
'//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/select2.min.js',
|
||||
'django_select2/django_select2.js'
|
||||
]
|
||||
|
||||
|
||||
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 '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/js/select2.min.js' in result
|
||||
assert '//cdnjs.cloudflare.com/ajax/libs/select2/4.0.3/css/select2.min.css' in result
|
||||
assert 'django_select2/django_select2.js' in result
|
||||
|
||||
def test_js_setting(self, settings):
|
||||
@@ -123,7 +166,9 @@ 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), \
|
||||
not_required_field.widget.render('primary_genre', 1) or \
|
||||
'<option value="1" selected>One</option>' in \
|
||||
not_required_field.widget.render('primary_genre', 1), \
|
||||
not_required_field.widget.render('primary_genre', 1)
|
||||
|
||||
def test_many_selected_option(self, db, genres):
|
||||
@@ -131,10 +176,12 @@ class TestHeavySelect2Mixin(TestSelect2Mixin):
|
||||
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_option_a = '<option value="{pk}" selected>{value}</option>'.format(pk=1, value='One')
|
||||
selected_option2 = '<option value="{pk}" selected="selected">{value}</option>'.format(pk=2, value='Two')
|
||||
selected_option2a = '<option value="{pk}" selected>{value}</option>'.format(pk=2, value='Two')
|
||||
|
||||
assert selected_option in widget_output, widget_output
|
||||
assert selected_option2 in widget_output
|
||||
assert selected_option in widget_output or selected_option_a in widget_output, widget_output
|
||||
assert selected_option2 in widget_output or selected_option2a in widget_output
|
||||
|
||||
def test_multiple_widgets(self, db, live_server, driver):
|
||||
driver.get(live_server + self.url)
|
||||
@@ -143,9 +190,14 @@ class TestHeavySelect2Mixin(TestSelect2Mixin):
|
||||
|
||||
elem1, elem2 = driver.find_elements_by_css_selector('.select2-selection')
|
||||
elem1.click()
|
||||
result1 = driver.find_element_by_css_selector('.select2-results li:first-child').text
|
||||
|
||||
result1 = WebDriverWait(driver, 60).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li:first-child'))
|
||||
).text
|
||||
elem2.click()
|
||||
result2 = driver.find_element_by_css_selector('.select2-results li:first-child').text
|
||||
result2 = WebDriverWait(driver, 60).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li:first-child'))
|
||||
).text
|
||||
|
||||
assert result1 != result2
|
||||
|
||||
@@ -170,6 +222,7 @@ class TestHeavySelect2Mixin(TestSelect2Mixin):
|
||||
|
||||
class TestModelSelect2Mixin(TestHeavySelect2Mixin):
|
||||
form = forms.AlbumModelSelect2WidgetForm(initial={'primary_genre': 1})
|
||||
multiple_form = forms.ArtistModelSelect2MultipleWidgetForm()
|
||||
|
||||
def test_initial_data(self, genres):
|
||||
genre = genres[0]
|
||||
@@ -182,7 +235,7 @@ class TestModelSelect2Mixin(TestHeavySelect2Mixin):
|
||||
genre.save()
|
||||
|
||||
form = self.form.__class__(initial={'primary_genre': genre.pk})
|
||||
assert genre.title not in form.as_p()
|
||||
assert genre.title not in form.as_p(), form.as_p()
|
||||
assert genre.title.upper() in form.as_p()
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -198,10 +251,12 @@ class TestModelSelect2Mixin(TestHeavySelect2Mixin):
|
||||
'primary_genre', genre.pk)
|
||||
selected_option = '<option value="{pk}" selected="selected">{value}</option>'.format(
|
||||
pk=genre.pk, value=force_text(genre))
|
||||
selected_option_a = '<option value="{pk}" selected>{value}</option>'.format(
|
||||
pk=genre.pk, value=force_text(genre))
|
||||
unselected_option = '<option value="{pk}">{value}</option>'.format(
|
||||
pk=genre2.pk, value=force_text(genre2))
|
||||
|
||||
assert selected_option in widget_output, widget_output
|
||||
assert selected_option in widget_output or selected_option_a in widget_output, widget_output
|
||||
assert unselected_option not in widget_output
|
||||
|
||||
def test_selected_option_label_from_instance(self, db, genres):
|
||||
@@ -212,14 +267,15 @@ class TestModelSelect2Mixin(TestHeavySelect2Mixin):
|
||||
field = self.form.fields['primary_genre']
|
||||
widget_output = field.widget.render('primary_genre', genre.pk)
|
||||
|
||||
def get_selected_option(genre):
|
||||
def get_selected_options(genre):
|
||||
return '<option value="{pk}" selected="selected">{value}</option>'.format(
|
||||
pk=genre.pk, value=force_text(genre)), '<option value="{pk}" selected>{value}</option>'.format(
|
||||
pk=genre.pk, value=force_text(genre))
|
||||
|
||||
assert get_selected_option(genre) not in widget_output
|
||||
assert all(o not in widget_output for o in get_selected_options(genre))
|
||||
genre.title = genre.title.upper()
|
||||
|
||||
assert get_selected_option(genre) in widget_output
|
||||
assert any(o in widget_output for o in get_selected_options(genre))
|
||||
|
||||
def test_get_queryset(self):
|
||||
widget = ModelSelect2Widget()
|
||||
@@ -308,8 +364,13 @@ class TestHeavySelect2MultipleWidget(object):
|
||||
form = forms.HeavySelect2MultipleWidgetForm()
|
||||
widget_cls = HeavySelect2MultipleWidget
|
||||
|
||||
@pytest.mark.xfail(bool(os.environ.get('CI', False)),
|
||||
reason='https://bugs.chromium.org/p/chromedriver/issues/detail?id=1772')
|
||||
def test_widgets_selected_after_validation_error(self, db, live_server, driver):
|
||||
driver.get(live_server + self.url)
|
||||
WebDriverWait(driver, 3).until(
|
||||
expected_conditions.presence_of_element_located((By.ID, 'id_title'))
|
||||
)
|
||||
title = driver.find_element_by_id('id_title')
|
||||
title.send_keys('fo')
|
||||
genres, fartists = driver.find_elements_by_css_selector('.select2-selection--multiple')
|
||||
@@ -319,8 +380,72 @@ class TestHeavySelect2MultipleWidget(object):
|
||||
driver.find_element_by_css_selector('.select2-results li:nth-child(2)').click()
|
||||
genres.submit()
|
||||
# there is a ValidationError raised, check for it
|
||||
errstring = driver.find_element_by_css_selector('ul.errorlist li').text
|
||||
errstring = WebDriverWait(driver, 3).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, 'ul.errorlist li'))
|
||||
).text
|
||||
assert errstring == "Title must have more than 3 characters."
|
||||
# genres should still have One as selected option
|
||||
result_title = driver.find_element_by_css_selector('.select2-selection--multiple li').get_attribute('title')
|
||||
assert result_title == 'One'
|
||||
|
||||
|
||||
class TestAddressChainedSelect2Widget(object):
|
||||
url = reverse('model_chained_select2_widget')
|
||||
form = forms.AddressChainedSelect2WidgetForm()
|
||||
|
||||
def test_widgets_selected_after_validation_error(self, db, live_server, driver, countries, cities):
|
||||
driver.get(live_server + self.url)
|
||||
|
||||
WebDriverWait(driver, 60).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-selection--single'))
|
||||
)
|
||||
country_container, city_container = driver.find_elements_by_css_selector('.select2-selection--single')
|
||||
|
||||
# clicking city select2 lists all available cities
|
||||
city_container.click()
|
||||
WebDriverWait(driver, 60).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li'))
|
||||
)
|
||||
city_options = driver.find_elements_by_css_selector('.select2-results li')
|
||||
city_names_from_browser = {option.text for option in city_options}
|
||||
city_names_from_db = set(City.objects.values_list('name', flat=True))
|
||||
assert len(city_names_from_browser) == City.objects.count()
|
||||
assert city_names_from_browser == city_names_from_db
|
||||
|
||||
# selecting a country really does it
|
||||
country_container.click()
|
||||
WebDriverWait(driver, 60).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li:nth-child(2)'))
|
||||
)
|
||||
country_option = driver.find_element_by_css_selector('.select2-results li:nth-child(2)')
|
||||
country_name = country_option.text
|
||||
country_option.click()
|
||||
assert country_name == country_container.text
|
||||
|
||||
# clicking city select2 lists reduced list of cities belonging to the country
|
||||
city_container.click()
|
||||
WebDriverWait(driver, 60).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li'))
|
||||
)
|
||||
city_options = driver.find_elements_by_css_selector('.select2-results li')
|
||||
city_names_from_browser = {option.text for option in city_options}
|
||||
city_names_from_db = set(Country.objects.get(name=country_name).cities.values_list('name', flat=True))
|
||||
assert len(city_names_from_browser) != City.objects.count()
|
||||
assert city_names_from_browser == city_names_from_db
|
||||
|
||||
# selecting a city reaaly does it
|
||||
city_option = driver.find_element_by_css_selector('.select2-results li:nth-child(2)')
|
||||
city_name = city_option.text
|
||||
city_option.click()
|
||||
assert city_name == city_container.text
|
||||
|
||||
# clicking country select2 lists reduced list to the only country available to the city
|
||||
country_container.click()
|
||||
WebDriverWait(driver, 60).until(
|
||||
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li'))
|
||||
)
|
||||
country_options = driver.find_elements_by_css_selector('.select2-results li')
|
||||
country_names_from_browser = {option.text for option in country_options}
|
||||
country_names_from_db = {City.objects.get(name=city_name).country.name}
|
||||
assert len(country_names_from_browser) != Country.objects.count()
|
||||
assert country_names_from_browser == country_names_from_db
|
||||
|
||||
+5
-1
@@ -4,7 +4,6 @@ from __future__ import absolute_import, unicode_literals
|
||||
import json
|
||||
|
||||
from django.core import signing
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.encoding import smart_text
|
||||
|
||||
from django_select2.cache import cache
|
||||
@@ -14,6 +13,11 @@ from tests.testapp.forms import (
|
||||
)
|
||||
from tests.testapp.models import Genre
|
||||
|
||||
try:
|
||||
from django.urls import reverse
|
||||
except ImportError:
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
|
||||
class TestAutoResponseView(object):
|
||||
def test_get(self, client, artists):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
!coverage.py: This is a private format, don't read it directly!{"lines":{}}
|
||||
+32
-2
@@ -10,7 +10,7 @@ from django_select2.forms import (
|
||||
Select2Widget
|
||||
)
|
||||
from tests.testapp import models
|
||||
from tests.testapp.models import Album
|
||||
from tests.testapp.models import Album, City, Country
|
||||
|
||||
|
||||
class TitleSearchFieldMixin(object):
|
||||
@@ -117,7 +117,13 @@ class ArtistModelSelect2MultipleWidgetForm(forms.Form):
|
||||
genres = forms.ModelMultipleChoiceField(widget=ModelSelect2MultipleWidget(
|
||||
queryset=models.Genre.objects.all(),
|
||||
search_fields=['title__icontains'],
|
||||
), queryset=models.Genre.objects.all(), required=False)
|
||||
), queryset=models.Genre.objects.all(), required=True)
|
||||
|
||||
featured_artists = forms.ModelMultipleChoiceField(widget=ModelSelect2MultipleWidget(
|
||||
queryset=models.Artist.objects.all(),
|
||||
search_fields=['title__icontains'],
|
||||
), queryset=models.Artist.objects.all(), required=False)
|
||||
|
||||
|
||||
NUMBER_CHOICES = [
|
||||
(1, 'One'),
|
||||
@@ -168,3 +174,27 @@ class ModelSelect2TagWidgetForm(forms.ModelForm):
|
||||
widgets = {
|
||||
'genres': GenreSelect2TagWidget
|
||||
}
|
||||
|
||||
|
||||
class AddressChainedSelect2WidgetForm(forms.Form):
|
||||
country = forms.ModelChoiceField(
|
||||
queryset=Country.objects.all(),
|
||||
label='Country',
|
||||
widget=ModelSelect2Widget(
|
||||
model=Country,
|
||||
search_fields=['name__icontains'],
|
||||
max_results=500,
|
||||
dependent_fields={'city': 'cities'},
|
||||
)
|
||||
)
|
||||
|
||||
city = forms.ModelChoiceField(
|
||||
queryset=City.objects.all(),
|
||||
label='City',
|
||||
widget=ModelSelect2Widget(
|
||||
model=City,
|
||||
search_fields=['name__icontains'],
|
||||
dependent_fields={'country': 'country'},
|
||||
max_results=500,
|
||||
)
|
||||
)
|
||||
|
||||
+20
-2
@@ -25,10 +25,28 @@ class Artist(models.Model):
|
||||
@python_2_unicode_compatible
|
||||
class Album(models.Model):
|
||||
title = models.CharField(max_length=255)
|
||||
artist = models.ForeignKey(Artist)
|
||||
artist = models.ForeignKey(Artist, on_delete=models.CASCADE)
|
||||
featured_artists = models.ManyToManyField(Artist, blank=True, related_name='featured_album_set')
|
||||
primary_genre = models.ForeignKey(Genre, blank=True, null=True, related_name='primary_album_set')
|
||||
primary_genre = models.ForeignKey(Genre, on_delete=models.CASCADE, blank=True, null=True,
|
||||
related_name='primary_album_set')
|
||||
genres = models.ManyToManyField(Genre)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Country(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class City(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
country = models.ForeignKey('Country', related_name="cities", on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -33,21 +33,31 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||
SITE_ID = 1
|
||||
ROOT_URLCONF = 'tests.testapp.urls'
|
||||
|
||||
LANGUAGES = [
|
||||
('de', 'German'),
|
||||
('en', 'English'),
|
||||
]
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'APP_DIRS': True,
|
||||
'DIRS': ['templates'],
|
||||
},
|
||||
]
|
||||
|
||||
SECRET_KEY = '123456'
|
||||
|
||||
USE_L10N = True
|
||||
USE_I18N = True
|
||||
|
||||
if os.environ.get('TRAVIS'):
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
|
||||
'LOCATION': 'localhost:11211',
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": "redis://127.0.0.1:6379/1",
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
{{ form }}
|
||||
<input type="submit" value="Submit Form"/>
|
||||
</form>
|
||||
<script src="{% static '//code.jquery.com/jquery-2.1.4.min.js' %}"></script>
|
||||
<script src="//code.jquery.com/jquery-2.1.4.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
window.onerror = function (msg) {
|
||||
$("body").attr("JSError", msg);
|
||||
}
|
||||
</script>
|
||||
{{ form.media.js }}
|
||||
</body>
|
||||
</body>
|
||||
|
||||
@@ -4,8 +4,9 @@ from __future__ import absolute_import, unicode_literals
|
||||
from django.conf.urls import include, url
|
||||
|
||||
from .forms import (
|
||||
AlbumModelSelect2WidgetForm, HeavySelect2MultipleWidgetForm,
|
||||
HeavySelect2WidgetForm, ModelSelect2TagWidgetForm, Select2WidgetForm
|
||||
AddressChainedSelect2WidgetForm, AlbumModelSelect2WidgetForm,
|
||||
HeavySelect2MultipleWidgetForm, HeavySelect2WidgetForm,
|
||||
ModelSelect2TagWidgetForm, Select2WidgetForm
|
||||
)
|
||||
from .views import TemplateFormView, heavy_data_1, heavy_data_2
|
||||
|
||||
@@ -26,6 +27,10 @@ urlpatterns = [
|
||||
TemplateFormView.as_view(form_class=ModelSelect2TagWidgetForm),
|
||||
name='model_select2_tag_widget'),
|
||||
|
||||
url(r'^model_chained_select2_widget/$',
|
||||
TemplateFormView.as_view(form_class=AddressChainedSelect2WidgetForm),
|
||||
name='model_chained_select2_widget'),
|
||||
|
||||
url(r'^heavy_data_1/$', heavy_data_1, name='heavy_data_1'),
|
||||
url(r'^heavy_data_2/$', heavy_data_2, name='heavy_data_2'),
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
[tox]
|
||||
envlist = py{27,35,36}-dj{18,110,111,master},qa,docs
|
||||
[testenv]
|
||||
setenv=
|
||||
PYTHONPATH = {toxinidir}
|
||||
passenv=CI
|
||||
deps=
|
||||
-rrequirements-dev.txt
|
||||
dj18: https://github.com/django/django/archive/stable/1.8.x.tar.gz#egg=django
|
||||
dj110: https://github.com/django/django/archive/stable/1.10.x.tar.gz#egg=django
|
||||
dj111: https://github.com/django/django/archive/stable/1.11.x.tar.gz#egg=django
|
||||
djmaster: https://github.com/django/django/archive/master.tar.gz#egg=django
|
||||
commands=
|
||||
coverage run --source=django_select2 -m 'pytest' \
|
||||
--basetemp={envtmpdir} \
|
||||
--ignore=.tox \
|
||||
{posargs}
|
||||
|
||||
[testenv:qa]
|
||||
changedir={toxinidir}
|
||||
deps=
|
||||
-rrequirements-dev.txt
|
||||
commands=
|
||||
isort --check-only --recursive --diff {posargs}
|
||||
flake8 --jobs=2 {posargs}
|
||||
pydocstyle --verbose --explain --source --count {posargs}
|
||||
|
||||
[testenv:docs]
|
||||
whitelist_externals=make
|
||||
changedir={toxinidir}/docs
|
||||
deps=
|
||||
-rrequirements-dev.txt
|
||||
https://github.com/django/django/archive/stable/1.10.x.tar.gz#egg=django
|
||||
commands=make spelling
|
||||
Reference in New Issue
Block a user