├── .bandit ├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.yml │ └── config.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .readthedocs.yaml ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── SECURITY.md ├── django_select2 ├── __init__.py ├── apps.py ├── cache.py ├── conf.py ├── forms.py ├── static │ └── django_select2 │ │ ├── django_select2.css │ │ └── django_select2.js ├── urls.py └── views.py ├── docs ├── CONTRIBUTING.rst ├── conf.py ├── django_select2.rst ├── extra.rst └── index.rst ├── example ├── README.md ├── example │ ├── __init__.py │ ├── asgi.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── settings.py │ ├── templates │ │ └── example │ │ │ └── book_form.html │ ├── urls.py │ ├── views.py │ └── wsgi.py ├── manage.py └── requirements.txt ├── linter-requirements.txt ├── package.json ├── pyproject.toml ├── set_version.py ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── test_cache.py ├── test_forms.py ├── test_views.py └── testapp ├── __init__.py ├── forms.py ├── manage.py ├── models.py ├── settings.py ├── templates └── form.html ├── urls.py └── views.py /.bandit: -------------------------------------------------------------------------------- 1 | [bandit] 2 | exclude: ./tests 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.{json,yml,yaml,js,jsx,toml}] 14 | indent_size = 2 15 | 16 | [LICENSE] 17 | insert_final_newline = false 18 | 19 | [Makefile] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: codingjoe 2 | tidelift: pypi/django-select2 3 | custom: https://paypal.me/codingjoe 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug 2 | description: Report a technical issue. 3 | title: '🐛 ' 4 | labels: 5 | - bug 6 | assignees: 7 | - codingjoe 8 | body: 9 | 10 | - type: markdown 11 | attributes: 12 | value: | 13 | Thank you for taking the time to report a bug. 14 | Please fill in as much of the template below as you're able. 15 | 16 | - type: markdown 17 | attributes: 18 | value: | 19 | ## Security issues 20 | Please do not report security issues here. 21 | Instead, disclose them as described in our [security policy](https://github.com/codingjoe/django-select2/security). 22 | 23 | - type: textarea 24 | id: bug-description 25 | attributes: 26 | label: Bug Description 27 | description: A clear and concise description of what the bug is. 28 | placeholder: I found a bug 29 | validations: 30 | required: true 31 | 32 | - type: textarea 33 | id: bug-steps 34 | attributes: 35 | label: Steps to Reproduce 36 | description: Steps to reproduce the behavior. 37 | placeholder: | 38 | 1. Go to '...' 39 | 2. Click on '....' 40 | 3. Scroll down to '....' 41 | 4. See error 42 | validations: 43 | required: true 44 | 45 | - type: textarea 46 | id: bug-expected 47 | attributes: 48 | label: Expected Behavior 49 | description: A clear and concise description of what you expected to happen. 50 | placeholder: I expected the app to do X 51 | validations: 52 | required: true 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ✨ Feature Requests 4 | url: https://github.com/codingjoe/django-select2/discussions/categories/ideas 5 | about: Please use the GitHub Discussions to request new features. 6 | - name: 🙋 Questions & Help 7 | url: https://github.com/codingjoe/django-select2/discussions/categories/q-a 8 | about: Please use the GitHub Discussions to ask questions. 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: npm 8 | directory: "/" 9 | schedule: 10 | interval: daily 11 | - package-ecosystem: github-actions 12 | directory: "/" 13 | schedule: 14 | interval: daily 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | 10 | lint: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | lint-command: 15 | - bandit -r . -x ./tests 16 | - black --check --diff . 17 | - flake8 . 18 | - isort --check-only --diff . 19 | - pydocstyle . 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-python@v5 23 | with: 24 | python-version: "3.x" 25 | cache: 'pip' 26 | cache-dependency-path: 'linter-requirements.txt' 27 | - run: python -m pip install -r linter-requirements.txt 28 | - run: ${{ matrix.lint-command }} 29 | 30 | dist: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: actions/setup-python@v5 35 | with: 36 | python-version: "3.x" 37 | - run: python -m pip install --upgrade pip build wheel twine readme-renderer 38 | - run: python -m build --sdist --wheel 39 | - run: python -m twine check dist/* 40 | 41 | standardjs: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: actions/setup-node@v4 46 | with: 47 | node-version-file: '.nvmrc' 48 | - run: npm install -g standard 49 | - run: standard 50 | 51 | docs: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v4 55 | - uses: actions/setup-python@v5 56 | with: 57 | python-version: "3.10" 58 | - run: sudo apt-get install -y gettext graphviz 59 | - run: python -m pip install -e .[docs] 60 | - run: python -m sphinx -W -b doctest -b html docs docs/_build 61 | 62 | PyTest: 63 | needs: 64 | - lint 65 | - standardjs 66 | strategy: 67 | matrix: 68 | python-version: 69 | - "3.10" 70 | - "3.11" 71 | - "3.12" 72 | - "3.13" 73 | django-version: 74 | - "4.2" 75 | - "5.1" 76 | runs-on: ubuntu-latest 77 | steps: 78 | - uses: actions/checkout@v4 79 | - uses: actions/setup-python@v5 80 | with: 81 | python-version: ${{ matrix.python-version }} 82 | - run: python -m pip install Django~="${{ matrix.django-version }}.0" 83 | - run: python -m pip install -e .[test] 84 | - run: python -m pytest -m "not selenium" 85 | - uses: codecov/codecov-action@v5 86 | 87 | Selenium: 88 | needs: 89 | - lint 90 | - standardjs 91 | strategy: 92 | matrix: 93 | python-version: 94 | - "3.x" 95 | runs-on: ubuntu-latest 96 | steps: 97 | - uses: actions/checkout@v4 98 | - name: Install Chrome 99 | run: sudo apt-get install -y google-chrome-stable 100 | - name: Install Selenium 101 | run: | 102 | mkdir bin 103 | curl -O https://chromedriver.storage.googleapis.com/`curl -s https://chromedriver.storage.googleapis.com/LATEST_RELEASE`/chromedriver_linux64.zip 104 | unzip chromedriver_linux64.zip -d bin 105 | - uses: actions/setup-python@v5 106 | with: 107 | python-version: ${{ matrix.python-version }} 108 | - run: python -m pip install Django 109 | - run: python -m pip install -e .[test,selenium] 110 | - run: python -m pytest -m selenium 111 | - uses: codecov/codecov-action@v5 112 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | PyPI: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-python@v5 13 | with: 14 | python-version: "3.x" 15 | - run: python -m pip install --upgrade pip build wheel twine 16 | - name: Build dist packages 17 | run: python -m build --sdist --wheel 18 | - run: python -m twine upload dist/* 19 | env: 20 | TWINE_USERNAME: __token__ 21 | TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} 22 | 23 | npm: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-node@v4 28 | - uses: actions/setup-python@v5 29 | with: 30 | python-version: "3.x" 31 | - run: python -m pip install --upgrade setuptools_scm 32 | - run: python set_version.py 33 | - name: Upload packages 34 | run: npm publish 35 | env: 36 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | *.egg-info 4 | dist 5 | build 6 | 7 | node_modules/ 8 | 9 | docs/_build 10 | 11 | # Intellij 12 | .idea/ 13 | *.iml 14 | *.iws 15 | env/ 16 | venv/ 17 | .cache/ 18 | .tox/ 19 | geckodriver.log 20 | ghostdriver.log 21 | .coverage 22 | 23 | coverage.xml 24 | .eggs/ 25 | db.sqlite3 26 | 27 | _version.py 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN} 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-20.04 9 | apt_packages: 10 | - graphviz 11 | tools: 12 | python: "3.10" 13 | 14 | sphinx: 15 | configuration: docs/conf.py 16 | 17 | 18 | python: 19 | install: 20 | - method: pip 21 | path: . 22 | extra_requirements: 23 | - docs 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Before you start editing the python code, you will need to make sure 5 | you have binary dependencies installed:: 6 | 7 | # Debian 8 | sudo apt install -y gettext graphviz google-chrome-stable 9 | # macOS 10 | brew install -y gettext graphviz google-chrome-stable 11 | 12 | To install the package and its dependencies for development 13 | including tests dependencies, please do: 14 | 15 | python -m pip install -e .[test] 16 | 17 | You may ran the tests via:: 18 | 19 | python -m pytest 20 | 21 | Documentation pull requests welcome. The Sphinx documentation can be compiled via:: 22 | 23 | python -m pip install -e .[docs] 24 | python -m sphinx -W -b doctest -b html docs docs/_build 25 | 26 | Bug reports welcome, even more so if they include a correct patch. Much 27 | more so if you start your patch by adding a failing unit test, and correct 28 | the code until zero unit tests fail. 29 | 30 | The list of supported Django and Python version can be found in the CI suite setup. 31 | Please make sure to verify that none of the linters or tests failed, before you submit 32 | a patch for review. 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Johannes Maron and other contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include django_select2/static/django_select2/django_select2.js 2 | include django_select2/static/django_select2/django_select2.css 3 | prune .github 4 | exclude .* 5 | 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |header| 2 | 3 | ============== 4 | Django-Select2 5 | ============== 6 | 7 | |version| |coverage| |license| 8 | 9 | Custom autocomplete fields for `Django`_. 10 | 11 | Documentation 12 | ------------- 13 | 14 | Documentation available at https://django-select2.readthedocs.io/. 15 | 16 | .. note:: 17 | Django's admin comes with builtin support for Select2 18 | via the `autocomplete_fields`_ feature. 19 | 20 | 21 | .. _Django: https://www.djangoproject.com/ 22 | .. _Select2: https://select2.org/ 23 | .. _autocomplete_fields: https://docs.djangoproject.com/en/stable/ref/contrib/admin/#django.contrib.admin.ModelAdmin.autocomplete_fields 24 | 25 | .. |header| image:: https://repository-images.githubusercontent.com/266545281/c6db7d26-9f60-454b-845e-395d45c43fa7 26 | .. |version| image:: https://img.shields.io/pypi/v/Django-Select2.svg 27 | :target: https://pypi.python.org/pypi/Django-Select2/ 28 | .. |coverage| image:: https://codecov.io/gh/codingjoe/django-select2/branch/master/graph/badge.svg 29 | :target: https://codecov.io/gh/codingjoe/django-select2 30 | .. |license| image:: https://img.shields.io/badge/license-APL2-blue.svg 31 | :target: https://raw.githubusercontent.com/codingjoe/django-select2/master/LICENSE.txt 32 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | ## Security contact information 4 | 5 | To report a security vulnerability, please use the 6 | [Tidelift security contact](https://tidelift.com/security). 7 | Tidelift will coordinate the fix and disclosure. 8 | -------------------------------------------------------------------------------- /django_select2/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a Django_ integration of Select2_. 3 | 4 | The application includes Select2 driven Django Widgets and Form Fields. 5 | 6 | .. _Django: https://www.djangoproject.com/ 7 | .. _Select2: https://select2.org/ 8 | 9 | """ 10 | 11 | from django import get_version 12 | 13 | from . import _version 14 | 15 | __version__ = _version.version 16 | VERSION = _version.version_tuple 17 | 18 | if get_version() < "3.2": 19 | default_app_config = "django_select2.apps.Select2AppConfig" 20 | -------------------------------------------------------------------------------- /django_select2/apps.py: -------------------------------------------------------------------------------- 1 | """Django application configuration.""" 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class Select2AppConfig(AppConfig): 7 | """Django application configuration.""" 8 | 9 | name = "django_select2" 10 | verbose_name = "Select2" 11 | 12 | def ready(self): 13 | from . import conf # noqa 14 | -------------------------------------------------------------------------------- /django_select2/cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Shared memory across multiple machines to the heavy AJAX lookups. 3 | 4 | Select2 uses django.core.cache_ to share fields across 5 | multiple threads and even machines. 6 | 7 | Select2 uses the cache backend defined in the setting 8 | ``SELECT2_CACHE_BACKEND`` [default=``default``]. 9 | 10 | It is advised to always setup a separate cache server for Select2. 11 | 12 | .. _django.core.cache: https://docs.djangoproject.com/en/dev/topics/cache/ 13 | """ 14 | 15 | from django.core.cache import caches 16 | 17 | from .conf import settings 18 | 19 | __all__ = ("cache",) 20 | 21 | cache = caches[settings.SELECT2_CACHE_BACKEND] 22 | -------------------------------------------------------------------------------- /django_select2/conf.py: -------------------------------------------------------------------------------- 1 | """Settings for Django-Select2.""" 2 | 3 | from appconf import AppConf 4 | from django.conf import settings # NOQA 5 | 6 | __all__ = ("settings", "Select2Conf") 7 | 8 | from django.contrib.admin.widgets import SELECT2_TRANSLATIONS 9 | 10 | 11 | class Select2Conf(AppConf): 12 | """Settings for Django-Select2.""" 13 | 14 | CACHE_BACKEND = "default" 15 | """ 16 | Django-Select2 uses Django's cache to sure a consistent state across multiple machines. 17 | 18 | Example of settings.py:: 19 | 20 | CACHES = { 21 | "default": { 22 | "BACKEND": "django_redis.cache.RedisCache", 23 | "LOCATION": "redis://127.0.0.1:6379/1", 24 | "OPTIONS": { 25 | "CLIENT_CLASS": "django_redis.client.DefaultClient", 26 | } 27 | }, 28 | 'select2': { 29 | "BACKEND": "django_redis.cache.RedisCache", 30 | "LOCATION": "redis://127.0.0.1:6379/2", 31 | "OPTIONS": { 32 | "CLIENT_CLASS": "django_redis.client.DefaultClient", 33 | } 34 | } 35 | } 36 | 37 | # Set the cache backend to select2 38 | SELECT2_CACHE_BACKEND = 'select2' 39 | 40 | .. tip:: To ensure a consistent state across all you machines you need to user 41 | a consistent external cache backend like Memcached, Redis or a database. 42 | 43 | .. note:: 44 | Should you have copied the example configuration please make sure you 45 | have Redis setup. It's recommended to run a separate Redis server in a 46 | production environment. 47 | 48 | .. note:: The timeout of select2's caching backend determines 49 | how long a browser session can last. 50 | Once widget is dropped from the cache the json response view will return a 404. 51 | """ 52 | CACHE_PREFIX = "select2_" 53 | """ 54 | If you caching backend does not support multiple databases 55 | you can isolate select2 using the cache prefix setting. 56 | It has set `select2_` as a default value, which you can change if needed. 57 | """ 58 | 59 | JS = ["admin/js/vendor/select2/select2.full.min.js"] 60 | """ 61 | The URI for the Select2 JS file. By default this points to version shipped with Django. 62 | 63 | If you want to select the version of the JS library used, or want to serve it from 64 | the local 'static' resources, add a line to your settings.py like so:: 65 | 66 | SELECT2_JS = ['assets/js/select2.min.js'] 67 | 68 | If you provide your own JS and would not like Django-Select2 to load any, change 69 | this setting to a blank string like so:: 70 | 71 | SELECT2_JS = [] 72 | 73 | .. tip:: Change this setting to a local asset in your development environment to 74 | develop without an Internet connection. 75 | """ 76 | 77 | CSS = ["admin/css/vendor/select2/select2.min.css"] 78 | """ 79 | The URI for the Select2 CSS file. By default this points to version shipped with Django. 80 | 81 | If you want to select the version of the library used, or want to serve it from 82 | the local 'static' resources, add a line to your settings.py like so:: 83 | 84 | SELECT2_CSS = ['assets/css/select2.css'] 85 | 86 | If you want to add more css (usually used in select2 themes), add a line 87 | in settings.py like this:: 88 | 89 | SELECT2_CSS = [ 90 | 'assets/css/select2.css', 91 | 'assets/css/select2-theme.css', 92 | ] 93 | 94 | If you provide your own CSS and would not like Django-Select2 to load any, change 95 | this setting to a blank string like so:: 96 | 97 | SELECT2_CSS = [] 98 | 99 | .. tip:: Change this setting to a local asset in your development environment to 100 | develop without an Internet connection. 101 | """ 102 | 103 | THEME = "default" 104 | """ 105 | Select2 supports custom themes using the theme option so you can style Select2 106 | to match the rest of your application. 107 | 108 | .. tip:: When using other themes, you may need use select2 css and theme css. 109 | """ 110 | 111 | I18N_PATH = "admin/js/vendor/select2/i18n" 112 | """ 113 | The base URI for the Select2 i18n files. By default this points to version shipped with Django. 114 | 115 | If you want to select the version of the I18N library used, or want to serve it from 116 | the local 'static' resources, add a line to your settings.py like so:: 117 | 118 | SELECT2_I18N_PATH = 'assets/js/i18n' 119 | 120 | .. tip:: Change this setting to a local asset in your development environment to 121 | develop without an Internet connection. 122 | """ 123 | 124 | I18N_AVAILABLE_LANGUAGES = list(SELECT2_TRANSLATIONS.values()) 125 | """ 126 | List of available translations. 127 | 128 | List of ISO 639-1 language codes that are supported by Select2. 129 | If currently set language code (e.g. using the HTTP ``Accept-Language`` header) 130 | is in this list, Django-Select2 will use the language code to create load 131 | the proper translation. 132 | 133 | The full path for the language file consists of:: 134 | 135 | from django.utils import translations 136 | 137 | full_path = "{i18n_path}/{language_code}.js".format( 138 | i18n_path=settings.DJANGO_SELECT2_I18N, 139 | language_code=translations.get_language(), 140 | ) 141 | 142 | ``settings.DJANGO_SELECT2_I18N`` refers to :attr:`.I18N_PATH`. 143 | """ 144 | 145 | JSON_ENCODER = "django.core.serializers.json.DjangoJSONEncoder" 146 | """ 147 | A :class:`JSONEncoder` used to generate the API response for the model widgets. 148 | 149 | A custom JSON encoder might be useful when your models uses 150 | a special primary key, that isn't serializable by the default encoder. 151 | """ 152 | 153 | class Meta: 154 | """Prefix for all Django-Select2 settings.""" 155 | 156 | prefix = "SELECT2" 157 | -------------------------------------------------------------------------------- /django_select2/forms.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django-Select2 Widgets. 3 | 4 | These components are responsible for rendering 5 | the necessary HTML data markups. Since this whole 6 | package is to render choices using Select2 JavaScript 7 | library, hence these components are meant to be used 8 | with choice fields. 9 | 10 | Widgets are generally of tree types: 11 | Light, Heavy and Model. 12 | 13 | Light 14 | ~~~~~ 15 | 16 | They are not meant to be used when there 17 | are too many options, say, in thousands. 18 | This is because all those options would 19 | have to be pre-rendered onto the page 20 | and JavaScript would be used to search 21 | through them. Said that, they are also one 22 | the easiest to use. They are a 23 | drop-in-replacement for Django's default 24 | select widgets. 25 | 26 | Heavy 27 | ~~~~~ 28 | 29 | They are suited for scenarios when the number of options 30 | are large and need complex queries (from maybe different 31 | sources) to get the options. 32 | 33 | This dynamic fetching of options undoubtedly requires 34 | Ajax communication with the server. Django-Select2 includes 35 | a helper JS file which is included automatically, 36 | so you need not worry about writing any Ajax related JS code. 37 | Although on the server side you do need to create a view 38 | specifically to respond to the queries. 39 | 40 | Model 41 | ~~~~~ 42 | 43 | Model-widgets are a further specialized versions of Heavies. 44 | These do not require views to serve Ajax requests. 45 | When they are instantiated, they register themselves 46 | with one central view which handles Ajax requests for them. 47 | 48 | Heavy and Model widgets have respectively the word 'Heavy' and 'Model' in 49 | their name. Light widgets are normally named, i.e. there is no 'Light' word 50 | in their names. 51 | 52 | .. inheritance-diagram:: django_select2.forms 53 | :parts: 1 54 | 55 | """ 56 | 57 | import operator 58 | import uuid 59 | from functools import reduce 60 | from itertools import chain 61 | from pickle import PicklingError # nosec 62 | 63 | from django import forms 64 | from django.contrib.admin.utils import lookup_spawns_duplicates 65 | from django.contrib.admin.widgets import AutocompleteMixin 66 | from django.core import signing 67 | from django.db.models import Q 68 | from django.forms.models import ModelChoiceIterator 69 | from django.urls import reverse 70 | 71 | from .cache import cache 72 | from .conf import settings 73 | 74 | 75 | class Select2Mixin: 76 | """ 77 | The base mixin of all Select2 widgets. 78 | 79 | This mixin is responsible for rendering the necessary 80 | data attributes for select2 as well as adding the static 81 | form media. 82 | """ 83 | 84 | css_class_name = "django-select2" 85 | theme = None 86 | 87 | empty_label = "" 88 | 89 | @property 90 | def i18n_name(self): 91 | """Name of the i18n file for the current language.""" 92 | from django.contrib.admin.widgets import get_select2_language 93 | 94 | return get_select2_language() 95 | 96 | def build_attrs(self, base_attrs, extra_attrs=None): 97 | """Add select2 data attributes.""" 98 | default_attrs = { 99 | "lang": self.i18n_name, 100 | "data-minimum-input-length": 0, 101 | "data-theme": self.theme or settings.SELECT2_THEME, 102 | } 103 | if self.is_required: 104 | default_attrs["data-allow-clear"] = "false" 105 | else: 106 | default_attrs["data-allow-clear"] = "true" 107 | default_attrs["data-placeholder"] = self.empty_label or "" 108 | 109 | default_attrs.update(base_attrs) 110 | attrs = super().build_attrs(default_attrs, extra_attrs=extra_attrs) 111 | 112 | if "class" in attrs: 113 | attrs["class"] += " " + self.css_class_name 114 | else: 115 | attrs["class"] = self.css_class_name 116 | return attrs 117 | 118 | def optgroups(self, name, value, attrs=None): 119 | """Add empty option for clearable selects.""" 120 | if not self.is_required and not self.allow_multiple_selected: 121 | self.choices = list(chain([("", "")], self.choices)) 122 | return super().optgroups(name, value, attrs=attrs) 123 | 124 | @property 125 | def media(self): 126 | """ 127 | Construct Media as a dynamic property. 128 | 129 | .. Note:: For more information visit 130 | https://docs.djangoproject.com/en/stable/topics/forms/media/#media-as-a-dynamic-property 131 | """ 132 | select2_js = settings.SELECT2_JS if settings.SELECT2_JS else [] 133 | select2_css = settings.SELECT2_CSS if settings.SELECT2_CSS else [] 134 | 135 | if isinstance(select2_js, str): 136 | select2_js = [select2_js] 137 | if isinstance(select2_css, str): 138 | select2_css = [select2_css] 139 | 140 | i18n_file = [] 141 | if self.i18n_name in settings.SELECT2_I18N_AVAILABLE_LANGUAGES: 142 | i18n_file = [f"{settings.SELECT2_I18N_PATH}/{self.i18n_name}.js"] 143 | 144 | return forms.Media( 145 | js=select2_js + i18n_file + ["django_select2/django_select2.js"], 146 | css={"screen": select2_css + ["django_select2/django_select2.css"]}, 147 | ) 148 | 149 | 150 | class Select2AdminMixin: 151 | """Select2 mixin that uses Django's own select template.""" 152 | 153 | theme = "admin-autocomplete" 154 | 155 | @property 156 | def media(self): 157 | css = {**AutocompleteMixin(None, None).media._css} 158 | css["screen"].append("django_select2/django_select2.css") 159 | js = [*Select2Mixin().media._js] 160 | js.insert( 161 | js.index("django_select2/django_select2.js"), "admin/js/jquery.init.js" 162 | ) 163 | return forms.Media( 164 | js=js, 165 | css=css, 166 | ) 167 | 168 | 169 | class Select2TagMixin: 170 | """Mixin to add select2 tag functionality.""" 171 | 172 | def build_attrs(self, base_attrs, extra_attrs=None): 173 | """Add select2's tag attributes.""" 174 | default_attrs = { 175 | "data-minimum-input-length": 1, 176 | "data-tags": "true", 177 | "data-token-separators": '[",", " "]', 178 | } 179 | default_attrs.update(base_attrs) 180 | return super().build_attrs(default_attrs, extra_attrs=extra_attrs) 181 | 182 | 183 | class Select2Widget(Select2Mixin, forms.Select): 184 | """ 185 | Select2 drop in widget. 186 | 187 | Example usage:: 188 | 189 | class MyModelForm(forms.ModelForm): 190 | class Meta: 191 | model = MyModel 192 | fields = ('my_field', ) 193 | widgets = { 194 | 'my_field': Select2Widget 195 | } 196 | 197 | or:: 198 | 199 | class MyForm(forms.Form): 200 | my_choice = forms.ChoiceField(widget=Select2Widget) 201 | 202 | """ 203 | 204 | 205 | class Select2MultipleWidget(Select2Mixin, forms.SelectMultiple): 206 | """ 207 | Select2 drop in widget for multiple select. 208 | 209 | Works just like :class:`.Select2Widget` but for multi select. 210 | """ 211 | 212 | 213 | class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple): 214 | """ 215 | Select2 drop in widget with tagging support. It allows to dynamically create new options from text input by the user. 216 | 217 | Example for :class:`.django.contrib.postgres.fields.ArrayField`:: 218 | 219 | class MyWidget(Select2TagWidget): 220 | 221 | def value_from_datadict(self, data, files, name): 222 | values = super().value_from_datadict(data, files, name) 223 | return ",".join(values) 224 | 225 | def optgroups(self, name, value, attrs=None): 226 | values = value[0].split(',') if value[0] else [] 227 | selected = set(values) 228 | subgroup = [self.create_option(name, v, v, selected, i) for i, v in enumerate(values)] 229 | return [(None, subgroup, 0)] 230 | 231 | """ 232 | 233 | 234 | class HeavySelect2Mixin: 235 | """Mixin that adds select2's AJAX options and registers itself on Django's cache.""" 236 | 237 | dependent_fields = {} 238 | data_view = None 239 | data_url = None 240 | 241 | def __init__(self, attrs=None, choices=(), **kwargs): 242 | """ 243 | Return HeavySelect2Mixin. 244 | 245 | Args: 246 | data_view (str): URL pattern name 247 | data_url (str): URL 248 | dependent_fields (dict): Dictionary of dependent parent fields. 249 | The value of the dependent field will be passed as to :func:`.filter_queryset`. 250 | It can be used to further restrict the search results. For example, a city 251 | widget could be dependent on a country. 252 | Key is a name of a field in a form. 253 | Value is a name of a field in a model (used in `queryset`). 254 | 255 | """ 256 | super().__init__(attrs, choices) 257 | 258 | self.data_view = kwargs.pop("data_view", self.data_view) 259 | self.data_url = kwargs.pop("data_url", self.data_url) 260 | 261 | dependent_fields = kwargs.pop("dependent_fields", None) 262 | if dependent_fields is not None: 263 | self.dependent_fields = dict(dependent_fields) 264 | if not (self.data_view or self.data_url): 265 | raise ValueError('You must either specify "data_view" or "data_url".') 266 | self.userGetValTextFuncName = kwargs.pop("userGetValTextFuncName", "null") 267 | 268 | def get_url(self): 269 | """Return URL from instance or by reversing :attr:`.data_view`.""" 270 | if self.data_url: 271 | return self.data_url 272 | return reverse(self.data_view) 273 | 274 | def build_attrs(self, base_attrs, extra_attrs=None): 275 | """Set select2's AJAX attributes.""" 276 | self.uuid = str(uuid.uuid4()) 277 | self.field_id = signing.dumps(self.uuid) 278 | default_attrs = { 279 | "data-ajax--url": self.get_url(), 280 | "data-ajax--cache": "true", 281 | "data-ajax--type": "GET", 282 | "data-minimum-input-length": 2, 283 | } 284 | 285 | if self.dependent_fields: 286 | default_attrs["data-select2-dependent-fields"] = " ".join( 287 | self.dependent_fields 288 | ) 289 | 290 | default_attrs.update(base_attrs) 291 | 292 | attrs = super().build_attrs(default_attrs, extra_attrs=extra_attrs) 293 | 294 | attrs["data-field_id"] = self.field_id 295 | 296 | attrs["class"] += " django-select2-heavy" 297 | return attrs 298 | 299 | def render(self, *args, **kwargs): 300 | """Render widget and register it in Django's cache.""" 301 | output = super().render(*args, **kwargs) 302 | self.set_to_cache() 303 | return output 304 | 305 | def _get_cache_key(self): 306 | return f"{settings.SELECT2_CACHE_PREFIX}{self.uuid}" 307 | 308 | def set_to_cache(self): 309 | """ 310 | Add widget object to Django's cache. 311 | 312 | You may need to overwrite this method, to pickle all information 313 | that is required to serve your JSON response view. 314 | """ 315 | try: 316 | cache.set(self._get_cache_key(), {"widget": self, "url": self.get_url()}) 317 | except (PicklingError, AttributeError): 318 | msg = 'You need to overwrite "set_to_cache" or ensure that %s is serialisable.' 319 | raise NotImplementedError(msg % self.__class__.__name__) 320 | 321 | 322 | class HeavySelect2Widget(HeavySelect2Mixin, Select2Widget): 323 | """ 324 | Select2 widget with AJAX support that registers itself to Django's Cache. 325 | 326 | Usage example:: 327 | 328 | class MyWidget(HeavySelect2Widget): 329 | data_view = 'my_view_name' 330 | 331 | or:: 332 | 333 | class MyForm(forms.Form): 334 | my_field = forms.ChoiceField( 335 | widget=HeavySelect2Widget( 336 | data_url='/url/to/json/response' 337 | ) 338 | ) 339 | 340 | """ 341 | 342 | 343 | class HeavySelect2MultipleWidget(HeavySelect2Mixin, Select2MultipleWidget): 344 | """Select2 multi select widget similar to :class:`.HeavySelect2Widget`.""" 345 | 346 | 347 | class HeavySelect2TagWidget(HeavySelect2Mixin, Select2TagWidget): 348 | """Select2 tag widget.""" 349 | 350 | 351 | # Auto Heavy widgets 352 | 353 | 354 | class ModelSelect2Mixin: 355 | """Widget mixin that provides attributes and methods for :class:`.AutoResponseView`.""" 356 | 357 | model = None 358 | queryset = None 359 | search_fields = [] 360 | """ 361 | Model lookups that are used to filter the QuerySet. 362 | 363 | Example:: 364 | 365 | search_fields = [ 366 | 'title__icontains', 367 | ] 368 | 369 | """ 370 | 371 | max_results = 25 372 | """Maximal results returned by :class:`.AutoResponseView`.""" 373 | 374 | @property 375 | def empty_label(self): 376 | if isinstance(self.choices, ModelChoiceIterator): 377 | return self.choices.field.empty_label 378 | return "" 379 | 380 | def __init__(self, *args, **kwargs): 381 | """ 382 | Overwrite class parameters if passed as keyword arguments. 383 | 384 | Args: 385 | model (django.db.models.Model): Model to select choices from. 386 | queryset (django.db.models.query.QuerySet): QuerySet to select choices from. 387 | search_fields (list): List of model lookup strings. 388 | max_results (int): Max. JsonResponse view page size. 389 | 390 | """ 391 | self.model = kwargs.pop("model", self.model) 392 | self.queryset = kwargs.pop("queryset", self.queryset) 393 | self.search_fields = kwargs.pop("search_fields", self.search_fields) 394 | self.max_results = kwargs.pop("max_results", self.max_results) 395 | defaults = {"data_view": "django_select2:auto-json"} 396 | defaults.update(kwargs) 397 | super().__init__(*args, **defaults) 398 | 399 | def set_to_cache(self): 400 | """ 401 | Add widget's attributes to Django's cache. 402 | 403 | Split the QuerySet, to not pickle the result set. 404 | """ 405 | queryset = self.get_queryset() 406 | cache.set( 407 | self._get_cache_key(), 408 | { 409 | "queryset": [queryset.none(), queryset.query], 410 | "cls": self.__class__, 411 | "search_fields": tuple(self.search_fields), 412 | "max_results": int(self.max_results), 413 | "url": str(self.get_url()), 414 | "dependent_fields": dict(self.dependent_fields), 415 | }, 416 | ) 417 | 418 | def filter_queryset(self, request, term, queryset=None, **dependent_fields): 419 | """ 420 | Return QuerySet filtered by search_fields matching the passed term. 421 | 422 | Args: 423 | request (django.http.request.HttpRequest): The request is being passed from 424 | the JSON view and can be used to dynamically alter the response queryset. 425 | term (str): Search term 426 | queryset (django.db.models.query.QuerySet): QuerySet to select choices from. 427 | **dependent_fields: Dependent fields and their values. If you want to inherit 428 | from ModelSelect2Mixin and later call to this method, be sure to pop 429 | everything from keyword arguments that is not a dependent field. 430 | 431 | Returns: 432 | QuerySet: Filtered QuerySet 433 | 434 | """ 435 | if queryset is None: 436 | queryset = self.get_queryset() 437 | search_fields = self.get_search_fields() 438 | select = Q() 439 | 440 | use_distinct = False 441 | if search_fields and term: 442 | for bit in term.split(): 443 | or_queries = [Q(**{orm_lookup: bit}) for orm_lookup in search_fields] 444 | select &= reduce(operator.or_, or_queries) 445 | or_queries = [Q(**{orm_lookup: term}) for orm_lookup in search_fields] 446 | select |= reduce(operator.or_, or_queries) 447 | use_distinct |= any( 448 | lookup_spawns_duplicates(queryset.model._meta, search_spec) 449 | for search_spec in search_fields 450 | ) 451 | 452 | if dependent_fields: 453 | select &= Q(**dependent_fields) 454 | 455 | use_distinct |= any( 456 | lookup_spawns_duplicates(queryset.model._meta, search_spec) 457 | for search_spec in dependent_fields.keys() 458 | ) 459 | 460 | if use_distinct: 461 | return queryset.filter(select).distinct() 462 | return queryset.filter(select) 463 | 464 | def get_queryset(self): 465 | """ 466 | Return QuerySet based on :attr:`.queryset` or :attr:`.model`. 467 | 468 | Returns: 469 | QuerySet: QuerySet of available choices. 470 | 471 | """ 472 | if self.queryset is not None: 473 | queryset = self.queryset 474 | elif hasattr(self.choices, "queryset"): 475 | queryset = self.choices.queryset 476 | elif self.model is not None: 477 | queryset = self.model._default_manager.all() 478 | else: 479 | raise NotImplementedError( 480 | "%(cls)s is missing a QuerySet. Define " 481 | "%(cls)s.model, %(cls)s.queryset, or override " 482 | "%(cls)s.get_queryset()." % {"cls": self.__class__.__name__} 483 | ) 484 | return queryset 485 | 486 | def get_search_fields(self): 487 | """Return list of lookup names.""" 488 | if self.search_fields: 489 | return self.search_fields 490 | raise NotImplementedError( 491 | '%s, must implement "search_fields".' % self.__class__.__name__ 492 | ) 493 | 494 | def optgroups(self, name, value, attrs=None): 495 | """Return only selected options and set QuerySet from `ModelChoicesIterator`.""" 496 | default = (None, [], 0) 497 | groups = [default] 498 | has_selected = False 499 | selected_choices = {str(v) for v in value} 500 | if not self.is_required and not self.allow_multiple_selected: 501 | default[1].append(self.create_option(name, "", "", False, 0)) 502 | if not isinstance(self.choices, ModelChoiceIterator): 503 | return super().optgroups(name, value, attrs=attrs) 504 | selected_choices = { 505 | c for c in selected_choices if c not in self.choices.field.empty_values 506 | } 507 | field_name = self.choices.field.to_field_name or "pk" 508 | query = Q(**{"%s__in" % field_name: selected_choices}) 509 | for obj in self.choices.queryset.filter(query): 510 | option_value = self.choices.choice(obj)[0] 511 | option_label = self.label_from_instance(obj) 512 | 513 | selected = str(option_value) in value and ( 514 | has_selected is False or self.allow_multiple_selected 515 | ) 516 | if selected is True and has_selected is False: 517 | has_selected = True 518 | index = len(default[1]) 519 | subgroup = default[1] 520 | subgroup.append( 521 | self.create_option( 522 | name, option_value, option_label, selected_choices, index 523 | ) 524 | ) 525 | return groups 526 | 527 | def label_from_instance(self, obj): 528 | """ 529 | Return option label representation from instance. 530 | 531 | Can be overridden to change the representation of each choice. 532 | 533 | Example usage:: 534 | 535 | class MyWidget(ModelSelect2Widget): 536 | def label_from_instance(obj): 537 | return str(obj.title).upper() 538 | 539 | Args: 540 | obj (django.db.models.Model): Instance of Django Model. 541 | 542 | Returns: 543 | str: Option label. 544 | 545 | """ 546 | return str(obj) 547 | 548 | def result_from_instance(self, obj, request): 549 | """ 550 | Return a dictionary representing the object. 551 | 552 | Can be overridden to change the result returned by 553 | :class:`.AutoResponseView` for each object. 554 | 555 | The request passed in will correspond to the request sent to the 556 | :class:`.AutoResponseView` by the widget. 557 | 558 | Example usage:: 559 | 560 | class MyWidget(ModelSelect2Widget): 561 | def result_from_instance(obj, request): 562 | return { 563 | 'id': obj.pk, 564 | 'text': self.label_from_instance(obj), 565 | 'extra_data': obj.extra_data, 566 | } 567 | """ 568 | return {"id": obj.pk, "text": self.label_from_instance(obj)} 569 | 570 | 571 | class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget): 572 | """ 573 | Select2 drop in model select widget. 574 | 575 | Example usage:: 576 | 577 | class MyWidget(ModelSelect2Widget): 578 | search_fields = [ 579 | 'title__icontains', 580 | ] 581 | 582 | class MyModelForm(forms.ModelForm): 583 | class Meta: 584 | model = MyModel 585 | fields = ('my_field', ) 586 | widgets = { 587 | 'my_field': MyWidget, 588 | } 589 | 590 | or:: 591 | 592 | class MyForm(forms.Form): 593 | my_choice = forms.ChoiceField( 594 | widget=ModelSelect2Widget( 595 | model=MyOtherModel, 596 | search_fields=['title__icontains'] 597 | ) 598 | ) 599 | 600 | .. tip:: The ModelSelect2(Multiple)Widget will try 601 | to get the QuerySet from the fields choices. 602 | Therefore you don't need to define a QuerySet, 603 | if you just drop in the widget for a ForeignKey field. 604 | """ 605 | 606 | 607 | class ModelSelect2MultipleWidget(ModelSelect2Mixin, HeavySelect2MultipleWidget): 608 | """ 609 | Select2 drop in model multiple select widget. 610 | 611 | Works just like :class:`.ModelSelect2Widget` but for multi select. 612 | """ 613 | 614 | 615 | class ModelSelect2TagWidget(ModelSelect2Mixin, HeavySelect2TagWidget): 616 | """ 617 | Select2 model widget with tag support. 618 | 619 | This it not a simple drop in widget. 620 | It requires to implement you own :func:`.value_from_datadict` 621 | that adds missing tags to you QuerySet. 622 | 623 | Example:: 624 | 625 | class MyModelSelect2TagWidget(ModelSelect2TagWidget): 626 | queryset = MyModel.objects.all() 627 | 628 | def value_from_datadict(self, data, files, name): 629 | '''Create objects for given non-pimary-key values. Return list of all primary keys.''' 630 | values = set(super().value_from_datadict(data, files, name)) 631 | # This may only work for MyModel, if MyModel has title field. 632 | # You need to implement this method yourself, to ensure proper object creation. 633 | pks = self.queryset.filter(**{'pk__in': list(values)}).values_list('pk', flat=True) 634 | pks = set(map(str, pks)) 635 | cleaned_values = list(pks) 636 | for val in values - pks: 637 | cleaned_values.append(self.queryset.create(title=val).pk) 638 | return cleaned_values 639 | 640 | """ 641 | -------------------------------------------------------------------------------- /django_select2/static/django_select2/django_select2.css: -------------------------------------------------------------------------------- 1 | .change-form select.django-select2 { 2 | width: 20em; 3 | } 4 | -------------------------------------------------------------------------------- /django_select2/static/django_select2/django_select2.js: -------------------------------------------------------------------------------- 1 | /* global define, jQuery */ 2 | (function (factory) { 3 | if (typeof define === 'function' && define.amd) { 4 | define(['jquery'], factory) 5 | } else if (typeof module === 'object' && module.exports) { 6 | module.exports = factory(require('jquery')) 7 | } else { 8 | // Browser globals 9 | factory(jQuery || window.django.jQuery) 10 | } 11 | }(function ($) { 12 | 'use strict' 13 | const init = function ($element, options) { 14 | $element.select2(options) 15 | } 16 | 17 | const initHeavy = function ($element, options) { 18 | const settings = $.extend({ 19 | ajax: { 20 | data: function (params) { 21 | const result = { 22 | term: params.term, 23 | page: params.page, 24 | field_id: $element.data('field_id') 25 | } 26 | 27 | let dependentFields = $element.data('select2-dependent-fields') 28 | if (dependentFields) { 29 | const findElement = function (selector) { 30 | const result = $(selector, $element.closest(`:has(${selector})`)) 31 | if (result.length > 0) return result 32 | else return null 33 | } 34 | dependentFields = dependentFields.trim().split(/\s+/) 35 | $.each(dependentFields, function (i, dependentField) { 36 | const nameIs = `[name=${dependentField}]` 37 | const nameEndsWith = `[name$=-${dependentField}]` 38 | result[dependentField] = (findElement(nameIs) || findElement(nameEndsWith)).val() 39 | }) 40 | } 41 | 42 | return result 43 | }, 44 | processResults: function (data, page) { 45 | return { 46 | results: data.results, 47 | pagination: { 48 | more: data.more 49 | } 50 | } 51 | } 52 | } 53 | }, options) 54 | 55 | $element.select2(settings) 56 | } 57 | 58 | $.fn.djangoSelect2 = function (options) { 59 | const settings = $.extend({}, options) 60 | $.each(this, function (i, element) { 61 | const $element = $(element) 62 | if ($element.hasClass('django-select2-heavy')) { 63 | initHeavy($element, settings) 64 | } else { 65 | init($element, settings) 66 | } 67 | $element.on('select2:select', function (e) { 68 | const name = $(e.currentTarget).attr('name') 69 | $('[data-select2-dependent-fields~=' + name + ']').each(function () { 70 | $(this).val('').trigger('change') 71 | }) 72 | }) 73 | }) 74 | return this 75 | } 76 | 77 | $(function () { 78 | $('.django-select2').not('[name*=__prefix__]').djangoSelect2() 79 | 80 | document.addEventListener('formset:added', (event) => { 81 | $(event.target).find('.django-select2').djangoSelect2() 82 | }) 83 | }) 84 | 85 | return $.fn.djangoSelect2 86 | })) 87 | -------------------------------------------------------------------------------- /django_select2/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django-Select2 URL configuration. 3 | 4 | Add `django_select` to your ``urlconf`` **if** you use any 'Model' fields:: 5 | 6 | from django.urls import path 7 | 8 | 9 | path('select2/', include('django_select2.urls')), 10 | 11 | """ 12 | 13 | from django.urls import path 14 | 15 | from .views import AutoResponseView 16 | 17 | app_name = "django_select2" 18 | 19 | urlpatterns = [ 20 | path("fields/auto.json", AutoResponseView.as_view(), name="auto-json"), 21 | ] 22 | -------------------------------------------------------------------------------- /django_select2/views.py: -------------------------------------------------------------------------------- 1 | """JSONResponse views for model widgets.""" 2 | 3 | from django.core import signing 4 | from django.core.signing import BadSignature 5 | from django.http import Http404, JsonResponse 6 | from django.utils.module_loading import import_string 7 | from django.views.generic.list import BaseListView 8 | 9 | from .cache import cache 10 | from .conf import settings 11 | 12 | 13 | class AutoResponseView(BaseListView): 14 | """ 15 | View that handles requests from heavy model widgets. 16 | 17 | The view only supports HTTP's GET method. 18 | """ 19 | 20 | def get(self, request, *args, **kwargs): 21 | """ 22 | Return a :class:`.django.http.JsonResponse`. 23 | 24 | Each result will be rendered by the widget's 25 | :func:`django_select2.forms.ModelSelect2Mixin.result_from_instance` method. 26 | 27 | Example:: 28 | 29 | { 30 | 'results': [ 31 | { 32 | 'text': "foo", 33 | 'id': 123 34 | } 35 | ], 36 | 'more': true 37 | } 38 | 39 | """ 40 | self.widget = self.get_widget_or_404() 41 | self.term = kwargs.get("term", request.GET.get("term", "")) 42 | self.object_list = self.get_queryset() 43 | context = self.get_context_data() 44 | return JsonResponse( 45 | { 46 | "results": [ 47 | self.widget.result_from_instance(obj, request) 48 | for obj in context["object_list"] 49 | ], 50 | "more": context["page_obj"].has_next(), 51 | }, 52 | encoder=import_string(settings.SELECT2_JSON_ENCODER), 53 | ) 54 | 55 | def get_queryset(self): 56 | """Get QuerySet from cached widget.""" 57 | kwargs = { 58 | model_field_name: self.request.GET.get(form_field_name) 59 | for form_field_name, model_field_name in self.widget.dependent_fields.items() 60 | } 61 | kwargs.update( 62 | { 63 | f"{model_field_name}__in": self.request.GET.getlist( 64 | f"{form_field_name}[]", [] 65 | ) 66 | for form_field_name, model_field_name in self.widget.dependent_fields.items() 67 | } 68 | ) 69 | return self.widget.filter_queryset( 70 | self.request, 71 | self.term, 72 | self.queryset, 73 | **{k: v for k, v in kwargs.items() if v}, 74 | ) 75 | 76 | def get_paginate_by(self, queryset): 77 | """Paginate response by size of widget's `max_results` parameter.""" 78 | return self.widget.max_results 79 | 80 | def get_widget_or_404(self): 81 | """ 82 | Get and return widget from cache. 83 | 84 | Raises: 85 | Http404: If if the widget can not be found or no id is provided. 86 | 87 | Returns: 88 | ModelSelect2Mixin: Widget from cache. 89 | 90 | """ 91 | field_id = self.kwargs.get("field_id", self.request.GET.get("field_id", None)) 92 | if not field_id: 93 | raise Http404('No "field_id" provided.') 94 | try: 95 | key = signing.loads(field_id) 96 | except BadSignature: 97 | raise Http404('Invalid "field_id".') 98 | else: 99 | cache_key = f"{settings.SELECT2_CACHE_PREFIX}{key}" 100 | widget_dict = cache.get(cache_key) 101 | if widget_dict is None: 102 | raise Http404("field_id not found") 103 | if widget_dict.pop("url") != self.request.path: 104 | raise Http404("field_id was issued for the view.") 105 | qs, qs.query = widget_dict.pop("queryset") 106 | self.queryset = qs.all() 107 | widget_dict["queryset"] = self.queryset 108 | widget_cls = widget_dict.pop("cls") 109 | return widget_cls(**widget_dict) 110 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | import sys 4 | 5 | from pkg_resources import get_distribution 6 | 7 | BASE_DIR = pathlib.Path(__file__).resolve(strict=True).parent.parent 8 | 9 | # This is needed since django_select2 requires django model modules 10 | # and those modules assume that django settings is configured and 11 | # have proper DB settings. 12 | # Using this we give a proper environment with working django settings. 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.testapp.settings") 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | sys.path.insert(0, str(BASE_DIR / "tests" / "testapp")) 19 | sys.path.insert(0, str(BASE_DIR)) 20 | 21 | 22 | project = "Django-Select2" 23 | author = "Johannes Hoppe" 24 | copyright = "2017-2020, Johannes Hoppe" 25 | release = get_distribution("django_select2").version 26 | version = ".".join(release.split(".")[:2]) 27 | 28 | 29 | master_doc = "index" # default in Sphinx v2 30 | 31 | 32 | extensions = [ 33 | "sphinx.ext.autodoc", 34 | "sphinx.ext.autosectionlabel", 35 | "sphinx.ext.napoleon", 36 | "sphinx.ext.inheritance_diagram", 37 | "sphinx.ext.intersphinx", 38 | "sphinx.ext.viewcode", 39 | "sphinx.ext.doctest", 40 | ] 41 | 42 | intersphinx_mapping = { 43 | "python": ("https://docs.python.org/3", None), 44 | "django": ( 45 | "https://docs.djangoproject.com/en/stable/", 46 | "https://docs.djangoproject.com/en/stable/_objects/", 47 | ), 48 | } 49 | 50 | autodoc_default_flags = ["members", "show-inheritance"] 51 | autodoc_member_order = "bysource" 52 | 53 | inheritance_graph_attrs = dict(rankdir="TB") 54 | 55 | inheritance_node_attrs = dict( 56 | shape="rect", fontsize=14, fillcolor="gray90", color="gray30", style="filled" 57 | ) 58 | 59 | inheritance_edge_attrs = dict(penwidth=0.75) 60 | -------------------------------------------------------------------------------- /docs/django_select2.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | Configuration 5 | ------------- 6 | 7 | .. automodule:: django_select2.conf 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | Widgets 13 | ------- 14 | 15 | .. automodule:: django_select2.forms 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | URLs 21 | ---- 22 | 23 | .. automodule:: django_select2.urls 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | Views 29 | ----- 30 | 31 | .. automodule:: django_select2.views 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | 36 | Cache 37 | ----- 38 | 39 | .. automodule:: django_select2.cache 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | 44 | 45 | JavaScript 46 | ---------- 47 | 48 | DjangoSelect2 handles the initialization of select2 fields automatically. Just include 49 | ``{{ form.media.js }}`` in your template before the closing ``body`` tag. That's it! 50 | 51 | If you insert forms after page load or if you want to handle the initialization 52 | yourself, DjangoSelect2 provides a jQuery plugin, replacing and enhancing the Select2 53 | plugin. It will handle both normal and heavy fields. Simply call 54 | ``djangoSelect2(options)`` on your select fields.:: 55 | 56 | $('.django-select2').djangoSelect2(); 57 | 58 | Please replace all your ``.select2`` invocations with the here provided 59 | ``.djangoSelect2``. 60 | 61 | 62 | Configuring Select2 63 | ------------------- 64 | 65 | Select2 options can be configured either directly from Javascript or from within Django 66 | using widget attributes. `(List of options in the Select2 docs) `_. 67 | 68 | To pass options in javascript 69 | 70 | .. code-block:: javascript 71 | 72 | $('.django-select2').djangoSelect2({ 73 | minimumInputLength: 0, 74 | placeholder: 'Select an option', 75 | }); 76 | 77 | From Django, you can use ``data-`` attributes using the same names in camel-case and 78 | passing them to your widget. Select2 will then pick these up. For example when 79 | initialising a widget in a form, you could do: 80 | 81 | .. code-block:: python 82 | 83 | class MyForm(forms.Form): 84 | my_field = forms.ModelMultipleChoiceField( 85 | widget=ModelSelect2MultipleWidget( 86 | model=MyModel 87 | search_fields=['another_field'] 88 | attrs={ 89 | "data-minimum-input-length": 0, 90 | "data-placeholder": "Select an option", 91 | "data-close-on-select": "false", 92 | } 93 | ) 94 | ) 95 | 96 | (If you do not want to initialize the widget, you could add the attributes by overriding 97 | a widget method and adding them in a super call, e.g. `get_context() `_) 98 | 99 | 100 | Security & Authentication 101 | ------------------------- 102 | 103 | Security is important. Therefore make sure to read and understand what 104 | the security measures in place and their limitations. 105 | 106 | Set up a separate cache. If you have a public form that uses a model widget 107 | make sure to setup a separate cache database for Select2. An attacker 108 | could constantly reload your site and fill up the select2 cache. 109 | Having a separate cache allows you to limit the effect to select2 only. 110 | 111 | You might want to add a secure select2 JSON endpoint for data you don't 112 | want to be accessible to the general public. Doing so is easy:: 113 | 114 | class UserSelect2View(LoginRequiredMixin, AutoResponseView): 115 | pass 116 | 117 | class UserSelect2WidgetMixin(object): 118 | def __init__(self, *args, **kwargs): 119 | kwargs['data_view'] = 'user-select2-view' 120 | super(UserSelect2WidgetMixin, self).__init__(*args, **kwargs) 121 | 122 | class MySecretWidget(UserSelect2WidgetMixin, ModelSelect2Widget): 123 | model = MySecretModel 124 | search_fields = ['title__icontains'] 125 | -------------------------------------------------------------------------------- /docs/extra.rst: -------------------------------------------------------------------------------- 1 | Extra 2 | ===== 3 | 4 | Chained select2 5 | --------------- 6 | 7 | Suppose you have an address form where a user should choose a Country and a City. 8 | When the user selects a country we want to show only cities belonging to that country. 9 | So the one selector depends on another one. 10 | 11 | .. note:: 12 | Does not work with the 'light' version (django_select2.forms.Select2Widget). 13 | 14 | Models 15 | `````` 16 | 17 | Here are our two models: 18 | 19 | .. code-block:: python 20 | 21 | class Country(models.Model): 22 | name = models.CharField(max_length=255) 23 | 24 | 25 | class City(models.Model): 26 | name = models.CharField(max_length=255) 27 | country = models.ForeignKey('Country', related_name="cities") 28 | 29 | 30 | Customizing a Form 31 | `````````````````` 32 | 33 | Lets link two widgets via a *dependent_fields* dictionary. The key represents the name of 34 | the field in the form. The value represents the name of the field in the model (used in `queryset`). 35 | 36 | .. code-block:: python 37 | :emphasize-lines: 17 38 | 39 | class AddressForm(forms.Form): 40 | country = forms.ModelChoiceField( 41 | queryset=Country.objects.all(), 42 | label="Country", 43 | widget=ModelSelect2Widget( 44 | model=Country, 45 | search_fields=['name__icontains'], 46 | ) 47 | ) 48 | 49 | city = forms.ModelChoiceField( 50 | queryset=City.objects.all(), 51 | label="City", 52 | widget=ModelSelect2Widget( 53 | model=City, 54 | search_fields=['name__icontains'], 55 | dependent_fields={'country': 'country'}, 56 | max_results=500, 57 | ) 58 | ) 59 | 60 | 61 | Interdependent select2 62 | ---------------------- 63 | 64 | Also you may want not to restrict the user to which field should be selected first. 65 | Instead you want to suggest to the user options for any select2 depending of his selection in another one. 66 | 67 | Customize the form in a manner: 68 | 69 | .. code-block:: python 70 | :emphasize-lines: 7 71 | 72 | class AddressForm(forms.Form): 73 | country = forms.ModelChoiceField( 74 | queryset=Country.objects.all(), 75 | label="Country", 76 | widget=ModelSelect2Widget( 77 | search_fields=['name__icontains'], 78 | dependent_fields={'city': 'cities'}, 79 | ) 80 | ) 81 | 82 | city = forms.ModelChoiceField( 83 | queryset=City.objects.all(), 84 | label="City", 85 | widget=ModelSelect2Widget( 86 | search_fields=['name__icontains'], 87 | dependent_fields={'country': 'country'}, 88 | max_results=500, 89 | ) 90 | ) 91 | 92 | Take attention to country's dependent_fields. The value of 'city' is 'cities' because of 93 | related name used in a filter condition `cities` which differs from widget field name `city`. 94 | 95 | .. caution:: 96 | Be aware of using interdependent select2 in parent-child relation. 97 | When a child is selected, you are restricted to change parent (only one value is available). 98 | Probably you should let the user reset the child first to release parent select2. 99 | 100 | 101 | Multi-dependent select2 102 | ----------------------- 103 | 104 | Furthermore you may want to filter options on two or more select2 selections (some code is dropped for clarity): 105 | 106 | .. code-block:: python 107 | :emphasize-lines: 14 108 | 109 | class SomeForm(forms.Form): 110 | field1 = forms.ModelChoiceField( 111 | widget=ModelSelect2Widget( 112 | ) 113 | ) 114 | 115 | field2 = forms.ModelChoiceField( 116 | widget=ModelSelect2Widget( 117 | ) 118 | ) 119 | 120 | field3 = forms.ModelChoiceField( 121 | widget=ModelSelect2Widget( 122 | dependent_fields={'field1': 'field1', 'field2': 'field2'}, 123 | ) 124 | ) 125 | 126 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | Installation 4 | ------------ 5 | 6 | Install ``django-select2``:: 7 | 8 | python3 -m pip install django-select2 9 | 10 | Add ``django_select2`` to your ``INSTALLED_APPS`` in your project settings. 11 | Since version 8, please ensure that Django's admin app is enabled too: 12 | 13 | .. code-block:: python 14 | 15 | INSTALLED_APPS = [ 16 | # other django apps… 17 | 'django.contrib.admin', 18 | # other 3rd party apps… 19 | 'django_select2', 20 | ] 21 | 22 | Add ``django_select`` to your URL root configuration: 23 | 24 | .. code-block:: python 25 | 26 | from django.urls import include, path 27 | 28 | urlpatterns = [ 29 | # other patterns… 30 | path("select2/", include("django_select2.urls")), 31 | # other patterns… 32 | ] 33 | 34 | 35 | The :ref:`Model` -widgets require a **persistent** cache backend across 36 | all application servers. This is because the widget needs to store 37 | meta data to be able to fetch the results based on the user input. 38 | 39 | **This means that the** :class:`.DummyCache` **backend will not work!** 40 | 41 | The default cache backend is :class:`.LocMemCache`, which is persistent 42 | across a single node. For projects with a single application server 43 | this will work fine, however you will run into issues when 44 | you scale up into multiple servers. 45 | 46 | Below is an example setup using Redis, which is a solution that 47 | works for multi-server setups: 48 | 49 | Make sure you have a Redis server up and running:: 50 | 51 | # Debian 52 | sudo apt-get install redis-server 53 | 54 | # macOS 55 | brew install redis 56 | 57 | # install Redis python client 58 | python3 -m pip install django-redis 59 | 60 | Next, add the cache configuration to your ``settings.py`` as follows: 61 | 62 | .. code-block:: python 63 | 64 | CACHES = { 65 | # … default cache config and others 66 | "select2": { 67 | "BACKEND": "django_redis.cache.RedisCache", 68 | "LOCATION": "redis://127.0.0.1:6379/2", 69 | "OPTIONS": { 70 | "CLIENT_CLASS": "django_redis.client.DefaultClient", 71 | } 72 | } 73 | } 74 | 75 | # Tell select2 which cache configuration to use: 76 | SELECT2_CACHE_BACKEND = "select2" 77 | 78 | .. note:: 79 | A custom timeout for your cache backend, will serve as an indirect session limit. 80 | Auto select fields will stop working after, once the cache has expired. 81 | It's recommended to use a dedicated cache database with an adequate 82 | cache replacement policy such as LRU, FILO, etc. 83 | 84 | 85 | External Dependencies 86 | --------------------- 87 | 88 | - jQuery is not included in the package since it is 89 | expected that in most scenarios this would already be available. 90 | 91 | 92 | Quick Start 93 | ----------- 94 | 95 | Here is a quick example to get you started: 96 | 97 | First make sure you followed the installation instructions above. 98 | Once everything is setup, let's start with a simple example. 99 | 100 | We have the following model: 101 | 102 | .. code-block:: python 103 | 104 | # models.py 105 | from django.conf import settings 106 | from django.db import models 107 | 108 | 109 | class Book(models.Model): 110 | author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 111 | co_authors = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='co_authored_by') 112 | 113 | 114 | Next, we create a model form with custom Select2 widgets. 115 | 116 | .. code-block:: python 117 | 118 | # forms.py 119 | from django import forms 120 | from django_select2 import forms as s2forms 121 | 122 | from . import models 123 | 124 | 125 | class AuthorWidget(s2forms.ModelSelect2Widget): 126 | search_fields = [ 127 | "username__icontains", 128 | "email__icontains", 129 | ] 130 | 131 | 132 | class CoAuthorsWidget(s2forms.ModelSelect2MultipleWidget): 133 | search_fields = [ 134 | "username__icontains", 135 | "email__icontains", 136 | ] 137 | 138 | 139 | class BookForm(forms.ModelForm): 140 | class Meta: 141 | model = models.Book 142 | fields = "__all__" 143 | widgets = { 144 | "author": AuthorWidget, 145 | "co_authors": CoAuthorsWidget, 146 | } 147 | 148 | A simple class based view will do, to render your form: 149 | 150 | .. code-block:: python 151 | 152 | # views.py 153 | from django.views import generic 154 | 155 | from . import forms, models 156 | 157 | 158 | class BookCreateView(generic.CreateView): 159 | model = models.Book 160 | form_class = forms.BookForm 161 | success_url = "/" 162 | 163 | Make sure to add the view to your ``urls.py``: 164 | 165 | .. code-block:: python 166 | 167 | # urls.py 168 | from django.urls import include, path 169 | 170 | from . import views 171 | 172 | urlpatterns = [ 173 | # … other patterns 174 | path("select2/", include("django_select2.urls")), 175 | # … other patterns 176 | path("book/create", views.BookCreateView.as_view(), name="book-create"), 177 | ] 178 | 179 | 180 | Finally, we need a little template, ``myapp/templates/myapp/book_form.html`` 181 | 182 | .. code-block:: HTML 183 | 184 | 185 | 186 | 187 | Create Book 188 | {{ form.media.css }} 189 | 192 | 193 | 194 |

Create a new Book

195 |
196 | {% csrf_token %} 197 | {{ form.as_p }} 198 | 199 |
200 | 201 | {{ form.media.js }} 202 | 203 | 204 | 205 | Done - enjoy the wonders of Select2! 206 | 207 | 208 | Changelog 209 | --------- 210 | 211 | See `Github releases`_. 212 | 213 | .. _Github releases: https://github.com/codingjoe/django-select2/releases 214 | 215 | All Contents 216 | ============ 217 | 218 | Contents: 219 | 220 | .. toctree:: 221 | :maxdepth: 2 222 | :glob: 223 | 224 | django_select2 225 | extra 226 | CONTRIBUTING 227 | 228 | Indices and tables 229 | ================== 230 | 231 | * :ref:`genindex` 232 | * :ref:`modindex` 233 | * :ref:`search` 234 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Sample App 2 | 3 | Before you start, make sure you have Redis installed, since 4 | we need if for caching purposes. 5 | 6 | ``` 7 | # Debian 8 | sudo apt-get install redis-server -y 9 | # macOS 10 | brew install redis 11 | ``` 12 | 13 | Now, to run the sample app, please execute: 14 | 15 | ``` 16 | git clone https://github.com/codingjoe/django-select2.git 17 | cd django-select2/example 18 | python3 -m pip install -r requirements.txt 19 | python3 manage.py migrate 20 | python3 manage.py createsuperuser 21 | # follow the instructions to create a superuser 22 | python3 manage.py runserver 23 | # follow the instructions and open your browser 24 | ``` 25 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingjoe/django-select2/e5f41e6edba004d35f94915ff5e2559f44853412/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for example project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /example/example/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from django_select2 import forms as s2forms 4 | 5 | from . import models 6 | 7 | 8 | class AuthorWidget(s2forms.ModelSelect2Widget): 9 | search_fields = ["username__istartswith", "email__icontains"] 10 | 11 | 12 | class CoAuthorsWidget(s2forms.ModelSelect2MultipleWidget): 13 | search_fields = ["username__istartswith", "email__icontains"] 14 | 15 | 16 | class BookForm(forms.ModelForm): 17 | class Meta: 18 | model = models.Book 19 | fields = "__all__" 20 | widgets = { 21 | "author": AuthorWidget, 22 | "co_authors": CoAuthorsWidget, 23 | } 24 | -------------------------------------------------------------------------------- /example/example/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1a1 on 2020-05-23 17:06 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Book", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ( 29 | "author", 30 | models.ForeignKey( 31 | on_delete=django.db.models.deletion.CASCADE, 32 | to=settings.AUTH_USER_MODEL, 33 | ), 34 | ), 35 | ( 36 | "co_authors", 37 | models.ManyToManyField( 38 | related_name="co_authored_by", to=settings.AUTH_USER_MODEL 39 | ), 40 | ), 41 | ], 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /example/example/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingjoe/django-select2/e5f41e6edba004d35f94915ff5e2559f44853412/example/example/migrations/__init__.py -------------------------------------------------------------------------------- /example/example/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | 4 | 5 | class Book(models.Model): 6 | author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 7 | co_authors = models.ManyToManyField( 8 | settings.AUTH_USER_MODEL, related_name="co_authored_by" 9 | ) 10 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1a1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/dev/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve(strict=True).parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "kstexlapcf3lucx@47mmxsu9-9eixia+6n97aw)4$qo&!laxad" # nosec 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "django_select2", 41 | "example", 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "example.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [BASE_DIR / "templates"], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = "example.wsgi.application" 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/dev/ref/settings/#databases 77 | 78 | DATABASES = { 79 | "default": { 80 | "ENGINE": "django.db.backends.sqlite3", 81 | "NAME": BASE_DIR / "db.sqlite3", 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 92 | }, 93 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 94 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 95 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 96 | ] 97 | 98 | 99 | # Internationalization 100 | # https://docs.djangoproject.com/en/dev/topics/i18n/ 101 | 102 | LANGUAGE_CODE = "en-us" 103 | 104 | TIME_ZONE = "UTC" 105 | 106 | USE_I18N = True 107 | 108 | USE_TZ = True 109 | 110 | 111 | # Static files (CSS, JavaScript, Images) 112 | # https://docs.djangoproject.com/en/dev/howto/static-files/ 113 | 114 | STATIC_URL = "/static/" 115 | 116 | CACHES = { 117 | "default": { 118 | "BACKEND": "django.core.cache.backends.redis.RedisCache", 119 | "LOCATION": "redis://127.0.0.1:6379/1", 120 | }, 121 | "select2": { 122 | "BACKEND": "django.core.cache.backends.redis.RedisCache", 123 | "LOCATION": "redis://127.0.0.1:6379/2", 124 | }, 125 | } 126 | 127 | SELECT2_CACHE_BACKEND = "select2" 128 | -------------------------------------------------------------------------------- /example/example/templates/example/book_form.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | Create Book 5 | {{ form.media.css }} 6 | 9 | 10 | 11 |

Create a new Book

12 |
13 | {% csrf_token %} 14 | {{ form.as_p }} 15 | 16 |
17 | 18 | {{ form.media.js }} 19 | 20 | 21 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path("", views.BookCreateView.as_view(), name="book-create"), 8 | path("select2/", include("django_select2.urls")), 9 | path("admin/", admin.site.urls), 10 | ] 11 | -------------------------------------------------------------------------------- /example/example/views.py: -------------------------------------------------------------------------------- 1 | from django.views import generic 2 | 3 | from . import forms, models 4 | 5 | 6 | class BookCreateView(generic.CreateView): 7 | model = models.Book 8 | form_class = forms.BookForm 9 | success_url = "/" 10 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | -e .. 2 | redis 3 | -------------------------------------------------------------------------------- /linter-requirements.txt: -------------------------------------------------------------------------------- 1 | bandit==1.8.3 2 | black==25.1.0 3 | flake8==7.2.0 4 | isort==6.0.1 5 | pydocstyle[toml]==6.3.0 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-select2", 3 | "version": "0.0.0", 4 | "description": "This is a Django integration of Select2.", 5 | "files": [ 6 | "django_select2/static/**/*" 7 | ], 8 | "main": "django_select2/static/django_select2/django_select2.js", 9 | "directories": { 10 | "doc": "docs", 11 | "test": "tests" 12 | }, 13 | "scripts": { 14 | "test": "standard" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git://github.com/codingjoe/django-select2.git" 19 | }, 20 | "keywords": [ 21 | "django", 22 | "select2" 23 | ], 24 | "author": "Johannes Hoppe", 25 | "license": "Apache-2.0", 26 | "bugs": { 27 | "url": "https://github.com/codingjoe/django-select2/issues" 28 | }, 29 | "homepage": "https://github.com/codingjoe/django-select2#readme", 30 | "peerDependencies": { 31 | "select2": "*", 32 | "jquery": ">= 1.2" 33 | }, 34 | "devDependencies": { 35 | "standard": "*" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core>=3.2", "flit_scm", "wheel"] 3 | build-backend = "flit_scm:buildapi" 4 | 5 | [project] 6 | name = "django-select2" 7 | authors = [ 8 | { name = "Johannes Maron", email = "johannes@maron.family" }, 9 | ] 10 | readme = "README.rst" 11 | license = { file = "LICENSE" } 12 | keywords = ["Django", "select2", "autocomplete", "typeahead"] 13 | dynamic = ["version", "description"] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "License :: OSI Approved :: MIT License", 17 | "Intended Audience :: Developers", 18 | "Environment :: Web Environment", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3 :: Only", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Programming Language :: Python :: 3.13", 27 | "Framework :: Django", 28 | "Framework :: Django :: 4.2", 29 | "Framework :: Django :: 5.1", 30 | "Topic :: Software Development", 31 | ] 32 | requires-python = ">=3.10" 33 | dependencies = [ 34 | "django>=4.2", 35 | "django-appconf>=0.6.0" 36 | ] 37 | 38 | [project.optional-dependencies] 39 | test = [ 40 | "pytest", 41 | "pytest-cov", 42 | "pytest-django", 43 | "selenium", 44 | ] 45 | selenium = [ 46 | "selenium", 47 | ] 48 | docs = [ 49 | "sphinx", 50 | ] 51 | 52 | [project.urls] 53 | Project-URL = "https://github.com/codingjoe/django-select2" 54 | Changelog = "https://github.com/codingjoe/django-select2/releases" 55 | Source = "https://github.com/codingjoe/django-select2" 56 | Documentation = "https://django-select2.rtfd.io/" 57 | Issue-Tracker = "https://github.com/codingjoe/django-select2/issues" 58 | 59 | [tool.flit.module] 60 | name = "django_select2" 61 | 62 | [tool.setuptools_scm] 63 | write_to = "django_select2/_version.py" 64 | 65 | [tool.pytest.ini_options] 66 | minversion = "6.0" 67 | addopts = "--cov --tb=short -rxs" 68 | testpaths = ["tests"] 69 | DJANGO_SETTINGS_MODULE = "tests.testapp.settings" 70 | filterwarnings = ["ignore::PendingDeprecationWarning", "error::RuntimeWarning"] 71 | 72 | [tool.coverage.run] 73 | source = ["django_select2"] 74 | 75 | [tool.coverage.report] 76 | show_missing = true 77 | 78 | [tool.isort] 79 | atomic = true 80 | line_length = 88 81 | multi_line_output = 3 82 | include_trailing_comma = true 83 | force_grid_wrap = 0 84 | use_parentheses = true 85 | known_first_party = "django_select2, tests" 86 | default_section = "THIRDPARTY" 87 | combine_as_imports = true 88 | 89 | [tool.pydocstyle] 90 | add_ignore = "D1" 91 | -------------------------------------------------------------------------------- /set_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Set the version in NPM's package.json to match the git tag.""" 3 | import json 4 | 5 | from setuptools_scm import get_version 6 | 7 | if __name__ == "__main__": 8 | with open("package.json", "r+") as f: 9 | data = json.load(f) 10 | f.seek(0) 11 | data["version"] = get_version(root=".", relative_to=__file__) 12 | json.dump(data, f) 13 | f.truncate() 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=88 3 | select = C,E,F,W,B,B950 4 | ignore = E203, E501, W503, E731 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingjoe/django-select2/e5f41e6edba004d35f94915ff5e2559f44853412/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | import pytest 5 | from selenium import webdriver 6 | from selenium.common.exceptions import WebDriverException 7 | 8 | 9 | def pytest_configure(config): 10 | config.addinivalue_line("markers", "selenium: skip if selenium is not installed") 11 | 12 | 13 | def random_string(n): 14 | return "".join( 15 | random.choice(string.ascii_uppercase + string.digits) for _ in range(n) 16 | ) 17 | 18 | 19 | def random_name(n): 20 | words = ( 21 | "".join(random.choice(string.ascii_lowercase + " ") for _ in range(n)) 22 | .strip() 23 | .split(" ") 24 | ) 25 | return "-".join([x.capitalize() for x in words]) 26 | 27 | 28 | @pytest.fixture(scope="session") 29 | def driver(): 30 | chrome_options = webdriver.ChromeOptions() 31 | chrome_options.headless = True 32 | try: 33 | b = webdriver.Chrome(options=chrome_options) 34 | except WebDriverException as e: 35 | pytest.skip(str(e)) 36 | else: 37 | yield b 38 | b.quit() 39 | 40 | 41 | @pytest.fixture 42 | def genres(db): 43 | from .testapp.models import Genre 44 | 45 | return Genre.objects.bulk_create( 46 | [Genre(pk=pk, title=random_string(50)) for pk in range(100)] 47 | ) 48 | 49 | 50 | @pytest.fixture 51 | def artists(db): 52 | from .testapp.models import Artist 53 | 54 | return Artist.objects.bulk_create( 55 | [Artist(pk=pk, title=random_string(50)) for pk in range(100)] 56 | ) 57 | 58 | 59 | @pytest.fixture 60 | def countries(db): 61 | from .testapp.models import Country 62 | 63 | return Country.objects.bulk_create( 64 | [Country(pk=pk, name=random_name(random.randint(10, 20))) for pk in range(10)] 65 | ) 66 | 67 | 68 | @pytest.fixture 69 | def cities(db, countries): 70 | from .testapp.models import City 71 | 72 | return City.objects.bulk_create( 73 | [ 74 | City( 75 | pk=pk, 76 | name=random_name(random.randint(5, 15)), 77 | country=random.choice(countries), 78 | ) 79 | for pk in range(100) 80 | ] 81 | ) 82 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | def test_default_cache(): 2 | from django_select2.cache import cache 3 | 4 | cache.set("key", "value") 5 | 6 | assert cache.get("key") == "value" 7 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from collections.abc import Iterable 4 | 5 | import pytest 6 | from django.db.models import QuerySet 7 | from django.urls import reverse 8 | from django.utils import translation 9 | from django.utils.encoding import force_str 10 | from selenium.common.exceptions import NoSuchElementException 11 | from selenium.webdriver.common.by import By 12 | from selenium.webdriver.support import expected_conditions 13 | from selenium.webdriver.support.wait import WebDriverWait 14 | 15 | from django_select2.cache import cache 16 | from django_select2.forms import ( 17 | HeavySelect2MultipleWidget, 18 | HeavySelect2Widget, 19 | ModelSelect2TagWidget, 20 | ModelSelect2Widget, 21 | Select2AdminMixin, 22 | Select2Widget, 23 | ) 24 | from tests.testapp import forms 25 | from tests.testapp.forms import ( 26 | NUMBER_CHOICES, 27 | HeavySelect2MultipleWidgetForm, 28 | TitleModelSelect2Widget, 29 | ) 30 | from tests.testapp.models import Artist, City, Country, Genre, Groupie 31 | 32 | 33 | class TestSelect2Mixin: 34 | url = reverse("select2_widget") 35 | form = forms.AlbumSelect2WidgetForm() 36 | multiple_form = forms.AlbumSelect2MultipleWidgetForm() 37 | widget_cls = Select2Widget 38 | 39 | def test_initial_data(self, genres): 40 | genre = genres[0] 41 | form = self.form.__class__(initial={"primary_genre": genre.pk}) 42 | assert str(genre) in form.as_p() 43 | 44 | def test_initial_form_class(self): 45 | widget = self.widget_cls(attrs={"class": "my-class"}) 46 | assert "my-class" in widget.render("name", None) 47 | assert "django-select2" in widget.render("name", None) 48 | 49 | def test_lang_attr(self): 50 | with translation.override("de"): 51 | widget = Select2Widget() 52 | assert 'lang="de"' in widget.render("name", None) 53 | 54 | # Regression test for #163 55 | widget = Select2Widget() 56 | assert widget.i18n_name == "en" 57 | with translation.override("de"): 58 | assert widget.i18n_name == "de" 59 | 60 | def test_allow_clear(self, db): 61 | required_field = self.form.fields["artist"] 62 | assert required_field.required is True 63 | assert 'data-allow-clear="true"' not in required_field.widget.render( 64 | "artist", None 65 | ) 66 | assert 'data-allow-clear="false"' in required_field.widget.render( 67 | "artist", None 68 | ) 69 | assert '' not in required_field.widget.render( 70 | "artist", None 71 | ) 72 | 73 | not_required_field = self.form.fields["primary_genre"] 74 | assert not_required_field.required is False 75 | assert 'data-allow-clear="true"' in not_required_field.widget.render( 76 | "primary_genre", None 77 | ) 78 | assert 'data-allow-clear="false"' not in not_required_field.widget.render( 79 | "primary_genre", None 80 | ) 81 | assert "data-placeholder" in not_required_field.widget.render( 82 | "primary_genre", None 83 | ) 84 | assert '' in not_required_field.widget.render( 85 | "primary_genre", None 86 | ) 87 | 88 | @pytest.mark.selenium 89 | def test_no_js_error(self, db, live_server, driver): 90 | driver.get(live_server + self.url) 91 | with pytest.raises(NoSuchElementException): 92 | error = driver.find_element(By.XPATH, "//body[@JSError]") 93 | pytest.fail(error.get_attribute("JSError")) 94 | 95 | @pytest.mark.selenium 96 | def test_selecting(self, db, live_server, driver): 97 | driver.get(live_server + self.url) 98 | with pytest.raises(NoSuchElementException): 99 | driver.find_element(By.CSS_SELECTOR, ".select2-results") 100 | elem = driver.find_element(By.CSS_SELECTOR, ".select2-selection") 101 | elem.click() 102 | results = driver.find_element(By.CSS_SELECTOR, ".select2-results") 103 | assert results.is_displayed() is True 104 | elem = results.find_element(By.CSS_SELECTOR, ".select2-results__option") 105 | elem.click() 106 | 107 | with pytest.raises(NoSuchElementException): 108 | error = driver.find_element(By.XPATH, "//body[@JSError]") 109 | pytest.fail(error.get_attribute("JSError")) 110 | 111 | def test_data_url(self): 112 | with pytest.raises(ValueError): 113 | HeavySelect2Widget() 114 | 115 | widget = HeavySelect2Widget(data_url="/foo/bar") 116 | assert widget.get_url() == "/foo/bar" 117 | 118 | def test_empty_option(self, db): 119 | # Empty options is only required for single selects 120 | # https://select2.github.io/options.html#allowClear 121 | single_select = self.form.fields["primary_genre"] 122 | assert single_select.required is False 123 | assert '' in single_select.widget.render( 124 | "primary_genre", None 125 | ) 126 | 127 | multiple_select = self.multiple_form.fields["featured_artists"] 128 | assert multiple_select.required is False 129 | assert multiple_select.widget.allow_multiple_selected 130 | output = multiple_select.widget.render("featured_artists", None) 131 | assert '' not in output 132 | assert 'data-placeholder=""' in output 133 | 134 | def test_i18n(self): 135 | translation.activate("de") 136 | assert tuple(Select2Widget().media._js) == ( 137 | "admin/js/vendor/select2/select2.full.min.js", 138 | "admin/js/vendor/select2/i18n/de.js", 139 | "django_select2/django_select2.js", 140 | ) 141 | 142 | translation.activate("en") 143 | assert tuple(Select2Widget().media._js) == ( 144 | "admin/js/vendor/select2/select2.full.min.js", 145 | "admin/js/vendor/select2/i18n/en.js", 146 | "django_select2/django_select2.js", 147 | ) 148 | 149 | translation.activate("00") 150 | assert tuple(Select2Widget().media._js) == ( 151 | "admin/js/vendor/select2/select2.full.min.js", 152 | "django_select2/django_select2.js", 153 | ) 154 | 155 | translation.activate("sr-Cyrl") 156 | assert tuple(Select2Widget().media._js) == ( 157 | "admin/js/vendor/select2/select2.full.min.js", 158 | "admin/js/vendor/select2/i18n/sr-Cyrl.js", 159 | "django_select2/django_select2.js", 160 | ) 161 | 162 | pytest.importorskip("django", minversion="2.0.4") 163 | 164 | translation.activate("zh-hans") 165 | assert tuple(Select2Widget().media._js) == ( 166 | "admin/js/vendor/select2/select2.full.min.js", 167 | "admin/js/vendor/select2/i18n/zh-CN.js", 168 | "django_select2/django_select2.js", 169 | ) 170 | 171 | translation.activate("zh-hant") 172 | assert tuple(Select2Widget().media._js) == ( 173 | "admin/js/vendor/select2/select2.full.min.js", 174 | "admin/js/vendor/select2/i18n/zh-TW.js", 175 | "django_select2/django_select2.js", 176 | ) 177 | 178 | def test_theme_setting(self, settings): 179 | settings.SELECT2_THEME = "classic" 180 | widget = self.widget_cls() 181 | assert 'data-theme="classic"' in widget.render("name", None) 182 | 183 | 184 | class TestSelect2AdminMixin: 185 | def test_media(self): 186 | translation.activate("en") 187 | assert tuple(Select2AdminMixin().media._js) == ( 188 | "admin/js/vendor/select2/select2.full.min.js", 189 | "admin/js/vendor/select2/i18n/en.js", 190 | "admin/js/jquery.init.js", 191 | "django_select2/django_select2.js", 192 | ) 193 | 194 | assert dict(Select2AdminMixin().media._css) == { 195 | "screen": [ 196 | "admin/css/vendor/select2/select2.min.css", 197 | "admin/css/autocomplete.css", 198 | "django_select2/django_select2.css", 199 | ] 200 | } 201 | 202 | 203 | class TestSelect2MixinSettings: 204 | def test_default_media(self): 205 | sut = Select2Widget() 206 | result = sut.media.render() 207 | assert "admin/js/vendor/select2/select2.full.min.js" in result 208 | assert "admin/css/vendor/select2/select2.min.css" in result 209 | assert "django_select2/django_select2.js" in result 210 | 211 | def test_js_setting(self, settings): 212 | settings.SELECT2_JS = "alternate.js" 213 | sut = Select2Widget() 214 | result = sut.media.render() 215 | assert "alternate.js" in result 216 | assert "django_select2/django_select2.js" in result 217 | 218 | def test_empty_js_setting(self, settings): 219 | settings.SELECT2_JS = "" 220 | sut = Select2Widget() 221 | result = sut.media.render() 222 | assert "django_select2/django_select2.js" in result 223 | 224 | def test_css_setting(self, settings): 225 | settings.SELECT2_CSS = "alternate.css" 226 | sut = Select2Widget() 227 | result = sut.media.render() 228 | assert "alternate.css" in result 229 | 230 | def test_empty_css_setting(self, settings): 231 | settings.SELECT2_CSS = "" 232 | sut = Select2Widget() 233 | result = sut.media.render() 234 | assert "/select2.css" not in result 235 | 236 | def test_multiple_css_setting(self, settings): 237 | settings.SELECT2_CSS = ["select2.css", "select2-theme.css"] 238 | sut = Select2Widget() 239 | result = sut.media.render() 240 | assert "select2.css" in result 241 | assert "select2-theme.css" in result 242 | 243 | 244 | class TestHeavySelect2Mixin(TestSelect2Mixin): 245 | url = reverse("heavy_select2_widget") 246 | form = forms.HeavySelect2WidgetForm(initial={"primary_genre": 1}) 247 | widget_cls = HeavySelect2Widget 248 | 249 | def test_initial_data(self): 250 | assert "One" in self.form.as_p() 251 | 252 | def test_initial_form_class(self): 253 | widget = self.widget_cls(data_view="heavy_data_1", attrs={"class": "my-class"}) 254 | assert "my-class" in widget.render("name", None) 255 | assert "django-select2" in widget.render("name", None) 256 | assert "django-select2-heavy" in widget.render("name", None), widget.render( 257 | "name", None 258 | ) 259 | 260 | def test_lang_attr(self): 261 | with translation.override("fr"): 262 | widget = self.widget_cls(data_view="heavy_data_1") 263 | assert 'lang="fr"' in widget.render("name", None) 264 | 265 | def test_selected_option(self, db): 266 | not_required_field = self.form.fields["primary_genre"] 267 | assert not_required_field.required is False 268 | assert ( 269 | '' 270 | in not_required_field.widget.render("primary_genre", 1) 271 | or '' 272 | in not_required_field.widget.render("primary_genre", 1) 273 | ), not_required_field.widget.render( 274 | "primary_genre", 1 275 | ) 276 | 277 | def test_many_selected_option(self, db, genres): 278 | field = HeavySelect2MultipleWidgetForm().fields["genres"] 279 | field.widget.choices = NUMBER_CHOICES 280 | widget_output = field.widget.render("genres", [1, 2]) 281 | selected_option = ( 282 | ''.format( 283 | pk=1, value="One" 284 | ) 285 | ) 286 | selected_option_a = ''.format( 287 | pk=1, value="One" 288 | ) 289 | selected_option2 = ( 290 | ''.format( 291 | pk=2, value="Two" 292 | ) 293 | ) 294 | selected_option2a = ''.format( 295 | pk=2, value="Two" 296 | ) 297 | 298 | assert ( 299 | selected_option in widget_output or selected_option_a in widget_output 300 | ), widget_output 301 | assert selected_option2 in widget_output or selected_option2a in widget_output 302 | 303 | @pytest.mark.selenium 304 | def test_multiple_widgets(self, db, live_server, driver): 305 | driver.get(live_server + self.url) 306 | with pytest.raises(NoSuchElementException): 307 | driver.find_element(By.CSS_SELECTOR, ".select2-results") 308 | 309 | elem1, elem2 = driver.find_elements(By.CSS_SELECTOR, ".select2-selection") 310 | 311 | elem1.click() 312 | search1 = driver.find_element(By.CSS_SELECTOR, ".select2-search__field") 313 | search1.send_keys("fo") 314 | result1 = ( 315 | WebDriverWait(driver, 60) 316 | .until( 317 | expected_conditions.presence_of_element_located( 318 | (By.CSS_SELECTOR, ".select2-results li:first-child") 319 | ) 320 | ) 321 | .text 322 | ) 323 | 324 | elem2.click() 325 | search2 = driver.find_element(By.CSS_SELECTOR, ".select2-search__field") 326 | search2.send_keys("fo") 327 | result2 = ( 328 | WebDriverWait(driver, 60) 329 | .until( 330 | expected_conditions.presence_of_element_located( 331 | (By.CSS_SELECTOR, ".select2-results li:first-child") 332 | ) 333 | ) 334 | .text 335 | ) 336 | 337 | assert result1 != result2 338 | 339 | with pytest.raises(NoSuchElementException): 340 | error = driver.find_element(By.XPATH, "//body[@JSError]") 341 | pytest.fail(error.get_attribute("JSError")) 342 | 343 | def test_get_url(self): 344 | widget = self.widget_cls(data_view="heavy_data_1", attrs={"class": "my-class"}) 345 | assert isinstance(widget.get_url(), str) 346 | 347 | def test_can_not_pickle(self): 348 | widget = self.widget_cls(data_view="heavy_data_1", attrs={"class": "my-class"}) 349 | 350 | class NoPickle: 351 | pass 352 | 353 | widget.no_pickle = NoPickle() 354 | with pytest.raises(NotImplementedError): 355 | widget.set_to_cache() 356 | 357 | def test_theme_setting(self, settings): 358 | settings.SELECT2_THEME = "classic" 359 | widget = self.widget_cls(data_view="heavy_data_1") 360 | assert 'data-theme="classic"' in widget.render("name", None) 361 | 362 | def test_cache_key_leak(self): 363 | bob = self.widget_cls(data_url="/test/") 364 | alice = self.widget_cls(data_url="/test/") 365 | bob.render("name", "value") 366 | bob_key_request_1 = bob._get_cache_key() 367 | alice.render("name", "value") 368 | assert bob._get_cache_key() != alice._get_cache_key() 369 | bob.render("name", "value") 370 | bob_key_request_2 = bob._get_cache_key() 371 | assert bob_key_request_1 != bob_key_request_2 372 | 373 | 374 | class TestModelSelect2Mixin(TestHeavySelect2Mixin): 375 | form = forms.AlbumModelSelect2WidgetForm(initial={"primary_genre": 1}) 376 | multiple_form = forms.ArtistModelSelect2MultipleWidgetForm() 377 | 378 | def test_initial_data(self, genres): 379 | genre = genres[0] 380 | form = self.form.__class__(initial={"primary_genre": genre.pk}) 381 | assert str(genre) in form.as_p() 382 | 383 | def test_label_from_instance_initial(self, genres): 384 | genre = genres[0] 385 | genre.title = genre.title.lower() 386 | genre.save() 387 | 388 | form = self.form.__class__(initial={"primary_genre": genre.pk}) 389 | assert genre.title not in form.as_p(), form.as_p() 390 | assert genre.title.upper() in form.as_p() 391 | 392 | @pytest.fixture(autouse=True) 393 | def genres(self, genres): 394 | return genres 395 | 396 | def test_selected_option(self, db, genres): 397 | genre = genres[0] 398 | genre2 = genres[1] 399 | not_required_field = self.form.fields["primary_genre"] 400 | assert not_required_field.required is False 401 | widget_output = not_required_field.widget.render("primary_genre", genre.pk) 402 | selected_option = ( 403 | ''.format( 404 | pk=genre.pk, value=force_str(genre) 405 | ) 406 | ) 407 | selected_option_a = ''.format( 408 | pk=genre.pk, value=force_str(genre) 409 | ) 410 | unselected_option = ''.format( 411 | pk=genre2.pk, value=force_str(genre2) 412 | ) 413 | 414 | assert ( 415 | selected_option in widget_output or selected_option_a in widget_output 416 | ), widget_output 417 | assert unselected_option not in widget_output 418 | 419 | def test_selected_option_label_from_instance(self, db, genres): 420 | genre = genres[0] 421 | genre.title = genre.title.lower() 422 | genre.save() 423 | 424 | field = self.form.fields["primary_genre"] 425 | widget_output = field.widget.render("primary_genre", genre.pk) 426 | 427 | def get_selected_options(genre): 428 | return ( 429 | ''.format( 430 | pk=genre.pk, value=force_str(genre) 431 | ), 432 | ''.format( 433 | pk=genre.pk, value=force_str(genre) 434 | ), 435 | ) 436 | 437 | assert all(o not in widget_output for o in get_selected_options(genre)) 438 | genre.title = genre.title.upper() 439 | 440 | assert any(o in widget_output for o in get_selected_options(genre)) 441 | 442 | def test_get_queryset(self): 443 | widget = ModelSelect2Widget() 444 | with pytest.raises(NotImplementedError): 445 | widget.get_queryset() 446 | widget.model = Genre 447 | assert isinstance(widget.get_queryset(), QuerySet) 448 | widget.model = None 449 | widget.queryset = Genre.objects.all() 450 | assert isinstance(widget.get_queryset(), QuerySet) 451 | 452 | def test_result_from_instance_ModelSelect2Widget(self, genres): 453 | widget = ModelSelect2Widget() 454 | widget.model = Genre 455 | genre = Genre.objects.first() 456 | assert widget.result_from_instance(genre, request=None) == { 457 | "id": genre.pk, 458 | "text": str(genre), 459 | } 460 | 461 | def test_tag_attrs_Select2Widget(self): 462 | widget = Select2Widget() 463 | output = widget.render("name", "value") 464 | assert 'data-minimum-input-length="0"' in output 465 | 466 | def test_custom_tag_attrs_Select2Widget(self): 467 | widget = Select2Widget(attrs={"data-minimum-input-length": "3"}) 468 | output = widget.render("name", "value") 469 | assert 'data-minimum-input-length="3"' in output 470 | 471 | def test_tag_attrs_ModelSelect2Widget(self): 472 | widget = ModelSelect2Widget( 473 | queryset=Genre.objects.all(), search_fields=["title__icontains"] 474 | ) 475 | output = widget.render("name", "value") 476 | assert 'data-minimum-input-length="2"' in output 477 | 478 | def test_tag_attrs_ModelSelect2TagWidget(self): 479 | widget = ModelSelect2TagWidget( 480 | queryset=Genre.objects.all(), search_fields=["title__icontains"] 481 | ) 482 | output = widget.render("name", "value") 483 | assert 'data-minimum-input-length="2"' in output 484 | 485 | def test_tag_attrs_HeavySelect2Widget(self): 486 | widget = HeavySelect2Widget(data_url="/foo/bar/") 487 | output = widget.render("name", "value") 488 | assert 'data-minimum-input-length="2"' in output 489 | 490 | def test_custom_tag_attrs_ModelSelect2Widget(self): 491 | widget = ModelSelect2Widget( 492 | queryset=Genre.objects.all(), 493 | search_fields=["title__icontains"], 494 | attrs={"data-minimum-input-length": "3"}, 495 | ) 496 | output = widget.render("name", "value") 497 | assert 'data-minimum-input-length="3"' in output 498 | 499 | def test_get_search_fields(self): 500 | widget = ModelSelect2Widget() 501 | with pytest.raises(NotImplementedError): 502 | widget.get_search_fields() 503 | 504 | widget.search_fields = ["title__icontains"] 505 | assert isinstance(widget.get_search_fields(), Iterable) 506 | assert all(isinstance(x, str) for x in widget.get_search_fields()) 507 | 508 | def test_filter_queryset(self, genres): 509 | widget = TitleModelSelect2Widget(queryset=Genre.objects.all()) 510 | assert widget.filter_queryset(None, genres[0].title[:3]).exists() 511 | 512 | widget = TitleModelSelect2Widget( 513 | search_fields=["title__icontains"], queryset=Genre.objects.all() 514 | ) 515 | qs = widget.filter_queryset( 516 | None, " ".join([genres[0].title[:3], genres[0].title[3:]]) 517 | ) 518 | assert qs.exists() 519 | 520 | def test_filter_queryset__empty(self, genres): 521 | widget = TitleModelSelect2Widget(queryset=Genre.objects.all()) 522 | assert widget.filter_queryset(None, genres[0].title[:3]).exists() 523 | 524 | widget = TitleModelSelect2Widget( 525 | search_fields=["title__icontains"], queryset=Genre.objects.all() 526 | ) 527 | qs = widget.filter_queryset(None, "") 528 | assert qs.exists() 529 | 530 | def test_filter_queryset__startswith(self, genres): 531 | genre = Genre.objects.create(title="Space Genre") 532 | widget = TitleModelSelect2Widget(queryset=Genre.objects.all()) 533 | assert widget.filter_queryset(None, genre.title).exists() 534 | 535 | widget = TitleModelSelect2Widget( 536 | search_fields=["title__istartswith"], queryset=Genre.objects.all() 537 | ) 538 | qs = widget.filter_queryset(None, "Space Gen") 539 | assert qs.exists() 540 | 541 | qs = widget.filter_queryset(None, "Gen") 542 | assert not qs.exists() 543 | 544 | def test_filter_queryset__contains(self, genres): 545 | genre = Genre.objects.create(title="Space Genre") 546 | widget = TitleModelSelect2Widget(queryset=Genre.objects.all()) 547 | assert widget.filter_queryset(None, genre.title).exists() 548 | 549 | widget = TitleModelSelect2Widget( 550 | search_fields=["title__contains"], queryset=Genre.objects.all() 551 | ) 552 | qs = widget.filter_queryset(None, "Space Gen") 553 | assert qs.exists() 554 | 555 | qs = widget.filter_queryset(None, "NOT Gen") 556 | assert not qs.exists(), "contains works even if all bits match" 557 | 558 | def test_filter_queryset__multiple_fields(self, genres): 559 | genre = Genre.objects.create(title="Space Genre") 560 | widget = TitleModelSelect2Widget(queryset=Genre.objects.all()) 561 | assert widget.filter_queryset(None, genre.title).exists() 562 | 563 | widget = TitleModelSelect2Widget( 564 | search_fields=[ 565 | "title__startswith", 566 | "title__endswith", 567 | ], 568 | queryset=Genre.objects.all(), 569 | ) 570 | qs = widget.filter_queryset(None, "Space") 571 | assert qs.exists() 572 | 573 | qs = widget.filter_queryset(None, "Genre") 574 | assert qs.exists() 575 | 576 | def test_model_kwarg(self): 577 | widget = ModelSelect2Widget(model=Genre, search_fields=["title__icontains"]) 578 | genre = Genre.objects.last() 579 | result = widget.filter_queryset(None, genre.title) 580 | assert result.exists() 581 | 582 | def test_queryset_kwarg(self): 583 | widget = ModelSelect2Widget( 584 | queryset=Genre.objects.all(), search_fields=["title__icontains"] 585 | ) 586 | genre = Genre.objects.last() 587 | result = widget.filter_queryset(None, genre.title) 588 | assert result.exists() 589 | 590 | def test_ajax_view_registration(self, client): 591 | widget = ModelSelect2Widget( 592 | queryset=Genre.objects.all(), search_fields=["title__icontains"] 593 | ) 594 | widget.render("name", "value") 595 | url = reverse("django_select2:auto-json") 596 | genre = Genre.objects.last() 597 | response = client.get( 598 | url, data=dict(field_id=widget.field_id, term=genre.title) 599 | ) 600 | assert response.status_code == 200, response.content 601 | data = json.loads(response.content.decode("utf-8")) 602 | assert data["results"] 603 | assert genre.pk in [result["id"] for result in data["results"]] 604 | 605 | def test_render(self): 606 | widget = ModelSelect2Widget(queryset=Genre.objects.all()) 607 | widget.render("name", "value") 608 | cached_widget = cache.get(widget._get_cache_key()) 609 | assert cached_widget["max_results"] == widget.max_results 610 | assert cached_widget["search_fields"] == tuple(widget.search_fields) 611 | qs = widget.get_queryset() 612 | assert isinstance(cached_widget["queryset"][0], qs.__class__) 613 | assert str(cached_widget["queryset"][1]) == str(qs.query) 614 | 615 | def test_get_url(self): 616 | widget = ModelSelect2Widget( 617 | queryset=Genre.objects.all(), search_fields=["title__icontains"] 618 | ) 619 | assert isinstance(widget.get_url(), str) 620 | 621 | def test_custom_to_field_name(self): 622 | the_best_band_in_the_world = Artist.objects.create(title="Take That") 623 | groupie = Groupie.objects.create(obsession=the_best_band_in_the_world) 624 | form = forms.GroupieForm(instance=groupie) 625 | assert '' in form.as_p() 626 | 627 | def test_empty_label(self, db): 628 | # Empty options is only required for single selects 629 | # https://select2.github.io/options.html#allowClear 630 | single_select = self.form.fields["primary_genre"] 631 | single_select.empty_label = "Hello World" 632 | assert single_select.required is False 633 | assert 'data-placeholder="Hello World"' in single_select.widget.render( 634 | "primary_genre", None 635 | ) 636 | 637 | 638 | class TestHeavySelect2TagWidget(TestHeavySelect2Mixin): 639 | def test_tag_attrs(self): 640 | widget = ModelSelect2TagWidget( 641 | queryset=Genre.objects.all(), search_fields=["title__icontains"] 642 | ) 643 | output = widget.render("name", "value") 644 | assert 'data-minimum-input-length="2"' in output 645 | assert 'data-tags="true"' in output 646 | assert "data-token-separators" in output 647 | 648 | def test_custom_tag_attrs(self): 649 | widget = ModelSelect2TagWidget( 650 | queryset=Genre.objects.all(), 651 | search_fields=["title__icontains"], 652 | attrs={"data-minimum-input-length": "3"}, 653 | ) 654 | output = widget.render("name", "value") 655 | assert 'data-minimum-input-length="3"' in output 656 | 657 | 658 | class TestHeavySelect2MultipleWidget: 659 | url = reverse("heavy_select2_multiple_widget") 660 | form = forms.HeavySelect2MultipleWidgetForm() 661 | widget_cls = HeavySelect2MultipleWidget 662 | 663 | @pytest.mark.xfail( 664 | bool(os.environ.get("CI", False)), 665 | reason="https://bugs.chromium.org/p/chromedriver/issues/detail?id=1772", 666 | ) 667 | @pytest.mark.selenium 668 | def test_widgets_selected_after_validation_error(self, db, live_server, driver): 669 | driver.get(live_server + self.url) 670 | WebDriverWait(driver, 3).until( 671 | expected_conditions.presence_of_element_located((By.ID, "id_title")) 672 | ) 673 | title = driver.find_element(By.ID, "id_title") 674 | title.send_keys("fo") 675 | genres, fartists = driver.find_elements( 676 | By.CSS_SELECTOR, ".select2-selection--multiple" 677 | ) 678 | genres.click() 679 | genres.send_keys("o") # results are Zero One Two Four 680 | # select second element - One 681 | driver.find_element(By.CSS_SELECTOR, ".select2-results li:nth-child(2)").click() 682 | genres.submit() 683 | # there is a ValidationError raised, check for it 684 | errstring = ( 685 | WebDriverWait(driver, 3) 686 | .until( 687 | expected_conditions.presence_of_element_located( 688 | (By.CSS_SELECTOR, "ul.errorlist li") 689 | ) 690 | ) 691 | .text 692 | ) 693 | assert errstring == "Title must have more than 3 characters." 694 | # genres should still have One as selected option 695 | result_title = driver.find_element( 696 | By.CSS_SELECTOR, ".select2-selection--multiple li" 697 | ).get_attribute("title") 698 | assert result_title == "One" 699 | 700 | 701 | class TestAddressChainedSelect2Widget: 702 | url = reverse("model_chained_select2_widget") 703 | form = forms.AddressChainedSelect2WidgetForm() 704 | 705 | @pytest.mark.selenium 706 | def test_widgets_selected_after_validation_error( 707 | self, db, live_server, driver, countries, cities 708 | ): 709 | driver.get(live_server + self.url) 710 | 711 | WebDriverWait(driver, 60).until( 712 | expected_conditions.presence_of_element_located( 713 | (By.CSS_SELECTOR, ".select2-selection--single") 714 | ) 715 | ) 716 | ( 717 | country_container, 718 | city_container, 719 | city2_container, 720 | ) = driver.find_elements(By.CSS_SELECTOR, ".select2-selection--single") 721 | 722 | # clicking city select2 lists all available cities 723 | city_container.click() 724 | WebDriverWait(driver, 60).until( 725 | expected_conditions.presence_of_element_located( 726 | (By.CSS_SELECTOR, ".select2-results li") 727 | ) 728 | ) 729 | city_options = driver.find_elements(By.CSS_SELECTOR, ".select2-results li") 730 | city_names_from_browser = {option.text for option in city_options} 731 | city_names_from_db = set(City.objects.values_list("name", flat=True)) 732 | assert len(city_names_from_browser) == City.objects.count() 733 | assert city_names_from_browser == city_names_from_db 734 | 735 | # selecting a country really does it 736 | country_container.click() 737 | WebDriverWait(driver, 60).until( 738 | expected_conditions.presence_of_element_located( 739 | (By.CSS_SELECTOR, ".select2-results li:nth-child(2)") 740 | ) 741 | ) 742 | country_option = driver.find_element( 743 | By.CSS_SELECTOR, ".select2-results li:nth-child(2)" 744 | ) 745 | country_name = country_option.text 746 | country_option.click() 747 | assert country_name == country_container.text 748 | 749 | # clicking city select2 lists reduced list of cities belonging to the country 750 | city_container.click() 751 | WebDriverWait(driver, 60).until( 752 | expected_conditions.presence_of_element_located( 753 | (By.CSS_SELECTOR, ".select2-results li") 754 | ) 755 | ) 756 | city_options = driver.find_elements(By.CSS_SELECTOR, ".select2-results li") 757 | city_names_from_browser = {option.text for option in city_options} 758 | city_names_from_db = set( 759 | Country.objects.get(name=country_name).cities.values_list("name", flat=True) 760 | ) 761 | assert len(city_names_from_browser) != City.objects.count() 762 | assert city_names_from_browser == city_names_from_db 763 | 764 | # selecting a city really does it 765 | city_option = driver.find_element( 766 | By.CSS_SELECTOR, ".select2-results li:nth-child(2)" 767 | ) 768 | city_name = city_option.text 769 | city_option.click() 770 | assert city_name == city_container.text 771 | 772 | # clicking country select2 lists reduced list to the only country available to the city 773 | country_container.click() 774 | WebDriverWait(driver, 60).until( 775 | expected_conditions.presence_of_element_located( 776 | (By.CSS_SELECTOR, ".select2-results li") 777 | ) 778 | ) 779 | country_options = driver.find_elements(By.CSS_SELECTOR, ".select2-results li") 780 | country_names_from_browser = {option.text for option in country_options} 781 | country_names_from_db = {City.objects.get(name=city_name).country.name} 782 | assert len(country_names_from_browser) != Country.objects.count() 783 | assert country_names_from_browser == country_names_from_db 784 | 785 | @pytest.mark.selenium 786 | def test_dependent_fields_clear_after_change_parent( 787 | self, db, live_server, driver, countries, cities 788 | ): 789 | driver.get(live_server + self.url) 790 | ( 791 | country_container, 792 | city_container, 793 | city2_container, 794 | ) = driver.find_elements(By.CSS_SELECTOR, ".select2-selection--single") 795 | 796 | # selecting a country really does it 797 | country_container.click() 798 | WebDriverWait(driver, 60).until( 799 | expected_conditions.presence_of_element_located( 800 | (By.CSS_SELECTOR, ".select2-results li:nth-child(2)") 801 | ) 802 | ) 803 | country_option = driver.find_element( 804 | By.CSS_SELECTOR, ".select2-results li:nth-child(2)" 805 | ) 806 | country_name = country_option.text 807 | country_option.click() 808 | assert country_name == country_container.text 809 | 810 | # selecting a city2 811 | city2_container.click() 812 | WebDriverWait(driver, 60).until( 813 | expected_conditions.presence_of_element_located( 814 | (By.CSS_SELECTOR, ".select2-results li") 815 | ) 816 | ) 817 | city2_option = driver.find_element( 818 | By.CSS_SELECTOR, ".select2-results li:nth-child(2)" 819 | ) 820 | city2_name = city2_option.text 821 | city2_option.click() 822 | assert city2_name == city2_container.text 823 | 824 | # change a country 825 | country_container.click() 826 | WebDriverWait(driver, 60).until( 827 | expected_conditions.presence_of_element_located( 828 | (By.CSS_SELECTOR, ".select2-results li:nth-child(3)") 829 | ) 830 | ) 831 | country_option = driver.find_element( 832 | By.CSS_SELECTOR, ".select2-results li:nth-child(3)" 833 | ) 834 | country_name = country_option.text 835 | country_option.click() 836 | assert country_name == country_container.text 837 | 838 | # check the value in city2 839 | city2_container.click() 840 | WebDriverWait(driver, 60).until( 841 | expected_conditions.presence_of_element_located( 842 | (By.CSS_SELECTOR, ".select2-results li") 843 | ) 844 | ) 845 | assert city2_container.text == "" 846 | 847 | 848 | @pytest.fixture( 849 | name="widget", 850 | params=[ 851 | (Select2Widget, {}), 852 | (HeavySelect2Widget, {"data_view": "heavy_data_1"}), 853 | (HeavySelect2MultipleWidget, {"data_view": "heavy_data_1"}), 854 | (ModelSelect2Widget, {}), 855 | (ModelSelect2TagWidget, {}), 856 | ], 857 | ids=lambda p: p[0], 858 | ) 859 | def widget_fixture(request): 860 | widget_class, widget_kwargs = request.param 861 | return widget_class(**widget_kwargs) 862 | 863 | 864 | @pytest.mark.parametrize( 865 | "locale,expected", 866 | [ 867 | ("fr-FR", "fr"), 868 | # Some locales with a country code are natively supported by select2's i18n 869 | ("pt-BR", "pt-BR"), 870 | ("sr-Cyrl", "sr-Cyrl"), 871 | ], 872 | ids=repr, 873 | ) 874 | def test_i18n_name_property_with_country_code_in_locale(widget, locale, expected): 875 | """Test we fall back to the language code if the locale contain an unsupported country code.""" 876 | with translation.override(locale): 877 | assert widget.i18n_name == expected 878 | 879 | 880 | def test_i18n_media_js_with_country_code_in_locale(widget): 881 | translation.activate("fr-FR") 882 | assert tuple(widget.media._js) == ( 883 | "admin/js/vendor/select2/select2.full.min.js", 884 | "admin/js/vendor/select2/i18n/fr.js", 885 | "django_select2/django_select2.js", 886 | ) 887 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.urls import reverse 4 | from django.utils.encoding import smart_str 5 | 6 | from django_select2.cache import cache 7 | from django_select2.forms import ModelSelect2Widget 8 | from tests.testapp.forms import ( 9 | AlbumModelSelect2WidgetForm, 10 | ArtistCustomTitleWidget, 11 | CityForm, 12 | ) 13 | from tests.testapp.models import Genre 14 | 15 | 16 | class TestAutoResponseView: 17 | def test_get(self, client, artists): 18 | artist = artists[0] 19 | form = AlbumModelSelect2WidgetForm() 20 | assert form.as_p() 21 | field_id = form.fields["artist"].widget.field_id 22 | url = reverse("django_select2:auto-json") 23 | response = client.get(url, {"field_id": field_id, "term": artist.title}) 24 | assert response.status_code == 200 25 | data = json.loads(response.content.decode("utf-8")) 26 | assert data["results"] 27 | assert {"id": artist.pk, "text": smart_str(artist)} in data["results"] 28 | 29 | def test_no_field_id(self, client, artists): 30 | artist = artists[0] 31 | url = reverse("django_select2:auto-json") 32 | response = client.get(url, {"term": artist.title}) 33 | assert response.status_code == 404 34 | 35 | def test_wrong_field_id(self, client, artists): 36 | artist = artists[0] 37 | url = reverse("django_select2:auto-json") 38 | response = client.get(url, {"field_id": 123, "term": artist.title}) 39 | assert response.status_code == 404 40 | 41 | def test_field_id_not_found(self, client, artists): 42 | artist = artists[0] 43 | field_id = "not-exists" 44 | url = reverse("django_select2:auto-json") 45 | response = client.get(url, {"field_id": field_id, "term": artist.title}) 46 | assert response.status_code == 404 47 | 48 | def test_pagination(self, genres, client): 49 | url = reverse("django_select2:auto-json") 50 | widget = ModelSelect2Widget( 51 | max_results=10, model=Genre, search_fields=["title__icontains"] 52 | ) 53 | widget.render("name", None) 54 | field_id = widget.field_id 55 | 56 | response = client.get(url, {"field_id": field_id, "term": ""}) 57 | assert response.status_code == 200 58 | data = json.loads(response.content.decode("utf-8")) 59 | assert data["more"] is True 60 | 61 | response = client.get(url, {"field_id": field_id, "term": "", "page": 1000}) 62 | assert response.status_code == 404 63 | 64 | response = client.get(url, {"field_id": field_id, "term": "", "page": "last"}) 65 | assert response.status_code == 200 66 | data = json.loads(response.content.decode("utf-8")) 67 | assert data["more"] is False 68 | 69 | def test_label_from_instance(self, artists, client): 70 | url = reverse("django_select2:auto-json") 71 | 72 | form = AlbumModelSelect2WidgetForm() 73 | form.fields["artist"].widget = ArtistCustomTitleWidget() 74 | assert form.as_p() 75 | field_id = form.fields["artist"].widget.field_id 76 | 77 | artist = artists[0] 78 | response = client.get(url, {"field_id": field_id, "term": artist.title}) 79 | assert response.status_code == 200 80 | 81 | data = json.loads(response.content.decode("utf-8")) 82 | assert data["results"] 83 | assert {"id": artist.pk, "text": smart_str(artist.title.upper())} in data[ 84 | "results" 85 | ] 86 | 87 | def test_result_from_instance(self, cities, client): 88 | url = reverse("django_select2:auto-json") 89 | 90 | form = CityForm() 91 | assert form.as_p() 92 | field_id = form.fields["city"].widget.field_id 93 | city = cities[0] 94 | response = client.get(url, {"field_id": field_id, "term": city.name}) 95 | assert response.status_code == 200 96 | data = json.loads(response.content.decode("utf-8")) 97 | assert data["results"] 98 | assert { 99 | "id": city.pk, 100 | "text": smart_str(city), 101 | "country": smart_str(city.country), 102 | } in data["results"] 103 | 104 | def test_url_check(self, client, artists): 105 | artist = artists[0] 106 | form = AlbumModelSelect2WidgetForm() 107 | assert form.as_p() 108 | field_id = form.fields["artist"].widget.field_id 109 | cache_key = form.fields["artist"].widget._get_cache_key() 110 | widget_dict = cache.get(cache_key) 111 | widget_dict["url"] = "yet/another/url" 112 | cache.set(cache_key, widget_dict) 113 | url = reverse("django_select2:auto-json") 114 | response = client.get(url, {"field_id": field_id, "term": artist.title}) 115 | assert response.status_code == 404 116 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingjoe/django-select2/e5f41e6edba004d35f94915ff5e2559f44853412/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.encoding import force_str 3 | 4 | from django_select2.forms import ( 5 | HeavySelect2MultipleWidget, 6 | HeavySelect2Widget, 7 | ModelSelect2MultipleWidget, 8 | ModelSelect2TagWidget, 9 | ModelSelect2Widget, 10 | Select2MultipleWidget, 11 | Select2Widget, 12 | ) 13 | from tests.testapp import models 14 | from tests.testapp.models import Album, City, Country 15 | 16 | 17 | class TitleSearchFieldMixin: 18 | search_fields = ["title__icontains", "pk__startswith"] 19 | 20 | 21 | class TitleModelSelect2Widget(TitleSearchFieldMixin, ModelSelect2Widget): 22 | pass 23 | 24 | 25 | class TitleModelSelect2MultipleWidget( 26 | TitleSearchFieldMixin, ModelSelect2MultipleWidget 27 | ): 28 | pass 29 | 30 | 31 | class GenreSelect2TagWidget(TitleSearchFieldMixin, ModelSelect2TagWidget): 32 | model = models.Genre 33 | 34 | def create_value(self, value): 35 | self.get_queryset().create(title=value) 36 | 37 | 38 | class ArtistCustomTitleWidget(ModelSelect2Widget): 39 | model = models.Artist 40 | search_fields = ["title__icontains"] 41 | 42 | def label_from_instance(self, obj): 43 | return force_str(obj.title).upper() 44 | 45 | 46 | class GenreCustomTitleWidget(ModelSelect2Widget): 47 | model = models.Genre 48 | search_fields = ["title__icontains"] 49 | 50 | def label_from_instance(self, obj): 51 | return force_str(obj.title).upper() 52 | 53 | 54 | class ArtistDataViewWidget(HeavySelect2Widget): 55 | data_view = "heavy_data_1" 56 | 57 | 58 | class PrimaryGenreDataUrlWidget(HeavySelect2Widget): 59 | data_url = "/heavy_data_2/" 60 | 61 | 62 | class AlbumSelect2WidgetForm(forms.ModelForm): 63 | class Meta: 64 | model = models.Album 65 | fields = ( 66 | "artist", 67 | "primary_genre", 68 | ) 69 | widgets = { 70 | "artist": Select2Widget, 71 | "primary_genre": Select2Widget, 72 | } 73 | 74 | 75 | class AlbumSelect2MultipleWidgetForm(forms.ModelForm): 76 | class Meta: 77 | model = models.Album 78 | fields = ( 79 | "genres", 80 | "featured_artists", 81 | ) 82 | widgets = { 83 | "genres": Select2MultipleWidget, 84 | "featured_artists": Select2MultipleWidget, 85 | } 86 | 87 | 88 | class AlbumModelSelect2WidgetForm(forms.ModelForm): 89 | class Meta: 90 | model = models.Album 91 | fields = ( 92 | "artist", 93 | "primary_genre", 94 | ) 95 | widgets = { 96 | "artist": ArtistCustomTitleWidget(), 97 | "primary_genre": GenreCustomTitleWidget(), 98 | } 99 | 100 | def __init__(self, *args, **kwargs): 101 | super().__init__(*args, **kwargs) 102 | self.fields["primary_genre"].initial = 2 103 | 104 | 105 | class AlbumModelSelect2MultipleWidgetRequiredForm(forms.ModelForm): 106 | class Meta: 107 | model = Album 108 | fields = ( 109 | "genres", 110 | "featured_artists", 111 | ) 112 | widgets = { 113 | "genres": TitleModelSelect2MultipleWidget, 114 | "featured_artists": TitleModelSelect2MultipleWidget, 115 | } 116 | 117 | 118 | class ArtistModelSelect2MultipleWidgetForm(forms.Form): 119 | title = forms.CharField(max_length=50) 120 | genres = forms.ModelMultipleChoiceField( 121 | widget=ModelSelect2MultipleWidget( 122 | queryset=models.Genre.objects.all(), 123 | search_fields=["title__icontains"], 124 | ), 125 | queryset=models.Genre.objects.all(), 126 | required=True, 127 | ) 128 | 129 | featured_artists = forms.ModelMultipleChoiceField( 130 | widget=ModelSelect2MultipleWidget( 131 | queryset=models.Artist.objects.all(), 132 | search_fields=["title__icontains"], 133 | ), 134 | queryset=models.Artist.objects.all(), 135 | required=False, 136 | ) 137 | 138 | 139 | NUMBER_CHOICES = [ 140 | (1, "One"), 141 | (2, "Two"), 142 | (3, "Three"), 143 | (4, "Four"), 144 | ] 145 | 146 | 147 | class Select2WidgetForm(forms.Form): 148 | number = forms.ChoiceField( 149 | widget=Select2Widget, choices=NUMBER_CHOICES, required=False 150 | ) 151 | 152 | 153 | class HeavySelect2WidgetForm(forms.Form): 154 | artist = forms.ChoiceField(widget=ArtistDataViewWidget(), choices=NUMBER_CHOICES) 155 | primary_genre = forms.ChoiceField( 156 | widget=PrimaryGenreDataUrlWidget(), 157 | required=False, 158 | choices=NUMBER_CHOICES, 159 | ) 160 | 161 | 162 | class HeavySelect2MultipleWidgetForm(forms.Form): 163 | title = forms.CharField(max_length=50) 164 | genres = forms.MultipleChoiceField( 165 | widget=HeavySelect2MultipleWidget( 166 | data_view="heavy_data_1", 167 | choices=NUMBER_CHOICES, 168 | attrs={"data-minimum-input-length": 0}, 169 | ), 170 | choices=NUMBER_CHOICES, 171 | ) 172 | featured_artists = forms.MultipleChoiceField( 173 | widget=HeavySelect2MultipleWidget( 174 | data_view="heavy_data_2", 175 | choices=NUMBER_CHOICES, 176 | attrs={"data-minimum-input-length": 0}, 177 | ), 178 | choices=NUMBER_CHOICES, 179 | required=False, 180 | ) 181 | 182 | def clean_title(self): 183 | if len(self.cleaned_data["title"]) < 3: 184 | raise forms.ValidationError("Title must have more than 3 characters.") 185 | return self.cleaned_data["title"] 186 | 187 | 188 | class ModelSelect2TagWidgetForm(forms.ModelForm): 189 | class Meta: 190 | model = Album 191 | fields = ["genres"] 192 | widgets = {"genres": GenreSelect2TagWidget} 193 | 194 | 195 | class AddressChainedSelect2WidgetForm(forms.Form): 196 | country = forms.ModelChoiceField( 197 | queryset=Country.objects.all(), 198 | label="Country", 199 | widget=ModelSelect2Widget( 200 | search_fields=["name__icontains"], 201 | max_results=500, 202 | dependent_fields={"city": "cities"}, 203 | attrs={"data-minimum-input-length": 0}, 204 | ), 205 | ) 206 | 207 | city = forms.ModelChoiceField( 208 | queryset=City.objects.all(), 209 | label="City", 210 | widget=ModelSelect2Widget( 211 | search_fields=["name__icontains"], 212 | dependent_fields={"country": "country"}, 213 | max_results=500, 214 | attrs={"data-minimum-input-length": 0}, 215 | ), 216 | ) 217 | 218 | city2 = forms.ModelChoiceField( 219 | queryset=City.objects.all(), 220 | label="City not Interdependent", 221 | widget=ModelSelect2Widget( 222 | search_fields=["name__icontains"], 223 | dependent_fields={"country": "country"}, 224 | max_results=500, 225 | attrs={"data-minimum-input-length": 0}, 226 | ), 227 | ) 228 | 229 | 230 | class GroupieForm(forms.ModelForm): 231 | class Meta: 232 | model = models.Groupie 233 | fields = "__all__" 234 | widgets = {"obsession": ArtistCustomTitleWidget} 235 | 236 | 237 | class CityModelSelect2Widget(ModelSelect2Widget): 238 | model = City 239 | search_fields = ["name"] 240 | 241 | def result_from_instance(self, obj, request): 242 | return {"id": obj.pk, "text": obj.name, "country": str(obj.country)} 243 | 244 | 245 | class CityForm(forms.Form): 246 | city = forms.ModelChoiceField( 247 | queryset=City.objects.all(), widget=CityModelSelect2Widget(), required=False 248 | ) 249 | -------------------------------------------------------------------------------- /tests/testapp/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Genre(models.Model): 5 | title = models.CharField(max_length=50) 6 | 7 | class Meta: 8 | ordering = ("title",) 9 | 10 | def __str__(self): 11 | return self.title 12 | 13 | 14 | class Artist(models.Model): 15 | title = models.CharField(max_length=50, unique=True) 16 | genres = models.ManyToManyField(Genre) 17 | 18 | class Meta: 19 | ordering = ("title",) 20 | 21 | def __str__(self): 22 | return self.title 23 | 24 | 25 | class Album(models.Model): 26 | title = models.CharField(max_length=255) 27 | artist = models.ForeignKey(Artist, on_delete=models.CASCADE) 28 | featured_artists = models.ManyToManyField( 29 | Artist, blank=True, related_name="featured_album_set" 30 | ) 31 | primary_genre = models.ForeignKey( 32 | Genre, 33 | on_delete=models.CASCADE, 34 | blank=True, 35 | null=True, 36 | related_name="primary_album_set", 37 | ) 38 | genres = models.ManyToManyField(Genre) 39 | 40 | class Meta: 41 | ordering = ("title",) 42 | 43 | def __str__(self): 44 | return self.title 45 | 46 | 47 | class Country(models.Model): 48 | name = models.CharField(max_length=255) 49 | 50 | class Meta: 51 | ordering = ("name",) 52 | 53 | def __str__(self): 54 | return self.name 55 | 56 | 57 | class City(models.Model): 58 | name = models.CharField(max_length=255) 59 | country = models.ForeignKey( 60 | "Country", related_name="cities", on_delete=models.CASCADE 61 | ) 62 | 63 | class Meta: 64 | ordering = ("name",) 65 | 66 | def __str__(self): 67 | return self.name 68 | 69 | 70 | class Groupie(models.Model): 71 | obsession = models.ForeignKey(Artist, to_field="title", on_delete=models.CASCADE) 72 | -------------------------------------------------------------------------------- /tests/testapp/settings.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 4 | DEBUG = True 5 | 6 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 7 | 8 | INSTALLED_APPS = ( 9 | "django.contrib.auth", 10 | "django.contrib.contenttypes", 11 | "django.contrib.sessions", 12 | "django.contrib.staticfiles", 13 | "django.contrib.admin", 14 | "django_select2", 15 | "tests.testapp", 16 | ) 17 | 18 | STATIC_URL = "/static/" 19 | 20 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 21 | 22 | SITE_ID = 1 23 | ROOT_URLCONF = "tests.testapp.urls" 24 | 25 | LANGUAGES = [ 26 | ("de", "German"), 27 | ("en", "English"), 28 | ] 29 | LANGUAGE_CODE = "en" 30 | 31 | TEMPLATES = [ 32 | { 33 | "BACKEND": "django.template.backends.django.DjangoTemplates", 34 | "APP_DIRS": True, 35 | "DIRS": ["templates"], 36 | }, 37 | ] 38 | 39 | SECRET_KEY = "123456" 40 | 41 | USE_I18N = True 42 | USE_TZ = True 43 | -------------------------------------------------------------------------------- /tests/testapp/templates/form.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | {{ form.media.css }} 5 | 10 | 11 | 12 |
13 | {% csrf_token %} 14 | {{ form }} 15 | 16 |
17 | 18 | 23 | {{ form.media.js }} 24 | 25 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | from .forms import ( 4 | AddressChainedSelect2WidgetForm, 5 | AlbumModelSelect2WidgetForm, 6 | HeavySelect2MultipleWidgetForm, 7 | HeavySelect2WidgetForm, 8 | ModelSelect2TagWidgetForm, 9 | Select2WidgetForm, 10 | ) 11 | from .views import TemplateFormView, heavy_data_1, heavy_data_2 12 | 13 | urlpatterns = [ 14 | path( 15 | "select2_widget", 16 | TemplateFormView.as_view(form_class=Select2WidgetForm), 17 | name="select2_widget", 18 | ), 19 | path( 20 | "heavy_select2_widget", 21 | TemplateFormView.as_view(form_class=HeavySelect2WidgetForm), 22 | name="heavy_select2_widget", 23 | ), 24 | path( 25 | "heavy_select2_multiple_widget", 26 | TemplateFormView.as_view( 27 | form_class=HeavySelect2MultipleWidgetForm, success_url="/" 28 | ), 29 | name="heavy_select2_multiple_widget", 30 | ), 31 | path( 32 | "model_select2_widget", 33 | TemplateFormView.as_view(form_class=AlbumModelSelect2WidgetForm), 34 | name="model_select2_widget", 35 | ), 36 | path( 37 | "model_select2_tag_widget", 38 | TemplateFormView.as_view(form_class=ModelSelect2TagWidgetForm), 39 | name="model_select2_tag_widget", 40 | ), 41 | path( 42 | "model_chained_select2_widget", 43 | TemplateFormView.as_view(form_class=AddressChainedSelect2WidgetForm), 44 | name="model_chained_select2_widget", 45 | ), 46 | path("heavy_data_1", heavy_data_1, name="heavy_data_1"), 47 | path("heavy_data_2", heavy_data_2, name="heavy_data_2"), 48 | path("select2/", include("django_select2.urls")), 49 | ] 50 | -------------------------------------------------------------------------------- /tests/testapp/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.http import HttpResponse 4 | from django.views.generic import FormView 5 | 6 | 7 | class TemplateFormView(FormView): 8 | template_name = "form.html" 9 | 10 | 11 | def heavy_data_1(request): 12 | term = request.GET.get("term", "") 13 | numbers = ["Zero", "One", "Two", "Three", "Four", "Five"] 14 | numbers = filter(lambda num: term.lower() in num.lower(), numbers) 15 | results = [{"id": index, "text": value} for (index, value) in enumerate(numbers)] 16 | return HttpResponse( 17 | json.dumps({"err": "nil", "results": results}), content_type="application/json" 18 | ) 19 | 20 | 21 | def heavy_data_2(request): 22 | term = request.GET.get("term", "") 23 | numbers = ["Six", "Seven", "Eight", "Nine", "Ten", "Fortytwo"] 24 | numbers = filter(lambda num: term.lower() in num.lower(), numbers) 25 | results = [{"id": index, "text": value} for (index, value) in enumerate(numbers)] 26 | return HttpResponse( 27 | json.dumps({"err": "nil", "results": results}), content_type="application/json" 28 | ) 29 | --------------------------------------------------------------------------------