diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..0c01cd1
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,32 @@
+name: Test
+
+on: push
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: [3.7, 3.8, 3.9]
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Setup chromedriver
+ uses: nanasess/setup-chromedriver@v1.0.5
+
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install tox tox-gh-actions
+
+ - name: Test with tox
+ run: |
+ tox -- -v --selenosis-driver=chrome-headless || \
+ tox -- -v --selenosis-driver=chrome-headless || \
+ tox -- -v --selenosis-driver=chrome-headless
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index dad0c8a..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,47 +0,0 @@
-language: python
-
-sudo: false
-
-cache:
- pip: true
- directories:
- - bin
-
-addons:
- chrome: stable
-
-env:
- global:
- - PATH=$HOME/bin:$PATH
-
-matrix:
- include:
- - { python: 2.7, env: TOXENV=py27-dj111 }
- - { python: 3.6, env: TOXENV=py36-dj111, dist: xenial }
- - { python: 3.6, env: TOXENV=py36-dj20, dist: xenial }
- - { python: 3.7, env: TOXENV=py37-dj111, dist: xenial }
- - { python: 3.7, env: TOXENV=py37-dj20, dist: xenial }
- - { python: 3.7, env: TOXENV=py37-dj20-grp, dist: xenial }
-
-before_script:
- - mkdir -p ~/bin
- - |
- if [ ! -e ~/bin/chromedriver ]; then
- export CHROMEDRIVER_VERSION=$(curl -q http://chromedriver.storage.googleapis.com/LATEST_RELEASE)
- wget -N http://chromedriver.storage.googleapis.com/$CHROMEDRIVER_VERSION/chromedriver_linux64.zip -P ~/
- unzip ~/chromedriver_linux64.zip -d ~/bin
- rm ~/chromedriver_linux64.zip
- chmod +x ~/bin/chromedriver
- fi
-
-install:
- - pip install tox
-
-script:
- - |
- travis_retry travis_retry travis_retry tox -- \
- --selenium=chrome-headless \
- --verbosity=2 \
- --failfast
-
-
diff --git a/README.rst b/README.rst
index 31264fd..0c38c38 100644
--- a/README.rst
+++ b/README.rst
@@ -9,15 +9,125 @@ fields that use the `Select2 javascript
plugin `_. It was created by
developers at `The Atlantic `_.
+.. contents:: Table of Contents:
+
Support
=======
Being that Django added select2 support in 2.0, we will support up to that version
for compatibility purposes.
-* ~=v2.0.2: Python ~=2.7,~=3.6 | Django >=1.8,<2.1
-* ~=v2.1: Python ~=2.7,>=3.6,<3.8 | Django >=1.11,<2.1
-* ~=v3.0: __Python >=3.6,<3.9 | Django >=2.0,<2.1 (future release)__
+* ~=v3.0: Python >=3.7,<3.9 | Django 2.2,3.1,3.2 (current release)
+
+Local Development & Testing
+===========================
+
+The following steps should only need to be done once when you first begin:
+
+Install ``pyenv``
+-----------------
+
+These instructions assume that you have `Homebrew `_ installed,
+but not ``pyenv``.
+
+.. code:: bash
+
+ brew install pyenv
+ touch ~/.bash_profile
+
+Add the following line to your ``~/bash_profile`` or ``.zshrc``::
+
+ eval "$(pyenv init -)"
+
+Reload your shell:
+
+.. code:: bash
+
+ . ~/.bash_profile
+or
+
+.. code:: bash
+
+ . ~/.zshrc
+
+Python Repository Setup
+-----------------------
+
+First, clone the repository and prep your Python environment:
+
+.. code:: bash
+
+ git clone https://github.com/theatlantic/django-select2-forms.git
+ pyenv install 3.7.2
+ pyenv install 3.8.0
+ pyenv install 3.9.0
+ pyenv local 3.7.2 3.8.0 3.9.0
+ python -V
+
+The output of the previous command should be ``Python 3.7.2``.
+
+Finally:
+
+.. code:: bash
+
+ python -m venv venv
+
+Activate Your Environment
+-------------------------
+
+From the base directory:
+
+.. code:: bash
+
+ deactivate # ignore: -bash: deactivate: command not found
+ . venv/bin/activate
+ pip install -U tox
+
+Running tests
+-------------
+
+If you have not already done so, set up your environment by chromedriver:
+
+.. code:: bash
+
+ brew install --cask chromedriver
+
+Run all tests:
+
+.. code:: bash
+
+ tox -- --selenosis-driver=chrome-headless
+
+Show all available ``tox`` commands:
+
+.. code:: bash
+
+ tox -av
+
+Run only a specific environment:
+
+.. code:: bash
+
+ tox -e -- --selenosis-driver=chrome-headless # example: tox -e py37-django22
+
+Only run a specific test:
+
+.. code:: bash
+
+ tox -- pytest -k test_something --selenosis-driver=chrome-headless
+
+Run an arbitrary command in a specific environment:
+
+.. code:: bash
+
+ tox -e py37-django22 -- python # runs the Python REPL in that environment
+
+Setup a development environment:
+
+.. code:: bash
+
+ tox -e --develop -r
+ . .tox//bin/activate
Installation
============
diff --git a/runtests.py b/runtests.py
deleted file mode 100755
index c7700b9..0000000
--- a/runtests.py
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/usr/bin/env python
-import sys
-import warnings
-import selenosis
-
-def main():
- warnings.simplefilter("error", Warning)
- # Ignore warning from sortedm2m
- warnings.filterwarnings("ignore", "Usage of field.rel")
- warnings.filterwarnings("ignore", "Usage of ForeignObjectRel.to")
- warnings.filterwarnings("ignore", "on_delete")
- warnings.filterwarnings("ignore", "add_lazy_relation")
- if sys.version_info >= (3, 7):
- warnings.filterwarnings("ignore", "Using or importing the ABCs")
- runtests = selenosis.RunTests("select2.tests.settings", "select2")
- runtests()
-
-
-if __name__ == '__main__':
- main()
diff --git a/select2/fields.py b/select2/fields.py
index 691c040..144976a 100644
--- a/select2/fields.py
+++ b/select2/fields.py
@@ -1,12 +1,10 @@
import django
from django import forms
from django.db import models
-from django.core.exceptions import ImproperlyConfigured, ValidationError
-from django.db.models.fields import FieldDoesNotExist
+from django.core.exceptions import ImproperlyConfigured, ValidationError, FieldDoesNotExist
from django.forms.models import ModelChoiceIterator
-from django.utils.encoding import force_text
+from django.utils.encoding import force_str
from django.utils.functional import Promise
-from django.utils import six
try:
from django.db.models.fields.related import lazy_related_operation
except ImportError:
@@ -172,7 +170,7 @@ def clean(self, value):
elif not self.required and not value:
return []
- if isinstance(value, six.string_types):
+ if isinstance(value, str):
value = value.split(',')
if not isinstance(value, (list, tuple)):
@@ -188,14 +186,14 @@ def clean(self, value):
qs = self.queryset.filter(**{
('%s__in' % key): value,
})
- pks = set([force_text(getattr(o, key)) for o in qs])
+ pks = set([force_str(getattr(o, key)) for o in qs])
# Create a dictionary for storing the original order of the items
# passed from the form
pk_positions = {}
for i, val in enumerate(value):
- pk = force_text(val)
+ pk = force_str(val)
if pk not in pks:
raise ValidationError(self.error_messages['invalid_choice'] % val)
pk_positions[pk] = i
@@ -209,7 +207,7 @@ def clean(self, value):
sort_value_field_name = self.sort_field.name
objs = []
for i, obj in enumerate(qs):
- pk = force_text(getattr(obj, key))
+ pk = force_str(getattr(obj, key))
setattr(obj, sort_value_field_name, pk_positions[pk])
objs.append(obj)
return sorted(objs, key=lambda obj: getattr(obj, sort_value_field_name))
@@ -273,7 +271,7 @@ def contribute_to_related_class(self, cls, related):
'field_name': self.name,
'app_label': self.model._meta.app_label,
'object_name': self.model._meta.object_name})
- if not callable(self.search_field) and not isinstance(self.search_field, six.string_types):
+ if not callable(self.search_field) and not isinstance(self.search_field, str):
raise TypeError(
("keyword argument 'search_field' must be either callable or "
"string on field '%(field_name)s' of model "
@@ -281,7 +279,7 @@ def contribute_to_related_class(self, cls, related):
'field_name': self.name,
'app_label': self.model._meta.app_label,
'object_name': self.model._meta.object_name})
- if isinstance(self.search_field, six.string_types):
+ if isinstance(self.search_field, str):
try:
opts = related.parent_model._meta
except AttributeError:
@@ -354,7 +352,7 @@ def contribute_to_class(self, cls, name):
def resolve_sort_field(field, model, cls):
model._sort_field_name = field.sort_value_field_name
field.sort_field = model._meta.get_field(field.sort_value_field_name)
- if isinstance(compat_rel(self).through, six.string_types):
+ if isinstance(compat_rel(self).through, str):
compat_add_lazy_relation(cls, self, compat_rel(self).through, resolve_sort_field)
else:
resolve_sort_field(self, compat_rel(self).through, cls)
diff --git a/select2/models/base.py b/select2/models/base.py
index 39d7a0a..fc99a44 100644
--- a/select2/models/base.py
+++ b/select2/models/base.py
@@ -2,7 +2,6 @@
import sys
from django.db import models
-from django.utils import six
from django.apps import apps
from django.db.models.base import ModelBase
from django.utils.functional import SimpleLazyObject
@@ -68,7 +67,7 @@ def _get_model():
return super_new(cls, name, bases, attrs)
-class SortableThroughModel(six.with_metaclass(SortableThroughModelBase, models.Model)):
+class SortableThroughModel(models.Model, metaclass=SortableThroughModelBase):
class Meta:
abstract = True
diff --git a/select2/tests/fixtures/select2-test-data.xml b/select2/tests/fixtures/select2-test-data.xml
deleted file mode 100644
index be8b2c7..0000000
--- a/select2/tests/fixtures/select2-test-data.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/select2/tests/settings.py b/select2/tests/settings.py
deleted file mode 100755
index 304539f..0000000
--- a/select2/tests/settings.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import django
-import dj_database_url
-
-from selenosis.settings import *
-
-
-DATABASES['default'] = dj_database_url.parse(os.environ.get('DATABASE_URL', 'sqlite://:memory:'))
-
-INSTALLED_APPS += (
- 'select2',
- 'select2.tests',
-)
-
-ROOT_URLCONF = 'select2.tests.urls'
-
-if 'grappelli' in INSTALLED_APPS:
- # django-grappelli has issues with string_if_invalid,
- # so don't use this setting if testing suit.
- TEMPLATES[0]['OPTIONS'].pop('string_if_invalid')
-
-TEMPLATES[0]['OPTIONS']['debug'] = True
-DEBUG_PROPAGATE_EXCEPTIONS = True
diff --git a/select2/tests/urls.py b/select2/tests/urls.py
deleted file mode 100644
index f14b171..0000000
--- a/select2/tests/urls.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import django
-from django.conf.urls import include, url
-from django.contrib import admin
-
-
-admin.autodiscover()
-
-urlpatterns = [
- url(r"^select2/", include("select2.urls")),
-]
-
-if django.VERSION < (1, 9):
- urlpatterns += [url(r'^admin/', include(admin.site.urls))]
-else:
- urlpatterns += [url(r'^admin/', admin.site.urls)]
-
-try:
- import grappelli.urls
-except ImportError:
- pass
-else:
- urlpatterns += [url(r"^grappelli/", include(grappelli.urls))]
diff --git a/select2/urls.py b/select2/urls.py
index c3bbd78..bbd2b0c 100644
--- a/select2/urls.py
+++ b/select2/urls.py
@@ -1,14 +1,11 @@
-try:
- from django.conf.urls import url
-except ImportError:
- from django.conf.urls.defaults import url
+from django.urls import re_path
import select2.views
urlpatterns = [
- url(r'^fetch_items/(?P[^\/]+)/(?P[^\/]+)/(?P[^\/]+)/$',
+ re_path(r'^fetch_items/(?P[^\/]+)/(?P[^\/]+)/(?P[^\/]+)/$',
select2.views.fetch_items, name='select2_fetch_items'),
- url(r'^init_selection/(?P[^\/]+)/(?P[^\/]+)/(?P[^\/]+)/$',
+ re_path(r'^init_selection/(?P[^\/]+)/(?P[^\/]+)/(?P[^\/]+)/$',
select2.views.init_selection, name='select2_init_selection'),
]
diff --git a/select2/utils.py b/select2/utils.py
index 4eaa3b1..849cab3 100644
--- a/select2/utils.py
+++ b/select2/utils.py
@@ -1,14 +1,11 @@
import re
-from django.utils import six
-
-
re_spaces = re.compile(r"\s+")
def combine_css_classes(classes, new_classes):
if not classes:
- if isinstance(new_classes, six.string_types):
+ if isinstance(new_classes, str):
return new_classes
else:
try:
@@ -16,7 +13,7 @@ def combine_css_classes(classes, new_classes):
except TypeError:
return new_classes
- if isinstance(classes, six.string_types):
+ if isinstance(classes, str):
classes = set(re_spaces.split(classes))
else:
try:
@@ -24,7 +21,7 @@ def combine_css_classes(classes, new_classes):
except TypeError:
return classes
- if isinstance(new_classes, six.string_types):
+ if isinstance(new_classes, str):
new_classes = set(re_spaces.split(new_classes))
else:
try:
diff --git a/select2/views.py b/select2/views.py
index 2056dfa..dca61fb 100644
--- a/select2/views.py
+++ b/select2/views.py
@@ -5,8 +5,11 @@
from django.apps import apps
from django.forms.models import ModelChoiceIterator
from django.http import HttpResponse
-from django.utils.encoding import force_text
-from django.utils import six
+from django.utils.encoding import force_str
+try:
+ from django.forms.models import ModelChoiceIteratorValue
+except ImportError:
+ ModelChoiceIteratorValue = None
from .fields import ManyToManyField, compat_rel
@@ -24,7 +27,7 @@ class JsonResponse(HttpResponse):
callback = None
def __init__(self, content='', callback=None, content_type="application/json", *args, **kwargs):
- if not isinstance(content, six.string_types):
+ if not isinstance(content, str):
content = json.dumps(content)
if callback is not None:
self.callback = callback
@@ -103,6 +106,11 @@ def get_data(self, queryset, page=None, page_limit=None):
for value, label in iterator:
if value is u'':
continue
+
+ if ModelChoiceIteratorValue and isinstance(value, ModelChoiceIteratorValue):
+ # ModelChoiceIteratorValue was added in Django 3.1
+ value = value.value
+
data['results'].append({
'id': value,
'text': label,
@@ -113,7 +121,7 @@ def init_selection(self):
try:
field, model_cls = self.get_field_and_model()
except ViewException as e:
- return self.get_response({'error': six.text_type(e)}, status=500)
+ return self.get_response({'error': str(e)}, status=500)
q = self.request.GET.get('q', None)
try:
@@ -126,18 +134,18 @@ def init_selection(self):
raise InvalidParameter("q parameter must be comma separated "
"list of integers")
except InvalidParameter as e:
- return self.get_response({'error': six.text_type(e)}, status=500)
+ return self.get_response({'error': str(e)}, status=500)
queryset = field.queryset.filter(**{
(u'%s__in' % compat_rel(field).get_related_field().name): pks,
}).distinct()
- pk_ordering = dict([(force_text(pk), i) for i, pk in enumerate(pks)])
+ pk_ordering = dict([(force_str(pk), i) for i, pk in enumerate(pks)])
data = self.get_data(queryset)
# Make sure we return in the same order we were passed
def results_sort_callback(item):
- pk = force_text(item['id'])
+ pk = force_str(item['id'])
return pk_ordering[pk]
data['results'] = sorted(data['results'], key=results_sort_callback)
@@ -158,7 +166,7 @@ def fetch_items(self):
try:
field, model_cls = self.get_field_and_model()
except ViewException as e:
- return self.get_response({'error': six.text_type(e)}, status=500)
+ return self.get_response({'error': str(e)}, status=500)
queryset = copy.deepcopy(field.queryset)
@@ -185,7 +193,7 @@ def fetch_items(self):
if page < 1:
raise InvalidParameter("Invalid page '%s' passed")
except InvalidParameter as e:
- return self.get_response({'error': six.text_type(e)}, status=500)
+ return self.get_response({'error': str(e)}, status=500)
search_field = field.search_field
if callable(search_field):
diff --git a/select2/widgets.py b/select2/widgets.py
index 6b88cf3..ecc30bc 100644
--- a/select2/widgets.py
+++ b/select2/widgets.py
@@ -7,9 +7,8 @@
from django.forms.utils import flatatt
from django.utils.datastructures import MultiValueDict
from django.utils.html import escape, conditional_escape
-from django.utils.encoding import force_text
+from django.utils.encoding import force_str
from django.utils.safestring import mark_safe
-from django.utils import six
from .utils import combine_css_classes
@@ -55,7 +54,7 @@ def __init__(self, attrs=None, choices=(), js_options=None, *args, **kwargs):
self.ajax = kwargs.pop('ajax', self.ajax)
self.js_options = {}
if js_options is not None:
- for k, v in six.iteritems(js_options):
+ for k, v in js_options.items():
if k in self.js_options_map:
k = self.js_options_map[k]
self.js_options[k] = v
@@ -89,12 +88,12 @@ def option_to_data(self, option_value, option_label):
return
if isinstance(option_label, (list, tuple)):
return {
- "text": force_text(option_value),
+ "text": force_str(option_value),
"children": [_f for _f in [self.option_to_data(v, l) for v, l in option_label] if _f],
}
return {
- "id": force_text(option_value),
- "text": force_text(option_label),
+ "id": force_str(option_value),
+ "text": force_str(option_label),
}
def render(self, name, value, attrs=None, choices=(), js_options=None, **kwargs):
@@ -102,7 +101,7 @@ def render(self, name, value, attrs=None, choices=(), js_options=None, **kwargs)
attrs = dict(self.attrs, **(attrs or {}))
js_options = js_options or {}
- for k, v in six.iteritems(dict(self.js_options, **js_options)):
+ for k, v in dict(self.js_options, **js_options).items():
if k in self.js_options_map:
k = self.js_options_map[k]
options[k] = v
@@ -119,7 +118,7 @@ def render(self, name, value, attrs=None, choices=(), js_options=None, **kwargs)
'dataType': 'jsonp' if is_jsonp else 'json',
'quietMillis': quiet_millis,
}
- for k, v in six.iteritems(ajax_opts):
+ for k, v in ajax_opts.items():
if k in self.js_options_map:
k = self.js_options_map[k]
default_ajax_opts[k] = v
@@ -164,7 +163,7 @@ def render_select(self, name, value, attrs=None, choices=()):
return mark_safe(u'\n'.join(output))
def render_option(self, selected_choices, option_value, option_label):
- option_value = force_text(option_value)
+ option_value = force_str(option_value)
if option_value in selected_choices:
selected_html = u' selected="selected"'
if not self.allow_multiple_selected:
@@ -174,15 +173,15 @@ def render_option(self, selected_choices, option_value, option_label):
selected_html = ''
return u'' % (
escape(option_value), selected_html,
- conditional_escape(force_text(option_label)))
+ conditional_escape(str(option_label)))
def render_options(self, choices, selected_choices):
# Normalize to strings.
- selected_choices = set(force_text(v) for v in selected_choices)
+ selected_choices = set(force_str(v) for v in selected_choices)
output = []
for option_value, option_label in chain(self.choices, choices):
if isinstance(option_label, (list, tuple)):
- output.append(u'