diff --git a/.bandit b/.bandit index fba0d25d..b7df1762 100644 --- a/.bandit +++ b/.bandit @@ -1,2 +1,2 @@ [bandit] -exclude: tests +exclude: ./tests diff --git a/.editorconfig b/.editorconfig index f69b8ec1..d7e0de91 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,7 +10,7 @@ insert_final_newline = true charset = utf-8 end_of_line = lf -[*.{json,yml,yaml,js,jsx}] +[*.{json,yml,yaml,js,jsx,toml}] indent_size = 2 [LICENSE] diff --git a/.fussyfox.yml b/.fussyfox.yml deleted file mode 100644 index 600df195..00000000 --- a/.fussyfox.yml +++ /dev/null @@ -1,4 +0,0 @@ -- bandit -- flake8 -- isort -- pydocstyle diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index ae3c5338..da739aec 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,3 @@ github: codingjoe +tidelift: pypi/django-select2 custom: https://paypal.me/codingjoe diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..66e8eda0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,52 @@ +name: šŸ› Bug +description: Report a technical issue. +title: 'šŸ› ' +labels: + - bug +assignees: + - codingjoe +body: + + - type: markdown + attributes: + value: | + Thank you for taking the time to report a bug. + Please fill in as much of the template below as you're able. + + - type: markdown + attributes: + value: | + ## Security issues + Please do not report security issues here. + Instead, disclose them as described in our [security policy](https://github.com/codingjoe/django-select2/security). + + - type: textarea + id: bug-description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: I found a bug + validations: + required: true + + - type: textarea + id: bug-steps + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: true + + - type: textarea + id: bug-expected + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen. + placeholder: I expected the app to do X + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 61090361..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: codingjoe - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**Exception & Traceback** -Should you have run into an exception, please provide us with the exception as well as with the full traceback. - -**Code Snippet** -Please provide us with a code example on how to reproduce the error. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..68d01326 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: ✨ Feature Requests + url: https://github.com/codingjoe/django-select2/discussions/categories/ideas + about: Please use the GitHub Discussions to request new features. + - name: šŸ™‹ Questions & Help + url: https://github.com/codingjoe/django-select2/discussions/categories/q-a + about: Please use the GitHub Discussions to ask questions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index bbcbbe7d..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/problem-with-django-admin.md b/.github/ISSUE_TEMPLATE/problem-with-django-admin.md deleted file mode 100644 index ce54f92c..00000000 --- a/.github/ISSUE_TEMPLATE/problem-with-django-admin.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: Problem with Django admin -about: You are facing a problem integrating django-select2 into Django's admin interface -title: '' -labels: wontfix -assignees: '' - ---- - -Django-Select2 does NOT support Django admin, since Django admin has a built-in feature called `autocomplete_fields`. Autocomplete fields are superior and we recommend using them, instead of this package for the admin. - -You can find more information here: -https://docs.djangoproject.com/en/stable/ref/contrib/admin/#django.contrib.admin.ModelAdmin.autocomplete_fields diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md deleted file mode 100644 index ae936a5e..00000000 --- a/.github/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Question -about: You have a question about Django-Select2 -title: '' -labels: question -assignees: '' - ---- - -**Goal** -Please describe your goal in all detail. What are you trying to do - -**Problem** -Please describe your problem in all detail. Where are you struggling? - -**Code Snippet** -Please provide a code snippet of your problem. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..3f541100 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: daily +- package-ecosystem: npm + directory: "/" + schedule: + interval: daily +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf58dc22..7438534a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,81 +2,110 @@ name: CI on: push: branches: - - master + - main pull_request: jobs: - black: + lint: runs-on: ubuntu-latest + strategy: + matrix: + lint-command: + - bandit -r . -x ./tests + - black --check --diff . + - flake8 . + - isort --check-only --diff . + - pydocstyle . steps: - - uses: actions/setup-python@v2 - - uses: actions/checkout@v2 - - run: python -m pip install black - - run: black --check --diff . + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + cache: 'pip' + cache-dependency-path: 'linter-requirements.txt' + - run: python -m pip install -r linter-requirements.txt + - run: ${{ matrix.lint-command }} dist: runs-on: ubuntu-latest steps: - - uses: actions/setup-python@v2 - - run: python -m pip install --upgrade pip setuptools wheel twine readme-renderer - - uses: actions/checkout@v2 - - run: python setup.py sdist bdist_wheel + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: python -m pip install --upgrade pip build wheel twine readme-renderer + - run: python -m build --sdist --wheel - run: python -m twine check dist/* standardjs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: - node-version: '12.x' + node-version-file: '.nvmrc' - run: npm install -g standard - run: standard docs: runs-on: ubuntu-latest steps: - - uses: actions/setup-python@v2 - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.10" - run: sudo apt-get install -y gettext graphviz - - run: python setup.py develop - - run: python setup.py build_sphinx -W -b doctest -b html + - run: python -m pip install -e .[docs] + - run: python -m sphinx -W -b doctest -b html docs docs/_build - pytest: + PyTest: needs: + - lint - standardjs - - black strategy: matrix: python-version: - - "3.6" - - "3.7" - - "3.8" + - "3.10" + - "3.11" + - "3.12" + - "3.13" django-version: - - "2.2" - - "3.0" + - "4.2" + - "5.1" runs-on: ubuntu-latest steps: - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - run: python -m pip install Django~="${{ matrix.django-version }}.0" + - run: python -m pip install -e .[test] + - run: python -m pytest -m "not selenium" + - uses: codecov/codecov-action@v5 + + Selenium: + needs: + - lint + - standardjs + strategy: + matrix: + python-version: + - "3.x" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - name: Install Chrome run: sudo apt-get install -y google-chrome-stable - name: Install Selenium run: | mkdir bin - curl -O https://chromedriver.storage.googleapis.com/77.0.3865.40/chromedriver_linux64.zip + curl -O https://chromedriver.storage.googleapis.com/`curl -s https://chromedriver.storage.googleapis.com/LATEST_RELEASE`/chromedriver_linux64.zip unzip chromedriver_linux64.zip -d bin - - uses: actions/checkout@v1 - - name: Install dependencies - run: | - python -m pip install --upgrade pip setuptools wheel codecov - pip install -e .[test] - pip install django~=${{ matrix.django-version }} - - name: Run tests - run: PATH=$PATH:$(pwd)/bin py.test - - run: codecov - env: - CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install Django + - run: python -m pip install -e .[test,selenium] + - run: python -m pytest -m selenium + - uses: codecov/codecov-action@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3a25ac0e..29ff0b81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,14 +8,14 @@ jobs: PyPI: runs-on: ubuntu-latest steps: - - uses: actions/setup-python@v2 - - uses: actions/checkout@v2 - - name: Install Python dependencies - run: python -m pip install --upgrade pip setuptools wheel twine + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: python -m pip install --upgrade pip build wheel twine - name: Build dist packages - run: python setup.py sdist bdist_wheel - - name: Upload packages - run: python -m twine upload dist/* + run: python -m build --sdist --wheel + - run: python -m twine upload dist/* env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} @@ -23,8 +23,13 @@ jobs: npm: runs-on: ubuntu-latest steps: - - uses: actions/setup-node@v1 - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - run: python -m pip install --upgrade setuptools_scm + - run: python set_version.py - name: Upload packages run: npm publish env: diff --git a/.gitignore b/.gitignore index 875d21f9..8c43d48c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ *.pyc -Django_Select2.egg-info -Django_Select2_Py3.egg-info +*.egg-info dist build @@ -24,3 +23,5 @@ ghostdriver.log coverage.xml .eggs/ db.sqlite3 + +_version.py diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..b009dfb9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/* diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..9b618d15 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,23 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 + +build: + os: ubuntu-20.04 + apt_packages: + - graphviz + tools: + python: "3.10" + +sphinx: + configuration: docs/conf.py + + +python: + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index d3ccb2e0..f4367f9c 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,17 +1,27 @@ Contributing ============ -This package uses the pyTest test runner. To run the tests locally simply run:: +Before you start editing the python code, you will need to make sure +you have binary dependencies installed:: - python setup.py test + # Debian + sudo apt install -y gettext graphviz google-chrome-stable + # macOS + brew install -y gettext graphviz google-chrome-stable -If you need to the development dependencies installed of you local IDE, you can run:: +To install the package and its dependencies for development +including tests dependencies, please do: - python setup.py develop + python -m pip install -e .[test] + +You may ran the tests via:: + + python -m pytest Documentation pull requests welcome. The Sphinx documentation can be compiled via:: - python setup.py build_sphinx + python -m pip install -e .[docs] + python -m sphinx -W -b doctest -b html docs docs/_build Bug reports welcome, even more so if they include a correct patch. Much more so if you start your patch by adding a failing unit test, and correct diff --git a/LICENSE b/LICENSE index d5d8ec80..621b9067 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 Johannes Hoppe +Copyright (c) 2022 Johannes Maron and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index 33ef41f2..45907113 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,5 @@ include django_select2/static/django_select2/django_select2.js -prune tests +include django_select2/static/django_select2/django_select2.css prune .github -exclude .fussyfox.yml -exclude .travis.yml -exclude .gitignore +exclude .* + diff --git a/README.rst b/README.rst index 11f5f469..93d87344 100644 --- a/README.rst +++ b/README.rst @@ -1,12 +1,12 @@ +|header| + ============== Django-Select2 ============== |version| |coverage| |license| -This is a `Django`_ integration of `Select2`_. - -The app includes Select2 driven Django Widgets. +Custom autocomplete fields for `Django`_. Documentation ------------- @@ -22,6 +22,7 @@ Documentation available at https://django-select2.readthedocs.io/. .. _Select2: https://select2.org/ .. _autocomplete_fields: https://docs.djangoproject.com/en/stable/ref/contrib/admin/#django.contrib.admin.ModelAdmin.autocomplete_fields +.. |header| image:: https://repository-images.githubusercontent.com/266545281/c6db7d26-9f60-454b-845e-395d45c43fa7 .. |version| image:: https://img.shields.io/pypi/v/Django-Select2.svg :target: https://pypi.python.org/pypi/Django-Select2/ .. |coverage| image:: https://codecov.io/gh/codingjoe/django-select2/branch/master/graph/badge.svg diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..b0208c5f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security + +## Security contact information + +To report a security vulnerability, please use the +[Tidelift security contact](https://tidelift.com/security). +Tidelift will coordinate the fix and disclosure. diff --git a/django_select2/__init__.py b/django_select2/__init__.py index 546ae234..4966ddc6 100644 --- a/django_select2/__init__.py +++ b/django_select2/__init__.py @@ -7,4 +7,13 @@ .. _Select2: https://select2.org/ """ -default_app_config = "django_select2.apps.Select2AppConfig" + +from django import get_version + +from . import _version + +__version__ = _version.version +VERSION = _version.version_tuple + +if get_version() < "3.2": + default_app_config = "django_select2.apps.Select2AppConfig" diff --git a/django_select2/apps.py b/django_select2/apps.py index 921269b6..cc7d0b26 100644 --- a/django_select2/apps.py +++ b/django_select2/apps.py @@ -1,4 +1,5 @@ """Django application configuration.""" + from django.apps import AppConfig @@ -7,3 +8,6 @@ class Select2AppConfig(AppConfig): name = "django_select2" verbose_name = "Select2" + + def ready(self): + from . import conf # noqa diff --git a/django_select2/cache.py b/django_select2/cache.py index ce8953d0..6dbac226 100644 --- a/django_select2/cache.py +++ b/django_select2/cache.py @@ -11,6 +11,7 @@ .. _django.core.cache: https://docs.djangoproject.com/en/dev/topics/cache/ """ + from django.core.cache import caches from .conf import settings diff --git a/django_select2/conf.py b/django_select2/conf.py index 3896fb62..e66b55d9 100644 --- a/django_select2/conf.py +++ b/django_select2/conf.py @@ -1,16 +1,16 @@ """Settings for Django-Select2.""" + from appconf import AppConf from django.conf import settings # NOQA __all__ = ("settings", "Select2Conf") +from django.contrib.admin.widgets import SELECT2_TRANSLATIONS + class Select2Conf(AppConf): """Settings for Django-Select2.""" - LIB_VERSION = "4.0.12" - """Version of the Select2 library.""" - CACHE_BACKEND = "default" """ Django-Select2 uses Django's cache to sure a consistent state across multiple machines. @@ -56,51 +56,61 @@ class Select2Conf(AppConf): It has set `select2_` as a default value, which you can change if needed. """ - JS = "https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bversion%7D/js/select2.min.js".format( - version=LIB_VERSION - ) + JS = ["admin/js/vendor/select2/select2.full.min.js"] """ - The URI for the Select2 JS file. By default this points to the Cloudflare CDN. + The URI for the Select2 JS file. By default this points to version shipped with Django. If you want to select the version of the JS library used, or want to serve it from the local 'static' resources, add a line to your settings.py like so:: - SELECT2_JS = 'assets/js/select2.min.js' + SELECT2_JS = ['assets/js/select2.min.js'] If you provide your own JS and would not like Django-Select2 to load any, change this setting to a blank string like so:: - SELECT2_JS = '' + SELECT2_JS = [] .. tip:: Change this setting to a local asset in your development environment to develop without an Internet connection. """ - CSS = "https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bversion%7D/css/select2.min.css".format( - version=LIB_VERSION - ) + CSS = ["admin/css/vendor/select2/select2.min.css"] """ - The URI for the Select2 CSS file. By default this points to the Cloudflare CDN. + The URI for the Select2 CSS file. By default this points to version shipped with Django. If you want to select the version of the library used, or want to serve it from the local 'static' resources, add a line to your settings.py like so:: - SELECT2_CSS = 'assets/css/select2.css' + SELECT2_CSS = ['assets/css/select2.css'] + + If you want to add more css (usually used in select2 themes), add a line + in settings.py like this:: + + SELECT2_CSS = [ + 'assets/css/select2.css', + 'assets/css/select2-theme.css', + ] If you provide your own CSS and would not like Django-Select2 to load any, change this setting to a blank string like so:: - SELECT2_CSS = '' + SELECT2_CSS = [] .. tip:: Change this setting to a local asset in your development environment to develop without an Internet connection. """ - I18N_PATH = "https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bversion%7D/js/i18n".format( - version=LIB_VERSION - ) + THEME = "default" + """ + Select2 supports custom themes using the theme option so you can style Select2 + to match the rest of your application. + + .. tip:: When using other themes, you may need use select2 css and theme css. """ - The base URI for the Select2 i18n files. By default this points to the Cloudflare CDN. + + I18N_PATH = "admin/js/vendor/select2/i18n" + """ + The base URI for the Select2 i18n files. By default this points to version shipped with Django. If you want to select the version of the I18N library used, or want to serve it from the local 'static' resources, add a line to your settings.py like so:: @@ -111,55 +121,7 @@ class Select2Conf(AppConf): develop without an Internet connection. """ - I18N_AVAILABLE_LANGUAGES = [ - "ar", - "az", - "bg", - "ca", - "cs", - "da", - "de", - "el", - "en", - "es", - "et", - "eu", - "fa", - "fi", - "fr", - "gl", - "he", - "hi", - "hr", - "hu", - "id", - "is", - "it", - "ja", - "km", - "ko", - "lt", - "lv", - "mk", - "ms", - "nb", - "nl", - "pl", - "pt-BR", - "pt", - "ro", - "ru", - "sk", - "sr-Cyrl", - "sr", - "sv", - "th", - "tr", - "uk", - "vi", - "zh-CN", - "zh-TW", - ] + I18N_AVAILABLE_LANGUAGES = list(SELECT2_TRANSLATIONS.values()) """ List of available translations. @@ -180,6 +142,14 @@ class Select2Conf(AppConf): ``settings.DJANGO_SELECT2_I18N`` refers to :attr:`.I18N_PATH`. """ + JSON_ENCODER = "django.core.serializers.json.DjangoJSONEncoder" + """ + A :class:`JSONEncoder` used to generate the API response for the model widgets. + + A custom JSON encoder might be useful when your models uses + a special primary key, that isn't serializable by the default encoder. + """ + class Meta: """Prefix for all Django-Select2 settings.""" diff --git a/django_select2/forms.py b/django_select2/forms.py index 1f5554b9..f94a8c1e 100644 --- a/django_select2/forms.py +++ b/django_select2/forms.py @@ -7,36 +7,43 @@ library, hence these components are meant to be used with choice fields. -Widgets are generally of two types: - - 1. **Light** -- - They are not meant to be used when there - are too many options, say, in thousands. - This is because all those options would - have to be pre-rendered onto the page - and JavaScript would be used to search - through them. Said that, they are also one - the most easiest to use. They are a - drop-in-replacement for Django's default - select widgets. - - 2(a). **Heavy** -- - They are suited for scenarios when the number of options - are large and need complex queries (from maybe different - sources) to get the options. - - This dynamic fetching of options undoubtedly requires - Ajax communication with the server. Django-Select2 includes - a helper JS file which is included automatically, - so you need not worry about writing any Ajax related JS code. - Although on the server side you do need to create a view - specifically to respond to the queries. - - 2(b). **Model** -- - Model-widgets are a further specialized versions of Heavies. - These do not require views to serve Ajax requests. - When they are instantiated, they register themselves - with one central view which handles Ajax requests for them. +Widgets are generally of tree types: +Light, Heavy and Model. + +Light +~~~~~ + +They are not meant to be used when there +are too many options, say, in thousands. +This is because all those options would +have to be pre-rendered onto the page +and JavaScript would be used to search +through them. Said that, they are also one +the easiest to use. They are a +drop-in-replacement for Django's default +select widgets. + +Heavy +~~~~~ + +They are suited for scenarios when the number of options +are large and need complex queries (from maybe different +sources) to get the options. + +This dynamic fetching of options undoubtedly requires +Ajax communication with the server. Django-Select2 includes +a helper JS file which is included automatically, +so you need not worry about writing any Ajax related JS code. +Although on the server side you do need to create a view +specifically to respond to the queries. + +Model +~~~~~ + +Model-widgets are a further specialized versions of Heavies. +These do not require views to serve Ajax requests. +When they are instantiated, they register themselves +with one central view which handles Ajax requests for them. Heavy and Model widgets have respectively the word 'Heavy' and 'Model' in their name. Light widgets are normally named, i.e. there is no 'Light' word @@ -46,18 +53,20 @@ :parts: 1 """ + +import operator import uuid from functools import reduce from itertools import chain from pickle import PicklingError # nosec from django import forms -from django.contrib.admin.widgets import SELECT2_TRANSLATIONS +from django.contrib.admin.utils import lookup_spawns_duplicates +from django.contrib.admin.widgets import AutocompleteMixin from django.core import signing from django.db.models import Q from django.forms.models import ModelChoiceIterator from django.urls import reverse -from django.utils.translation import get_language from .cache import cache from .conf import settings @@ -72,11 +81,25 @@ class Select2Mixin: form media. """ + css_class_name = "django-select2" + theme = None + empty_label = "" + @property + def i18n_name(self): + """Name of the i18n file for the current language.""" + from django.contrib.admin.widgets import get_select2_language + + return get_select2_language() + def build_attrs(self, base_attrs, extra_attrs=None): """Add select2 data attributes.""" - default_attrs = {"data-minimum-input-length": 0} + default_attrs = { + "lang": self.i18n_name, + "data-minimum-input-length": 0, + "data-theme": self.theme or settings.SELECT2_THEME, + } if self.is_required: default_attrs["data-allow-clear"] = "false" else: @@ -87,9 +110,9 @@ def build_attrs(self, base_attrs, extra_attrs=None): attrs = super().build_attrs(default_attrs, extra_attrs=extra_attrs) if "class" in attrs: - attrs["class"] += " django-select2" + attrs["class"] += " " + self.css_class_name else: - attrs["class"] = "django-select2" + attrs["class"] = self.css_class_name return attrs def optgroups(self, name, value, attrs=None): @@ -98,31 +121,49 @@ def optgroups(self, name, value, attrs=None): self.choices = list(chain([("", "")], self.choices)) return super().optgroups(name, value, attrs=attrs) - def _get_media(self): + @property + def media(self): """ Construct Media as a dynamic property. .. Note:: For more information visit https://docs.djangoproject.com/en/stable/topics/forms/media/#media-as-a-dynamic-property """ - lang = get_language() - select2_js = (settings.SELECT2_JS,) if settings.SELECT2_JS else () - select2_css = (settings.SELECT2_CSS,) if settings.SELECT2_CSS else () + select2_js = settings.SELECT2_JS if settings.SELECT2_JS else [] + select2_css = settings.SELECT2_CSS if settings.SELECT2_CSS else [] - i18n_name = SELECT2_TRANSLATIONS.get(lang) - if i18n_name not in settings.SELECT2_I18N_AVAILABLE_LANGUAGES: - i18n_name = None + if isinstance(select2_js, str): + select2_js = [select2_js] + if isinstance(select2_css, str): + select2_css = [select2_css] - i18n_file = ( - ("%s/%s.js" % (settings.SELECT2_I18N_PATH, i18n_name),) if i18n_name else () - ) + i18n_file = [] + if self.i18n_name in settings.SELECT2_I18N_AVAILABLE_LANGUAGES: + i18n_file = [f"{settings.SELECT2_I18N_PATH}/{self.i18n_name}.js"] return forms.Media( - js=select2_js + i18n_file + ("django_select2/django_select2.js",), - css={"screen": select2_css}, + js=select2_js + i18n_file + ["django_select2/django_select2.js"], + css={"screen": select2_css + ["django_select2/django_select2.css"]}, ) - media = property(_get_media) + +class Select2AdminMixin: + """Select2 mixin that uses Django's own select template.""" + + theme = "admin-autocomplete" + + @property + def media(self): + css = {**AutocompleteMixin(None, None).media._css} + css["screen"].append("django_select2/django_select2.css") + js = [*Select2Mixin().media._js] + js.insert( + js.index("django_select2/django_select2.js"), "admin/js/jquery.init.js" + ) + return forms.Media( + js=js, + css=css, + ) class Select2TagMixin: @@ -171,14 +212,14 @@ class Select2MultipleWidget(Select2Mixin, forms.SelectMultiple): class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple): """ - Select2 drop in widget for for tagging. + Select2 drop in widget with tagging support. It allows to dynamically create new options from text input by the user. Example for :class:`.django.contrib.postgres.fields.ArrayField`:: class MyWidget(Select2TagWidget): def value_from_datadict(self, data, files, name): - values = super().value_from_datadict(data, files, name): + values = super().value_from_datadict(data, files, name) return ",".join(values) def optgroups(self, name, value, attrs=None): @@ -194,6 +235,8 @@ class HeavySelect2Mixin: """Mixin that adds select2's AJAX options and registers itself on Django's cache.""" dependent_fields = {} + data_view = None + data_url = None def __init__(self, attrs=None, choices=(), **kwargs): """ @@ -210,22 +253,16 @@ def __init__(self, attrs=None, choices=(), **kwargs): Value is a name of a field in a model (used in `queryset`). """ - self.choices = choices - if attrs is not None: - self.attrs = attrs.copy() - else: - self.attrs = {} + super().__init__(attrs, choices) - self.uuid = str(uuid.uuid4()) - self.field_id = signing.dumps(self.uuid) - self.data_view = kwargs.pop("data_view", None) - self.data_url = kwargs.pop("data_url", None) + self.data_view = kwargs.pop("data_view", self.data_view) + self.data_url = kwargs.pop("data_url", self.data_url) dependent_fields = kwargs.pop("dependent_fields", None) if dependent_fields is not None: self.dependent_fields = dict(dependent_fields) if not (self.data_view or self.data_url): - raise ValueError('You must ether specify "data_view" or "data_url".') + raise ValueError('You must either specify "data_view" or "data_url".') self.userGetValTextFuncName = kwargs.pop("userGetValTextFuncName", "null") def get_url(http://github.com/self): @@ -236,6 +273,8 @@ def get_url(http://github.com/self): def build_attrs(self, base_attrs, extra_attrs=None): """Set select2's AJAX attributes.""" + self.uuid = str(uuid.uuid4()) + self.field_id = signing.dumps(self.uuid) default_attrs = { "data-ajax--url": self.get_url(), "data-ajax--cache": "true", @@ -264,7 +303,7 @@ def render(self, *args, **kwargs): return output def _get_cache_key(self): - return "%s%s" % (settings.SELECT2_CACHE_PREFIX, self.uuid) + return f"{settings.SELECT2_CACHE_PREFIX}{self.uuid}" def set_to_cache(self): """ @@ -397,18 +436,30 @@ def filter_queryset(self, request, term, queryset=None, **dependent_fields): queryset = self.get_queryset() search_fields = self.get_search_fields() select = Q() - term = term.replace("\t", " ") - term = term.replace("\n", " ") - for t in [t for t in term.split(" ") if not t == ""]: - select &= reduce( - lambda x, y: x | Q(**{y: t}), - search_fields[1:], - Q(**{search_fields[0]: t}), + + use_distinct = False + if search_fields and term: + for bit in term.split(): + or_queries = [Q(**{orm_lookup: bit}) for orm_lookup in search_fields] + select &= reduce(operator.or_, or_queries) + or_queries = [Q(**{orm_lookup: term}) for orm_lookup in search_fields] + select |= reduce(operator.or_, or_queries) + use_distinct |= any( + lookup_spawns_duplicates(queryset.model._meta, search_spec) + for search_spec in search_fields ) + if dependent_fields: select &= Q(**dependent_fields) - return queryset.filter(select).distinct() + use_distinct |= any( + lookup_spawns_duplicates(queryset.model._meta, search_spec) + for search_spec in dependent_fields.keys() + ) + + if use_distinct: + return queryset.filter(select).distinct() + return queryset.filter(select) def get_queryset(self): """ @@ -494,6 +545,28 @@ def label_from_instance(obj): """ return str(obj) + def result_from_instance(self, obj, request): + """ + Return a dictionary representing the object. + + Can be overridden to change the result returned by + :class:`.AutoResponseView` for each object. + + The request passed in will correspond to the request sent to the + :class:`.AutoResponseView` by the widget. + + Example usage:: + + class MyWidget(ModelSelect2Widget): + def result_from_instance(obj, request): + return { + 'id': obj.pk, + 'text': self.label_from_instance(obj), + 'extra_data': obj.extra_data, + } + """ + return {"id": obj.pk, "text": self.label_from_instance(obj)} + class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget): """ @@ -559,7 +632,7 @@ def value_from_datadict(self, data, files, name): # You need to implement this method yourself, to ensure proper object creation. pks = self.queryset.filter(**{'pk__in': list(values)}).values_list('pk', flat=True) pks = set(map(str, pks)) - cleaned_values = list(values) + cleaned_values = list(pks) for val in values - pks: cleaned_values.append(self.queryset.create(title=val).pk) return cleaned_values diff --git a/django_select2/static/django_select2/django_select2.css b/django_select2/static/django_select2/django_select2.css new file mode 100644 index 00000000..09a93207 --- /dev/null +++ b/django_select2/static/django_select2/django_select2.css @@ -0,0 +1,3 @@ +.change-form select.django-select2 { + width: 20em; +} diff --git a/django_select2/static/django_select2/django_select2.js b/django_select2/static/django_select2/django_select2.js index 200534ba..f299e367 100644 --- a/django_select2/static/django_select2/django_select2.js +++ b/django_select2/static/django_select2/django_select2.js @@ -6,29 +6,36 @@ module.exports = factory(require('jquery')) } else { // Browser globals - factory(jQuery) + factory(jQuery || window.django.jQuery) } }(function ($) { 'use strict' - var init = function ($element, options) { + const init = function ($element, options) { $element.select2(options) } - var initHeavy = function ($element, options) { - var settings = $.extend({ + const initHeavy = function ($element, options) { + const settings = $.extend({ ajax: { data: function (params) { - var result = { + const result = { term: params.term, page: params.page, field_id: $element.data('field_id') } - var dependentFields = $element.data('select2-dependent-fields') + let dependentFields = $element.data('select2-dependent-fields') if (dependentFields) { + const findElement = function (selector) { + const result = $(selector, $element.closest(`:has(${selector})`)) + if (result.length > 0) return result + else return null + } dependentFields = dependentFields.trim().split(/\s+/) $.each(dependentFields, function (i, dependentField) { - result[dependentField] = $('[name=' + dependentField + ']', $element.closest('form')).val() + const nameIs = `[name=${dependentField}]` + const nameEndsWith = `[name$=-${dependentField}]` + result[dependentField] = (findElement(nameIs) || findElement(nameEndsWith)).val() }) } @@ -49,17 +56,17 @@ } $.fn.djangoSelect2 = function (options) { - var settings = $.extend({}, options) + const settings = $.extend({}, options) $.each(this, function (i, element) { - var $element = $(element) + const $element = $(element) if ($element.hasClass('django-select2-heavy')) { initHeavy($element, settings) } else { init($element, settings) } $element.on('select2:select', function (e) { - var name = $(e.currentTarget).attr('name') - $('[data-select2-dependent-fields=' + name + ']').each(function () { + const name = $(e.currentTarget).attr('name') + $('[data-select2-dependent-fields~=' + name + ']').each(function () { $(this).val('').trigger('change') }) }) @@ -68,7 +75,11 @@ } $(function () { - $('.django-select2').djangoSelect2() + $('.django-select2').not('[name*=__prefix__]').djangoSelect2() + + document.addEventListener('formset:added', (event) => { + $(event.target).find('.django-select2').djangoSelect2() + }) }) return $.fn.djangoSelect2 diff --git a/django_select2/urls.py b/django_select2/urls.py index 69c0f148..9f40776d 100644 --- a/django_select2/urls.py +++ b/django_select2/urls.py @@ -9,6 +9,7 @@ path('select2/', include('django_select2.urls')), """ + from django.urls import path from .views import AutoResponseView diff --git a/django_select2/views.py b/django_select2/views.py index ec541d11..f78f19ea 100644 --- a/django_select2/views.py +++ b/django_select2/views.py @@ -1,7 +1,9 @@ """JSONResponse views for model widgets.""" + from django.core import signing from django.core.signing import BadSignature from django.http import Http404, JsonResponse +from django.utils.module_loading import import_string from django.views.generic.list import BaseListView from .cache import cache @@ -19,6 +21,9 @@ def get(self, request, *args, **kwargs): """ Return a :class:`.django.http.JsonResponse`. + Each result will be rendered by the widget's + :func:`django_select2.forms.ModelSelect2Mixin.result_from_instance` method. + Example:: { @@ -39,11 +44,12 @@ def get(self, request, *args, **kwargs): return JsonResponse( { "results": [ - {"text": self.widget.label_from_instance(obj), "id": obj.pk} + self.widget.result_from_instance(obj, request) for obj in context["object_list"] ], "more": context["page_obj"].has_next(), - } + }, + encoder=import_string(settings.SELECT2_JSON_ENCODER), ) def get_queryset(self): @@ -51,11 +57,20 @@ def get_queryset(self): kwargs = { model_field_name: self.request.GET.get(form_field_name) for form_field_name, model_field_name in self.widget.dependent_fields.items() - if form_field_name in self.request.GET - and self.request.GET.get(form_field_name, "") != "" } + kwargs.update( + { + f"{model_field_name}__in": self.request.GET.getlist( + f"{form_field_name}[]", [] + ) + for form_field_name, model_field_name in self.widget.dependent_fields.items() + } + ) return self.widget.filter_queryset( - self.request, self.term, self.queryset, **kwargs + self.request, + self.term, + self.queryset, + **{k: v for k, v in kwargs.items() if v}, ) def get_paginate_by(self, queryset): @@ -81,7 +96,7 @@ def get_widget_or_404(self): except BadSignature: raise Http404('Invalid "field_id".') else: - cache_key = "%s%s" % (settings.SELECT2_CACHE_PREFIX, key) + cache_key = f"{settings.SELECT2_CACHE_PREFIX}{key}" widget_dict = cache.get(cache_key) if widget_dict is None: raise Http404("field_id not found") diff --git a/docs/conf.py b/docs/conf.py index b339972f..845ceecd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,6 +31,7 @@ extensions = [ "sphinx.ext.autodoc", + "sphinx.ext.autosectionlabel", "sphinx.ext.napoleon", "sphinx.ext.inheritance_diagram", "sphinx.ext.intersphinx", diff --git a/docs/django_select2.rst b/docs/django_select2.rst index b726099a..512739b1 100644 --- a/docs/django_select2.rst +++ b/docs/django_select2.rst @@ -55,13 +55,47 @@ plugin. It will handle both normal and heavy fields. Simply call $('.django-select2').djangoSelect2(); +Please replace all your ``.select2`` invocations with the here provided +``.djangoSelect2``. -You can pass see `Select2 options `_ if needed:: - $('.django-select2').djangoSelect2({placeholder: 'Select an option'}); +Configuring Select2 +------------------- + +Select2 options can be configured either directly from Javascript or from within Django +using widget attributes. `(List of options in the Select2 docs) `_. + +To pass options in javascript + +.. code-block:: javascript + + $('.django-select2').djangoSelect2({ + minimumInputLength: 0, + placeholder: 'Select an option', + }); + +From Django, you can use ``data-`` attributes using the same names in camel-case and +passing them to your widget. Select2 will then pick these up. For example when +initialising a widget in a form, you could do: + +.. code-block:: python + + class MyForm(forms.Form): + my_field = forms.ModelMultipleChoiceField( + widget=ModelSelect2MultipleWidget( + model=MyModel + search_fields=['another_field'] + attrs={ + "data-minimum-input-length": 0, + "data-placeholder": "Select an option", + "data-close-on-select": "false", + } + ) + ) + +(If you do not want to initialize the widget, you could add the attributes by overriding +a widget method and adding them in a super call, e.g. `get_context() `_) -Please replace all your ``.select2`` invocations with the here provided -``.djangoSelect2``. Security & Authentication ------------------------- diff --git a/docs/extra.rst b/docs/extra.rst index 664fe202..4de25275 100644 --- a/docs/extra.rst +++ b/docs/extra.rst @@ -8,6 +8,9 @@ Suppose you have an address form where a user should choose a Country and a City When the user selects a country we want to show only cities belonging to that country. So the one selector depends on another one. +.. note:: + Does not work with the 'light' version (django_select2.forms.Select2Widget). + Models `````` @@ -27,15 +30,16 @@ Here are our two models: Customizing a Form `````````````````` -Lets link two widgets via *dependent_fields*. +Lets link two widgets via a *dependent_fields* dictionary. The key represents the name of +the field in the form. The value represents the name of the field in the model (used in `queryset`). .. code-block:: python - :emphasize-lines: 15 + :emphasize-lines: 17 class AddressForm(forms.Form): country = forms.ModelChoiceField( queryset=Country.objects.all(), - label=u"Country", + label="Country", widget=ModelSelect2Widget( model=Country, search_fields=['name__icontains'], @@ -44,7 +48,7 @@ Lets link two widgets via *dependent_fields*. city = forms.ModelChoiceField( queryset=City.objects.all(), - label=u"City", + label="City", widget=ModelSelect2Widget( model=City, search_fields=['name__icontains'], @@ -68,7 +72,7 @@ Customize the form in a manner: class AddressForm(forms.Form): country = forms.ModelChoiceField( queryset=Country.objects.all(), - label=u"Country", + label="Country", widget=ModelSelect2Widget( search_fields=['name__icontains'], dependent_fields={'city': 'cities'}, @@ -77,7 +81,7 @@ Customize the form in a manner: city = forms.ModelChoiceField( queryset=City.objects.all(), - label=u"City", + label="City", widget=ModelSelect2Widget( search_fields=['name__icontains'], dependent_fields={'country': 'country'}, diff --git a/docs/index.rst b/docs/index.rst index c27598a6..0b2772ea 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,13 +3,21 @@ Installation ------------ -Install ``django-select2`` - -.. code-block:: python +Install ``django-select2``:: python3 -m pip install django-select2 Add ``django_select2`` to your ``INSTALLED_APPS`` in your project settings. +Since version 8, please ensure that Django's admin app is enabled too: + +.. code-block:: python + + INSTALLED_APPS = [ + # other django apps… + 'django.contrib.admin', + # other 3rd party apps… + 'django_select2', + ] Add ``django_select`` to your URL root configuration: @@ -18,14 +26,27 @@ Add ``django_select`` to your URL root configuration: from django.urls import include, path urlpatterns = [ - # … other patterns + # other patterns… path("select2/", include("django_select2.urls")), - # … other patterns + # other patterns… ] -Finally make sure you have a persistent cache backend setup (NOT -:class:`.DummyCache` or :class:`.LocMemCache`), we will use Redis in this -example. Make sure you have a Redis server up and running:: + +The :ref:`Model` -widgets require a **persistent** cache backend across +all application servers. This is because the widget needs to store +meta data to be able to fetch the results based on the user input. + +**This means that the** :class:`.DummyCache` **backend will not work!** + +The default cache backend is :class:`.LocMemCache`, which is persistent +across a single node. For projects with a single application server +this will work fine, however you will run into issues when +you scale up into multiple servers. + +Below is an example setup using Redis, which is a solution that +works for multi-server setups: + +Make sure you have a Redis server up and running:: # Debian sudo apt-get install redis-server @@ -54,6 +75,12 @@ Next, add the cache configuration to your ``settings.py`` as follows: # Tell select2 which cache configuration to use: SELECT2_CACHE_BACKEND = "select2" +.. note:: + A custom timeout for your cache backend, will serve as an indirect session limit. + Auto select fields will stop working after, once the cache has expired. + It's recommended to use a dedicated cache database with an adequate + cache replacement policy such as LRU, FILO, etc. + External Dependencies --------------------- diff --git a/example/example/migrations/0001_initial.py b/example/example/migrations/0001_initial.py index db45b1bb..a578fdce 100644 --- a/example/example/migrations/0001_initial.py +++ b/example/example/migrations/0001_initial.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ diff --git a/example/example/settings.py b/example/example/settings.py index 83067236..7fed8450 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -20,7 +20,7 @@ # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "kstexlapcf3lucx@47mmxsu9-9eixia+6n97aw)4$qo&!laxad" +SECRET_KEY = "kstexlapcf3lucx@47mmxsu9-9eixia+6n97aw)4$qo&!laxad" # nosec # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -105,8 +105,6 @@ USE_I18N = True -USE_L10N = True - USE_TZ = True @@ -117,14 +115,12 @@ CACHES = { "default": { - "BACKEND": "django_redis.cache.RedisCache", + "BACKEND": "django.core.cache.backends.redis.RedisCache", "LOCATION": "redis://127.0.0.1:6379/1", - "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, }, "select2": { - "BACKEND": "django_redis.cache.RedisCache", + "BACKEND": "django.core.cache.backends.redis.RedisCache", "LOCATION": "redis://127.0.0.1:6379/2", - "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, }, } diff --git a/example/example/templates/example/book_form.html b/example/example/templates/example/book_form.html index c2ff60bf..daea5304 100644 --- a/example/example/templates/example/book_form.html +++ b/example/example/templates/example/book_form.html @@ -1,4 +1,4 @@ - +{% load static %} Create Book @@ -14,7 +14,7 @@

Create a new Book

{{ form.as_p }} - + {{ form.media.js }} diff --git a/example/requirements.txt b/example/requirements.txt index 53500ba2..b93550d1 100644 --- a/example/requirements.txt +++ b/example/requirements.txt @@ -1,2 +1,2 @@ -e .. -django-redis +redis diff --git a/linter-requirements.txt b/linter-requirements.txt new file mode 100644 index 00000000..f879331e --- /dev/null +++ b/linter-requirements.txt @@ -0,0 +1,5 @@ +bandit==1.8.3 +black==25.1.0 +flake8==7.2.0 +isort==6.0.1 +pydocstyle[toml]==6.3.0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..2f0ff4da --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,90 @@ +[build-system] +requires = ["flit_core>=3.2", "flit_scm", "wheel"] +build-backend = "flit_scm:buildapi" + +[project] +name = "django-select2" +authors = [ + { name = "Johannes Maron", email = "johannes@maron.family" }, +] +readme = "README.rst" +license = { file = "LICENSE" } +keywords = ["Django", "select2", "autocomplete", "typeahead"] +dynamic = ["version", "description"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Environment :: Web Environment", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Framework :: Django", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.1", + "Topic :: Software Development", +] +requires-python = ">=3.10" +dependencies = [ + "django>=4.2", + "django-appconf>=0.6.0" +] + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-cov", + "pytest-django", + "selenium", +] +selenium = [ + "selenium", +] +docs = [ + "sphinx", +] + +[project.urls] +Project-URL = "https://github.com/codingjoe/django-select2" +Changelog = "https://github.com/codingjoe/django-select2/releases" +Source = "https://github.com/codingjoe/django-select2" +Documentation = "https://django-select2.rtfd.io/" +Issue-Tracker = "https://github.com/codingjoe/django-select2/issues" + +[tool.flit.module] +name = "django_select2" + +[tool.setuptools_scm] +write_to = "django_select2/_version.py" + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "--cov --tb=short -rxs" +testpaths = ["tests"] +DJANGO_SETTINGS_MODULE = "tests.testapp.settings" +filterwarnings = ["ignore::PendingDeprecationWarning", "error::RuntimeWarning"] + +[tool.coverage.run] +source = ["django_select2"] + +[tool.coverage.report] +show_missing = true + +[tool.isort] +atomic = true +line_length = 88 +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +known_first_party = "django_select2, tests" +default_section = "THIRDPARTY" +combine_as_imports = true + +[tool.pydocstyle] +add_ignore = "D1" diff --git a/set_version.py b/set_version.py index 50ffa7bf..fa18ce33 100755 --- a/set_version.py +++ b/set_version.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 """Set the version in NPM's package.json to match the git tag.""" import json -import os + +from setuptools_scm import get_version if __name__ == "__main__": with open("package.json", "r+") as f: data = json.load(f) f.seek(0) - data["version"] = os.environ["GITHUB_REF"].rsplit("/")[-1] + data["version"] = get_version(root=".", relative_to=__file__) json.dump(data, f) f.truncate() diff --git a/setup.cfg b/setup.cfg index 98161532..6f60592c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,90 +1,4 @@ -[metadata] -name = django-select2 -author = Johannes Hoppe -author_email = info@johanneshoppe.com -description = Select2 option fields for Django -long_description = file: README.rst -url = https://github.com/codingjoe/django-select2 -license = MIT -license_file = LICENSE -classifier = - Development Status :: 5 - Production/Stable - Environment :: Web Environment - Intended Audience :: Developers - License :: OSI Approved :: Apache Software License - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Framework :: Django - Framework :: Django :: 2.2 - Framework :: Django :: 3.0 - -[options] -include_package_data = True -packages = django_select2 -install_requires = - django>=2.2 - django-appconf>=0.6.0 -setup_requires = - setuptools_scm - sphinx - pytest-runner -tests_require = - pytest - pytest-cov - pytest-django - selenium - -[options.extras_require] -test = - pytest - pytest-cov - pytest-django - selenium - -[bdist_wheel] -universal = 1 - -[bdist_rpm] -requires = - python-django-appconf >= 2.0 - python-django-appconf >= 0.6 - -[aliases] -test = pytest - -[build_sphinx] -source-dir = docs -build-dir = docs/_build - -[tool:pytest] -addopts = - tests - --doctest-glob='*.rst' - --doctest-modules - --cov=django_select2 -DJANGO_SETTINGS_MODULE=tests.testapp.settings - [flake8] max-line-length=88 select = C,E,F,W,B,B950 -ignore = E203, E501, W503 -exclude = venv,.tox,.eggs - -[pydocstyle] -add-ignore = D1 - -[isort] -atomic = true -line_length = 88 -multi_line_output = 3 -include_trailing_comma = True -force_grid_wrap = 0 -use_parentheses = True -known_first_party = django_select2, tests -default_section = THIRDPARTY -combine_as_imports = true +ignore = E203, E501, W503, E731 diff --git a/setup.py b/setup.py deleted file mode 100755 index f4bd8e57..00000000 --- a/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python - -from setuptools import setup - -setup(name="django-select2", use_scm_version=True) diff --git a/tests/conftest.py b/tests/conftest.py index e8d3455c..60bbe399 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,10 @@ from selenium.common.exceptions import WebDriverException +def pytest_configure(config): + config.addinivalue_line("markers", "selenium: skip if selenium is not installed") + + def random_string(n): return "".join( random.choice(string.ascii_uppercase + string.digits) for _ in range(n) @@ -21,7 +25,7 @@ def random_name(n): return "-".join([x.capitalize() for x in words]) -@pytest.yield_fixture(scope="session") +@pytest.fixture(scope="session") def driver(): chrome_options = webdriver.ChromeOptions() chrome_options.headless = True diff --git a/tests/test_forms.py b/tests/test_forms.py index 89ef8b69..a2b87522 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -13,12 +13,12 @@ from selenium.webdriver.support.wait import WebDriverWait from django_select2.cache import cache -from django_select2.conf import settings from django_select2.forms import ( HeavySelect2MultipleWidget, HeavySelect2Widget, ModelSelect2TagWidget, ModelSelect2Widget, + Select2AdminMixin, Select2Widget, ) from tests.testapp import forms @@ -46,6 +46,17 @@ def test_initial_form_class(self): assert "my-class" in widget.render("name", None) assert "django-select2" in widget.render("name", None) + def test_lang_attr(self): + with translation.override("de"): + widget = Select2Widget() + assert 'lang="de"' in widget.render("name", None) + + # Regression test for #163 + widget = Select2Widget() + assert widget.i18n_name == "en" + with translation.override("de"): + assert widget.i18n_name == "de" + def test_allow_clear(self, db): required_field = self.form.fields["artist"] assert required_field.required is True @@ -74,25 +85,27 @@ def test_allow_clear(self, db): "primary_genre", None ) + @pytest.mark.selenium def test_no_js_error(self, db, live_server, driver): driver.get(live_server + self.url) with pytest.raises(NoSuchElementException): - error = driver.find_element_by_xpath("//body[@JSError]") + error = driver.find_element(By.XPATH, "//body[@JSError]") pytest.fail(error.get_attribute("JSError")) + @pytest.mark.selenium def test_selecting(self, db, live_server, driver): driver.get(live_server + self.url) with pytest.raises(NoSuchElementException): - driver.find_element_by_css_selector(".select2-results") - elem = driver.find_element_by_css_selector(".select2-selection") + driver.find_element(By.CSS_SELECTOR, ".select2-results") + elem = driver.find_element(By.CSS_SELECTOR, ".select2-selection") elem.click() - results = driver.find_element_by_css_selector(".select2-results") + results = driver.find_element(By.CSS_SELECTOR, ".select2-results") assert results.is_displayed() is True - elem = results.find_element_by_css_selector(".select2-results__option") + elem = results.find_element(By.CSS_SELECTOR, ".select2-results__option") elem.click() with pytest.raises(NoSuchElementException): - error = driver.find_element_by_xpath("//body[@JSError]") + error = driver.find_element(By.XPATH, "//body[@JSError]") pytest.fail(error.get_attribute("JSError")) def test_data_url(http://github.com/self): @@ -121,28 +134,28 @@ def test_empty_option(self, db): def test_i18n(self): translation.activate("de") assert tuple(Select2Widget().media._js) == ( - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/js/select2.min.js", - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/js/i18n/de.js", + "admin/js/vendor/select2/select2.full.min.js", + "admin/js/vendor/select2/i18n/de.js", "django_select2/django_select2.js", ) translation.activate("en") assert tuple(Select2Widget().media._js) == ( - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/js/select2.min.js", - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/js/i18n/en.js", + "admin/js/vendor/select2/select2.full.min.js", + "admin/js/vendor/select2/i18n/en.js", "django_select2/django_select2.js", ) translation.activate("00") assert tuple(Select2Widget().media._js) == ( - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/js/select2.min.js", + "admin/js/vendor/select2/select2.full.min.js", "django_select2/django_select2.js", ) - translation.activate("sr-cyrl") + translation.activate("sr-Cyrl") assert tuple(Select2Widget().media._js) == ( - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/js/select2.min.js", - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/js/i18n/sr-Cyrl.js", + "admin/js/vendor/select2/select2.full.min.js", + "admin/js/vendor/select2/i18n/sr-Cyrl.js", "django_select2/django_select2.js", ) @@ -150,31 +163,49 @@ def test_i18n(self): translation.activate("zh-hans") assert tuple(Select2Widget().media._js) == ( - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/js/select2.min.js", - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/js/i18n/zh-CN.js", + "admin/js/vendor/select2/select2.full.min.js", + "admin/js/vendor/select2/i18n/zh-CN.js", "django_select2/django_select2.js", ) translation.activate("zh-hant") assert tuple(Select2Widget().media._js) == ( - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/js/select2.min.js", - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/js/i18n/zh-TW.js", + "admin/js/vendor/select2/select2.full.min.js", + "admin/js/vendor/select2/i18n/zh-TW.js", + "django_select2/django_select2.js", + ) + + def test_theme_setting(self, settings): + settings.SELECT2_THEME = "classic" + widget = self.widget_cls() + assert 'data-theme="classic"' in widget.render("name", None) + + +class TestSelect2AdminMixin: + def test_media(self): + translation.activate("en") + assert tuple(Select2AdminMixin().media._js) == ( + "admin/js/vendor/select2/select2.full.min.js", + "admin/js/vendor/select2/i18n/en.js", + "admin/js/jquery.init.js", "django_select2/django_select2.js", ) + assert dict(Select2AdminMixin().media._css) == { + "screen": [ + "admin/css/vendor/select2/select2.min.css", + "admin/css/autocomplete.css", + "django_select2/django_select2.css", + ] + } + class TestSelect2MixinSettings: def test_default_media(self): sut = Select2Widget() result = sut.media.render() - assert ( - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/js/select2.min.js" - in result - ) - assert ( - f"https://cdnjs.cloudflare.com/ajax/libs/select2/%7Bsettings.SELECT2_LIB_VERSION%7D/css/select2.min.css" - in result - ) + assert "admin/js/vendor/select2/select2.full.min.js" in result + assert "admin/css/vendor/select2/select2.min.css" in result assert "django_select2/django_select2.js" in result def test_js_setting(self, settings): @@ -200,7 +231,14 @@ def test_empty_css_setting(self, settings): settings.SELECT2_CSS = "" sut = Select2Widget() result = sut.media.render() - assert ".css" not in result + assert "/select2.css" not in result + + def test_multiple_css_setting(self, settings): + settings.SELECT2_CSS = ["select2.css", "select2-theme.css"] + sut = Select2Widget() + result = sut.media.render() + assert "select2.css" in result + assert "select2-theme.css" in result class TestHeavySelect2Mixin(TestSelect2Mixin): @@ -219,13 +257,19 @@ def test_initial_form_class(self): "name", None ) + def test_lang_attr(self): + with translation.override("fr"): + widget = self.widget_cls(data_view="heavy_data_1") + assert 'lang="fr"' in widget.render("name", None) + def test_selected_option(self, db): 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 - ) or '' in not_required_field.widget.render( - "primary_genre", 1 + assert ( + '' + in 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 ) @@ -234,14 +278,18 @@ 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 = ''.format( - pk=1, value="One" + selected_option = ( + ''.format( + pk=1, value="One" + ) ) selected_option_a = ''.format( pk=1, value="One" ) - selected_option2 = ''.format( - pk=2, value="Two" + selected_option2 = ( + ''.format( + pk=2, value="Two" + ) ) selected_option2a = ''.format( pk=2, value="Two" @@ -252,15 +300,16 @@ def test_many_selected_option(self, db, genres): ), widget_output assert selected_option2 in widget_output or selected_option2a in widget_output + @pytest.mark.selenium def test_multiple_widgets(self, db, live_server, driver): driver.get(live_server + self.url) with pytest.raises(NoSuchElementException): - driver.find_element_by_css_selector(".select2-results") + driver.find_element(By.CSS_SELECTOR, ".select2-results") - elem1, elem2 = driver.find_elements_by_css_selector(".select2-selection") + elem1, elem2 = driver.find_elements(By.CSS_SELECTOR, ".select2-selection") elem1.click() - search1 = driver.find_element_by_css_selector(".select2-search__field") + search1 = driver.find_element(By.CSS_SELECTOR, ".select2-search__field") search1.send_keys("fo") result1 = ( WebDriverWait(driver, 60) @@ -273,7 +322,7 @@ def test_multiple_widgets(self, db, live_server, driver): ) elem2.click() - search2 = driver.find_element_by_css_selector(".select2-search__field") + search2 = driver.find_element(By.CSS_SELECTOR, ".select2-search__field") search2.send_keys("fo") result2 = ( WebDriverWait(driver, 60) @@ -288,7 +337,7 @@ def test_multiple_widgets(self, db, live_server, driver): assert result1 != result2 with pytest.raises(NoSuchElementException): - error = driver.find_element_by_xpath("//body[@JSError]") + error = driver.find_element(By.XPATH, "//body[@JSError]") pytest.fail(error.get_attribute("JSError")) def test_get_url(http://github.com/self): @@ -305,6 +354,22 @@ class NoPickle: with pytest.raises(NotImplementedError): widget.set_to_cache() + def test_theme_setting(self, settings): + settings.SELECT2_THEME = "classic" + widget = self.widget_cls(data_view="heavy_data_1") + assert 'data-theme="classic"' in widget.render("name", None) + + def test_cache_key_leak(self): + bob = self.widget_cls(data_url="/test/") + alice = self.widget_cls(data_url="/test/") + bob.render("name", "value") + bob_key_request_1 = bob._get_cache_key() + alice.render("name", "value") + assert bob._get_cache_key() != alice._get_cache_key() + bob.render("name", "value") + bob_key_request_2 = bob._get_cache_key() + assert bob_key_request_1 != bob_key_request_2 + class TestModelSelect2Mixin(TestHeavySelect2Mixin): form = forms.AlbumModelSelect2WidgetForm(initial={"primary_genre": 1}) @@ -334,8 +399,10 @@ def test_selected_option(self, db, genres): not_required_field = self.form.fields["primary_genre"] assert not_required_field.required is False widget_output = not_required_field.widget.render("primary_genre", genre.pk) - selected_option = ''.format( - pk=genre.pk, value=force_str(genre) + selected_option = ( + ''.format( + pk=genre.pk, value=force_str(genre) + ) ) selected_option_a = ''.format( pk=genre.pk, value=force_str(genre) @@ -382,6 +449,15 @@ def test_get_queryset(self): widget.queryset = Genre.objects.all() assert isinstance(widget.get_queryset(), QuerySet) + def test_result_from_instance_ModelSelect2Widget(self, genres): + widget = ModelSelect2Widget() + widget.model = Genre + genre = Genre.objects.first() + assert widget.result_from_instance(genre, request=None) == { + "id": genre.pk, + "text": str(genre), + } + def test_tag_attrs_Select2Widget(self): widget = Select2Widget() output = widget.render("name", "value") @@ -441,6 +517,62 @@ def test_filter_queryset(self, genres): ) assert qs.exists() + def test_filter_queryset__empty(self, genres): + widget = TitleModelSelect2Widget(queryset=Genre.objects.all()) + assert widget.filter_queryset(None, genres[0].title[:3]).exists() + + widget = TitleModelSelect2Widget( + search_fields=["title__icontains"], queryset=Genre.objects.all() + ) + qs = widget.filter_queryset(None, "") + assert qs.exists() + + def test_filter_queryset__startswith(self, genres): + genre = Genre.objects.create(title="Space Genre") + widget = TitleModelSelect2Widget(queryset=Genre.objects.all()) + assert widget.filter_queryset(None, genre.title).exists() + + widget = TitleModelSelect2Widget( + search_fields=["title__istartswith"], queryset=Genre.objects.all() + ) + qs = widget.filter_queryset(None, "Space Gen") + assert qs.exists() + + qs = widget.filter_queryset(None, "Gen") + assert not qs.exists() + + def test_filter_queryset__contains(self, genres): + genre = Genre.objects.create(title="Space Genre") + widget = TitleModelSelect2Widget(queryset=Genre.objects.all()) + assert widget.filter_queryset(None, genre.title).exists() + + widget = TitleModelSelect2Widget( + search_fields=["title__contains"], queryset=Genre.objects.all() + ) + qs = widget.filter_queryset(None, "Space Gen") + assert qs.exists() + + qs = widget.filter_queryset(None, "NOT Gen") + assert not qs.exists(), "contains works even if all bits match" + + def test_filter_queryset__multiple_fields(self, genres): + genre = Genre.objects.create(title="Space Genre") + widget = TitleModelSelect2Widget(queryset=Genre.objects.all()) + assert widget.filter_queryset(None, genre.title).exists() + + widget = TitleModelSelect2Widget( + search_fields=[ + "title__startswith", + "title__endswith", + ], + queryset=Genre.objects.all(), + ) + qs = widget.filter_queryset(None, "Space") + assert qs.exists() + + qs = widget.filter_queryset(None, "Genre") + assert qs.exists() + def test_model_kwarg(self): widget = ModelSelect2Widget(model=Genre, search_fields=["title__icontains"]) genre = Genre.objects.last() @@ -532,20 +664,21 @@ class TestHeavySelect2MultipleWidget: bool(os.environ.get("CI", False)), reason="https://bugs.chromium.org/p/chromedriver/issues/detail?id=1772", ) + @pytest.mark.selenium def test_widgets_selected_after_validation_error(self, db, live_server, driver): driver.get(live_server + self.url) WebDriverWait(driver, 3).until( expected_conditions.presence_of_element_located((By.ID, "id_title")) ) - title = driver.find_element_by_id("id_title") + title = driver.find_element(By.ID, "id_title") title.send_keys("fo") - genres, fartists = driver.find_elements_by_css_selector( - ".select2-selection--multiple" + genres, fartists = driver.find_elements( + By.CSS_SELECTOR, ".select2-selection--multiple" ) genres.click() genres.send_keys("o") # results are Zero One Two Four # select second element - One - driver.find_element_by_css_selector(".select2-results li:nth-child(2)").click() + driver.find_element(By.CSS_SELECTOR, ".select2-results li:nth-child(2)").click() genres.submit() # there is a ValidationError raised, check for it errstring = ( @@ -559,8 +692,8 @@ def test_widgets_selected_after_validation_error(self, db, live_server, driver): ) assert errstring == "Title must have more than 3 characters." # genres should still have One as selected option - result_title = driver.find_element_by_css_selector( - ".select2-selection--multiple li" + result_title = driver.find_element( + By.CSS_SELECTOR, ".select2-selection--multiple li" ).get_attribute("title") assert result_title == "One" @@ -569,6 +702,7 @@ class TestAddressChainedSelect2Widget: url = reverse("model_chained_select2_widget") form = forms.AddressChainedSelect2WidgetForm() + @pytest.mark.selenium def test_widgets_selected_after_validation_error( self, db, live_server, driver, countries, cities ): @@ -583,7 +717,7 @@ def test_widgets_selected_after_validation_error( country_container, city_container, city2_container, - ) = driver.find_elements_by_css_selector(".select2-selection--single") + ) = driver.find_elements(By.CSS_SELECTOR, ".select2-selection--single") # clicking city select2 lists all available cities city_container.click() @@ -592,7 +726,7 @@ def test_widgets_selected_after_validation_error( (By.CSS_SELECTOR, ".select2-results li") ) ) - city_options = driver.find_elements_by_css_selector(".select2-results li") + city_options = driver.find_elements(By.CSS_SELECTOR, ".select2-results li") city_names_from_browser = {option.text for option in city_options} city_names_from_db = set(City.objects.values_list("name", flat=True)) assert len(city_names_from_browser) == City.objects.count() @@ -605,8 +739,8 @@ def test_widgets_selected_after_validation_error( (By.CSS_SELECTOR, ".select2-results li:nth-child(2)") ) ) - country_option = driver.find_element_by_css_selector( - ".select2-results li:nth-child(2)" + country_option = driver.find_element( + By.CSS_SELECTOR, ".select2-results li:nth-child(2)" ) country_name = country_option.text country_option.click() @@ -619,7 +753,7 @@ def test_widgets_selected_after_validation_error( (By.CSS_SELECTOR, ".select2-results li") ) ) - city_options = driver.find_elements_by_css_selector(".select2-results li") + city_options = driver.find_elements(By.CSS_SELECTOR, ".select2-results li") city_names_from_browser = {option.text for option in city_options} city_names_from_db = set( Country.objects.get(name=country_name).cities.values_list("name", flat=True) @@ -627,9 +761,9 @@ def test_widgets_selected_after_validation_error( assert len(city_names_from_browser) != City.objects.count() assert city_names_from_browser == city_names_from_db - # selecting a city reaaly does it - city_option = driver.find_element_by_css_selector( - ".select2-results li:nth-child(2)" + # selecting a city really does it + city_option = driver.find_element( + By.CSS_SELECTOR, ".select2-results li:nth-child(2)" ) city_name = city_option.text city_option.click() @@ -642,12 +776,13 @@ def test_widgets_selected_after_validation_error( (By.CSS_SELECTOR, ".select2-results li") ) ) - country_options = driver.find_elements_by_css_selector(".select2-results li") + country_options = driver.find_elements(By.CSS_SELECTOR, ".select2-results li") country_names_from_browser = {option.text for option in country_options} country_names_from_db = {City.objects.get(name=city_name).country.name} assert len(country_names_from_browser) != Country.objects.count() assert country_names_from_browser == country_names_from_db + @pytest.mark.selenium def test_dependent_fields_clear_after_change_parent( self, db, live_server, driver, countries, cities ): @@ -656,7 +791,7 @@ def test_dependent_fields_clear_after_change_parent( country_container, city_container, city2_container, - ) = driver.find_elements_by_css_selector(".select2-selection--single") + ) = driver.find_elements(By.CSS_SELECTOR, ".select2-selection--single") # selecting a country really does it country_container.click() @@ -665,8 +800,8 @@ def test_dependent_fields_clear_after_change_parent( (By.CSS_SELECTOR, ".select2-results li:nth-child(2)") ) ) - country_option = driver.find_element_by_css_selector( - ".select2-results li:nth-child(2)" + country_option = driver.find_element( + By.CSS_SELECTOR, ".select2-results li:nth-child(2)" ) country_name = country_option.text country_option.click() @@ -679,8 +814,8 @@ def test_dependent_fields_clear_after_change_parent( (By.CSS_SELECTOR, ".select2-results li") ) ) - city2_option = driver.find_element_by_css_selector( - ".select2-results li:nth-child(2)" + city2_option = driver.find_element( + By.CSS_SELECTOR, ".select2-results li:nth-child(2)" ) city2_name = city2_option.text city2_option.click() @@ -693,8 +828,8 @@ def test_dependent_fields_clear_after_change_parent( (By.CSS_SELECTOR, ".select2-results li:nth-child(3)") ) ) - country_option = driver.find_element_by_css_selector( - ".select2-results li:nth-child(3)" + country_option = driver.find_element( + By.CSS_SELECTOR, ".select2-results li:nth-child(3)" ) country_name = country_option.text country_option.click() @@ -708,3 +843,44 @@ def test_dependent_fields_clear_after_change_parent( ) ) assert city2_container.text == "" + + +@pytest.fixture( + name="widget", + params=[ + (Select2Widget, {}), + (HeavySelect2Widget, {"data_view": "heavy_data_1"}), + (HeavySelect2MultipleWidget, {"data_view": "heavy_data_1"}), + (ModelSelect2Widget, {}), + (ModelSelect2TagWidget, {}), + ], + ids=lambda p: p[0], +) +def widget_fixture(request): + widget_class, widget_kwargs = request.param + return widget_class(**widget_kwargs) + + +@pytest.mark.parametrize( + "locale,expected", + [ + ("fr-FR", "fr"), + # Some locales with a country code are natively supported by select2's i18n + ("pt-BR", "pt-BR"), + ("sr-Cyrl", "sr-Cyrl"), + ], + ids=repr, +) +def test_i18n_name_property_with_country_code_in_locale(widget, locale, expected): + """Test we fall back to the language code if the locale contain an unsupported country code.""" + with translation.override(locale): + assert widget.i18n_name == expected + + +def test_i18n_media_js_with_country_code_in_locale(widget): + translation.activate("fr-FR") + assert tuple(widget.media._js) == ( + "admin/js/vendor/select2/select2.full.min.js", + "admin/js/vendor/select2/i18n/fr.js", + "django_select2/django_select2.js", + ) diff --git a/tests/test_views.py b/tests/test_views.py index e63ffe80..b1bf8a46 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,17 +1,17 @@ import json +from django.urls import reverse from django.utils.encoding import smart_str from django_select2.cache import cache from django_select2.forms import ModelSelect2Widget -from tests.testapp.forms import AlbumModelSelect2WidgetForm, ArtistCustomTitleWidget +from tests.testapp.forms import ( + AlbumModelSelect2WidgetForm, + ArtistCustomTitleWidget, + CityForm, +) from tests.testapp.models import Genre -try: - from django.urls import reverse -except ImportError: - from django.core.urlresolvers import reverse - class TestAutoResponseView: def test_get(self, client, artists): @@ -84,6 +84,23 @@ def test_label_from_instance(self, artists, client): "results" ] + def test_result_from_instance(self, cities, client): + url = reverse("django_select2:auto-json") + + form = CityForm() + assert form.as_p() + field_id = form.fields["city"].widget.field_id + city = cities[0] + response = client.get(url, {"field_id": field_id, "term": city.name}) + assert response.status_code == 200 + data = json.loads(response.content.decode("utf-8")) + assert data["results"] + assert { + "id": city.pk, + "text": smart_str(city), + "country": smart_str(city.country), + } in data["results"] + def test_url_check(self, client, artists): artist = artists[0] form = AlbumModelSelect2WidgetForm() diff --git a/tests/testapp/forms.py b/tests/testapp/forms.py index 5fa245d9..0b115339 100644 --- a/tests/testapp/forms.py +++ b/tests/testapp/forms.py @@ -51,6 +51,14 @@ def label_from_instance(self, obj): return force_str(obj.title).upper() +class ArtistDataViewWidget(HeavySelect2Widget): + data_view = "heavy_data_1" + + +class PrimaryGenreDataUrlWidget(HeavySelect2Widget): + data_url = "/heavy_data_2/" + + class AlbumSelect2WidgetForm(forms.ModelForm): class Meta: model = models.Album @@ -111,7 +119,8 @@ class ArtistModelSelect2MultipleWidgetForm(forms.Form): title = forms.CharField(max_length=50) genres = forms.ModelMultipleChoiceField( widget=ModelSelect2MultipleWidget( - queryset=models.Genre.objects.all(), search_fields=["title__icontains"], + queryset=models.Genre.objects.all(), + search_fields=["title__icontains"], ), queryset=models.Genre.objects.all(), required=True, @@ -119,7 +128,8 @@ class ArtistModelSelect2MultipleWidgetForm(forms.Form): featured_artists = forms.ModelMultipleChoiceField( widget=ModelSelect2MultipleWidget( - queryset=models.Artist.objects.all(), search_fields=["title__icontains"], + queryset=models.Artist.objects.all(), + search_fields=["title__icontains"], ), queryset=models.Artist.objects.all(), required=False, @@ -141,11 +151,9 @@ class Select2WidgetForm(forms.Form): class HeavySelect2WidgetForm(forms.Form): - artist = forms.ChoiceField( - widget=HeavySelect2Widget(data_view="heavy_data_1"), choices=NUMBER_CHOICES - ) + artist = forms.ChoiceField(widget=ArtistDataViewWidget(), choices=NUMBER_CHOICES) primary_genre = forms.ChoiceField( - widget=HeavySelect2Widget(data_view="heavy_data_2"), + widget=PrimaryGenreDataUrlWidget(), required=False, choices=NUMBER_CHOICES, ) @@ -224,3 +232,17 @@ class Meta: model = models.Groupie fields = "__all__" widgets = {"obsession": ArtistCustomTitleWidget} + + +class CityModelSelect2Widget(ModelSelect2Widget): + model = City + search_fields = ["name"] + + def result_from_instance(self, obj, request): + return {"id": obj.pk, "text": obj.name, "country": str(obj.country)} + + +class CityForm(forms.Form): + city = forms.ModelChoiceField( + queryset=City.objects.all(), widget=CityModelSelect2Widget(), required=False + ) diff --git a/tests/testapp/settings.py b/tests/testapp/settings.py index b5ded121..09d58dfb 100644 --- a/tests/testapp/settings.py +++ b/tests/testapp/settings.py @@ -10,6 +10,7 @@ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.staticfiles", + "django.contrib.admin", "django_select2", "tests.testapp", ) @@ -25,6 +26,7 @@ ("de", "German"), ("en", "English"), ] +LANGUAGE_CODE = "en" TEMPLATES = [ { @@ -36,5 +38,5 @@ SECRET_KEY = "123456" -USE_L10N = True USE_I18N = True +USE_TZ = True diff --git a/tests/testapp/templates/form.html b/tests/testapp/templates/form.html index 7499bc8e..bd817eb2 100644 --- a/tests/testapp/templates/form.html +++ b/tests/testapp/templates/form.html @@ -1,6 +1,5 @@ -{% load static %} - - +{% load static %} + {{ form.media.css }}