Skip to content

Commit

Permalink
Add support for Django 1.11
Browse files Browse the repository at this point in the history
Drop support for Django 1.9
  • Loading branch information
codingjoe committed Apr 14, 2017
1 parent dc6e87e commit 175e994
Show file tree
Hide file tree
Showing 8 changed files with 110 additions and 38 deletions.
7 changes: 4 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@

testapp/tt.py

*.pyc

Django_Select2.egg-info
Expand All @@ -19,3 +16,7 @@ docs/_build
env/
venv/
.cache/
.tox/
geckodriver.log
ghostdriver.log
.coverage
17 changes: 14 additions & 3 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,27 @@ env:
- DISPLAY=:99.0
- GECKO_DRIVER_VERSION=v0.14.0
matrix:
- TOXENV=qa
- TOXENV=docs
- DJANGO=18
- DJANGO=19
- DJANGO=110
- DJANGO=111
- DJANGO=master
- TOXENV=qa
- TOXENV=docs
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
Expand Down
69 changes: 57 additions & 12 deletions django_select2/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@

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
Expand All @@ -63,6 +62,11 @@
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):
"""
Expand All @@ -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:
Expand All @@ -89,9 +93,15 @@ def build_attrs(self, extra_attrs=None, **kwargs):
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 and not self.allow_multiple_selected 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

Expand All @@ -113,12 +123,12 @@ def _get_media(self):
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):
Expand Down Expand Up @@ -175,7 +185,7 @@ def value_from_datadict(self, data, files, name):
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.
Expand All @@ -184,22 +194,27 @@ def __init__(self, **kwargs):
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`."""
if self.data_url:
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))
Expand Down Expand Up @@ -247,7 +262,7 @@ def render_options(self, *args):
choices = chain(self.choices, choices)
else:
choices = self.choices
output = ['<option></option>' if not self.is_required and not self.allow_multiple_selected 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]
for option_value, option_label in choices:
Expand Down Expand Up @@ -401,6 +416,36 @@ def get_search_fields(self):
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:
Expand All @@ -411,7 +456,7 @@ def render_options(self, *args):
else:
choices = self.choices
selected_choices = {force_text(v) for v in selected_choices}
output = ['<option></option>' if not self.is_required and not self.allow_multiple_selected else '']
output = ['<option value=""></option>' 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
Expand Down
1 change: 1 addition & 0 deletions docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ functionalities
plugin
multi
Indices
clearable
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def read(file_name):
"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',
Expand Down
42 changes: 27 additions & 15 deletions tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

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
Expand All @@ -26,6 +25,12 @@
)
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')
Expand All @@ -48,15 +53,15 @@ def test_allow_clear(self, db):
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
assert 'data-allow-clear="true"' in not_required_field.widget.render('primary_genre', None)
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)
Expand Down Expand Up @@ -91,12 +96,12 @@ def test_empty_option(self, db):
# https://select2.github.io/options.html#allowClear
single_select = self.form.fields['primary_genre']
assert single_select.required is False
assert '<option></option>' in single_select.widget.render('primary_genre', None)
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></option>' not in multiple_select.widget.render('featured_artists', None)
assert '<option value=""></option>' not in multiple_select.widget.render('featured_artists', None)


class TestSelect2MixinSettings(object):
Expand Down Expand Up @@ -139,18 +144,22 @@ def test_selected_option(self, db):
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):
field = HeavySelect2MultipleWidgetForm().fields['genres']
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)
Expand All @@ -160,11 +169,11 @@ def test_multiple_widgets(self, db, live_server, driver):
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

Expand Down Expand Up @@ -204,7 +213,7 @@ def test_label_from_instance_initial(self, genres):
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)
Expand All @@ -220,10 +229,12 @@ def test_selected_option(self, db, genres):
'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):
Expand All @@ -234,14 +245,15 @@ def test_selected_option_label_from_instance(self, db, genres):
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()
Expand Down
5 changes: 3 additions & 2 deletions tests/testapp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ def __str__(self):
@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):
Expand Down
4 changes: 2 additions & 2 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
[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
PYTHONPATH = {toxinidir}
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' \
Expand Down

0 comments on commit 175e994

Please sign in to comment.