diff --git a/.gitignore b/.gitignore
index bdd9c12..7de93f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/.travis.yml b/.travis.yml
index 21f9bfb..8f2d0bf 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -25,16 +25,27 @@ env:
- DISPLAY=:99.0
- GECKO_DRIVER_VERSION=v0.14.0
matrix:
+ - DJANGO=18
+ - DJANGO=110
+ - DJANGO=111
+ - DJANGO=master
- TOXENV=qa
- TOXENV=docs
- - DJANGO=18
- - DJANGO=19
- - DJANGO=110
- - DJANGO=master
matrix:
fast_finish: true
allow_failures:
- 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 tox
- pip install -U coveralls
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e577d9c..306c6a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,10 @@
Changelog Summary
=================
+### 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)
diff --git a/django_select2/__init__.py b/django_select2/__init__.py
index ccf8c5d..d3b714b 100644
--- a/django_select2/__init__.py
+++ b/django_select2/__init__.py
@@ -9,4 +9,4 @@ The app includes Select2 driven Django Widgets and Form Fields.
"""
-__version__ = "5.8.10"
+__version__ = "5.9.0"
diff --git a/django_select2/forms.py b/django_select2/forms.py
index f013bcc..ffbbd7a 100644
--- a/django_select2/forms.py
+++ b/django_select2/forms.py
@@ -54,7 +54,6 @@ 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
@@ -63,6 +62,11 @@ from django.utils.six.moves.cPickle import PicklingError as cPicklingError
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):
"""
@@ -73,9 +77,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:
@@ -89,9 +93,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 = '' if not self.is_required and not self.allow_multiple_selected else ''
+ output = '' if not self.is_required and not self.allow_multiple_selected else ''
output += super(Select2Mixin, self).render_options(*args, **kwargs)
return output
@@ -113,12 +123,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)
+ return super(Select2TagMixin, self).build_attrs(*args, **kwargs)
class Select2Widget(Select2Mixin, forms.Select):
@@ -175,7 +185,7 @@ 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):
+ def __init__(self, attrs=None, choices=(), **kwargs):
"""
Return HeavySelect2Mixin.
@@ -184,12 +194,17 @@ class HeavySelect2Mixin(object):
data_url (str): URL
"""
+ 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)
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`."""
@@ -197,9 +212,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))
@@ -247,7 +262,7 @@ class HeavySelect2Mixin(object):
choices = chain(self.choices, choices)
else:
choices = self.choices
- output = ['' if not self.is_required and not self.allow_multiple_selected else '']
+ output = ['' 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]
for option_value, option_label in choices:
@@ -401,6 +416,36 @@ 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:`ModelChoiceIterator`."""
try:
@@ -411,7 +456,7 @@ class ModelSelect2Mixin(object):
else:
choices = self.choices
selected_choices = {force_text(v) for v in selected_choices}
- output = ['' if not self.is_required and not self.allow_multiple_selected else '']
+ output = ['' if not self.is_required and not self.allow_multiple_selected else '']
if isinstance(self.choices, ModelChoiceIterator):
if self.queryset is None:
self.queryset = self.choices.queryset
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index 17f4100..fedb12e 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -15,3 +15,4 @@ functionalities
plugin
multi
Indices
+clearable
diff --git a/setup.py b/setup.py
index c78fa52..875e525 100755
--- a/setup.py
+++ b/setup.py
@@ -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',
diff --git a/tests/test_forms.py b/tests/test_forms.py
index 30d141d..fa4fe62 100644
--- a/tests/test_forms.py
+++ b/tests/test_forms.py
@@ -6,7 +6,6 @@ import json
import pytest
from django.core import signing
-from django.core.urlresolvers import reverse
from django.db.models import QuerySet
from django.utils.encoding import force_text
from django.utils.six import text_type
@@ -26,6 +25,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 TestSelect2Mixin(object):
url = reverse('select2_widget')
@@ -48,7 +52,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 '' not in required_field.widget.render('artist', None)
+ assert '' not in required_field.widget.render('artist', None)
not_required_field = self.form.fields['primary_genre']
assert not_required_field.required is False
@@ -56,7 +60,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 '' in not_required_field.widget.render('primary_genre', None)
+ assert '' 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)
@@ -91,12 +95,12 @@ class TestSelect2Mixin(object):
# https://select2.github.io/options.html#allowClear
single_select = self.form.fields['primary_genre']
assert single_select.required is False
- assert '' in single_select.widget.render('primary_genre', None)
+ assert '' 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 '' not in multiple_select.widget.render('featured_artists', None)
+ assert '' not in multiple_select.widget.render('featured_artists', None)
class TestSelect2MixinSettings(object):
@@ -139,7 +143,9 @@ class TestHeavySelect2Mixin(TestSelect2Mixin):
not_required_field = self.form.fields['primary_genre']
assert not_required_field.required is False
assert '' in \
- not_required_field.widget.render('primary_genre', 1), \
+ not_required_field.widget.render('primary_genre', 1) or \
+ '' 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):
@@ -147,10 +153,12 @@ class TestHeavySelect2Mixin(TestSelect2Mixin):
field.widget.choices = NUMBER_CHOICES
widget_output = field.widget.render('genres', [1, 2])
selected_option = ''.format(pk=1, value='One')
+ selected_option_a = ''.format(pk=1, value='One')
selected_option2 = ''.format(pk=2, value='Two')
+ selected_option2a = ''.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)
@@ -160,11 +168,11 @@ class TestHeavySelect2Mixin(TestSelect2Mixin):
elem1, elem2 = driver.find_elements_by_css_selector('.select2-selection')
elem1.click()
- result1 = WebDriverWait(driver, 10).until(
+ result1 = WebDriverWait(driver, 60).until(
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li:first-child'))
).text
elem2.click()
- result2 = WebDriverWait(driver, 10).until(
+ result2 = WebDriverWait(driver, 60).until(
expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.select2-results li:first-child'))
).text
@@ -204,7 +212,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)
@@ -220,10 +228,12 @@ class TestModelSelect2Mixin(TestHeavySelect2Mixin):
'primary_genre', genre.pk)
selected_option = ''.format(
pk=genre.pk, value=force_text(genre))
+ selected_option_a = ''.format(
+ pk=genre.pk, value=force_text(genre))
unselected_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):
@@ -234,14 +244,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 ''.format(
+ pk=genre.pk, value=force_text(genre)), ''.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()
diff --git a/tests/test_views.py b/tests/test_views.py
index 6e4f387..857ba98 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -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):
diff --git a/tests/testapp/models.py b/tests/testapp/models.py
index 44c0129..8733b44 100644
--- a/tests/testapp/models.py
+++ b/tests/testapp/models.py
@@ -25,9 +25,10 @@ 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):
diff --git a/tox.ini b/tox.ini
index 66c7936..9d7d739 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
[tox]
-envlist = py{27,35,36}-dj{18,19,110,master},qa,docs
+envlist = py{27,35,36}-dj{18,110,111,master},qa,docs
[testenv]
setenv=
DISPLAY=:99.0
@@ -7,8 +7,8 @@ setenv=
deps=
-rrequirements-dev.txt
dj18: https://github.com/django/django/archive/stable/1.8.x.tar.gz#egg=django
- dj19: https://github.com/django/django/archive/stable/1.9.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' \