├── .bandit ├── .editorconfig ├── .fussyfox.yml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── new_owner.md └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── django_select2 ├── __init__.py ├── apps.py ├── cache.py ├── conf.py ├── forms.py ├── static │ └── django_select2 │ │ └── 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 ├── package.json ├── set_version.py ├── setup.cfg ├── setup.py └── 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}] 14 | indent_size = 2 15 | 16 | [LICENSE] 17 | insert_final_newline = false 18 | 19 | [Makefile] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.fussyfox.yml: -------------------------------------------------------------------------------- 1 | - bandit 2 | - flake8 3 | - isort 4 | - pydocstyle 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: codingjoe 2 | custom: https://paypal.me/codingjoe 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new_owner.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: NEW OWNER 3 | about: This package moved to https://github.com/codingjoe/django-select2 4 | title: '' 5 | labels: bug 6 | assignees: codingjoe 7 | 8 | --- 9 | 10 | # New Owner 11 | 12 | This package moved to: https://github.com/codingjoe/django-select2 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | 8 | jobs: 9 | 10 | black: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/setup-python@v2 14 | - uses: actions/checkout@v2 15 | - run: python -m pip install black 16 | - run: black --check --diff . 17 | 18 | dist: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/setup-python@v2 22 | - run: python -m pip install --upgrade pip setuptools wheel twine readme-renderer 23 | - uses: actions/checkout@v2 24 | - run: python setup.py sdist bdist_wheel 25 | - run: python -m twine check dist/* 26 | 27 | standardjs: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v2 31 | - uses: actions/setup-node@v1 32 | with: 33 | node-version: '12.x' 34 | - run: npm install -g standard 35 | - run: standard 36 | 37 | docs: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/setup-python@v2 41 | - uses: actions/checkout@v2 42 | - run: sudo apt-get install -y gettext graphviz 43 | - run: python setup.py develop 44 | - run: python setup.py build_sphinx -W -b doctest -b html 45 | 46 | pytest: 47 | needs: 48 | - standardjs 49 | - black 50 | strategy: 51 | matrix: 52 | python-version: 53 | - "3.6" 54 | - "3.7" 55 | - "3.8" 56 | django-version: 57 | - "2.2" 58 | - "3.0" 59 | runs-on: ubuntu-latest 60 | steps: 61 | - name: Set up Python ${{ matrix.python-version }} 62 | uses: actions/setup-python@v2 63 | with: 64 | python-version: ${{ matrix.python-version }} 65 | - name: Install Chrome 66 | run: sudo apt-get install -y google-chrome-stable 67 | - name: Install Selenium 68 | run: | 69 | mkdir bin 70 | curl -O https://chromedriver.storage.googleapis.com/77.0.3865.40/chromedriver_linux64.zip 71 | unzip chromedriver_linux64.zip -d bin 72 | - uses: actions/checkout@v1 73 | - name: Install dependencies 74 | run: | 75 | python -m pip install --upgrade pip setuptools wheel codecov 76 | pip install -e .[test] 77 | pip install django~=${{ matrix.django-version }} 78 | - name: Run tests 79 | run: PATH=$PATH:$(pwd)/bin py.test 80 | - run: codecov 81 | env: 82 | CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} 83 | -------------------------------------------------------------------------------- /.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/setup-python@v2 12 | - uses: actions/checkout@v2 13 | - name: Install Python dependencies 14 | run: python -m pip install --upgrade pip setuptools wheel twine 15 | - name: Build dist packages 16 | run: python setup.py sdist bdist_wheel 17 | - name: Upload packages 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/setup-node@v1 27 | - uses: actions/checkout@v2 28 | - name: Upload packages 29 | run: npm publish 30 | env: 31 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | Django_Select2.egg-info 4 | Django_Select2_Py3.egg-info 5 | dist 6 | build 7 | 8 | node_modules/ 9 | 10 | docs/_build 11 | 12 | # Intellij 13 | .idea/ 14 | *.iml 15 | *.iws 16 | env/ 17 | venv/ 18 | .cache/ 19 | .tox/ 20 | geckodriver.log 21 | ghostdriver.log 22 | .coverage 23 | 24 | coverage.xml 25 | .eggs/ 26 | db.sqlite3 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN} 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | This package uses the pyTest test runner. To run the tests locally simply run:: 5 | 6 | python setup.py test 7 | 8 | If you need to the development dependencies installed of you local IDE, you can run:: 9 | 10 | python setup.py develop 11 | 12 | Documentation pull requests welcome. The Sphinx documentation can be compiled via:: 13 | 14 | python setup.py build_sphinx 15 | 16 | Bug reports welcome, even more so if they include a correct patch. Much 17 | more so if you start your patch by adding a failing unit test, and correct 18 | the code until zero unit tests fail. 19 | 20 | The list of supported Django and Python version can be found in the CI suite setup. 21 | Please make sure to verify that none of the linters or tests failed, before you submit 22 | a patch for review. 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Johannes Hoppe 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 | prune tests 3 | prune .github 4 | exclude .fussyfox.yml 5 | exclude .travis.yml 6 | exclude .gitignore 7 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | New Owner 3 | ========= 4 | 5 | This package moved to: https://github.com/codingjoe/django-select2 6 | -------------------------------------------------------------------------------- /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 | default_app_config = "django_select2.apps.Select2AppConfig" 11 | -------------------------------------------------------------------------------- /django_select2/apps.py: -------------------------------------------------------------------------------- 1 | """Django application configuration.""" 2 | from django.apps import AppConfig 3 | 4 | 5 | class Select2AppConfig(AppConfig): 6 | """Django application configuration.""" 7 | 8 | name = "django_select2" 9 | verbose_name = "Select2" 10 | -------------------------------------------------------------------------------- /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 | from django.core.cache import caches 15 | 16 | from .conf import settings 17 | 18 | __all__ = ("cache",) 19 | 20 | cache = caches[settings.SELECT2_CACHE_BACKEND] 21 | -------------------------------------------------------------------------------- /django_select2/conf.py: -------------------------------------------------------------------------------- 1 | """Settings for Django-Select2.""" 2 | from appconf import AppConf 3 | from django.conf import settings # NOQA 4 | 5 | __all__ = ("settings", "Select2Conf") 6 | 7 | 8 | class Select2Conf(AppConf): 9 | """Settings for Django-Select2.""" 10 | 11 | LIB_VERSION = "4.0.12" 12 | """Version of the Select2 library.""" 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 = "https://cdnjs.cloudflare.com/ajax/libs/select2/{version}/js/select2.min.js".format( 60 | version=LIB_VERSION 61 | ) 62 | """ 63 | The URI for the Select2 JS file. By default this points to the Cloudflare CDN. 64 | 65 | If you want to select the version of the JS library used, or want to serve it from 66 | the local 'static' resources, add a line to your settings.py like so:: 67 | 68 | SELECT2_JS = 'assets/js/select2.min.js' 69 | 70 | If you provide your own JS and would not like Django-Select2 to load any, change 71 | this setting to a blank string like so:: 72 | 73 | SELECT2_JS = '' 74 | 75 | .. tip:: Change this setting to a local asset in your development environment to 76 | develop without an Internet connection. 77 | """ 78 | 79 | CSS = "https://cdnjs.cloudflare.com/ajax/libs/select2/{version}/css/select2.min.css".format( 80 | version=LIB_VERSION 81 | ) 82 | """ 83 | The URI for the Select2 CSS file. By default this points to the Cloudflare CDN. 84 | 85 | If you want to select the version of the library used, or want to serve it from 86 | the local 'static' resources, add a line to your settings.py like so:: 87 | 88 | SELECT2_CSS = 'assets/css/select2.css' 89 | 90 | If you provide your own CSS and would not like Django-Select2 to load any, change 91 | this setting to a blank string like so:: 92 | 93 | SELECT2_CSS = '' 94 | 95 | .. tip:: Change this setting to a local asset in your development environment to 96 | develop without an Internet connection. 97 | """ 98 | 99 | I18N_PATH = "https://cdnjs.cloudflare.com/ajax/libs/select2/{version}/js/i18n".format( 100 | version=LIB_VERSION 101 | ) 102 | """ 103 | The base URI for the Select2 i18n files. By default this points to the Cloudflare CDN. 104 | 105 | If you want to select the version of the I18N library used, or want to serve it from 106 | the local 'static' resources, add a line to your settings.py like so:: 107 | 108 | SELECT2_I18N_PATH = 'assets/js/i18n' 109 | 110 | .. tip:: Change this setting to a local asset in your development environment to 111 | develop without an Internet connection. 112 | """ 113 | 114 | I18N_AVAILABLE_LANGUAGES = [ 115 | "ar", 116 | "az", 117 | "bg", 118 | "ca", 119 | "cs", 120 | "da", 121 | "de", 122 | "el", 123 | "en", 124 | "es", 125 | "et", 126 | "eu", 127 | "fa", 128 | "fi", 129 | "fr", 130 | "gl", 131 | "he", 132 | "hi", 133 | "hr", 134 | "hu", 135 | "id", 136 | "is", 137 | "it", 138 | "ja", 139 | "km", 140 | "ko", 141 | "lt", 142 | "lv", 143 | "mk", 144 | "ms", 145 | "nb", 146 | "nl", 147 | "pl", 148 | "pt-BR", 149 | "pt", 150 | "ro", 151 | "ru", 152 | "sk", 153 | "sr-Cyrl", 154 | "sr", 155 | "sv", 156 | "th", 157 | "tr", 158 | "uk", 159 | "vi", 160 | "zh-CN", 161 | "zh-TW", 162 | ] 163 | """ 164 | List of available translations. 165 | 166 | List of ISO 639-1 language codes that are supported by Select2. 167 | If currently set language code (e.g. using the HTTP ``Accept-Language`` header) 168 | is in this list, Django-Select2 will use the language code to create load 169 | the proper translation. 170 | 171 | The full path for the language file consists of:: 172 | 173 | from django.utils import translations 174 | 175 | full_path = "{i18n_path}/{language_code}.js".format( 176 | i18n_path=settings.DJANGO_SELECT2_I18N, 177 | language_code=translations.get_language(), 178 | ) 179 | 180 | ``settings.DJANGO_SELECT2_I18N`` refers to :attr:`.I18N_PATH`. 181 | """ 182 | 183 | class Meta: 184 | """Prefix for all Django-Select2 settings.""" 185 | 186 | prefix = "SELECT2" 187 | -------------------------------------------------------------------------------- /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 two types: 11 | 12 | 1. **Light** -- 13 | They are not meant to be used when there 14 | are too many options, say, in thousands. 15 | This is because all those options would 16 | have to be pre-rendered onto the page 17 | and JavaScript would be used to search 18 | through them. Said that, they are also one 19 | the most easiest to use. They are a 20 | drop-in-replacement for Django's default 21 | select widgets. 22 | 23 | 2(a). **Heavy** -- 24 | They are suited for scenarios when the number of options 25 | are large and need complex queries (from maybe different 26 | sources) to get the options. 27 | 28 | This dynamic fetching of options undoubtedly requires 29 | Ajax communication with the server. Django-Select2 includes 30 | a helper JS file which is included automatically, 31 | so you need not worry about writing any Ajax related JS code. 32 | Although on the server side you do need to create a view 33 | specifically to respond to the queries. 34 | 35 | 2(b). **Model** -- 36 | Model-widgets are a further specialized versions of Heavies. 37 | These do not require views to serve Ajax requests. 38 | When they are instantiated, they register themselves 39 | with one central view which handles Ajax requests for them. 40 | 41 | Heavy and Model widgets have respectively the word 'Heavy' and 'Model' in 42 | their name. Light widgets are normally named, i.e. there is no 'Light' word 43 | in their names. 44 | 45 | .. inheritance-diagram:: django_select2.forms 46 | :parts: 1 47 | 48 | """ 49 | import uuid 50 | from functools import reduce 51 | from itertools import chain 52 | from pickle import PicklingError # nosec 53 | 54 | from django import forms 55 | from django.contrib.admin.widgets import SELECT2_TRANSLATIONS 56 | from django.core import signing 57 | from django.db.models import Q 58 | from django.forms.models import ModelChoiceIterator 59 | from django.urls import reverse 60 | from django.utils.translation import get_language 61 | 62 | from .cache import cache 63 | from .conf import settings 64 | 65 | 66 | class Select2Mixin: 67 | """ 68 | The base mixin of all Select2 widgets. 69 | 70 | This mixin is responsible for rendering the necessary 71 | data attributes for select2 as well as adding the static 72 | form media. 73 | """ 74 | 75 | empty_label = "" 76 | 77 | def build_attrs(self, base_attrs, extra_attrs=None): 78 | """Add select2 data attributes.""" 79 | default_attrs = {"data-minimum-input-length": 0} 80 | if self.is_required: 81 | default_attrs["data-allow-clear"] = "false" 82 | else: 83 | default_attrs["data-allow-clear"] = "true" 84 | default_attrs["data-placeholder"] = self.empty_label or "" 85 | 86 | default_attrs.update(base_attrs) 87 | attrs = super().build_attrs(default_attrs, extra_attrs=extra_attrs) 88 | 89 | if "class" in attrs: 90 | attrs["class"] += " django-select2" 91 | else: 92 | attrs["class"] = "django-select2" 93 | return attrs 94 | 95 | def optgroups(self, name, value, attrs=None): 96 | """Add empty option for clearable selects.""" 97 | if not self.is_required and not self.allow_multiple_selected: 98 | self.choices = list(chain([("", "")], self.choices)) 99 | return super().optgroups(name, value, attrs=attrs) 100 | 101 | def _get_media(self): 102 | """ 103 | Construct Media as a dynamic property. 104 | 105 | .. Note:: For more information visit 106 | https://docs.djangoproject.com/en/stable/topics/forms/media/#media-as-a-dynamic-property 107 | """ 108 | lang = get_language() 109 | select2_js = (settings.SELECT2_JS,) if settings.SELECT2_JS else () 110 | select2_css = (settings.SELECT2_CSS,) if settings.SELECT2_CSS else () 111 | 112 | i18n_name = SELECT2_TRANSLATIONS.get(lang) 113 | if i18n_name not in settings.SELECT2_I18N_AVAILABLE_LANGUAGES: 114 | i18n_name = None 115 | 116 | i18n_file = ( 117 | ("%s/%s.js" % (settings.SELECT2_I18N_PATH, i18n_name),) if i18n_name else () 118 | ) 119 | 120 | return forms.Media( 121 | js=select2_js + i18n_file + ("django_select2/django_select2.js",), 122 | css={"screen": select2_css}, 123 | ) 124 | 125 | media = property(_get_media) 126 | 127 | 128 | class Select2TagMixin: 129 | """Mixin to add select2 tag functionality.""" 130 | 131 | def build_attrs(self, base_attrs, extra_attrs=None): 132 | """Add select2's tag attributes.""" 133 | default_attrs = { 134 | "data-minimum-input-length": 1, 135 | "data-tags": "true", 136 | "data-token-separators": '[",", " "]', 137 | } 138 | default_attrs.update(base_attrs) 139 | return super().build_attrs(default_attrs, extra_attrs=extra_attrs) 140 | 141 | 142 | class Select2Widget(Select2Mixin, forms.Select): 143 | """ 144 | Select2 drop in widget. 145 | 146 | Example usage:: 147 | 148 | class MyModelForm(forms.ModelForm): 149 | class Meta: 150 | model = MyModel 151 | fields = ('my_field', ) 152 | widgets = { 153 | 'my_field': Select2Widget 154 | } 155 | 156 | or:: 157 | 158 | class MyForm(forms.Form): 159 | my_choice = forms.ChoiceField(widget=Select2Widget) 160 | 161 | """ 162 | 163 | 164 | class Select2MultipleWidget(Select2Mixin, forms.SelectMultiple): 165 | """ 166 | Select2 drop in widget for multiple select. 167 | 168 | Works just like :class:`.Select2Widget` but for multi select. 169 | """ 170 | 171 | 172 | class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple): 173 | """ 174 | Select2 drop in widget for for tagging. 175 | 176 | Example for :class:`.django.contrib.postgres.fields.ArrayField`:: 177 | 178 | class MyWidget(Select2TagWidget): 179 | 180 | def value_from_datadict(self, data, files, name): 181 | values = super().value_from_datadict(data, files, name): 182 | return ",".join(values) 183 | 184 | def optgroups(self, name, value, attrs=None): 185 | values = value[0].split(',') if value[0] else [] 186 | selected = set(values) 187 | subgroup = [self.create_option(name, v, v, selected, i) for i, v in enumerate(values)] 188 | return [(None, subgroup, 0)] 189 | 190 | """ 191 | 192 | 193 | class HeavySelect2Mixin: 194 | """Mixin that adds select2's AJAX options and registers itself on Django's cache.""" 195 | 196 | dependent_fields = {} 197 | 198 | def __init__(self, attrs=None, choices=(), **kwargs): 199 | """ 200 | Return HeavySelect2Mixin. 201 | 202 | Args: 203 | data_view (str): URL pattern name 204 | data_url (str): URL 205 | dependent_fields (dict): Dictionary of dependent parent fields. 206 | The value of the dependent field will be passed as to :func:`.filter_queryset`. 207 | It can be used to further restrict the search results. For example, a city 208 | widget could be dependent on a country. 209 | Key is a name of a field in a form. 210 | Value is a name of a field in a model (used in `queryset`). 211 | 212 | """ 213 | self.choices = choices 214 | if attrs is not None: 215 | self.attrs = attrs.copy() 216 | else: 217 | self.attrs = {} 218 | 219 | self.uuid = str(uuid.uuid4()) 220 | self.field_id = signing.dumps(self.uuid) 221 | self.data_view = kwargs.pop("data_view", None) 222 | self.data_url = kwargs.pop("data_url", None) 223 | 224 | dependent_fields = kwargs.pop("dependent_fields", None) 225 | if dependent_fields is not None: 226 | self.dependent_fields = dict(dependent_fields) 227 | if not (self.data_view or self.data_url): 228 | raise ValueError('You must ether specify "data_view" or "data_url".') 229 | self.userGetValTextFuncName = kwargs.pop("userGetValTextFuncName", "null") 230 | 231 | def get_url(self): 232 | """Return URL from instance or by reversing :attr:`.data_view`.""" 233 | if self.data_url: 234 | return self.data_url 235 | return reverse(self.data_view) 236 | 237 | def build_attrs(self, base_attrs, extra_attrs=None): 238 | """Set select2's AJAX attributes.""" 239 | default_attrs = { 240 | "data-ajax--url": self.get_url(), 241 | "data-ajax--cache": "true", 242 | "data-ajax--type": "GET", 243 | "data-minimum-input-length": 2, 244 | } 245 | 246 | if self.dependent_fields: 247 | default_attrs["data-select2-dependent-fields"] = " ".join( 248 | self.dependent_fields 249 | ) 250 | 251 | default_attrs.update(base_attrs) 252 | 253 | attrs = super().build_attrs(default_attrs, extra_attrs=extra_attrs) 254 | 255 | attrs["data-field_id"] = self.field_id 256 | 257 | attrs["class"] += " django-select2-heavy" 258 | return attrs 259 | 260 | def render(self, *args, **kwargs): 261 | """Render widget and register it in Django's cache.""" 262 | output = super().render(*args, **kwargs) 263 | self.set_to_cache() 264 | return output 265 | 266 | def _get_cache_key(self): 267 | return "%s%s" % (settings.SELECT2_CACHE_PREFIX, self.uuid) 268 | 269 | def set_to_cache(self): 270 | """ 271 | Add widget object to Django's cache. 272 | 273 | You may need to overwrite this method, to pickle all information 274 | that is required to serve your JSON response view. 275 | """ 276 | try: 277 | cache.set(self._get_cache_key(), {"widget": self, "url": self.get_url()}) 278 | except (PicklingError, AttributeError): 279 | msg = 'You need to overwrite "set_to_cache" or ensure that %s is serialisable.' 280 | raise NotImplementedError(msg % self.__class__.__name__) 281 | 282 | 283 | class HeavySelect2Widget(HeavySelect2Mixin, Select2Widget): 284 | """ 285 | Select2 widget with AJAX support that registers itself to Django's Cache. 286 | 287 | Usage example:: 288 | 289 | class MyWidget(HeavySelect2Widget): 290 | data_view = 'my_view_name' 291 | 292 | or:: 293 | 294 | class MyForm(forms.Form): 295 | my_field = forms.ChoiceField( 296 | widget=HeavySelect2Widget( 297 | data_url='/url/to/json/response' 298 | ) 299 | ) 300 | 301 | """ 302 | 303 | 304 | class HeavySelect2MultipleWidget(HeavySelect2Mixin, Select2MultipleWidget): 305 | """Select2 multi select widget similar to :class:`.HeavySelect2Widget`.""" 306 | 307 | 308 | class HeavySelect2TagWidget(HeavySelect2Mixin, Select2TagWidget): 309 | """Select2 tag widget.""" 310 | 311 | 312 | # Auto Heavy widgets 313 | 314 | 315 | class ModelSelect2Mixin: 316 | """Widget mixin that provides attributes and methods for :class:`.AutoResponseView`.""" 317 | 318 | model = None 319 | queryset = None 320 | search_fields = [] 321 | """ 322 | Model lookups that are used to filter the QuerySet. 323 | 324 | Example:: 325 | 326 | search_fields = [ 327 | 'title__icontains', 328 | ] 329 | 330 | """ 331 | 332 | max_results = 25 333 | """Maximal results returned by :class:`.AutoResponseView`.""" 334 | 335 | @property 336 | def empty_label(self): 337 | if isinstance(self.choices, ModelChoiceIterator): 338 | return self.choices.field.empty_label 339 | return "" 340 | 341 | def __init__(self, *args, **kwargs): 342 | """ 343 | Overwrite class parameters if passed as keyword arguments. 344 | 345 | Args: 346 | model (django.db.models.Model): Model to select choices from. 347 | queryset (django.db.models.query.QuerySet): QuerySet to select choices from. 348 | search_fields (list): List of model lookup strings. 349 | max_results (int): Max. JsonResponse view page size. 350 | 351 | """ 352 | self.model = kwargs.pop("model", self.model) 353 | self.queryset = kwargs.pop("queryset", self.queryset) 354 | self.search_fields = kwargs.pop("search_fields", self.search_fields) 355 | self.max_results = kwargs.pop("max_results", self.max_results) 356 | defaults = {"data_view": "django_select2:auto-json"} 357 | defaults.update(kwargs) 358 | super().__init__(*args, **defaults) 359 | 360 | def set_to_cache(self): 361 | """ 362 | Add widget's attributes to Django's cache. 363 | 364 | Split the QuerySet, to not pickle the result set. 365 | """ 366 | queryset = self.get_queryset() 367 | cache.set( 368 | self._get_cache_key(), 369 | { 370 | "queryset": [queryset.none(), queryset.query], 371 | "cls": self.__class__, 372 | "search_fields": tuple(self.search_fields), 373 | "max_results": int(self.max_results), 374 | "url": str(self.get_url()), 375 | "dependent_fields": dict(self.dependent_fields), 376 | }, 377 | ) 378 | 379 | def filter_queryset(self, request, term, queryset=None, **dependent_fields): 380 | """ 381 | Return QuerySet filtered by search_fields matching the passed term. 382 | 383 | Args: 384 | request (django.http.request.HttpRequest): The request is being passed from 385 | the JSON view and can be used to dynamically alter the response queryset. 386 | term (str): Search term 387 | queryset (django.db.models.query.QuerySet): QuerySet to select choices from. 388 | **dependent_fields: Dependent fields and their values. If you want to inherit 389 | from ModelSelect2Mixin and later call to this method, be sure to pop 390 | everything from keyword arguments that is not a dependent field. 391 | 392 | Returns: 393 | QuerySet: Filtered QuerySet 394 | 395 | """ 396 | if queryset is None: 397 | queryset = self.get_queryset() 398 | search_fields = self.get_search_fields() 399 | select = Q() 400 | term = term.replace("\t", " ") 401 | term = term.replace("\n", " ") 402 | for t in [t for t in term.split(" ") if not t == ""]: 403 | select &= reduce( 404 | lambda x, y: x | Q(**{y: t}), 405 | search_fields[1:], 406 | Q(**{search_fields[0]: t}), 407 | ) 408 | if dependent_fields: 409 | select &= Q(**dependent_fields) 410 | 411 | return queryset.filter(select).distinct() 412 | 413 | def get_queryset(self): 414 | """ 415 | Return QuerySet based on :attr:`.queryset` or :attr:`.model`. 416 | 417 | Returns: 418 | QuerySet: QuerySet of available choices. 419 | 420 | """ 421 | if self.queryset is not None: 422 | queryset = self.queryset 423 | elif hasattr(self.choices, "queryset"): 424 | queryset = self.choices.queryset 425 | elif self.model is not None: 426 | queryset = self.model._default_manager.all() 427 | else: 428 | raise NotImplementedError( 429 | "%(cls)s is missing a QuerySet. Define " 430 | "%(cls)s.model, %(cls)s.queryset, or override " 431 | "%(cls)s.get_queryset()." % {"cls": self.__class__.__name__} 432 | ) 433 | return queryset 434 | 435 | def get_search_fields(self): 436 | """Return list of lookup names.""" 437 | if self.search_fields: 438 | return self.search_fields 439 | raise NotImplementedError( 440 | '%s, must implement "search_fields".' % self.__class__.__name__ 441 | ) 442 | 443 | def optgroups(self, name, value, attrs=None): 444 | """Return only selected options and set QuerySet from `ModelChoicesIterator`.""" 445 | default = (None, [], 0) 446 | groups = [default] 447 | has_selected = False 448 | selected_choices = {str(v) for v in value} 449 | if not self.is_required and not self.allow_multiple_selected: 450 | default[1].append(self.create_option(name, "", "", False, 0)) 451 | if not isinstance(self.choices, ModelChoiceIterator): 452 | return super().optgroups(name, value, attrs=attrs) 453 | selected_choices = { 454 | c for c in selected_choices if c not in self.choices.field.empty_values 455 | } 456 | field_name = self.choices.field.to_field_name or "pk" 457 | query = Q(**{"%s__in" % field_name: selected_choices}) 458 | for obj in self.choices.queryset.filter(query): 459 | option_value = self.choices.choice(obj)[0] 460 | option_label = self.label_from_instance(obj) 461 | 462 | selected = str(option_value) in value and ( 463 | has_selected is False or self.allow_multiple_selected 464 | ) 465 | if selected is True and has_selected is False: 466 | has_selected = True 467 | index = len(default[1]) 468 | subgroup = default[1] 469 | subgroup.append( 470 | self.create_option( 471 | name, option_value, option_label, selected_choices, index 472 | ) 473 | ) 474 | return groups 475 | 476 | def label_from_instance(self, obj): 477 | """ 478 | Return option label representation from instance. 479 | 480 | Can be overridden to change the representation of each choice. 481 | 482 | Example usage:: 483 | 484 | class MyWidget(ModelSelect2Widget): 485 | def label_from_instance(obj): 486 | return str(obj.title).upper() 487 | 488 | Args: 489 | obj (django.db.models.Model): Instance of Django Model. 490 | 491 | Returns: 492 | str: Option label. 493 | 494 | """ 495 | return str(obj) 496 | 497 | 498 | class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget): 499 | """ 500 | Select2 drop in model select widget. 501 | 502 | Example usage:: 503 | 504 | class MyWidget(ModelSelect2Widget): 505 | search_fields = [ 506 | 'title__icontains', 507 | ] 508 | 509 | class MyModelForm(forms.ModelForm): 510 | class Meta: 511 | model = MyModel 512 | fields = ('my_field', ) 513 | widgets = { 514 | 'my_field': MyWidget, 515 | } 516 | 517 | or:: 518 | 519 | class MyForm(forms.Form): 520 | my_choice = forms.ChoiceField( 521 | widget=ModelSelect2Widget( 522 | model=MyOtherModel, 523 | search_fields=['title__icontains'] 524 | ) 525 | ) 526 | 527 | .. tip:: The ModelSelect2(Multiple)Widget will try 528 | to get the QuerySet from the fields choices. 529 | Therefore you don't need to define a QuerySet, 530 | if you just drop in the widget for a ForeignKey field. 531 | """ 532 | 533 | 534 | class ModelSelect2MultipleWidget(ModelSelect2Mixin, HeavySelect2MultipleWidget): 535 | """ 536 | Select2 drop in model multiple select widget. 537 | 538 | Works just like :class:`.ModelSelect2Widget` but for multi select. 539 | """ 540 | 541 | 542 | class ModelSelect2TagWidget(ModelSelect2Mixin, HeavySelect2TagWidget): 543 | """ 544 | Select2 model widget with tag support. 545 | 546 | This it not a simple drop in widget. 547 | It requires to implement you own :func:`.value_from_datadict` 548 | that adds missing tags to you QuerySet. 549 | 550 | Example:: 551 | 552 | class MyModelSelect2TagWidget(ModelSelect2TagWidget): 553 | queryset = MyModel.objects.all() 554 | 555 | def value_from_datadict(self, data, files, name): 556 | '''Create objects for given non-pimary-key values. Return list of all primary keys.''' 557 | values = set(super().value_from_datadict(data, files, name)) 558 | # This may only work for MyModel, if MyModel has title field. 559 | # You need to implement this method yourself, to ensure proper object creation. 560 | pks = self.queryset.filter(**{'pk__in': list(values)}).values_list('pk', flat=True) 561 | pks = set(map(str, pks)) 562 | cleaned_values = list(values) 563 | for val in values - pks: 564 | cleaned_values.append(self.queryset.create(title=val).pk) 565 | return cleaned_values 566 | 567 | """ 568 | -------------------------------------------------------------------------------- /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) 10 | } 11 | }(function ($) { 12 | 'use strict' 13 | var init = function ($element, options) { 14 | $element.select2(options) 15 | } 16 | 17 | var initHeavy = function ($element, options) { 18 | var settings = $.extend({ 19 | ajax: { 20 | data: function (params) { 21 | var result = { 22 | term: params.term, 23 | page: params.page, 24 | field_id: $element.data('field_id') 25 | } 26 | 27 | var dependentFields = $element.data('select2-dependent-fields') 28 | if (dependentFields) { 29 | dependentFields = dependentFields.trim().split(/\s+/) 30 | $.each(dependentFields, function (i, dependentField) { 31 | result[dependentField] = $('[name=' + dependentField + ']', $element.closest('form')).val() 32 | }) 33 | } 34 | 35 | return result 36 | }, 37 | processResults: function (data, page) { 38 | return { 39 | results: data.results, 40 | pagination: { 41 | more: data.more 42 | } 43 | } 44 | } 45 | } 46 | }, options) 47 | 48 | $element.select2(settings) 49 | } 50 | 51 | $.fn.djangoSelect2 = function (options) { 52 | var settings = $.extend({}, options) 53 | $.each(this, function (i, element) { 54 | var $element = $(element) 55 | if ($element.hasClass('django-select2-heavy')) { 56 | initHeavy($element, settings) 57 | } else { 58 | init($element, settings) 59 | } 60 | $element.on('select2:select', function (e) { 61 | var name = $(e.currentTarget).attr('name') 62 | $('[data-select2-dependent-fields=' + name + ']').each(function () { 63 | $(this).val('').trigger('change') 64 | }) 65 | }) 66 | }) 67 | return this 68 | } 69 | 70 | $(function () { 71 | $('.django-select2').djangoSelect2() 72 | }) 73 | 74 | return $.fn.djangoSelect2 75 | })) 76 | -------------------------------------------------------------------------------- /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 | from django.urls import path 13 | 14 | from .views import AutoResponseView 15 | 16 | app_name = "django_select2" 17 | 18 | urlpatterns = [ 19 | path("fields/auto.json", AutoResponseView.as_view(), name="auto-json"), 20 | ] 21 | -------------------------------------------------------------------------------- /django_select2/views.py: -------------------------------------------------------------------------------- 1 | """JSONResponse views for model widgets.""" 2 | from django.core import signing 3 | from django.core.signing import BadSignature 4 | from django.http import Http404, JsonResponse 5 | from django.views.generic.list import BaseListView 6 | 7 | from .cache import cache 8 | from .conf import settings 9 | 10 | 11 | class AutoResponseView(BaseListView): 12 | """ 13 | View that handles requests from heavy model widgets. 14 | 15 | The view only supports HTTP's GET method. 16 | """ 17 | 18 | def get(self, request, *args, **kwargs): 19 | """ 20 | Return a :class:`.django.http.JsonResponse`. 21 | 22 | Example:: 23 | 24 | { 25 | 'results': [ 26 | { 27 | 'text': "foo", 28 | 'id': 123 29 | } 30 | ], 31 | 'more': true 32 | } 33 | 34 | """ 35 | self.widget = self.get_widget_or_404() 36 | self.term = kwargs.get("term", request.GET.get("term", "")) 37 | self.object_list = self.get_queryset() 38 | context = self.get_context_data() 39 | return JsonResponse( 40 | { 41 | "results": [ 42 | {"text": self.widget.label_from_instance(obj), "id": obj.pk} 43 | for obj in context["object_list"] 44 | ], 45 | "more": context["page_obj"].has_next(), 46 | } 47 | ) 48 | 49 | def get_queryset(self): 50 | """Get QuerySet from cached widget.""" 51 | kwargs = { 52 | model_field_name: self.request.GET.get(form_field_name) 53 | for form_field_name, model_field_name in self.widget.dependent_fields.items() 54 | if form_field_name in self.request.GET 55 | and self.request.GET.get(form_field_name, "") != "" 56 | } 57 | return self.widget.filter_queryset( 58 | self.request, self.term, self.queryset, **kwargs 59 | ) 60 | 61 | def get_paginate_by(self, queryset): 62 | """Paginate response by size of widget's `max_results` parameter.""" 63 | return self.widget.max_results 64 | 65 | def get_widget_or_404(self): 66 | """ 67 | Get and return widget from cache. 68 | 69 | Raises: 70 | Http404: If if the widget can not be found or no id is provided. 71 | 72 | Returns: 73 | ModelSelect2Mixin: Widget from cache. 74 | 75 | """ 76 | field_id = self.kwargs.get("field_id", self.request.GET.get("field_id", None)) 77 | if not field_id: 78 | raise Http404('No "field_id" provided.') 79 | try: 80 | key = signing.loads(field_id) 81 | except BadSignature: 82 | raise Http404('Invalid "field_id".') 83 | else: 84 | cache_key = "%s%s" % (settings.SELECT2_CACHE_PREFIX, key) 85 | widget_dict = cache.get(cache_key) 86 | if widget_dict is None: 87 | raise Http404("field_id not found") 88 | if widget_dict.pop("url") != self.request.path: 89 | raise Http404("field_id was issued for the view.") 90 | qs, qs.query = widget_dict.pop("queryset") 91 | self.queryset = qs.all() 92 | widget_dict["queryset"] = self.queryset 93 | widget_cls = widget_dict.pop("cls") 94 | return widget_cls(**widget_dict) 95 | -------------------------------------------------------------------------------- /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.napoleon", 35 | "sphinx.ext.inheritance_diagram", 36 | "sphinx.ext.intersphinx", 37 | "sphinx.ext.viewcode", 38 | "sphinx.ext.doctest", 39 | ] 40 | 41 | intersphinx_mapping = { 42 | "python": ("https://docs.python.org/3", None), 43 | "django": ( 44 | "https://docs.djangoproject.com/en/stable/", 45 | "https://docs.djangoproject.com/en/stable/_objects/", 46 | ), 47 | } 48 | 49 | autodoc_default_flags = ["members", "show-inheritance"] 50 | autodoc_member_order = "bysource" 51 | 52 | inheritance_graph_attrs = dict(rankdir="TB") 53 | 54 | inheritance_node_attrs = dict( 55 | shape="rect", fontsize=14, fillcolor="gray90", color="gray30", style="filled" 56 | ) 57 | 58 | inheritance_edge_attrs = dict(penwidth=0.75) 59 | -------------------------------------------------------------------------------- /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 | 59 | You can pass see `Select2 options `_ if needed:: 60 | 61 | $('.django-select2').djangoSelect2({placeholder: 'Select an option'}); 62 | 63 | Please replace all your ``.select2`` invocations with the here provided 64 | ``.djangoSelect2``. 65 | 66 | Security & Authentication 67 | ------------------------- 68 | 69 | Security is important. Therefore make sure to read and understand what 70 | the security measures in place and their limitations. 71 | 72 | Set up a separate cache. If you have a public form that uses a model widget 73 | make sure to setup a separate cache database for Select2. An attacker 74 | could constantly reload your site and fill up the select2 cache. 75 | Having a separate cache allows you to limit the effect to select2 only. 76 | 77 | You might want to add a secure select2 JSON endpoint for data you don't 78 | want to be accessible to the general public. Doing so is easy:: 79 | 80 | class UserSelect2View(LoginRequiredMixin, AutoResponseView): 81 | pass 82 | 83 | class UserSelect2WidgetMixin(object): 84 | def __init__(self, *args, **kwargs): 85 | kwargs['data_view'] = 'user-select2-view' 86 | super(UserSelect2WidgetMixin, self).__init__(*args, **kwargs) 87 | 88 | class MySecretWidget(UserSelect2WidgetMixin, ModelSelect2Widget): 89 | model = MySecretModel 90 | search_fields = ['title__icontains'] 91 | -------------------------------------------------------------------------------- /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 | Models 12 | `````` 13 | 14 | Here are our two models: 15 | 16 | .. code-block:: python 17 | 18 | class Country(models.Model): 19 | name = models.CharField(max_length=255) 20 | 21 | 22 | class City(models.Model): 23 | name = models.CharField(max_length=255) 24 | country = models.ForeignKey('Country', related_name="cities") 25 | 26 | 27 | Customizing a Form 28 | `````````````````` 29 | 30 | Lets link two widgets via *dependent_fields*. 31 | 32 | .. code-block:: python 33 | :emphasize-lines: 15 34 | 35 | class AddressForm(forms.Form): 36 | country = forms.ModelChoiceField( 37 | queryset=Country.objects.all(), 38 | label=u"Country", 39 | widget=ModelSelect2Widget( 40 | model=Country, 41 | search_fields=['name__icontains'], 42 | ) 43 | ) 44 | 45 | city = forms.ModelChoiceField( 46 | queryset=City.objects.all(), 47 | label=u"City", 48 | widget=ModelSelect2Widget( 49 | model=City, 50 | search_fields=['name__icontains'], 51 | dependent_fields={'country': 'country'}, 52 | max_results=500, 53 | ) 54 | ) 55 | 56 | 57 | Interdependent select2 58 | ---------------------- 59 | 60 | Also you may want not to restrict the user to which field should be selected first. 61 | Instead you want to suggest to the user options for any select2 depending of his selection in another one. 62 | 63 | Customize the form in a manner: 64 | 65 | .. code-block:: python 66 | :emphasize-lines: 7 67 | 68 | class AddressForm(forms.Form): 69 | country = forms.ModelChoiceField( 70 | queryset=Country.objects.all(), 71 | label=u"Country", 72 | widget=ModelSelect2Widget( 73 | search_fields=['name__icontains'], 74 | dependent_fields={'city': 'cities'}, 75 | ) 76 | ) 77 | 78 | city = forms.ModelChoiceField( 79 | queryset=City.objects.all(), 80 | label=u"City", 81 | widget=ModelSelect2Widget( 82 | search_fields=['name__icontains'], 83 | dependent_fields={'country': 'country'}, 84 | max_results=500, 85 | ) 86 | ) 87 | 88 | Take attention to country's dependent_fields. The value of 'city' is 'cities' because of 89 | related name used in a filter condition `cities` which differs from widget field name `city`. 90 | 91 | .. caution:: 92 | Be aware of using interdependent select2 in parent-child relation. 93 | When a child is selected, you are restricted to change parent (only one value is available). 94 | Probably you should let the user reset the child first to release parent select2. 95 | 96 | 97 | Multi-dependent select2 98 | ----------------------- 99 | 100 | Furthermore you may want to filter options on two or more select2 selections (some code is dropped for clarity): 101 | 102 | .. code-block:: python 103 | :emphasize-lines: 14 104 | 105 | class SomeForm(forms.Form): 106 | field1 = forms.ModelChoiceField( 107 | widget=ModelSelect2Widget( 108 | ) 109 | ) 110 | 111 | field2 = forms.ModelChoiceField( 112 | widget=ModelSelect2Widget( 113 | ) 114 | ) 115 | 116 | field3 = forms.ModelChoiceField( 117 | widget=ModelSelect2Widget( 118 | dependent_fields={'field1': 'field1', 'field2': 'field2'}, 119 | ) 120 | ) 121 | 122 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | Installation 4 | ------------ 5 | 6 | Install ``django-select2`` 7 | 8 | .. code-block:: python 9 | 10 | python3 -m pip install django-select2 11 | 12 | Add ``django_select2`` to your ``INSTALLED_APPS`` in your project settings. 13 | 14 | Add ``django_select`` to your URL root configuration: 15 | 16 | .. code-block:: python 17 | 18 | from django.urls import include, path 19 | 20 | urlpatterns = [ 21 | # … other patterns 22 | path("select2/", include("django_select2.urls")), 23 | # … other patterns 24 | ] 25 | 26 | Finally make sure you have a persistent cache backend setup (NOT 27 | :class:`.DummyCache` or :class:`.LocMemCache`), we will use Redis in this 28 | example. Make sure you have a Redis server up and running:: 29 | 30 | # Debian 31 | sudo apt-get install redis-server 32 | 33 | # macOS 34 | brew install redis 35 | 36 | # install Redis python client 37 | python3 -m pip install django-redis 38 | 39 | Next, add the cache configuration to your ``settings.py`` as follows: 40 | 41 | .. code-block:: python 42 | 43 | CACHES = { 44 | # … default cache config and others 45 | "select2": { 46 | "BACKEND": "django_redis.cache.RedisCache", 47 | "LOCATION": "redis://127.0.0.1:6379/2", 48 | "OPTIONS": { 49 | "CLIENT_CLASS": "django_redis.client.DefaultClient", 50 | } 51 | } 52 | } 53 | 54 | # Tell select2 which cache configuration to use: 55 | SELECT2_CACHE_BACKEND = "select2" 56 | 57 | 58 | External Dependencies 59 | --------------------- 60 | 61 | - jQuery is not included in the package since it is 62 | expected that in most scenarios this would already be available. 63 | 64 | 65 | Quick Start 66 | ----------- 67 | 68 | Here is a quick example to get you started: 69 | 70 | First make sure you followed the installation instructions above. 71 | Once everything is setup, let's start with a simple example. 72 | 73 | We have the following model: 74 | 75 | .. code-block:: python 76 | 77 | # models.py 78 | from django.conf import settings 79 | from django.db import models 80 | 81 | 82 | class Book(models.Model): 83 | author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 84 | co_authors = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='co_authored_by') 85 | 86 | 87 | Next, we create a model form with custom Select2 widgets. 88 | 89 | .. code-block:: python 90 | 91 | # forms.py 92 | from django import forms 93 | from django_select2 import forms as s2forms 94 | 95 | from . import models 96 | 97 | 98 | class AuthorWidget(s2forms.ModelSelect2Widget): 99 | search_fields = [ 100 | "username__icontains", 101 | "email__icontains", 102 | ] 103 | 104 | 105 | class CoAuthorsWidget(s2forms.ModelSelect2MultipleWidget): 106 | search_fields = [ 107 | "username__icontains", 108 | "email__icontains", 109 | ] 110 | 111 | 112 | class BookForm(forms.ModelForm): 113 | class Meta: 114 | model = models.Book 115 | fields = "__all__" 116 | widgets = { 117 | "author": AuthorWidget, 118 | "co_authors": CoAuthorsWidget, 119 | } 120 | 121 | A simple class based view will do, to render your form: 122 | 123 | .. code-block:: python 124 | 125 | # views.py 126 | from django.views import generic 127 | 128 | from . import forms, models 129 | 130 | 131 | class BookCreateView(generic.CreateView): 132 | model = models.Book 133 | form_class = forms.BookForm 134 | success_url = "/" 135 | 136 | Make sure to add the view to your ``urls.py``: 137 | 138 | .. code-block:: python 139 | 140 | # urls.py 141 | from django.urls import include, path 142 | 143 | from . import views 144 | 145 | urlpatterns = [ 146 | # … other patterns 147 | path("select2/", include("django_select2.urls")), 148 | # … other patterns 149 | path("book/create", views.BookCreateView.as_view(), name="book-create"), 150 | ] 151 | 152 | 153 | Finally, we need a little template, ``myapp/templates/myapp/book_form.html`` 154 | 155 | .. code-block:: HTML 156 | 157 | 158 | 159 | 160 | Create Book 161 | {{ form.media.css }} 162 | 165 | 166 | 167 |

Create a new Book

168 |
169 | {% csrf_token %} 170 | {{ form.as_p }} 171 | 172 |
173 | 174 | {{ form.media.js }} 175 | 176 | 177 | 178 | Done - enjoy the wonders of Select2! 179 | 180 | 181 | Changelog 182 | --------- 183 | 184 | See `Github releases`_. 185 | 186 | .. _Github releases: https://github.com/codingjoe/django-select2/releases 187 | 188 | All Contents 189 | ============ 190 | 191 | Contents: 192 | 193 | .. toctree:: 194 | :maxdepth: 2 195 | :glob: 196 | 197 | django_select2 198 | extra 199 | CONTRIBUTING 200 | 201 | Indices and tables 202 | ================== 203 | 204 | * :ref:`genindex` 205 | * :ref:`modindex` 206 | * :ref:`search` 207 | -------------------------------------------------------------------------------- /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/applegrew/django-select2/4e3bdc89185a45e319d54d16ed0b36f74674d58d/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 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="Book", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ( 30 | "author", 31 | models.ForeignKey( 32 | on_delete=django.db.models.deletion.CASCADE, 33 | to=settings.AUTH_USER_MODEL, 34 | ), 35 | ), 36 | ( 37 | "co_authors", 38 | models.ManyToManyField( 39 | related_name="co_authored_by", to=settings.AUTH_USER_MODEL 40 | ), 41 | ), 42 | ], 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /example/example/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/applegrew/django-select2/4e3bdc89185a45e319d54d16ed0b36f74674d58d/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" 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_L10N = True 109 | 110 | USE_TZ = True 111 | 112 | 113 | # Static files (CSS, JavaScript, Images) 114 | # https://docs.djangoproject.com/en/dev/howto/static-files/ 115 | 116 | STATIC_URL = "/static/" 117 | 118 | CACHES = { 119 | "default": { 120 | "BACKEND": "django_redis.cache.RedisCache", 121 | "LOCATION": "redis://127.0.0.1:6379/1", 122 | "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, 123 | }, 124 | "select2": { 125 | "BACKEND": "django_redis.cache.RedisCache", 126 | "LOCATION": "redis://127.0.0.1:6379/2", 127 | "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, 128 | }, 129 | } 130 | 131 | SELECT2_CACHE_BACKEND = "select2" 132 | -------------------------------------------------------------------------------- /example/example/templates/example/book_form.html: -------------------------------------------------------------------------------- 1 | 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 | django-redis 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | import os 5 | 6 | if __name__ == "__main__": 7 | with open("package.json", "r+") as f: 8 | data = json.load(f) 9 | f.seek(0) 10 | data["version"] = os.environ["GITHUB_REF"].rsplit("/")[-1] 11 | json.dump(data, f) 12 | f.truncate() 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-select2 3 | author = Johannes Hoppe 4 | author_email = info@johanneshoppe.com 5 | description = Select2 option fields for Django 6 | long_description = file: README.rst 7 | url = https://github.com/codingjoe/django-select2 8 | license = MIT 9 | license_file = LICENSE 10 | classifier = 11 | Development Status :: 5 - Production/Stable 12 | Environment :: Web Environment 13 | Intended Audience :: Developers 14 | License :: OSI Approved :: Apache Software License 15 | Operating System :: OS Independent 16 | Programming Language :: Python 17 | Programming Language :: Python :: 3 18 | Programming Language :: Python :: 3 :: Only 19 | Programming Language :: Python :: 3.6 20 | Programming Language :: Python :: 3.7 21 | Programming Language :: Python :: 3.8 22 | Framework :: Django 23 | Framework :: Django :: 2.2 24 | Framework :: Django :: 3.0 25 | 26 | [options] 27 | include_package_data = True 28 | packages = django_select2 29 | install_requires = 30 | django>=2.2 31 | django-appconf>=0.6.0 32 | setup_requires = 33 | setuptools_scm 34 | sphinx 35 | pytest-runner 36 | tests_require = 37 | pytest 38 | pytest-cov 39 | pytest-django 40 | selenium 41 | 42 | [options.extras_require] 43 | test = 44 | pytest 45 | pytest-cov 46 | pytest-django 47 | selenium 48 | 49 | [bdist_wheel] 50 | universal = 1 51 | 52 | [bdist_rpm] 53 | requires = 54 | python-django-appconf >= 2.0 55 | python-django-appconf >= 0.6 56 | 57 | [aliases] 58 | test = pytest 59 | 60 | [build_sphinx] 61 | source-dir = docs 62 | build-dir = docs/_build 63 | 64 | [tool:pytest] 65 | addopts = 66 | tests 67 | --doctest-glob='*.rst' 68 | --doctest-modules 69 | --cov=django_select2 70 | DJANGO_SETTINGS_MODULE=tests.testapp.settings 71 | 72 | [flake8] 73 | max-line-length=88 74 | select = C,E,F,W,B,B950 75 | ignore = E203, E501, W503 76 | exclude = venv,.tox,.eggs 77 | 78 | [pydocstyle] 79 | add-ignore = D1 80 | 81 | [isort] 82 | atomic = true 83 | line_length = 88 84 | multi_line_output = 3 85 | include_trailing_comma = True 86 | force_grid_wrap = 0 87 | use_parentheses = True 88 | known_first_party = django_select2, tests 89 | default_section = THIRDPARTY 90 | combine_as_imports = true 91 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup(name="django-select2", use_scm_version=True) 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/applegrew/django-select2/4e3bdc89185a45e319d54d16ed0b36f74674d58d/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 random_string(n): 10 | return "".join( 11 | random.choice(string.ascii_uppercase + string.digits) for _ in range(n) 12 | ) 13 | 14 | 15 | def random_name(n): 16 | words = ( 17 | "".join(random.choice(string.ascii_lowercase + " ") for _ in range(n)) 18 | .strip() 19 | .split(" ") 20 | ) 21 | return "-".join([x.capitalize() for x in words]) 22 | 23 | 24 | @pytest.yield_fixture(scope="session") 25 | def driver(): 26 | chrome_options = webdriver.ChromeOptions() 27 | chrome_options.headless = True 28 | try: 29 | b = webdriver.Chrome(options=chrome_options) 30 | except WebDriverException as e: 31 | pytest.skip(str(e)) 32 | else: 33 | yield b 34 | b.quit() 35 | 36 | 37 | @pytest.fixture 38 | def genres(db): 39 | from .testapp.models import Genre 40 | 41 | return Genre.objects.bulk_create( 42 | [Genre(pk=pk, title=random_string(50)) for pk in range(100)] 43 | ) 44 | 45 | 46 | @pytest.fixture 47 | def artists(db): 48 | from .testapp.models import Artist 49 | 50 | return Artist.objects.bulk_create( 51 | [Artist(pk=pk, title=random_string(50)) for pk in range(100)] 52 | ) 53 | 54 | 55 | @pytest.fixture 56 | def countries(db): 57 | from .testapp.models import Country 58 | 59 | return Country.objects.bulk_create( 60 | [Country(pk=pk, name=random_name(random.randint(10, 20))) for pk in range(10)] 61 | ) 62 | 63 | 64 | @pytest.fixture 65 | def cities(db, countries): 66 | from .testapp.models import City 67 | 68 | return City.objects.bulk_create( 69 | [ 70 | City( 71 | pk=pk, 72 | name=random_name(random.randint(5, 15)), 73 | country=random.choice(countries), 74 | ) 75 | for pk in range(100) 76 | ] 77 | ) 78 | -------------------------------------------------------------------------------- /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.conf import settings 17 | from django_select2.forms import ( 18 | HeavySelect2MultipleWidget, 19 | HeavySelect2Widget, 20 | ModelSelect2TagWidget, 21 | ModelSelect2Widget, 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_allow_clear(self, db): 50 | required_field = self.form.fields["artist"] 51 | assert required_field.required is True 52 | assert 'data-allow-clear="true"' not in required_field.widget.render( 53 | "artist", None 54 | ) 55 | assert 'data-allow-clear="false"' in required_field.widget.render( 56 | "artist", None 57 | ) 58 | assert '' not in required_field.widget.render( 59 | "artist", None 60 | ) 61 | 62 | not_required_field = self.form.fields["primary_genre"] 63 | assert not_required_field.required is False 64 | assert 'data-allow-clear="true"' in not_required_field.widget.render( 65 | "primary_genre", None 66 | ) 67 | assert 'data-allow-clear="false"' not in not_required_field.widget.render( 68 | "primary_genre", None 69 | ) 70 | assert "data-placeholder" in not_required_field.widget.render( 71 | "primary_genre", None 72 | ) 73 | assert '' in not_required_field.widget.render( 74 | "primary_genre", None 75 | ) 76 | 77 | def test_no_js_error(self, db, live_server, driver): 78 | driver.get(live_server + self.url) 79 | with pytest.raises(NoSuchElementException): 80 | error = driver.find_element_by_xpath("//body[@JSError]") 81 | pytest.fail(error.get_attribute("JSError")) 82 | 83 | def test_selecting(self, db, live_server, driver): 84 | driver.get(live_server + self.url) 85 | with pytest.raises(NoSuchElementException): 86 | driver.find_element_by_css_selector(".select2-results") 87 | elem = driver.find_element_by_css_selector(".select2-selection") 88 | elem.click() 89 | results = driver.find_element_by_css_selector(".select2-results") 90 | assert results.is_displayed() is True 91 | elem = results.find_element_by_css_selector(".select2-results__option") 92 | elem.click() 93 | 94 | with pytest.raises(NoSuchElementException): 95 | error = driver.find_element_by_xpath("//body[@JSError]") 96 | pytest.fail(error.get_attribute("JSError")) 97 | 98 | def test_data_url(self): 99 | with pytest.raises(ValueError): 100 | HeavySelect2Widget() 101 | 102 | widget = HeavySelect2Widget(data_url="/foo/bar") 103 | assert widget.get_url() == "/foo/bar" 104 | 105 | def test_empty_option(self, db): 106 | # Empty options is only required for single selects 107 | # https://select2.github.io/options.html#allowClear 108 | single_select = self.form.fields["primary_genre"] 109 | assert single_select.required is False 110 | assert '' in single_select.widget.render( 111 | "primary_genre", None 112 | ) 113 | 114 | multiple_select = self.multiple_form.fields["featured_artists"] 115 | assert multiple_select.required is False 116 | assert multiple_select.widget.allow_multiple_selected 117 | output = multiple_select.widget.render("featured_artists", None) 118 | assert '' not in output 119 | assert 'data-placeholder=""' in output 120 | 121 | def test_i18n(self): 122 | translation.activate("de") 123 | assert tuple(Select2Widget().media._js) == ( 124 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/js/select2.min.js", 125 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/js/i18n/de.js", 126 | "django_select2/django_select2.js", 127 | ) 128 | 129 | translation.activate("en") 130 | assert tuple(Select2Widget().media._js) == ( 131 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/js/select2.min.js", 132 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/js/i18n/en.js", 133 | "django_select2/django_select2.js", 134 | ) 135 | 136 | translation.activate("00") 137 | assert tuple(Select2Widget().media._js) == ( 138 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/js/select2.min.js", 139 | "django_select2/django_select2.js", 140 | ) 141 | 142 | translation.activate("sr-cyrl") 143 | assert tuple(Select2Widget().media._js) == ( 144 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/js/select2.min.js", 145 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/js/i18n/sr-Cyrl.js", 146 | "django_select2/django_select2.js", 147 | ) 148 | 149 | pytest.importorskip("django", minversion="2.0.4") 150 | 151 | translation.activate("zh-hans") 152 | assert tuple(Select2Widget().media._js) == ( 153 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/js/select2.min.js", 154 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/js/i18n/zh-CN.js", 155 | "django_select2/django_select2.js", 156 | ) 157 | 158 | translation.activate("zh-hant") 159 | assert tuple(Select2Widget().media._js) == ( 160 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/js/select2.min.js", 161 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/js/i18n/zh-TW.js", 162 | "django_select2/django_select2.js", 163 | ) 164 | 165 | 166 | class TestSelect2MixinSettings: 167 | def test_default_media(self): 168 | sut = Select2Widget() 169 | result = sut.media.render() 170 | assert ( 171 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/js/select2.min.js" 172 | in result 173 | ) 174 | assert ( 175 | f"https://cdnjs.cloudflare.com/ajax/libs/select2/{settings.SELECT2_LIB_VERSION}/css/select2.min.css" 176 | in result 177 | ) 178 | assert "django_select2/django_select2.js" in result 179 | 180 | def test_js_setting(self, settings): 181 | settings.SELECT2_JS = "alternate.js" 182 | sut = Select2Widget() 183 | result = sut.media.render() 184 | assert "alternate.js" in result 185 | assert "django_select2/django_select2.js" in result 186 | 187 | def test_empty_js_setting(self, settings): 188 | settings.SELECT2_JS = "" 189 | sut = Select2Widget() 190 | result = sut.media.render() 191 | assert "django_select2/django_select2.js" in result 192 | 193 | def test_css_setting(self, settings): 194 | settings.SELECT2_CSS = "alternate.css" 195 | sut = Select2Widget() 196 | result = sut.media.render() 197 | assert "alternate.css" in result 198 | 199 | def test_empty_css_setting(self, settings): 200 | settings.SELECT2_CSS = "" 201 | sut = Select2Widget() 202 | result = sut.media.render() 203 | assert ".css" not in result 204 | 205 | 206 | class TestHeavySelect2Mixin(TestSelect2Mixin): 207 | url = reverse("heavy_select2_widget") 208 | form = forms.HeavySelect2WidgetForm(initial={"primary_genre": 1}) 209 | widget_cls = HeavySelect2Widget 210 | 211 | def test_initial_data(self): 212 | assert "One" in self.form.as_p() 213 | 214 | def test_initial_form_class(self): 215 | widget = self.widget_cls(data_view="heavy_data_1", attrs={"class": "my-class"}) 216 | assert "my-class" in widget.render("name", None) 217 | assert "django-select2" in widget.render("name", None) 218 | assert "django-select2-heavy" in widget.render("name", None), widget.render( 219 | "name", None 220 | ) 221 | 222 | def test_selected_option(self, db): 223 | not_required_field = self.form.fields["primary_genre"] 224 | assert not_required_field.required is False 225 | assert '' in not_required_field.widget.render( 226 | "primary_genre", 1 227 | ) or '' in not_required_field.widget.render( 228 | "primary_genre", 1 229 | ), not_required_field.widget.render( 230 | "primary_genre", 1 231 | ) 232 | 233 | def test_many_selected_option(self, db, genres): 234 | field = HeavySelect2MultipleWidgetForm().fields["genres"] 235 | field.widget.choices = NUMBER_CHOICES 236 | widget_output = field.widget.render("genres", [1, 2]) 237 | selected_option = ''.format( 238 | pk=1, value="One" 239 | ) 240 | selected_option_a = ''.format( 241 | pk=1, value="One" 242 | ) 243 | selected_option2 = ''.format( 244 | pk=2, value="Two" 245 | ) 246 | selected_option2a = ''.format( 247 | pk=2, value="Two" 248 | ) 249 | 250 | assert ( 251 | selected_option in widget_output or selected_option_a in widget_output 252 | ), widget_output 253 | assert selected_option2 in widget_output or selected_option2a in widget_output 254 | 255 | def test_multiple_widgets(self, db, live_server, driver): 256 | driver.get(live_server + self.url) 257 | with pytest.raises(NoSuchElementException): 258 | driver.find_element_by_css_selector(".select2-results") 259 | 260 | elem1, elem2 = driver.find_elements_by_css_selector(".select2-selection") 261 | 262 | elem1.click() 263 | search1 = driver.find_element_by_css_selector(".select2-search__field") 264 | search1.send_keys("fo") 265 | result1 = ( 266 | WebDriverWait(driver, 60) 267 | .until( 268 | expected_conditions.presence_of_element_located( 269 | (By.CSS_SELECTOR, ".select2-results li:first-child") 270 | ) 271 | ) 272 | .text 273 | ) 274 | 275 | elem2.click() 276 | search2 = driver.find_element_by_css_selector(".select2-search__field") 277 | search2.send_keys("fo") 278 | result2 = ( 279 | WebDriverWait(driver, 60) 280 | .until( 281 | expected_conditions.presence_of_element_located( 282 | (By.CSS_SELECTOR, ".select2-results li:first-child") 283 | ) 284 | ) 285 | .text 286 | ) 287 | 288 | assert result1 != result2 289 | 290 | with pytest.raises(NoSuchElementException): 291 | error = driver.find_element_by_xpath("//body[@JSError]") 292 | pytest.fail(error.get_attribute("JSError")) 293 | 294 | def test_get_url(self): 295 | widget = self.widget_cls(data_view="heavy_data_1", attrs={"class": "my-class"}) 296 | assert isinstance(widget.get_url(), str) 297 | 298 | def test_can_not_pickle(self): 299 | widget = self.widget_cls(data_view="heavy_data_1", attrs={"class": "my-class"}) 300 | 301 | class NoPickle: 302 | pass 303 | 304 | widget.no_pickle = NoPickle() 305 | with pytest.raises(NotImplementedError): 306 | widget.set_to_cache() 307 | 308 | 309 | class TestModelSelect2Mixin(TestHeavySelect2Mixin): 310 | form = forms.AlbumModelSelect2WidgetForm(initial={"primary_genre": 1}) 311 | multiple_form = forms.ArtistModelSelect2MultipleWidgetForm() 312 | 313 | def test_initial_data(self, genres): 314 | genre = genres[0] 315 | form = self.form.__class__(initial={"primary_genre": genre.pk}) 316 | assert str(genre) in form.as_p() 317 | 318 | def test_label_from_instance_initial(self, genres): 319 | genre = genres[0] 320 | genre.title = genre.title.lower() 321 | genre.save() 322 | 323 | form = self.form.__class__(initial={"primary_genre": genre.pk}) 324 | assert genre.title not in form.as_p(), form.as_p() 325 | assert genre.title.upper() in form.as_p() 326 | 327 | @pytest.fixture(autouse=True) 328 | def genres(self, genres): 329 | return genres 330 | 331 | def test_selected_option(self, db, genres): 332 | genre = genres[0] 333 | genre2 = genres[1] 334 | not_required_field = self.form.fields["primary_genre"] 335 | assert not_required_field.required is False 336 | widget_output = not_required_field.widget.render("primary_genre", genre.pk) 337 | selected_option = ''.format( 338 | pk=genre.pk, value=force_str(genre) 339 | ) 340 | selected_option_a = ''.format( 341 | pk=genre.pk, value=force_str(genre) 342 | ) 343 | unselected_option = ''.format( 344 | pk=genre2.pk, value=force_str(genre2) 345 | ) 346 | 347 | assert ( 348 | selected_option in widget_output or selected_option_a in widget_output 349 | ), widget_output 350 | assert unselected_option not in widget_output 351 | 352 | def test_selected_option_label_from_instance(self, db, genres): 353 | genre = genres[0] 354 | genre.title = genre.title.lower() 355 | genre.save() 356 | 357 | field = self.form.fields["primary_genre"] 358 | widget_output = field.widget.render("primary_genre", genre.pk) 359 | 360 | def get_selected_options(genre): 361 | return ( 362 | ''.format( 363 | pk=genre.pk, value=force_str(genre) 364 | ), 365 | ''.format( 366 | pk=genre.pk, value=force_str(genre) 367 | ), 368 | ) 369 | 370 | assert all(o not in widget_output for o in get_selected_options(genre)) 371 | genre.title = genre.title.upper() 372 | 373 | assert any(o in widget_output for o in get_selected_options(genre)) 374 | 375 | def test_get_queryset(self): 376 | widget = ModelSelect2Widget() 377 | with pytest.raises(NotImplementedError): 378 | widget.get_queryset() 379 | widget.model = Genre 380 | assert isinstance(widget.get_queryset(), QuerySet) 381 | widget.model = None 382 | widget.queryset = Genre.objects.all() 383 | assert isinstance(widget.get_queryset(), QuerySet) 384 | 385 | def test_tag_attrs_Select2Widget(self): 386 | widget = Select2Widget() 387 | output = widget.render("name", "value") 388 | assert 'data-minimum-input-length="0"' in output 389 | 390 | def test_custom_tag_attrs_Select2Widget(self): 391 | widget = Select2Widget(attrs={"data-minimum-input-length": "3"}) 392 | output = widget.render("name", "value") 393 | assert 'data-minimum-input-length="3"' in output 394 | 395 | def test_tag_attrs_ModelSelect2Widget(self): 396 | widget = ModelSelect2Widget( 397 | queryset=Genre.objects.all(), search_fields=["title__icontains"] 398 | ) 399 | output = widget.render("name", "value") 400 | assert 'data-minimum-input-length="2"' in output 401 | 402 | def test_tag_attrs_ModelSelect2TagWidget(self): 403 | widget = ModelSelect2TagWidget( 404 | queryset=Genre.objects.all(), search_fields=["title__icontains"] 405 | ) 406 | output = widget.render("name", "value") 407 | assert 'data-minimum-input-length="2"' in output 408 | 409 | def test_tag_attrs_HeavySelect2Widget(self): 410 | widget = HeavySelect2Widget(data_url="/foo/bar/") 411 | output = widget.render("name", "value") 412 | assert 'data-minimum-input-length="2"' in output 413 | 414 | def test_custom_tag_attrs_ModelSelect2Widget(self): 415 | widget = ModelSelect2Widget( 416 | queryset=Genre.objects.all(), 417 | search_fields=["title__icontains"], 418 | attrs={"data-minimum-input-length": "3"}, 419 | ) 420 | output = widget.render("name", "value") 421 | assert 'data-minimum-input-length="3"' in output 422 | 423 | def test_get_search_fields(self): 424 | widget = ModelSelect2Widget() 425 | with pytest.raises(NotImplementedError): 426 | widget.get_search_fields() 427 | 428 | widget.search_fields = ["title__icontains"] 429 | assert isinstance(widget.get_search_fields(), Iterable) 430 | assert all(isinstance(x, str) for x in widget.get_search_fields()) 431 | 432 | def test_filter_queryset(self, genres): 433 | widget = TitleModelSelect2Widget(queryset=Genre.objects.all()) 434 | assert widget.filter_queryset(None, genres[0].title[:3]).exists() 435 | 436 | widget = TitleModelSelect2Widget( 437 | search_fields=["title__icontains"], queryset=Genre.objects.all() 438 | ) 439 | qs = widget.filter_queryset( 440 | None, " ".join([genres[0].title[:3], genres[0].title[3:]]) 441 | ) 442 | assert qs.exists() 443 | 444 | def test_model_kwarg(self): 445 | widget = ModelSelect2Widget(model=Genre, search_fields=["title__icontains"]) 446 | genre = Genre.objects.last() 447 | result = widget.filter_queryset(None, genre.title) 448 | assert result.exists() 449 | 450 | def test_queryset_kwarg(self): 451 | widget = ModelSelect2Widget( 452 | queryset=Genre.objects.all(), search_fields=["title__icontains"] 453 | ) 454 | genre = Genre.objects.last() 455 | result = widget.filter_queryset(None, genre.title) 456 | assert result.exists() 457 | 458 | def test_ajax_view_registration(self, client): 459 | widget = ModelSelect2Widget( 460 | queryset=Genre.objects.all(), search_fields=["title__icontains"] 461 | ) 462 | widget.render("name", "value") 463 | url = reverse("django_select2:auto-json") 464 | genre = Genre.objects.last() 465 | response = client.get( 466 | url, data=dict(field_id=widget.field_id, term=genre.title) 467 | ) 468 | assert response.status_code == 200, response.content 469 | data = json.loads(response.content.decode("utf-8")) 470 | assert data["results"] 471 | assert genre.pk in [result["id"] for result in data["results"]] 472 | 473 | def test_render(self): 474 | widget = ModelSelect2Widget(queryset=Genre.objects.all()) 475 | widget.render("name", "value") 476 | cached_widget = cache.get(widget._get_cache_key()) 477 | assert cached_widget["max_results"] == widget.max_results 478 | assert cached_widget["search_fields"] == tuple(widget.search_fields) 479 | qs = widget.get_queryset() 480 | assert isinstance(cached_widget["queryset"][0], qs.__class__) 481 | assert str(cached_widget["queryset"][1]) == str(qs.query) 482 | 483 | def test_get_url(self): 484 | widget = ModelSelect2Widget( 485 | queryset=Genre.objects.all(), search_fields=["title__icontains"] 486 | ) 487 | assert isinstance(widget.get_url(), str) 488 | 489 | def test_custom_to_field_name(self): 490 | the_best_band_in_the_world = Artist.objects.create(title="Take That") 491 | groupie = Groupie.objects.create(obsession=the_best_band_in_the_world) 492 | form = forms.GroupieForm(instance=groupie) 493 | assert '' in form.as_p() 494 | 495 | def test_empty_label(self, db): 496 | # Empty options is only required for single selects 497 | # https://select2.github.io/options.html#allowClear 498 | single_select = self.form.fields["primary_genre"] 499 | single_select.empty_label = "Hello World" 500 | assert single_select.required is False 501 | assert 'data-placeholder="Hello World"' in single_select.widget.render( 502 | "primary_genre", None 503 | ) 504 | 505 | 506 | class TestHeavySelect2TagWidget(TestHeavySelect2Mixin): 507 | def test_tag_attrs(self): 508 | widget = ModelSelect2TagWidget( 509 | queryset=Genre.objects.all(), search_fields=["title__icontains"] 510 | ) 511 | output = widget.render("name", "value") 512 | assert 'data-minimum-input-length="2"' in output 513 | assert 'data-tags="true"' in output 514 | assert "data-token-separators" in output 515 | 516 | def test_custom_tag_attrs(self): 517 | widget = ModelSelect2TagWidget( 518 | queryset=Genre.objects.all(), 519 | search_fields=["title__icontains"], 520 | attrs={"data-minimum-input-length": "3"}, 521 | ) 522 | output = widget.render("name", "value") 523 | assert 'data-minimum-input-length="3"' in output 524 | 525 | 526 | class TestHeavySelect2MultipleWidget: 527 | url = reverse("heavy_select2_multiple_widget") 528 | form = forms.HeavySelect2MultipleWidgetForm() 529 | widget_cls = HeavySelect2MultipleWidget 530 | 531 | @pytest.mark.xfail( 532 | bool(os.environ.get("CI", False)), 533 | reason="https://bugs.chromium.org/p/chromedriver/issues/detail?id=1772", 534 | ) 535 | def test_widgets_selected_after_validation_error(self, db, live_server, driver): 536 | driver.get(live_server + self.url) 537 | WebDriverWait(driver, 3).until( 538 | expected_conditions.presence_of_element_located((By.ID, "id_title")) 539 | ) 540 | title = driver.find_element_by_id("id_title") 541 | title.send_keys("fo") 542 | genres, fartists = driver.find_elements_by_css_selector( 543 | ".select2-selection--multiple" 544 | ) 545 | genres.click() 546 | genres.send_keys("o") # results are Zero One Two Four 547 | # select second element - One 548 | driver.find_element_by_css_selector(".select2-results li:nth-child(2)").click() 549 | genres.submit() 550 | # there is a ValidationError raised, check for it 551 | errstring = ( 552 | WebDriverWait(driver, 3) 553 | .until( 554 | expected_conditions.presence_of_element_located( 555 | (By.CSS_SELECTOR, "ul.errorlist li") 556 | ) 557 | ) 558 | .text 559 | ) 560 | assert errstring == "Title must have more than 3 characters." 561 | # genres should still have One as selected option 562 | result_title = driver.find_element_by_css_selector( 563 | ".select2-selection--multiple li" 564 | ).get_attribute("title") 565 | assert result_title == "One" 566 | 567 | 568 | class TestAddressChainedSelect2Widget: 569 | url = reverse("model_chained_select2_widget") 570 | form = forms.AddressChainedSelect2WidgetForm() 571 | 572 | def test_widgets_selected_after_validation_error( 573 | self, db, live_server, driver, countries, cities 574 | ): 575 | driver.get(live_server + self.url) 576 | 577 | WebDriverWait(driver, 60).until( 578 | expected_conditions.presence_of_element_located( 579 | (By.CSS_SELECTOR, ".select2-selection--single") 580 | ) 581 | ) 582 | ( 583 | country_container, 584 | city_container, 585 | city2_container, 586 | ) = driver.find_elements_by_css_selector(".select2-selection--single") 587 | 588 | # clicking city select2 lists all available cities 589 | city_container.click() 590 | WebDriverWait(driver, 60).until( 591 | expected_conditions.presence_of_element_located( 592 | (By.CSS_SELECTOR, ".select2-results li") 593 | ) 594 | ) 595 | city_options = driver.find_elements_by_css_selector(".select2-results li") 596 | city_names_from_browser = {option.text for option in city_options} 597 | city_names_from_db = set(City.objects.values_list("name", flat=True)) 598 | assert len(city_names_from_browser) == City.objects.count() 599 | assert city_names_from_browser == city_names_from_db 600 | 601 | # selecting a country really does it 602 | country_container.click() 603 | WebDriverWait(driver, 60).until( 604 | expected_conditions.presence_of_element_located( 605 | (By.CSS_SELECTOR, ".select2-results li:nth-child(2)") 606 | ) 607 | ) 608 | country_option = driver.find_element_by_css_selector( 609 | ".select2-results li:nth-child(2)" 610 | ) 611 | country_name = country_option.text 612 | country_option.click() 613 | assert country_name == country_container.text 614 | 615 | # clicking city select2 lists reduced list of cities belonging to the country 616 | city_container.click() 617 | WebDriverWait(driver, 60).until( 618 | expected_conditions.presence_of_element_located( 619 | (By.CSS_SELECTOR, ".select2-results li") 620 | ) 621 | ) 622 | city_options = driver.find_elements_by_css_selector(".select2-results li") 623 | city_names_from_browser = {option.text for option in city_options} 624 | city_names_from_db = set( 625 | Country.objects.get(name=country_name).cities.values_list("name", flat=True) 626 | ) 627 | assert len(city_names_from_browser) != City.objects.count() 628 | assert city_names_from_browser == city_names_from_db 629 | 630 | # selecting a city reaaly does it 631 | city_option = driver.find_element_by_css_selector( 632 | ".select2-results li:nth-child(2)" 633 | ) 634 | city_name = city_option.text 635 | city_option.click() 636 | assert city_name == city_container.text 637 | 638 | # clicking country select2 lists reduced list to the only country available to the city 639 | country_container.click() 640 | WebDriverWait(driver, 60).until( 641 | expected_conditions.presence_of_element_located( 642 | (By.CSS_SELECTOR, ".select2-results li") 643 | ) 644 | ) 645 | country_options = driver.find_elements_by_css_selector(".select2-results li") 646 | country_names_from_browser = {option.text for option in country_options} 647 | country_names_from_db = {City.objects.get(name=city_name).country.name} 648 | assert len(country_names_from_browser) != Country.objects.count() 649 | assert country_names_from_browser == country_names_from_db 650 | 651 | def test_dependent_fields_clear_after_change_parent( 652 | self, db, live_server, driver, countries, cities 653 | ): 654 | driver.get(live_server + self.url) 655 | ( 656 | country_container, 657 | city_container, 658 | city2_container, 659 | ) = driver.find_elements_by_css_selector(".select2-selection--single") 660 | 661 | # selecting a country really does it 662 | country_container.click() 663 | WebDriverWait(driver, 60).until( 664 | expected_conditions.presence_of_element_located( 665 | (By.CSS_SELECTOR, ".select2-results li:nth-child(2)") 666 | ) 667 | ) 668 | country_option = driver.find_element_by_css_selector( 669 | ".select2-results li:nth-child(2)" 670 | ) 671 | country_name = country_option.text 672 | country_option.click() 673 | assert country_name == country_container.text 674 | 675 | # selecting a city2 676 | city2_container.click() 677 | WebDriverWait(driver, 60).until( 678 | expected_conditions.presence_of_element_located( 679 | (By.CSS_SELECTOR, ".select2-results li") 680 | ) 681 | ) 682 | city2_option = driver.find_element_by_css_selector( 683 | ".select2-results li:nth-child(2)" 684 | ) 685 | city2_name = city2_option.text 686 | city2_option.click() 687 | assert city2_name == city2_container.text 688 | 689 | # change a country 690 | country_container.click() 691 | WebDriverWait(driver, 60).until( 692 | expected_conditions.presence_of_element_located( 693 | (By.CSS_SELECTOR, ".select2-results li:nth-child(3)") 694 | ) 695 | ) 696 | country_option = driver.find_element_by_css_selector( 697 | ".select2-results li:nth-child(3)" 698 | ) 699 | country_name = country_option.text 700 | country_option.click() 701 | assert country_name == country_container.text 702 | 703 | # check the value in city2 704 | city2_container.click() 705 | WebDriverWait(driver, 60).until( 706 | expected_conditions.presence_of_element_located( 707 | (By.CSS_SELECTOR, ".select2-results li") 708 | ) 709 | ) 710 | assert city2_container.text == "" 711 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.utils.encoding import smart_str 4 | 5 | from django_select2.cache import cache 6 | from django_select2.forms import ModelSelect2Widget 7 | from tests.testapp.forms import AlbumModelSelect2WidgetForm, ArtistCustomTitleWidget 8 | from tests.testapp.models import Genre 9 | 10 | try: 11 | from django.urls import reverse 12 | except ImportError: 13 | from django.core.urlresolvers import reverse 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_url_check(self, client, artists): 88 | artist = artists[0] 89 | form = AlbumModelSelect2WidgetForm() 90 | assert form.as_p() 91 | field_id = form.fields["artist"].widget.field_id 92 | cache_key = form.fields["artist"].widget._get_cache_key() 93 | widget_dict = cache.get(cache_key) 94 | widget_dict["url"] = "yet/another/url" 95 | cache.set(cache_key, widget_dict) 96 | url = reverse("django_select2:auto-json") 97 | response = client.get(url, {"field_id": field_id, "term": artist.title}) 98 | assert response.status_code == 404 99 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/applegrew/django-select2/4e3bdc89185a45e319d54d16ed0b36f74674d58d/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 AlbumSelect2WidgetForm(forms.ModelForm): 55 | class Meta: 56 | model = models.Album 57 | fields = ( 58 | "artist", 59 | "primary_genre", 60 | ) 61 | widgets = { 62 | "artist": Select2Widget, 63 | "primary_genre": Select2Widget, 64 | } 65 | 66 | 67 | class AlbumSelect2MultipleWidgetForm(forms.ModelForm): 68 | class Meta: 69 | model = models.Album 70 | fields = ( 71 | "genres", 72 | "featured_artists", 73 | ) 74 | widgets = { 75 | "genres": Select2MultipleWidget, 76 | "featured_artists": Select2MultipleWidget, 77 | } 78 | 79 | 80 | class AlbumModelSelect2WidgetForm(forms.ModelForm): 81 | class Meta: 82 | model = models.Album 83 | fields = ( 84 | "artist", 85 | "primary_genre", 86 | ) 87 | widgets = { 88 | "artist": ArtistCustomTitleWidget(), 89 | "primary_genre": GenreCustomTitleWidget(), 90 | } 91 | 92 | def __init__(self, *args, **kwargs): 93 | super().__init__(*args, **kwargs) 94 | self.fields["primary_genre"].initial = 2 95 | 96 | 97 | class AlbumModelSelect2MultipleWidgetRequiredForm(forms.ModelForm): 98 | class Meta: 99 | model = Album 100 | fields = ( 101 | "genres", 102 | "featured_artists", 103 | ) 104 | widgets = { 105 | "genres": TitleModelSelect2MultipleWidget, 106 | "featured_artists": TitleModelSelect2MultipleWidget, 107 | } 108 | 109 | 110 | class ArtistModelSelect2MultipleWidgetForm(forms.Form): 111 | title = forms.CharField(max_length=50) 112 | genres = forms.ModelMultipleChoiceField( 113 | widget=ModelSelect2MultipleWidget( 114 | queryset=models.Genre.objects.all(), search_fields=["title__icontains"], 115 | ), 116 | queryset=models.Genre.objects.all(), 117 | required=True, 118 | ) 119 | 120 | featured_artists = forms.ModelMultipleChoiceField( 121 | widget=ModelSelect2MultipleWidget( 122 | queryset=models.Artist.objects.all(), search_fields=["title__icontains"], 123 | ), 124 | queryset=models.Artist.objects.all(), 125 | required=False, 126 | ) 127 | 128 | 129 | NUMBER_CHOICES = [ 130 | (1, "One"), 131 | (2, "Two"), 132 | (3, "Three"), 133 | (4, "Four"), 134 | ] 135 | 136 | 137 | class Select2WidgetForm(forms.Form): 138 | number = forms.ChoiceField( 139 | widget=Select2Widget, choices=NUMBER_CHOICES, required=False 140 | ) 141 | 142 | 143 | class HeavySelect2WidgetForm(forms.Form): 144 | artist = forms.ChoiceField( 145 | widget=HeavySelect2Widget(data_view="heavy_data_1"), choices=NUMBER_CHOICES 146 | ) 147 | primary_genre = forms.ChoiceField( 148 | widget=HeavySelect2Widget(data_view="heavy_data_2"), 149 | required=False, 150 | choices=NUMBER_CHOICES, 151 | ) 152 | 153 | 154 | class HeavySelect2MultipleWidgetForm(forms.Form): 155 | title = forms.CharField(max_length=50) 156 | genres = forms.MultipleChoiceField( 157 | widget=HeavySelect2MultipleWidget( 158 | data_view="heavy_data_1", 159 | choices=NUMBER_CHOICES, 160 | attrs={"data-minimum-input-length": 0}, 161 | ), 162 | choices=NUMBER_CHOICES, 163 | ) 164 | featured_artists = forms.MultipleChoiceField( 165 | widget=HeavySelect2MultipleWidget( 166 | data_view="heavy_data_2", 167 | choices=NUMBER_CHOICES, 168 | attrs={"data-minimum-input-length": 0}, 169 | ), 170 | choices=NUMBER_CHOICES, 171 | required=False, 172 | ) 173 | 174 | def clean_title(self): 175 | if len(self.cleaned_data["title"]) < 3: 176 | raise forms.ValidationError("Title must have more than 3 characters.") 177 | return self.cleaned_data["title"] 178 | 179 | 180 | class ModelSelect2TagWidgetForm(forms.ModelForm): 181 | class Meta: 182 | model = Album 183 | fields = ["genres"] 184 | widgets = {"genres": GenreSelect2TagWidget} 185 | 186 | 187 | class AddressChainedSelect2WidgetForm(forms.Form): 188 | country = forms.ModelChoiceField( 189 | queryset=Country.objects.all(), 190 | label="Country", 191 | widget=ModelSelect2Widget( 192 | search_fields=["name__icontains"], 193 | max_results=500, 194 | dependent_fields={"city": "cities"}, 195 | attrs={"data-minimum-input-length": 0}, 196 | ), 197 | ) 198 | 199 | city = forms.ModelChoiceField( 200 | queryset=City.objects.all(), 201 | label="City", 202 | widget=ModelSelect2Widget( 203 | search_fields=["name__icontains"], 204 | dependent_fields={"country": "country"}, 205 | max_results=500, 206 | attrs={"data-minimum-input-length": 0}, 207 | ), 208 | ) 209 | 210 | city2 = forms.ModelChoiceField( 211 | queryset=City.objects.all(), 212 | label="City not Interdependent", 213 | widget=ModelSelect2Widget( 214 | search_fields=["name__icontains"], 215 | dependent_fields={"country": "country"}, 216 | max_results=500, 217 | attrs={"data-minimum-input-length": 0}, 218 | ), 219 | ) 220 | 221 | 222 | class GroupieForm(forms.ModelForm): 223 | class Meta: 224 | model = models.Groupie 225 | fields = "__all__" 226 | widgets = {"obsession": ArtistCustomTitleWidget} 227 | -------------------------------------------------------------------------------- /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_select2", 14 | "tests.testapp", 15 | ) 16 | 17 | STATIC_URL = "/static/" 18 | 19 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 20 | 21 | SITE_ID = 1 22 | ROOT_URLCONF = "tests.testapp.urls" 23 | 24 | LANGUAGES = [ 25 | ("de", "German"), 26 | ("en", "English"), 27 | ] 28 | 29 | TEMPLATES = [ 30 | { 31 | "BACKEND": "django.template.backends.django.DjangoTemplates", 32 | "APP_DIRS": True, 33 | "DIRS": ["templates"], 34 | }, 35 | ] 36 | 37 | SECRET_KEY = "123456" 38 | 39 | USE_L10N = True 40 | USE_I18N = True 41 | -------------------------------------------------------------------------------- /tests/testapp/templates/form.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | {{ form.media.css }} 6 | 11 | 12 | 13 |
14 | {% csrf_token %} 15 | {{ form }} 16 | 17 |
18 | 19 | 24 | {{ form.media.js }} 25 | 26 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------