From 5840622794bc282405370b02dc504bec4304c166 Mon Sep 17 00:00:00 2001 From: Johannes Hoppe Date: Fri, 14 Apr 2017 11:56:52 +0200 Subject: [PATCH] Add support for Django 1.11 Drop support for Django 1.9 --- .gitignore | 7 ++-- .travis.yml | 19 ++++++++--- CHANGELOG.md | 4 +++ django_select2/__init__.py | 2 +- django_select2/forms.py | 69 +++++++++++++++++++++++++++++++------- docs/spelling_wordlist.txt | 1 + setup.py | 3 +- tests/test_forms.py | 41 +++++++++++++--------- tests/test_views.py | 6 +++- tests/testapp/models.py | 5 +-- tox.ini | 4 +-- 11 files changed, 120 insertions(+), 41 deletions(-) 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' \