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 @@ - - - - University of Minnesota Press - US - - - Penguin Press - US - - - Presses Universitaires de France - FR - - - Columbia University Press - US - - - Gilles - Deleuze - False - - - Félix - Guattari - False - - - Thomas - Pynchon - True - - - Mark - Leyner - True - - \ 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'' % escape(force_text(option_value))) + output.append(u'' % escape(force_str(option_value))) for option in option_label: output.append(self.render_option(selected_choices, *option)) output.append(u'') @@ -215,7 +214,7 @@ def __init__(self, attrs=None, choices=(), js_options=None, *args, **kwargs): def format_value(self, value): if isinstance(value, list): - value = u','.join([force_text(v) for v in value]) + value = u','.join([force_str(v) for v in value]) return value if django.VERSION < (1, 10): @@ -227,6 +226,6 @@ def value_from_datadict(self, data, files, name): value = data.getlist(name) else: value = data.get(name, None) - if isinstance(value, six.string_types): + if isinstance(value, str): return [v for v in value.split(',') if v] return value diff --git a/setup.cfg b/setup.cfg index cd9591f..0fd660f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,11 +1,39 @@ [bumpversion] -current_version = 2.1.0 +current_version = 3.0.0 commit = True tag = True -message = v{new_version} [ci skip] - -[bdist_wheel] -universal = 1 [bumpversion:file:setup.py] +[metadata] +license_file = LICENSE + +[flake8] +exclude = tmp +ignore = E722 +max-line-length = 100 + +# For coverage options, see https://coverage.readthedocs.io/en/coverage-4.2/config.html +[coverage:run] +branch = True +source = select2 +omit = + +[coverage:html] +directory = build/coverage +title = select2 Coverage + +[coverage:report] +# Regexes for lines to exclude from consideration +ignore_errors = True +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + # Don't complain about missing debug-only code: + def __repr__ + if self\.debug + # Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + # Don't complain if non-runnable code isn't run: + if __name__ == .__main__.: diff --git a/setup.py b/setup.py index ce961ba..e1639c9 100755 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='django-select2-forms', - version='2.1.1', + version='3.0.0', description='Django form fields using the Select2 jQuery plugin', long_description=codecs.open(readme_rst, encoding='utf-8').read(), author='Frankie Dintino', @@ -26,14 +26,15 @@ 'Operating System :: OS Independent', 'Programming Language :: Python', 'Framework :: Django', - 'Framework :: Django :: 1.11', - 'Framework :: Django :: 2.0', + 'Framework :: Django :: 2.2', + 'Framework :: Django :: 3.1', + 'Framework :: Django :: 3.2', 'Programming Language :: Python', - "Programming Language :: Python :: 2", - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + ], include_package_data=True, zip_safe=False diff --git a/select2/tests/__init__.py b/tests/__init__.py similarity index 100% rename from select2/tests/__init__.py rename to tests/__init__.py diff --git a/select2/tests/admin.py b/tests/admin.py similarity index 74% rename from select2/tests/admin.py rename to tests/admin.py index d6350ef..755364d 100644 --- a/select2/tests/admin.py +++ b/tests/admin.py @@ -13,4 +13,6 @@ class LibraryAdmin(admin.ModelAdmin): inlines = [BookInline] -admin.site.register([Publisher, Author, Book], admin.ModelAdmin) +admin.site.register(Publisher) +admin.site.register(Author) +admin.site.register(Book) diff --git a/tests/manage.py b/tests/manage.py new file mode 100644 index 0000000..7646e46 --- /dev/null +++ b/tests/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + raise + execute_from_command_line(sys.argv) diff --git a/select2/tests/models.py b/tests/models.py similarity index 94% rename from select2/tests/models.py rename to tests/models.py index 53f5c0d..d936265 100644 --- a/select2/tests/models.py +++ b/tests/models.py @@ -1,12 +1,10 @@ from django.db import models from django.db.models import Q -from django.utils.encoding import python_2_unicode_compatible from select2.fields import ( ForeignKey as Select2ForeignKey, ManyToManyField as Select2ManyToManyField) -@python_2_unicode_compatible class Publisher(models.Model): name = models.CharField(max_length=100) country = models.CharField(max_length=2) @@ -18,7 +16,6 @@ def __str__(self): return self.name -@python_2_unicode_compatible class Author(models.Model): first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) @@ -31,7 +28,6 @@ def __str__(self): return "%s %s" % (self.first_name, self.last_name) -@python_2_unicode_compatible class Library(models.Model): name = models.CharField(max_length=100) @@ -39,7 +35,6 @@ def __str__(self): return self.name -@python_2_unicode_compatible class Book(models.Model): title = models.CharField(max_length=100) library = models.ForeignKey(Library, blank=True, null=True, on_delete=models.CASCADE) diff --git a/tests/settings.py b/tests/settings.py new file mode 100755 index 0000000..20e2288 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,47 @@ +import os +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +SECRET_KEY = 'select2-secret-key' +DEBUG = True +ROOT_URLCONF = 'urls' +WSGI_APPLICATION = None +TIME_ZONE = 'UTC' +STATIC_URL = '/static/' + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'select2', + 'tests', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +TEMPLATES = [{ + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, +},] + +DATABASES = { + 'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': 'db.sqlite'} +} diff --git a/select2/tests/test_admin.py b/tests/test_admin.py similarity index 92% rename from select2/tests/test_admin.py rename to tests/test_admin.py index 1d6664a..2e697ac 100644 --- a/select2/tests/test_admin.py +++ b/tests/test_admin.py @@ -9,11 +9,35 @@ from .models import Author, Publisher, Book, Library +def load_fixtures(): + Publisher.objects.bulk_create([ + Publisher(pk=pk, name=name, country=country) + for pk, name, country in [ + [1, "University of Minnesota Press", "US"], + [2, "Penguin Press", "US"], + [3, "Presses Universitaires de France", "FR"], + [4, "Columbia University Press", "US"], + ] + ]) + + Author.objects.bulk_create([ + Author(pk=pk, first_name=first, last_name=last, alive=alive) + for pk, first, last, alive in [ + [1, "Gilles", "Deleuze", False], + [2, "Félix", "Guattari", False], + [3, "Thomas", "Pynchon", True], + [4, "Mark", "Leyner", True], + ] + ]) + + class TestAdmin(AdminSelenosisTestCase): - root_urlconf = "select2.tests.urls" + root_urlconf = "tests.urls" - fixtures = ["select2-test-data.xml"] + def setUp(self): + super().setUp() + load_fixtures() def initialize_page(self): super(TestAdmin, self).initialize_page() diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..daa04a3 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,18 @@ +import django +from django.urls import include, re_path +from django.contrib import admin + + +admin.autodiscover() + +urlpatterns = [ + re_path(r"^select2/", include("select2.urls")), + re_path(r'^admin/', admin.site.urls) +] + +try: + import grappelli.urls +except ImportError: + pass +else: + urlpatterns += [re_path(r"^grappelli/", include(grappelli.urls))] diff --git a/tox.ini b/tox.ini index 19b389a..d7f04d2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,22 +1,63 @@ [tox] envlist = - py27-dj111{,-grp} - py{36,37}-dj{111,20}{,-grp} + py3{7,8,9}-django{22,31,32} + +[pytest] +django_find_project = false +DJANGO_SETTINGS_MODULE=tests.settings [testenv] -commands = - python runtests.py {posargs} --noinput +description = Run tests in {envname} environment setenv = - DJANGO_SELENIUM_TESTS = 1 -passenv = - TRAVIS - TRAVIS_* - DATABASE_URL + PYTHONPATH = {toxinidir}:{env:PYTHONPATH:} +commands = pytest {posargs} deps = + pytest>=5.2.0 + pytest-django selenium django-selenosis - dj-database-url - dj111: Django~=1.11.0 - dj20: Django~=2.0.0 - dj111-grp: django-grappelli==2.10.2 - dj20-grp: django-grappelli==2.10.2 + django22: Django>=2.2,<3.0 + django31: Django>=3.1,<3.2 + django32: Django>=3.2,<4.0 + django22-grp: django-grappelli==2.13.4 + django31-grp: django-grappelli==2.14.4 + django32-grp: django-grappelli==2.15.1 + +[testenv:clean] +description = Clean all build and test artifacts +skipsdist = true +skip_install = true +deps = +whitelist_externals = + find + rm +commands = + find {toxinidir} -type f -name "*.pyc" -delete + find {toxinidir} -type d -name "__pycache__" -delete + rm -f {toxinidir}/tests/db.sqlite {toxworkdir} {toxinidir}/.pytest_cache {toxinidir}/build + +[testenv:pep8] +description = Run PEP8 flake8 against the select2/ package directory +skipsdist = true +skip_install = true +basepython = python3.7 +deps = flake8 +commands = flake8 select2 tests + +[testenv:coverage] +description = Run test coverage and display results +deps = + {[testenv]deps} + coverage + pytest-cov +whitelist_externals = + echo +commands = + pytest --cov-config .coveragerc --cov-report html --cov-report term --cov=select2 + echo HTML coverage report: {toxinidir}/build/coverage/index.html + +[gh-actions] +python = + 3.7: py37 + 3.8: py38 + 3.9: py39