├── .nvmrc
├── tests
├── __init__.py
├── testapp
│ ├── __init__.py
│ ├── manage.py
│ ├── templates
│ │ └── form.html
│ ├── settings.py
│ ├── views.py
│ ├── urls.py
│ ├── models.py
│ └── forms.py
├── test_cache.py
├── conftest.py
├── test_views.py
└── test_forms.py
├── example
├── example
│ ├── __init__.py
│ ├── migrations
│ │ ├── __init__.py
│ │ └── 0001_initial.py
│ ├── views.py
│ ├── urls.py
│ ├── models.py
│ ├── asgi.py
│ ├── wsgi.py
│ ├── templates
│ │ └── example
│ │ │ └── book_form.html
│ ├── forms.py
│ └── settings.py
├── requirements.txt
├── README.md
└── manage.py
├── .bandit
├── docs
├── CONTRIBUTING.rst
├── conf.py
├── extra.rst
├── django_select2.rst
└── index.rst
├── .npmrc
├── .github
├── FUNDING.yml
├── dependabot.yml
├── ISSUE_TEMPLATE
│ ├── config.yml
│ └── bug.yml
└── workflows
│ ├── release.yml
│ └── ci.yml
├── django_select2
├── static
│ └── django_select2
│ │ ├── django_select2.css
│ │ └── django_select2.js
├── apps.py
├── urls.py
├── __init__.py
├── cache.py
├── views.py
├── conf.py
└── forms.py
├── .gitignore
├── .editorconfig
├── .readthedocs.yaml
├── package.json
├── CONTRIBUTING.rst
├── images
├── logo-light.svg
└── logo-dark.svg
├── LICENSE
├── .pre-commit-config.yaml
├── README.md
└── pyproject.toml
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/*
2 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/example/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/testapp/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/example/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.bandit:
--------------------------------------------------------------------------------
1 | [bandit]
2 | exclude: ./tests
3 |
--------------------------------------------------------------------------------
/example/requirements.txt:
--------------------------------------------------------------------------------
1 | -e ..
2 | redis
3 |
--------------------------------------------------------------------------------
/docs/CONTRIBUTING.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../CONTRIBUTING.rst
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | //registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN}
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: codingjoe
2 | tidelift: pypi/django-select2
3 | custom: https://paypal.me/codingjoe
4 |
--------------------------------------------------------------------------------
/django_select2/static/django_select2/django_select2.css:
--------------------------------------------------------------------------------
1 | .change-form select.django-select2 {
2 | width: 20em;
3 | }
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/django_select2/apps.py:
--------------------------------------------------------------------------------
1 | """Django application configuration."""
2 |
3 | from django.apps import AppConfig
4 |
5 |
6 | class Select2AppConfig(AppConfig):
7 | """Django application configuration."""
8 |
9 | name = "django_select2"
10 | verbose_name = "Select2"
11 |
12 | def ready(self):
13 | from . import conf # noqa
14 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: pip
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | - package-ecosystem: npm
8 | directory: "/"
9 | schedule:
10 | interval: daily
11 | - package-ecosystem: github-actions
12 | directory: "/"
13 | schedule:
14 | interval: daily
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 |
3 | *.egg-info
4 | dist
5 | build
6 |
7 | node_modules/
8 |
9 | docs/_build
10 |
11 | # Intellij
12 | .idea/
13 | *.iml
14 | *.iws
15 | env/
16 | venv/
17 | .cache/
18 | .tox/
19 | geckodriver.log
20 | ghostdriver.log
21 | .coverage
22 |
23 | coverage.xml
24 | .eggs/
25 | db.sqlite3
26 |
27 | _version.py
28 |
29 | # uv
30 | uv.lock
31 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # https://editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | indent_style = space
7 | indent_size = 4
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 | charset = utf-8
11 | end_of_line = lf
12 |
13 | [*.{json,yml,yaml,js,jsx,toml}]
14 | indent_size = 2
15 |
16 | [LICENSE]
17 | insert_final_newline = false
18 |
19 | [Makefile]
20 | indent_style = tab
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: ✨ Feature Requests
4 | url: https://github.com/codingjoe/django-select2/discussions/categories/ideas
5 | about: Please use the GitHub Discussions to request new features.
6 | - name: 🙋 Questions & Help
7 | url: https://github.com/codingjoe/django-select2/discussions/categories/q-a
8 | about: Please use the GitHub Discussions to ask questions.
9 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | # .readthedocs.yaml
2 | # Read the Docs configuration file
3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
4 | version: 2
5 | build:
6 | os: ubuntu-24.04
7 | apt_packages:
8 | - graphviz
9 | tools:
10 | python: "3"
11 | jobs:
12 | install:
13 | - curl -LsSf https://astral.sh/uv/install.sh | sh
14 | build:
15 | html:
16 | - $HOME/.local/bin/uv run sphinx-build -b html docs $READTHEDOCS_OUTPUT/html
17 |
--------------------------------------------------------------------------------
/django_select2/urls.py:
--------------------------------------------------------------------------------
1 | """
2 | Django-Select2 URL configuration.
3 |
4 | Add `django_select` to your ``urlconf`` **if** you use any 'Model' fields::
5 |
6 | from django.urls import path
7 |
8 |
9 | path('select2/', include('django_select2.urls')),
10 |
11 | """
12 |
13 | from django.urls import path
14 |
15 | from .views import AutoResponseView
16 |
17 | app_name = "django_select2"
18 |
19 | urlpatterns = [
20 | path("fields/auto.json", AutoResponseView.as_view(), name="auto-json"),
21 | ]
22 |
--------------------------------------------------------------------------------
/django_select2/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | This is a Django_ integration of Select2_.
3 |
4 | The application includes Select2 driven Django Widgets and Form Fields.
5 |
6 | .. _Django: https://www.djangoproject.com/
7 | .. _Select2: https://select2.org/
8 |
9 | """
10 |
11 | from django import get_version
12 |
13 | from . import _version
14 |
15 | __version__ = _version.version
16 | VERSION = _version.version_tuple
17 |
18 | if get_version() < "3.2":
19 | default_app_config = "django_select2.apps.Select2AppConfig"
20 |
--------------------------------------------------------------------------------
/example/example/templates/example/book_form.html:
--------------------------------------------------------------------------------
1 | {% load static %}
2 |
3 |
4 | Create Book
5 | {{ form.media.css }}
6 |
9 |
10 |
11 | Create a new Book
12 |
17 |
18 | {{ form.media.js }}
19 |
20 |
21 |
--------------------------------------------------------------------------------
/tests/testapp/templates/form.html:
--------------------------------------------------------------------------------
1 | {% load static %}
2 |
3 |
4 | {{ form.media.css }}
5 |
10 |
11 |
12 |
17 |
18 |
23 | {{ form.media.js }}
24 |
25 |
--------------------------------------------------------------------------------
/django_select2/cache.py:
--------------------------------------------------------------------------------
1 | """
2 | Shared memory across multiple machines to the heavy AJAX lookups.
3 |
4 | Select2 uses django.core.cache_ to share fields across
5 | multiple threads and even machines.
6 |
7 | Select2 uses the cache backend defined in the setting
8 | ``SELECT2_CACHE_BACKEND`` [default=``default``].
9 |
10 | It is advised to always setup a separate cache server for Select2.
11 |
12 | .. _django.core.cache: https://docs.djangoproject.com/en/dev/topics/cache/
13 | """
14 |
15 | from django.core.cache import caches
16 |
17 | from .conf import settings
18 |
19 | __all__ = ("cache",)
20 |
21 | cache = caches[settings.SELECT2_CACHE_BACKEND]
22 |
--------------------------------------------------------------------------------
/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/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/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 |
4 | import os
5 | import sys
6 |
7 |
8 | def main():
9 | """Run administrative tasks."""
10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings")
11 | try:
12 | from django.core.management import execute_from_command_line
13 | except ImportError as exc:
14 | raise ImportError(
15 | "Couldn't import Django. Are you sure it's installed and "
16 | "available on your PYTHONPATH environment variable? Did you "
17 | "forget to activate a virtual environment?"
18 | ) from exc
19 | execute_from_command_line(sys.argv)
20 |
21 |
22 | if __name__ == "__main__":
23 | main()
24 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tests/testapp/settings.py:
--------------------------------------------------------------------------------
1 | import os.path
2 |
3 | BASE_DIR = os.path.dirname(os.path.abspath(__file__))
4 | DEBUG = True
5 |
6 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
7 |
8 | INSTALLED_APPS = (
9 | "django.contrib.auth",
10 | "django.contrib.contenttypes",
11 | "django.contrib.sessions",
12 | "django.contrib.staticfiles",
13 | "django.contrib.admin",
14 | "django_select2",
15 | "tests.testapp",
16 | )
17 |
18 | STATIC_URL = "/static/"
19 |
20 | MEDIA_ROOT = os.path.join(BASE_DIR, "media")
21 |
22 | SITE_ID = 1
23 | ROOT_URLCONF = "tests.testapp.urls"
24 |
25 | LANGUAGES = [
26 | ("de", "German"),
27 | ("en", "English"),
28 | ]
29 | LANGUAGE_CODE = "en"
30 |
31 | TEMPLATES = [
32 | {
33 | "BACKEND": "django.template.backends.django.DjangoTemplates",
34 | "APP_DIRS": True,
35 | "DIRS": ["templates"],
36 | },
37 | ]
38 |
39 | SECRET_KEY = "123456"
40 |
41 | USE_I18N = True
42 | USE_TZ = True
43 |
--------------------------------------------------------------------------------
/CONTRIBUTING.rst:
--------------------------------------------------------------------------------
1 | ##############
2 | Contributing
3 | ##############
4 |
5 | Before you start editing the python code, you will need to make sure you
6 | have binary dependencies installed:
7 |
8 | .. code::
9 |
10 | # Debian
11 | sudo apt install -y gettext graphviz google-chrome-stable
12 | # macOS
13 | brew install -y gettext graphviz google-chrome-stable
14 |
15 | You may run the tests via:
16 |
17 | .. code::
18 |
19 | uv run pytest
20 |
21 | Documentation pull requests welcome. The Sphinx documentation can be
22 | compiled via:
23 |
24 | .. code::
25 |
26 | uv run sphinx-build -W -b doctest -b html docs docs/_build/html
27 |
28 | Bug reports welcome, even more so if they include a correct patch. Much
29 | more so if you start your patch by adding a failing unit test, and
30 | correct the code until zero unit tests fail.
31 |
32 | The list of supported Django and Python version can be found in the CI
33 | suite setup. Please make sure to verify that none of the linters or
34 | tests failed, before you submit a patch for review.
35 |
--------------------------------------------------------------------------------
/images/logo-light.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/images/logo-dark.svg:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Johannes Maron and other contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/example/example/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1a1 on 2020-05-23 17:06
2 |
3 | import django.db.models.deletion
4 | from django.conf import settings
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | initial = True
10 |
11 | dependencies = [
12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name="Book",
18 | fields=[
19 | (
20 | "id",
21 | models.AutoField(
22 | auto_created=True,
23 | primary_key=True,
24 | serialize=False,
25 | verbose_name="ID",
26 | ),
27 | ),
28 | (
29 | "author",
30 | models.ForeignKey(
31 | on_delete=django.db.models.deletion.CASCADE,
32 | to=settings.AUTH_USER_MODEL,
33 | ),
34 | ),
35 | (
36 | "co_authors",
37 | models.ManyToManyField(
38 | related_name="co_authored_by", to=settings.AUTH_USER_MODEL
39 | ),
40 | ),
41 | ],
42 | ),
43 | ]
44 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v6.0.0
4 | hooks:
5 | - id: trailing-whitespace
6 | - id: check-merge-conflict
7 | - id: check-ast
8 | - id: check-toml
9 | - id: check-yaml
10 | - id: check-symlinks
11 | - id: debug-statements
12 | - id: end-of-file-fixer
13 | - id: no-commit-to-branch
14 | args: [--branch, main]
15 | - repo: https://github.com/asottile/pyupgrade
16 | rev: v3.21.2
17 | hooks:
18 | - id: pyupgrade
19 | - repo: https://github.com/adamchainz/django-upgrade
20 | rev: 1.29.1
21 | hooks:
22 | - id: django-upgrade
23 | - repo: https://github.com/hukkin/mdformat
24 | rev: 1.0.0
25 | hooks:
26 | - id: mdformat
27 | additional_dependencies:
28 | - mdformat-ruff
29 | - mdformat-footnote
30 | - mdformat-gfm
31 | - mdformat-gfm-alerts
32 | - repo: https://github.com/astral-sh/ruff-pre-commit
33 | rev: v0.14.9
34 | hooks:
35 | - id: ruff-check
36 | args: [--fix, --exit-non-zero-on-fix]
37 | - id: ruff-format
38 | - repo: https://github.com/google/yamlfmt
39 | rev: v0.20.0
40 | hooks:
41 | - id: yamlfmt
42 | - repo: https://github.com/sphinx-contrib/sphinx-lint
43 | rev: v1.0.2
44 | hooks:
45 | - id: sphinx-lint
46 | ci:
47 | autoupdate_schedule: weekly
48 | skip:
49 | - no-commit-to-branch
50 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | release:
4 | types: [published]
5 | workflow_dispatch:
6 | jobs:
7 | pypi-build:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v6
11 | - uses: actions/setup-python@v6
12 | with:
13 | python-version: "3.x"
14 | - run: python -m pip install --upgrade pip build wheel
15 | - run: python -m build --sdist --wheel
16 | - uses: actions/upload-artifact@v6
17 | with:
18 | name: release-dists
19 | path: dist/
20 | pypi-publish:
21 | runs-on: ubuntu-latest
22 | needs:
23 | - pypi-build
24 | permissions:
25 | id-token: write
26 | steps:
27 | - uses: actions/download-artifact@v7
28 | with:
29 | name: release-dists
30 | path: dist/
31 | - uses: pypa/gh-action-pypi-publish@release/v1
32 | npmjs:
33 | name: npmjs.org
34 | runs-on: ubuntu-latest
35 | permissions:
36 | id-token: write
37 | steps:
38 | - uses: actions/checkout@v6
39 | - uses: actions/setup-node@v6
40 | with:
41 | node-version-file: .nvmrc
42 | registry-url: 'https://registry.npmjs.org'
43 | - run: npm install -g npm@latest
44 | - run: npm install
45 | - run: npm config set git-tag-version=false
46 | - run: npm version ${{ github.event.release.tag_name }}
47 | - run: npm run build --if-present
48 | - run: npm publish
49 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.yml:
--------------------------------------------------------------------------------
1 | name: 🐛 Bug
2 | description: Report a technical issue.
3 | title: '🐛 '
4 | labels:
5 | - bug
6 | assignees:
7 | - codingjoe
8 | body:
9 | - type: markdown
10 | attributes:
11 | value: |
12 | Thank you for taking the time to report a bug.
13 | Please fill in as much of the template below as you're able.
14 | - type: markdown
15 | attributes:
16 | value: |
17 | ## Security issues
18 | Please do not report security issues here.
19 | Instead, disclose them as described in our [security policy](https://github.com/codingjoe/django-select2/security).
20 | - type: textarea
21 | id: bug-description
22 | attributes:
23 | label: Bug Description
24 | description: A clear and concise description of what the bug is.
25 | placeholder: I found a bug
26 | validations:
27 | required: true
28 | - type: textarea
29 | id: bug-steps
30 | attributes:
31 | label: Steps to Reproduce
32 | description: Steps to reproduce the behavior.
33 | placeholder: |
34 | 1. Go to '...'
35 | 2. Click on '....'
36 | 3. Scroll down to '....'
37 | 4. See error
38 | validations:
39 | required: true
40 | - type: textarea
41 | id: bug-expected
42 | attributes:
43 | label: Expected Behavior
44 | description: A clear and concise description of what you expected to happen.
45 | placeholder: I expected the app to do X
46 | validations:
47 | required: true
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Documentation |
9 | Issues |
10 | Changelog |
11 | Funding 💚
12 |
13 |
14 | # Django-Select2
15 |
16 | [](https://pypi.python.org/pypi/Django-Select2/)
17 | [](https://codecov.io/gh/codingjoe/django-select2)
18 | [](https://raw.githubusercontent.com/codingjoe/django-select2/main/LICENSE.txt)
19 |
20 | Custom autocomplete fields for [Django](https://www.djangoproject.com/).
21 |
22 | ## Documentation
23 |
24 | Documentation available at .
25 |
26 | > [!NOTE]
27 | > Django's admin comes with builtin support for Select2 via the [autocomplete_fields](https://docs.djangoproject.com/en/stable/ref/contrib/admin/#django.contrib.admin.ModelAdmin.autocomplete_fields) feature.
28 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | import os
2 | import pathlib
3 | import sys
4 |
5 | from django_select2 import __version__ as release
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 Maron"
24 | version = ".".join(release.split(".")[:2])
25 |
26 | master_doc = "index" # default in Sphinx v2
27 | html_theme = "furo"
28 |
29 |
30 | extensions = [
31 | "sphinx.ext.autodoc",
32 | "sphinx.ext.autosectionlabel",
33 | "sphinx.ext.napoleon",
34 | "sphinx.ext.inheritance_diagram",
35 | "sphinx.ext.intersphinx",
36 | "sphinx.ext.viewcode",
37 | "sphinx.ext.doctest",
38 | ]
39 |
40 | intersphinx_mapping = {
41 | "python": ("https://docs.python.org/3", None),
42 | "django": (
43 | "https://docs.djangoproject.com/en/stable/",
44 | "https://docs.djangoproject.com/en/stable/_objects/",
45 | ),
46 | }
47 |
48 | autodoc_default_flags = ["members", "show-inheritance"]
49 | autodoc_member_order = "bysource"
50 |
51 | inheritance_graph_attrs = dict(rankdir="TB")
52 |
53 | inheritance_node_attrs = dict(
54 | shape="rect", fontsize=14, fillcolor="gray90", color="gray30", style="filled"
55 | )
56 |
57 | inheritance_edge_attrs = dict(penwidth=0.75)
58 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import random
2 | import string
3 |
4 | import pytest
5 | from selenium import webdriver
6 | from selenium.common.exceptions import WebDriverException
7 |
8 |
9 | def pytest_configure(config):
10 | config.addinivalue_line("markers", "selenium: skip if selenium is not installed")
11 |
12 |
13 | def random_string(n):
14 | return "".join(
15 | random.choice(string.ascii_uppercase + string.digits) for _ in range(n)
16 | )
17 |
18 |
19 | def random_name(n):
20 | words = (
21 | "".join(random.choice(string.ascii_lowercase + " ") for _ in range(n))
22 | .strip()
23 | .split(" ")
24 | )
25 | return "-".join([x.capitalize() for x in words])
26 |
27 |
28 | @pytest.fixture(scope="session", params=["Chrome", "Safari", "Firefox"])
29 | def driver(request):
30 | options = getattr(webdriver, f"{request.param}Options")()
31 | options.add_argument("--headless")
32 | try:
33 | b = getattr(webdriver, request.param)(options=options)
34 | except WebDriverException as e:
35 | pytest.skip(str(e))
36 | else:
37 | yield b
38 | b.quit()
39 |
40 |
41 | @pytest.fixture
42 | def genres(db):
43 | from .testapp.models import Genre
44 |
45 | return Genre.objects.bulk_create(
46 | [Genre(pk=pk, title=random_string(50)) for pk in range(100)]
47 | )
48 |
49 |
50 | @pytest.fixture
51 | def artists(db):
52 | from .testapp.models import Artist
53 |
54 | return Artist.objects.bulk_create(
55 | [Artist(pk=pk, title=random_string(50)) for pk in range(100)]
56 | )
57 |
58 |
59 | @pytest.fixture
60 | def countries(db):
61 | from .testapp.models import Country
62 |
63 | return Country.objects.bulk_create(
64 | [Country(pk=pk, name=random_name(random.randint(10, 20))) for pk in range(10)]
65 | )
66 |
67 |
68 | @pytest.fixture
69 | def cities(db, countries):
70 | from .testapp.models import City
71 |
72 | return City.objects.bulk_create(
73 | [
74 | City(
75 | pk=pk,
76 | name=random_name(random.randint(5, 15)),
77 | country=random.choice(countries),
78 | )
79 | for pk in range(100)
80 | ]
81 | )
82 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | jobs:
8 | dist:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v6
12 | - uses: astral-sh/setup-uv@v7
13 | - run: uvx --from build pyproject-build --sdist --wheel
14 | - run: uvx twine check dist/*
15 | - uses: actions/upload-artifact@v6
16 | with:
17 | path: dist/*
18 | standardjs:
19 | runs-on: ubuntu-latest
20 | steps:
21 | - uses: actions/checkout@v6
22 | - uses: actions/setup-node@v6
23 | with:
24 | node-version-file: '.nvmrc'
25 | - run: npm install -g standard
26 | - run: standard
27 | docs:
28 | runs-on: ubuntu-latest
29 | steps:
30 | - uses: actions/checkout@v6
31 | - run: sudo apt-get install -y gettext graphviz
32 | - uses: astral-sh/setup-uv@v7
33 | - run: uv run sphinx-build -W -b doctest -b html docs docs/_build
34 | PyTest:
35 | needs:
36 | - standardjs
37 | strategy:
38 | matrix:
39 | python-version:
40 | - "3.10"
41 | - "3.11"
42 | - "3.12"
43 | - "3.13"
44 | - "3.14"
45 | django-version:
46 | - "4.2"
47 | - "5.2"
48 | - "6.0"
49 | exclude:
50 | - python-version: "3.14"
51 | django-version: "4.2"
52 | - python-version: "3.10"
53 | django-version: "6.0"
54 | - python-version: "3.11"
55 | django-version: "6.0"
56 | runs-on: ubuntu-latest
57 | steps:
58 | - uses: actions/checkout@v6
59 | - uses: astral-sh/setup-uv@v7
60 | with:
61 | python-version: ${{ matrix.python-version }}
62 | - run: uv run --with Django~=${{ matrix.django-version }}.0 pytest -m "not selenium"
63 | - uses: codecov/codecov-action@v5
64 | with:
65 | token: ${{ secrets.CODECOV_TOKEN }}
66 | flags: python-${{ matrix.python-version }}
67 | selenium:
68 | needs:
69 | - PyTest
70 | strategy:
71 | fail-fast: false
72 | matrix:
73 | os: [ubuntu-latest, macos-latest]
74 | runs-on: ${{ matrix.os }}
75 | steps:
76 | - uses: actions/checkout@v6
77 | - uses: astral-sh/setup-uv@v7
78 | - run: uv run pytest -m selenium
79 | - uses: codecov/codecov-action@v5
80 | with:
81 | token: ${{ secrets.CODECOV_TOKEN }}
82 | flags: selenium-${{ matrix.os }}
83 |
--------------------------------------------------------------------------------
/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 - prefer Django's jQuery to avoid conflicts
9 | factory(window.django?.jQuery || jQuery)
10 | }
11 | }(function ($) {
12 | 'use strict'
13 | const init = function ($element, options) {
14 | $element.select2(options)
15 | }
16 |
17 | const initHeavy = function ($element, options) {
18 | const settings = $.extend({
19 | ajax: {
20 | data: function (params) {
21 | const result = {
22 | term: params.term,
23 | page: params.page,
24 | field_id: $element.data('field_id')
25 | }
26 |
27 | let dependentFields = $element.data('select2-dependent-fields')
28 | if (dependentFields) {
29 | const findElement = function (selector) {
30 | const result = $(selector, $element.closest(`:has(${selector})`))
31 | if (result.length > 0) return result
32 | else return null
33 | }
34 | dependentFields = dependentFields.trim().split(/\s+/)
35 | $.each(dependentFields, function (i, dependentField) {
36 | const nameIs = `[name=${dependentField}]`
37 | const nameEndsWith = `[name$=-${dependentField}]`
38 | result[dependentField] = (findElement(nameIs) || findElement(nameEndsWith)).val()
39 | })
40 | }
41 |
42 | return result
43 | },
44 | processResults: function (data, page) {
45 | return {
46 | results: data.results,
47 | pagination: {
48 | more: data.more
49 | }
50 | }
51 | }
52 | }
53 | }, options)
54 |
55 | $element.select2(settings)
56 | }
57 |
58 | $.fn.djangoSelect2 = function (options) {
59 | const settings = $.extend({}, options)
60 | $.each(this, function (i, element) {
61 | const $element = $(element)
62 | if ($element.hasClass('django-select2-heavy')) {
63 | initHeavy($element, settings)
64 | } else {
65 | init($element, settings)
66 | }
67 | $element.on('select2:select', function (e) {
68 | const name = $(e.currentTarget).attr('name')
69 | $('[data-select2-dependent-fields~=' + name + ']').each(function () {
70 | $(this).val('').trigger('change')
71 | })
72 | })
73 | })
74 | return this
75 | }
76 |
77 | $(function () {
78 | $('.django-select2').not('[name*=__prefix__]').djangoSelect2()
79 |
80 | document.addEventListener('formset:added', (event) => {
81 | $(event.target).find('.django-select2').djangoSelect2()
82 | })
83 | })
84 |
85 | return $.fn.djangoSelect2
86 | }))
87 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["flit_core>=3.2", "flit_scm", "wheel"]
3 | build-backend = "flit_scm:buildapi"
4 |
5 | [project]
6 | name = "django-select2"
7 | authors = [
8 | { name = "Johannes Maron", email = "johannes@maron.family" },
9 | ]
10 | readme = "README.md"
11 | license = { file = "LICENSE" }
12 | keywords = ["Django", "select2", "autocomplete", "typeahead"]
13 | dynamic = ["version", "description"]
14 | classifiers = [
15 | "Development Status :: 5 - Production/Stable",
16 | "License :: OSI Approved :: MIT License",
17 | "Intended Audience :: Developers",
18 | "Environment :: Web Environment",
19 | "Operating System :: OS Independent",
20 | "Programming Language :: Python",
21 | "Programming Language :: Python :: 3",
22 | "Programming Language :: Python :: 3 :: Only",
23 | "Programming Language :: Python :: 3.10",
24 | "Programming Language :: Python :: 3.11",
25 | "Programming Language :: Python :: 3.12",
26 | "Programming Language :: Python :: 3.13",
27 | "Programming Language :: Python :: 3.14",
28 | "Framework :: Django",
29 | "Framework :: Django :: 4.2",
30 | "Framework :: Django :: 5.2",
31 | "Framework :: Django :: 6.0",
32 | "Topic :: Software Development",
33 | ]
34 | requires-python = ">=3.10"
35 | dependencies = [
36 | "django>=4.2",
37 | "django-appconf>=0.6.0"
38 | ]
39 |
40 | [dependency-groups]
41 | dev = [
42 | { include-group = "test" },
43 | { include-group = "docs" },
44 | ]
45 | test = [
46 | "pytest",
47 | "pytest-cov",
48 | "pytest-django",
49 | "selenium",
50 | ]
51 | docs = [
52 | "sphinx",
53 | "furo",
54 | ]
55 |
56 | [project.urls]
57 | # https://packaging.python.org/en/latest/specifications/well-known-project-urls/#well-known-labels
58 | Homepage = "https://github.com/codingjoe/django-select2"
59 | Changelog = "https://github.com/codingjoe/django-select2/releases"
60 | Source = "https://github.com/codingjoe/django-select2"
61 | Releasenotes = "https://github.com/codingjoe/django-select2/releases/latest"
62 | Documentation = "https://django-select2.rtfd.io/"
63 | Issues = "https://github.com/codingjoe/django-select2/issues"
64 | Funding = "https://github.com/sponsors/codingjoe"
65 |
66 | [tool.flit.module]
67 | name = "django_select2"
68 |
69 | [tool.setuptools_scm]
70 | write_to = "django_select2/_version.py"
71 |
72 | [tool.pytest.ini_options]
73 | minversion = "6.0"
74 | addopts = "--cov --cov-report=xml --cov-report=term --tb=short -rxs"
75 | testpaths = ["tests"]
76 | DJANGO_SETTINGS_MODULE = "tests.testapp.settings"
77 | filterwarnings = ["ignore::PendingDeprecationWarning", "error::RuntimeWarning"]
78 |
79 | [tool.coverage.run]
80 | source = ["django_select2"]
81 |
82 | [tool.coverage.report]
83 | show_missing = true
84 | skip_covered = true
85 |
86 | [tool.isort]
87 | atomic = true
88 | line_length = 88
89 | multi_line_output = 3
90 | include_trailing_comma = true
91 | force_grid_wrap = 0
92 | use_parentheses = true
93 | known_first_party = "django_select2, tests"
94 | default_section = "THIRDPARTY"
95 | combine_as_imports = true
96 |
97 | [tool.pydocstyle]
98 | add_ignore = "D1"
99 |
--------------------------------------------------------------------------------
/example/example/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for example project.
3 |
4 | Generated by 'django-admin startproject' using Django 3.1a1.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/dev/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/dev/ref/settings/
11 | """
12 |
13 | from pathlib import Path
14 |
15 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
16 | BASE_DIR = Path(__file__).resolve(strict=True).parent.parent
17 |
18 |
19 | # Quick-start development settings - unsuitable for production
20 | # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/
21 |
22 | # SECURITY WARNING: keep the secret key used in production secret!
23 | SECRET_KEY = "kstexlapcf3lucx@47mmxsu9-9eixia+6n97aw)4$qo&!laxad" # nosec
24 |
25 | # SECURITY WARNING: don't run with debug turned on in production!
26 | DEBUG = True
27 |
28 | ALLOWED_HOSTS = []
29 |
30 |
31 | # Application definition
32 |
33 | INSTALLED_APPS = [
34 | "django.contrib.admin",
35 | "django.contrib.auth",
36 | "django.contrib.contenttypes",
37 | "django.contrib.sessions",
38 | "django.contrib.messages",
39 | "django.contrib.staticfiles",
40 | "django_select2",
41 | "example",
42 | ]
43 |
44 | MIDDLEWARE = [
45 | "django.middleware.security.SecurityMiddleware",
46 | "django.contrib.sessions.middleware.SessionMiddleware",
47 | "django.middleware.common.CommonMiddleware",
48 | "django.middleware.csrf.CsrfViewMiddleware",
49 | "django.contrib.auth.middleware.AuthenticationMiddleware",
50 | "django.contrib.messages.middleware.MessageMiddleware",
51 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
52 | ]
53 |
54 | ROOT_URLCONF = "example.urls"
55 |
56 | TEMPLATES = [
57 | {
58 | "BACKEND": "django.template.backends.django.DjangoTemplates",
59 | "DIRS": [BASE_DIR / "templates"],
60 | "APP_DIRS": True,
61 | "OPTIONS": {
62 | "context_processors": [
63 | "django.template.context_processors.debug",
64 | "django.template.context_processors.request",
65 | "django.contrib.auth.context_processors.auth",
66 | "django.contrib.messages.context_processors.messages",
67 | ],
68 | },
69 | },
70 | ]
71 |
72 | WSGI_APPLICATION = "example.wsgi.application"
73 |
74 |
75 | # Database
76 | # https://docs.djangoproject.com/en/dev/ref/settings/#databases
77 |
78 | DATABASES = {
79 | "default": {
80 | "ENGINE": "django.db.backends.sqlite3",
81 | "NAME": BASE_DIR / "db.sqlite3",
82 | }
83 | }
84 |
85 |
86 | # Password validation
87 | # https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
88 |
89 | AUTH_PASSWORD_VALIDATORS = [
90 | {
91 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
92 | },
93 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
94 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
95 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
96 | ]
97 |
98 |
99 | # Internationalization
100 | # https://docs.djangoproject.com/en/dev/topics/i18n/
101 |
102 | LANGUAGE_CODE = "en-us"
103 |
104 | TIME_ZONE = "UTC"
105 |
106 | USE_I18N = True
107 |
108 | USE_TZ = True
109 |
110 |
111 | # Static files (CSS, JavaScript, Images)
112 | # https://docs.djangoproject.com/en/dev/howto/static-files/
113 |
114 | STATIC_URL = "/static/"
115 |
116 | CACHES = {
117 | "default": {
118 | "BACKEND": "django.core.cache.backends.redis.RedisCache",
119 | "LOCATION": "redis://127.0.0.1:6379/1",
120 | },
121 | "select2": {
122 | "BACKEND": "django.core.cache.backends.redis.RedisCache",
123 | "LOCATION": "redis://127.0.0.1:6379/2",
124 | },
125 | }
126 |
127 | SELECT2_CACHE_BACKEND = "select2"
128 |
--------------------------------------------------------------------------------
/django_select2/views.py:
--------------------------------------------------------------------------------
1 | """JSONResponse views for model widgets."""
2 |
3 | from django.core import signing
4 | from django.core.signing import BadSignature
5 | from django.http import Http404, JsonResponse
6 | from django.utils.module_loading import import_string
7 | from django.views.generic.list import BaseListView
8 |
9 | from .cache import cache
10 | from .conf import settings
11 |
12 |
13 | class AutoResponseView(BaseListView):
14 | """
15 | View that handles requests from heavy model widgets.
16 |
17 | The view only supports HTTP's GET method.
18 | """
19 |
20 | def get(self, request, *args, **kwargs):
21 | """
22 | Return a :class:`.django.http.JsonResponse`.
23 |
24 | Each result will be rendered by the widget's
25 | :func:`django_select2.forms.ModelSelect2Mixin.result_from_instance` method.
26 |
27 | Example::
28 |
29 | {
30 | 'results': [
31 | {
32 | 'text': "foo",
33 | 'id': 123
34 | }
35 | ],
36 | 'more': true
37 | }
38 |
39 | """
40 | self.widget = self.get_widget_or_404()
41 | self.term = kwargs.get("term", request.GET.get("term", ""))
42 | self.object_list = self.get_queryset()
43 | context = self.get_context_data()
44 | return JsonResponse(
45 | {
46 | "results": [
47 | self.widget.result_from_instance(obj, request)
48 | for obj in context["object_list"]
49 | ],
50 | "more": context["page_obj"].has_next(),
51 | },
52 | encoder=import_string(settings.SELECT2_JSON_ENCODER),
53 | )
54 |
55 | def get_queryset(self):
56 | """Get QuerySet from cached widget."""
57 | kwargs = {
58 | model_field_name: self.request.GET.get(form_field_name)
59 | for form_field_name, model_field_name in self.widget.dependent_fields.items()
60 | }
61 | kwargs.update(
62 | {
63 | f"{model_field_name}__in": self.request.GET.getlist(
64 | f"{form_field_name}[]", []
65 | )
66 | for form_field_name, model_field_name in self.widget.dependent_fields.items()
67 | }
68 | )
69 | return self.widget.filter_queryset(
70 | self.request,
71 | self.term,
72 | self.queryset,
73 | **{k: v for k, v in kwargs.items() if v},
74 | )
75 |
76 | def get_paginate_by(self, queryset):
77 | """Paginate response by size of widget's `max_results` parameter."""
78 | return self.widget.max_results
79 |
80 | def get_widget_or_404(self):
81 | """
82 | Get and return widget from cache.
83 |
84 | Raises:
85 | Http404: If if the widget can not be found or no id is provided.
86 |
87 | Returns:
88 | ModelSelect2Mixin: Widget from cache.
89 |
90 | """
91 | field_id = self.kwargs.get("field_id", self.request.GET.get("field_id", None))
92 | if not field_id:
93 | raise Http404('No "field_id" provided.')
94 | try:
95 | key = signing.loads(field_id)
96 | except BadSignature:
97 | raise Http404('Invalid "field_id".')
98 | else:
99 | cache_key = f"{settings.SELECT2_CACHE_PREFIX}{key}"
100 | widget_dict = cache.get(cache_key)
101 | if widget_dict is None:
102 | raise Http404("field_id not found")
103 | if widget_dict.pop("url") != self.request.path:
104 | raise Http404("field_id was issued for the view.")
105 | qs, qs.query = widget_dict.pop("queryset")
106 | self.queryset = qs.all()
107 | widget_dict["queryset"] = self.queryset
108 | widget_cls = widget_dict.pop("cls")
109 | return widget_cls(**widget_dict)
110 |
--------------------------------------------------------------------------------
/docs/extra.rst:
--------------------------------------------------------------------------------
1 | #######
2 | Extra
3 | #######
4 |
5 | *****************
6 | Chained select2
7 | *****************
8 |
9 | Suppose you have an address form where a user should choose a Country
10 | and a City. When the user selects a country we want to show only cities
11 | belonging to that country. So the one selector depends on another one.
12 |
13 | .. note::
14 |
15 | Does not work with the 'light' version
16 | (django_select2.forms.Select2Widget).
17 |
18 | Models
19 | ======
20 |
21 | Here are our two models:
22 |
23 | .. code:: python
24 |
25 | class Country(models.Model):
26 | name = models.CharField(max_length=255)
27 |
28 |
29 | class City(models.Model):
30 | name = models.CharField(max_length=255)
31 | country = models.ForeignKey("Country", related_name="cities")
32 |
33 | Customizing a Form
34 | ==================
35 |
36 | Lets link two widgets via a *dependent_fields* dictionary. The key
37 | represents the name of the field in the form. The value represents the
38 | name of the field in the model (used in `queryset`).
39 |
40 | .. code-block:: python
41 | :emphasize-lines: 17
42 |
43 | class AddressForm(forms.Form):
44 | country = forms.ModelChoiceField(
45 | queryset=Country.objects.all(),
46 | label="Country",
47 | widget=ModelSelect2Widget(
48 | model=Country,
49 | search_fields=['name__icontains'],
50 | )
51 | )
52 |
53 | city = forms.ModelChoiceField(
54 | queryset=City.objects.all(),
55 | label="City",
56 | widget=ModelSelect2Widget(
57 | model=City,
58 | search_fields=['name__icontains'],
59 | dependent_fields={'country': 'country'},
60 | max_results=500,
61 | )
62 | )
63 |
64 | ************************
65 | Interdependent select2
66 | ************************
67 |
68 | Also you may want not to restrict the user to which field should be
69 | selected first. Instead you want to suggest to the user options for any
70 | select2 depending of his selection in another one.
71 |
72 | Customize the form in a manner:
73 |
74 | .. code-block:: python
75 | :emphasize-lines: 7
76 |
77 | class AddressForm(forms.Form):
78 | country = forms.ModelChoiceField(
79 | queryset=Country.objects.all(),
80 | label="Country",
81 | widget=ModelSelect2Widget(
82 | search_fields=['name__icontains'],
83 | dependent_fields={'city': 'cities'},
84 | )
85 | )
86 |
87 | city = forms.ModelChoiceField(
88 | queryset=City.objects.all(),
89 | label="City",
90 | widget=ModelSelect2Widget(
91 | search_fields=['name__icontains'],
92 | dependent_fields={'country': 'country'},
93 | max_results=500,
94 | )
95 | )
96 |
97 | Take attention to country's dependent_fields. The value of 'city' is
98 | 'cities' because of related name used in a filter condition `cities`
99 | which differs from widget field name `city`.
100 |
101 | .. caution::
102 |
103 | Be aware of using interdependent select2 in parent-child relation.
104 | When a child is selected, you are restricted to change parent (only
105 | one value is available). Probably you should let the user reset the
106 | child first to release parent select2.
107 |
108 | *************************
109 | Multi-dependent select2
110 | *************************
111 |
112 | Furthermore you may want to filter options on two or more select2
113 | selections (some code is dropped for clarity):
114 |
115 | .. code-block:: python
116 | :emphasize-lines: 14
117 |
118 | class SomeForm(forms.Form):
119 | field1 = forms.ModelChoiceField(
120 | widget=ModelSelect2Widget(
121 | )
122 | )
123 |
124 | field2 = forms.ModelChoiceField(
125 | widget=ModelSelect2Widget(
126 | )
127 | )
128 |
129 | field3 = forms.ModelChoiceField(
130 | widget=ModelSelect2Widget(
131 | dependent_fields={'field1': 'field1', 'field2': 'field2'},
132 | )
133 | )
134 |
--------------------------------------------------------------------------------
/docs/django_select2.rst:
--------------------------------------------------------------------------------
1 | ###################
2 | API Documentation
3 | ###################
4 |
5 | ***************
6 | Configuration
7 | ***************
8 |
9 | .. automodule:: django_select2.conf
10 | :members:
11 | :undoc-members:
12 | :show-inheritance:
13 |
14 | *********
15 | Widgets
16 | *********
17 |
18 | .. automodule:: django_select2.forms
19 | :members:
20 | :undoc-members:
21 | :show-inheritance:
22 |
23 | ******
24 | URLs
25 | ******
26 |
27 | .. automodule:: django_select2.urls
28 | :members:
29 | :undoc-members:
30 | :show-inheritance:
31 |
32 | *******
33 | Views
34 | *******
35 |
36 | .. automodule:: django_select2.views
37 | :members:
38 | :undoc-members:
39 | :show-inheritance:
40 |
41 | *******
42 | Cache
43 | *******
44 |
45 | .. automodule:: django_select2.cache
46 | :members:
47 | :undoc-members:
48 | :show-inheritance:
49 |
50 | ************
51 | JavaScript
52 | ************
53 |
54 | DjangoSelect2 handles the initialization of select2 fields
55 | automatically. Just include ``{{ form.media.js }}`` in your template
56 | before the closing ``body`` tag. That's it!
57 |
58 | If you insert forms after page load or if you want to handle the
59 | initialization yourself, DjangoSelect2 provides a jQuery plugin,
60 | replacing and enhancing the Select2 plugin. It will handle both normal
61 | and heavy fields. Simply call ``djangoSelect2(options)`` on your select
62 | fields.:
63 |
64 | .. code::
65 |
66 | $('.django-select2').djangoSelect2();
67 |
68 | Please replace all your ``.select2`` invocations with the here provided
69 | ``.djangoSelect2``.
70 |
71 | *********************
72 | Configuring Select2
73 | *********************
74 |
75 | Select2 options can be configured either directly from Javascript or
76 | from within Django using widget attributes. `(List of options in the
77 | Select2 docs) `_.
78 |
79 | To pass options in javascript
80 |
81 | .. code:: javascript
82 |
83 | $('.django-select2').djangoSelect2({
84 | minimumInputLength: 0,
85 | placeholder: 'Select an option',
86 | });
87 |
88 | From Django, you can use ``data-`` attributes using the same names in
89 | camel-case and passing them to your widget. Select2 will then pick these
90 | up. For example when initialising a widget in a form, you could do:
91 |
92 | .. code:: python
93 |
94 | class MyForm(forms.Form):
95 | my_field = forms.ModelMultipleChoiceField(
96 | widget=ModelSelect2MultipleWidget(
97 | model=MyModel
98 | search_fields=['another_field']
99 | attrs={
100 | "data-minimum-input-length": 0,
101 | "data-placeholder": "Select an option",
102 | "data-close-on-select": "false",
103 | }
104 | )
105 | )
106 |
107 | (If you do not want to initialize the widget, you could add the
108 | attributes by overriding a widget method and adding them in a super
109 | call, e.g. `get_context()
110 | `_)
111 |
112 | ***************************
113 | Security & Authentication
114 | ***************************
115 |
116 | Security is important. Therefore make sure to read and understand what
117 | the security measures in place and their limitations.
118 |
119 | Set up a separate cache. If you have a public form that uses a model
120 | widget make sure to setup a separate cache database for Select2. An
121 | attacker could constantly reload your site and fill up the select2
122 | cache. Having a separate cache allows you to limit the effect to select2
123 | only.
124 |
125 | You might want to add a secure select2 JSON endpoint for data you don't
126 | want to be accessible to the general public. Doing so is easy:
127 |
128 | .. code::
129 |
130 | class UserSelect2View(LoginRequiredMixin, AutoResponseView):
131 | pass
132 |
133 | class UserSelect2WidgetMixin(object):
134 | def __init__(self, *args, **kwargs):
135 | kwargs['data_view'] = 'user-select2-view'
136 | super(UserSelect2WidgetMixin, self).__init__(*args, **kwargs)
137 |
138 | class MySecretWidget(UserSelect2WidgetMixin, ModelSelect2Widget):
139 | model = MySecretModel
140 | search_fields = ['title__icontains']
141 |
--------------------------------------------------------------------------------
/tests/test_views.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from django.urls import reverse
4 | from django.utils.encoding import smart_str
5 |
6 | from django_select2.cache import cache
7 | from django_select2.forms import ModelSelect2Widget
8 | from tests.testapp.forms import (
9 | AlbumModelSelect2WidgetForm,
10 | ArtistCustomTitleWidget,
11 | CityForm,
12 | )
13 | from tests.testapp.models import Genre
14 |
15 |
16 | class TestAutoResponseView:
17 | def test_get(self, client, artists):
18 | artist = artists[0]
19 | form = AlbumModelSelect2WidgetForm()
20 | assert form.as_p()
21 | field_id = form.fields["artist"].widget.field_id
22 | url = reverse("django_select2:auto-json")
23 | response = client.get(url, {"field_id": field_id, "term": artist.title})
24 | assert response.status_code == 200
25 | data = json.loads(response.content.decode("utf-8"))
26 | assert data["results"]
27 | assert {"id": artist.pk, "text": smart_str(artist)} in data["results"]
28 |
29 | def test_no_field_id(self, client, artists):
30 | artist = artists[0]
31 | url = reverse("django_select2:auto-json")
32 | response = client.get(url, {"term": artist.title})
33 | assert response.status_code == 404
34 |
35 | def test_wrong_field_id(self, client, artists):
36 | artist = artists[0]
37 | url = reverse("django_select2:auto-json")
38 | response = client.get(url, {"field_id": 123, "term": artist.title})
39 | assert response.status_code == 404
40 |
41 | def test_field_id_not_found(self, client, artists):
42 | artist = artists[0]
43 | field_id = "not-exists"
44 | url = reverse("django_select2:auto-json")
45 | response = client.get(url, {"field_id": field_id, "term": artist.title})
46 | assert response.status_code == 404
47 |
48 | def test_pagination(self, genres, client):
49 | url = reverse("django_select2:auto-json")
50 | widget = ModelSelect2Widget(
51 | max_results=10, model=Genre, search_fields=["title__icontains"]
52 | )
53 | widget.render("name", None)
54 | field_id = widget.field_id
55 |
56 | response = client.get(url, {"field_id": field_id, "term": ""})
57 | assert response.status_code == 200
58 | data = json.loads(response.content.decode("utf-8"))
59 | assert data["more"] is True
60 |
61 | response = client.get(url, {"field_id": field_id, "term": "", "page": 1000})
62 | assert response.status_code == 404
63 |
64 | response = client.get(url, {"field_id": field_id, "term": "", "page": "last"})
65 | assert response.status_code == 200
66 | data = json.loads(response.content.decode("utf-8"))
67 | assert data["more"] is False
68 |
69 | def test_label_from_instance(self, artists, client):
70 | url = reverse("django_select2:auto-json")
71 |
72 | form = AlbumModelSelect2WidgetForm()
73 | form.fields["artist"].widget = ArtistCustomTitleWidget()
74 | assert form.as_p()
75 | field_id = form.fields["artist"].widget.field_id
76 |
77 | artist = artists[0]
78 | response = client.get(url, {"field_id": field_id, "term": artist.title})
79 | assert response.status_code == 200
80 |
81 | data = json.loads(response.content.decode("utf-8"))
82 | assert data["results"]
83 | assert {"id": artist.pk, "text": smart_str(artist.title.upper())} in data[
84 | "results"
85 | ]
86 |
87 | def test_result_from_instance(self, cities, client):
88 | url = reverse("django_select2:auto-json")
89 |
90 | form = CityForm()
91 | assert form.as_p()
92 | field_id = form.fields["city"].widget.field_id
93 | city = cities[0]
94 | response = client.get(url, {"field_id": field_id, "term": city.name})
95 | assert response.status_code == 200
96 | data = json.loads(response.content.decode("utf-8"))
97 | assert data["results"]
98 | assert {
99 | "id": city.pk,
100 | "text": smart_str(city),
101 | "country": smart_str(city.country),
102 | } in data["results"]
103 |
104 | def test_url_check(self, client, artists):
105 | artist = artists[0]
106 | form = AlbumModelSelect2WidgetForm()
107 | assert form.as_p()
108 | field_id = form.fields["artist"].widget.field_id
109 | cache_key = form.fields["artist"].widget._get_cache_key()
110 | widget_dict = cache.get(cache_key)
111 | widget_dict["url"] = "yet/another/url"
112 | cache.set(cache_key, widget_dict)
113 | url = reverse("django_select2:auto-json")
114 | response = client.get(url, {"field_id": field_id, "term": artist.title})
115 | assert response.status_code == 404
116 |
--------------------------------------------------------------------------------
/django_select2/conf.py:
--------------------------------------------------------------------------------
1 | """Settings for Django-Select2."""
2 |
3 | from appconf import AppConf
4 | from django.conf import settings # NOQA
5 |
6 | __all__ = ("settings", "Select2Conf")
7 |
8 | from django.contrib.admin.widgets import SELECT2_TRANSLATIONS
9 |
10 |
11 | class Select2Conf(AppConf):
12 | """Settings for Django-Select2."""
13 |
14 | CACHE_BACKEND = "default"
15 | """
16 | Django-Select2 uses Django's cache to sure a consistent state across multiple machines.
17 |
18 | Example of settings.py::
19 |
20 | CACHES = {
21 | "default": {
22 | "BACKEND": "django_redis.cache.RedisCache",
23 | "LOCATION": "redis://127.0.0.1:6379/1",
24 | "OPTIONS": {
25 | "CLIENT_CLASS": "django_redis.client.DefaultClient",
26 | }
27 | },
28 | 'select2': {
29 | "BACKEND": "django_redis.cache.RedisCache",
30 | "LOCATION": "redis://127.0.0.1:6379/2",
31 | "OPTIONS": {
32 | "CLIENT_CLASS": "django_redis.client.DefaultClient",
33 | }
34 | }
35 | }
36 |
37 | # Set the cache backend to select2
38 | SELECT2_CACHE_BACKEND = 'select2'
39 |
40 | .. tip:: To ensure a consistent state across all you machines you need to user
41 | a consistent external cache backend like Memcached, Redis or a database.
42 |
43 | .. note::
44 | Should you have copied the example configuration please make sure you
45 | have Redis setup. It's recommended to run a separate Redis server in a
46 | production environment.
47 |
48 | .. note:: The timeout of select2's caching backend determines
49 | how long a browser session can last.
50 | Once widget is dropped from the cache the json response view will return a 404.
51 | """
52 | CACHE_PREFIX = "select2_"
53 | """
54 | If you caching backend does not support multiple databases
55 | you can isolate select2 using the cache prefix setting.
56 | It has set `select2_` as a default value, which you can change if needed.
57 | """
58 |
59 | JS = ["admin/js/vendor/select2/select2.full.min.js"]
60 | """
61 | The URI for the Select2 JS file. By default this points to version shipped with Django.
62 |
63 | If you want to select the version of the JS library used, or want to serve it from
64 | the local 'static' resources, add a line to your settings.py like so::
65 |
66 | SELECT2_JS = ['assets/js/select2.min.js']
67 |
68 | If you provide your own JS and would not like Django-Select2 to load any, change
69 | this setting to a blank string like so::
70 |
71 | SELECT2_JS = []
72 |
73 | .. tip:: Change this setting to a local asset in your development environment to
74 | develop without an Internet connection.
75 | """
76 |
77 | CSS = ["admin/css/vendor/select2/select2.min.css"]
78 | """
79 | The URI for the Select2 CSS file. By default this points to version shipped with Django.
80 |
81 | If you want to select the version of the library used, or want to serve it from
82 | the local 'static' resources, add a line to your settings.py like so::
83 |
84 | SELECT2_CSS = ['assets/css/select2.css']
85 |
86 | If you want to add more css (usually used in select2 themes), add a line
87 | in settings.py like this::
88 |
89 | SELECT2_CSS = [
90 | 'assets/css/select2.css',
91 | 'assets/css/select2-theme.css',
92 | ]
93 |
94 | If you provide your own CSS and would not like Django-Select2 to load any, change
95 | this setting to a blank string like so::
96 |
97 | SELECT2_CSS = []
98 |
99 | .. tip:: Change this setting to a local asset in your development environment to
100 | develop without an Internet connection.
101 | """
102 |
103 | THEME = "default"
104 | """
105 | Select2 supports custom themes using the theme option so you can style Select2
106 | to match the rest of your application.
107 |
108 | .. tip:: When using other themes, you may need use select2 css and theme css.
109 | """
110 |
111 | I18N_PATH = "admin/js/vendor/select2/i18n"
112 | """
113 | The base URI for the Select2 i18n files. By default this points to version shipped with Django.
114 |
115 | If you want to select the version of the I18N library used, or want to serve it from
116 | the local 'static' resources, add a line to your settings.py like so::
117 |
118 | SELECT2_I18N_PATH = 'assets/js/i18n'
119 |
120 | .. tip:: Change this setting to a local asset in your development environment to
121 | develop without an Internet connection.
122 | """
123 |
124 | I18N_AVAILABLE_LANGUAGES = list(SELECT2_TRANSLATIONS.values())
125 | """
126 | List of available translations.
127 |
128 | List of ISO 639-1 language codes that are supported by Select2.
129 | If currently set language code (e.g. using the HTTP ``Accept-Language`` header)
130 | is in this list, Django-Select2 will use the language code to create load
131 | the proper translation.
132 |
133 | The full path for the language file consists of::
134 |
135 | from django.utils import translations
136 |
137 | full_path = "{i18n_path}/{language_code}.js".format(
138 | i18n_path=settings.DJANGO_SELECT2_I18N,
139 | language_code=translations.get_language(),
140 | )
141 |
142 | ``settings.DJANGO_SELECT2_I18N`` refers to :attr:`.I18N_PATH`.
143 | """
144 |
145 | JSON_ENCODER = "django.core.serializers.json.DjangoJSONEncoder"
146 | """
147 | A :class:`JSONEncoder` used to generate the API response for the model widgets.
148 |
149 | A custom JSON encoder might be useful when your models uses
150 | a special primary key, that isn't serializable by the default encoder.
151 | """
152 |
153 | class Meta:
154 | """Prefix for all Django-Select2 settings."""
155 |
156 | prefix = "SELECT2"
157 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. image:: https://github.com/codingjoe/django-select2/raw/main/images/logo-light.svg
2 | :align: center
3 | :alt: Django Select2: Custom autocomplete fields for Django
4 | :class: only-light
5 |
6 | .. image:: https://github.com/codingjoe/django-select2/raw/main/images/logo-dark.svg
7 | :align: center
8 | :alt: Django Select2: Custom autocomplete fields for Django
9 | :class: only-dark
10 |
11 | ################
12 | Django Select2
13 | ################
14 |
15 | *Custom autocomplete fields for Django.*
16 |
17 | `Documentation`_ | `Issues`_ | `Changelog`_ | `Funding`_ 💚
18 |
19 | .. _documentation: https://django-select2.rtfd.io/
20 | .. _issues: https://github.com/codingjoe/django-select2/issues/new/choose
21 | .. _changelog: https://github.com/codingjoe/django-select2/releases
22 | .. _funding: https://github.com/sponsors/codingjoe
23 |
24 | **************
25 | Installation
26 | **************
27 |
28 | Install ``django-select2``:
29 |
30 | .. code::
31 |
32 | python3 -m pip install django-select2
33 |
34 | Add ``django_select2`` to your ``INSTALLED_APPS`` in your project
35 | settings. Since version 8, please ensure that Django's admin app is
36 | enabled too:
37 |
38 | .. code:: python
39 |
40 | INSTALLED_APPS = [
41 | # other django apps…
42 | "django.contrib.admin",
43 | # other 3rd party apps…
44 | "django_select2",
45 | ]
46 |
47 | Add ``django_select`` to your URL root configuration:
48 |
49 | .. code:: python
50 |
51 | from django.urls import include, path
52 |
53 | urlpatterns = [
54 | # other patterns…
55 | path("select2/", include("django_select2.urls")),
56 | # other patterns…
57 | ]
58 |
59 | The :ref:`Model` -widgets require a **persistent** cache backend across
60 | all application servers. This is because the widget needs to store meta
61 | data to be able to fetch the results based on the user input.
62 |
63 | **This means that the** :class:`.DummyCache` **backend will not work!**
64 |
65 | The default cache backend is :class:`.LocMemCache`, which is persistent
66 | across a single node. For projects with a single application server this
67 | will work fine, however you will run into issues when you scale up into
68 | multiple servers.
69 |
70 | Below is an example setup using Redis, which is a solution that works
71 | for multi-server setups:
72 |
73 | Make sure you have a Redis server up and running:
74 |
75 | .. code::
76 |
77 | # Debian
78 | sudo apt-get install redis-server
79 |
80 | # macOS
81 | brew install redis
82 |
83 | # install Redis python client
84 | python3 -m pip install django-redis
85 |
86 | Next, add the cache configuration to your ``settings.py`` as follows:
87 |
88 | .. code:: python
89 |
90 | CACHES = {
91 | # … default cache config and others
92 | "select2": {
93 | "BACKEND": "django_redis.cache.RedisCache",
94 | "LOCATION": "redis://127.0.0.1:6379/2",
95 | "OPTIONS": {
96 | "CLIENT_CLASS": "django_redis.client.DefaultClient",
97 | },
98 | }
99 | }
100 |
101 | # Tell select2 which cache configuration to use:
102 | SELECT2_CACHE_BACKEND = "select2"
103 |
104 | .. note::
105 |
106 | A custom timeout for your cache backend, will serve as an indirect
107 | session limit. Auto select fields will stop working after, once the
108 | cache has expired. It's recommended to use a dedicated cache database
109 | with an adequate cache replacement policy such as LRU, FILO, etc.
110 |
111 | ***********************
112 | External Dependencies
113 | ***********************
114 |
115 | - jQuery is not included in the package since it is expected that in
116 | most scenarios this would already be available.
117 |
118 | *************
119 | Quick Start
120 | *************
121 |
122 | Here is a quick example to get you started:
123 |
124 | First make sure you followed the installation instructions above. Once
125 | everything is setup, let's start with a simple example.
126 |
127 | We have the following model:
128 |
129 | .. code:: python
130 |
131 | # models.py
132 | from django.conf import settings
133 | from django.db import models
134 |
135 |
136 | class Book(models.Model):
137 | author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
138 | co_authors = models.ManyToManyField(
139 | settings.AUTH_USER_MODEL, related_name="co_authored_by"
140 | )
141 |
142 | Next, we create a model form with custom Select2 widgets.
143 |
144 | .. code:: python
145 |
146 | # forms.py
147 | from django import forms
148 | from django_select2 import forms as s2forms
149 |
150 | from . import models
151 |
152 |
153 | class AuthorWidget(s2forms.ModelSelect2Widget):
154 | search_fields = [
155 | "username__icontains",
156 | "email__icontains",
157 | ]
158 |
159 |
160 | class CoAuthorsWidget(s2forms.ModelSelect2MultipleWidget):
161 | search_fields = [
162 | "username__icontains",
163 | "email__icontains",
164 | ]
165 |
166 |
167 | class BookForm(forms.ModelForm):
168 | class Meta:
169 | model = models.Book
170 | fields = "__all__"
171 | widgets = {
172 | "author": AuthorWidget,
173 | "co_authors": CoAuthorsWidget,
174 | }
175 |
176 | A simple class based view will do, to render your form:
177 |
178 | .. code:: python
179 |
180 | # views.py
181 | from django.views import generic
182 |
183 | from . import forms, models
184 |
185 |
186 | class BookCreateView(generic.CreateView):
187 | model = models.Book
188 | form_class = forms.BookForm
189 | success_url = "/"
190 |
191 | Make sure to add the view to your ``urls.py``:
192 |
193 | .. code:: python
194 |
195 | # urls.py
196 | from django.urls import include, path
197 |
198 | from . import views
199 |
200 | urlpatterns = [
201 | # … other patterns
202 | path("select2/", include("django_select2.urls")),
203 | # … other patterns
204 | path("book/create", views.BookCreateView.as_view(), name="book-create"),
205 | ]
206 |
207 | Finally, we need a little template,
208 | ``myapp/templates/myapp/book_form.html``
209 |
210 | .. code:: HTML
211 |
212 |
213 |
214 |
215 | Create Book
216 | {{ form.media.css }}
217 |
220 |
221 |
222 | Create a new Book
223 |
228 |
229 | {{ form.media.js }}
230 |
231 |
232 |
233 | Done - enjoy the wonders of Select2!
234 |
235 | ***********
236 | Changelog
237 | ***********
238 |
239 | See `Github releases`_.
240 |
241 | .. _github releases: https://github.com/codingjoe/django-select2/releases
242 |
243 | ##############
244 | All Contents
245 | ##############
246 |
247 | Contents:
248 |
249 | .. toctree::
250 | :maxdepth: 2
251 | :glob:
252 |
253 | django_select2
254 | extra
255 | CONTRIBUTING
256 |
257 | ####################
258 | Indices and tables
259 | ####################
260 |
261 | - :ref:`genindex`
262 | - :ref:`modindex`
263 | - :ref:`search`
264 |
--------------------------------------------------------------------------------
/tests/testapp/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from django.utils.encoding import force_str
3 |
4 | from django_select2.forms import (
5 | HeavySelect2MultipleWidget,
6 | HeavySelect2Widget,
7 | ModelSelect2MultipleWidget,
8 | ModelSelect2TagWidget,
9 | ModelSelect2Widget,
10 | Select2MultipleWidget,
11 | Select2Widget,
12 | )
13 | from tests.testapp import models
14 | from tests.testapp.models import Album, City, Country
15 |
16 |
17 | class TitleSearchFieldMixin:
18 | search_fields = ["title__icontains", "pk__startswith"]
19 |
20 |
21 | class TitleModelSelect2Widget(TitleSearchFieldMixin, ModelSelect2Widget):
22 | pass
23 |
24 |
25 | class TitleModelSelect2MultipleWidget(
26 | TitleSearchFieldMixin, ModelSelect2MultipleWidget
27 | ):
28 | pass
29 |
30 |
31 | class GenreSelect2TagWidget(TitleSearchFieldMixin, ModelSelect2TagWidget):
32 | model = models.Genre
33 |
34 | def create_value(self, value):
35 | self.get_queryset().create(title=value)
36 |
37 |
38 | class ArtistCustomTitleWidget(ModelSelect2Widget):
39 | model = models.Artist
40 | search_fields = ["title__icontains"]
41 |
42 | def label_from_instance(self, obj):
43 | return force_str(obj.title).upper()
44 |
45 |
46 | class GenreCustomTitleWidget(ModelSelect2Widget):
47 | model = models.Genre
48 | search_fields = ["title__icontains"]
49 |
50 | def label_from_instance(self, obj):
51 | return force_str(obj.title).upper()
52 |
53 |
54 | class ArtistDataViewWidget(HeavySelect2Widget):
55 | data_view = "heavy_data_1"
56 |
57 |
58 | class PrimaryGenreDataUrlWidget(HeavySelect2Widget):
59 | data_url = "/heavy_data_2/"
60 |
61 |
62 | class AlbumSelect2WidgetForm(forms.ModelForm):
63 | class Meta:
64 | model = models.Album
65 | fields = (
66 | "artist",
67 | "primary_genre",
68 | )
69 | widgets = {
70 | "artist": Select2Widget,
71 | "primary_genre": Select2Widget,
72 | }
73 |
74 |
75 | class AlbumSelect2MultipleWidgetForm(forms.ModelForm):
76 | class Meta:
77 | model = models.Album
78 | fields = (
79 | "genres",
80 | "featured_artists",
81 | )
82 | widgets = {
83 | "genres": Select2MultipleWidget,
84 | "featured_artists": Select2MultipleWidget,
85 | }
86 |
87 |
88 | class AlbumModelSelect2WidgetForm(forms.ModelForm):
89 | class Meta:
90 | model = models.Album
91 | fields = (
92 | "artist",
93 | "primary_genre",
94 | )
95 | widgets = {
96 | "artist": ArtistCustomTitleWidget(),
97 | "primary_genre": GenreCustomTitleWidget(),
98 | }
99 |
100 | def __init__(self, *args, **kwargs):
101 | super().__init__(*args, **kwargs)
102 | self.fields["primary_genre"].initial = 2
103 |
104 |
105 | class AlbumModelSelect2MultipleWidgetRequiredForm(forms.ModelForm):
106 | class Meta:
107 | model = Album
108 | fields = (
109 | "genres",
110 | "featured_artists",
111 | )
112 | widgets = {
113 | "genres": TitleModelSelect2MultipleWidget,
114 | "featured_artists": TitleModelSelect2MultipleWidget,
115 | }
116 |
117 |
118 | class ArtistModelSelect2MultipleWidgetForm(forms.Form):
119 | title = forms.CharField(max_length=50)
120 | genres = forms.ModelMultipleChoiceField(
121 | widget=ModelSelect2MultipleWidget(
122 | queryset=models.Genre.objects.all(),
123 | search_fields=["title__icontains"],
124 | ),
125 | queryset=models.Genre.objects.all(),
126 | required=True,
127 | )
128 |
129 | featured_artists = forms.ModelMultipleChoiceField(
130 | widget=ModelSelect2MultipleWidget(
131 | queryset=models.Artist.objects.all(),
132 | search_fields=["title__icontains"],
133 | ),
134 | queryset=models.Artist.objects.all(),
135 | required=False,
136 | )
137 |
138 |
139 | NUMBER_CHOICES = [
140 | (1, "One"),
141 | (2, "Two"),
142 | (3, "Three"),
143 | (4, "Four"),
144 | ]
145 |
146 |
147 | class Select2WidgetForm(forms.Form):
148 | number = forms.ChoiceField(
149 | widget=Select2Widget, choices=NUMBER_CHOICES, required=False
150 | )
151 |
152 |
153 | class HeavySelect2WidgetForm(forms.Form):
154 | artist = forms.ChoiceField(widget=ArtistDataViewWidget(), choices=NUMBER_CHOICES)
155 | primary_genre = forms.ChoiceField(
156 | widget=PrimaryGenreDataUrlWidget(),
157 | required=False,
158 | choices=NUMBER_CHOICES,
159 | )
160 |
161 |
162 | class HeavySelect2MultipleWidgetForm(forms.Form):
163 | title = forms.CharField(max_length=50)
164 | genres = forms.MultipleChoiceField(
165 | widget=HeavySelect2MultipleWidget(
166 | data_view="heavy_data_1",
167 | choices=NUMBER_CHOICES,
168 | attrs={"data-minimum-input-length": 0},
169 | ),
170 | choices=NUMBER_CHOICES,
171 | )
172 | featured_artists = forms.MultipleChoiceField(
173 | widget=HeavySelect2MultipleWidget(
174 | data_view="heavy_data_2",
175 | choices=NUMBER_CHOICES,
176 | attrs={"data-minimum-input-length": 0},
177 | ),
178 | choices=NUMBER_CHOICES,
179 | required=False,
180 | )
181 |
182 | def clean_title(self):
183 | if len(self.cleaned_data["title"]) < 3:
184 | raise forms.ValidationError("Title must have more than 3 characters.")
185 | return self.cleaned_data["title"]
186 |
187 |
188 | class ModelSelect2TagWidgetForm(forms.ModelForm):
189 | class Meta:
190 | model = Album
191 | fields = ["genres"]
192 | widgets = {"genres": GenreSelect2TagWidget}
193 |
194 |
195 | class AddressChainedSelect2WidgetForm(forms.Form):
196 | country = forms.ModelChoiceField(
197 | queryset=Country.objects.all(),
198 | label="Country",
199 | widget=ModelSelect2Widget(
200 | search_fields=["name__icontains"],
201 | max_results=500,
202 | dependent_fields={"city": "cities"},
203 | attrs={"data-minimum-input-length": 0},
204 | ),
205 | )
206 |
207 | city = forms.ModelChoiceField(
208 | queryset=City.objects.all(),
209 | label="City",
210 | widget=ModelSelect2Widget(
211 | search_fields=["name__icontains"],
212 | dependent_fields={"country": "country"},
213 | max_results=500,
214 | attrs={"data-minimum-input-length": 0},
215 | ),
216 | )
217 |
218 | city2 = forms.ModelChoiceField(
219 | queryset=City.objects.all(),
220 | label="City not Interdependent",
221 | widget=ModelSelect2Widget(
222 | search_fields=["name__icontains"],
223 | dependent_fields={"country": "country"},
224 | max_results=500,
225 | attrs={"data-minimum-input-length": 0},
226 | ),
227 | )
228 |
229 |
230 | class GroupieForm(forms.ModelForm):
231 | class Meta:
232 | model = models.Groupie
233 | fields = "__all__"
234 | widgets = {"obsession": ArtistCustomTitleWidget}
235 |
236 |
237 | class CityModelSelect2Widget(ModelSelect2Widget):
238 | model = City
239 | search_fields = ["name"]
240 |
241 | def result_from_instance(self, obj, request):
242 | return {"id": obj.pk, "text": obj.name, "country": str(obj.country)}
243 |
244 |
245 | class CityForm(forms.Form):
246 | city = forms.ModelChoiceField(
247 | queryset=City.objects.all(), widget=CityModelSelect2Widget(), required=False
248 | )
249 |
--------------------------------------------------------------------------------
/django_select2/forms.py:
--------------------------------------------------------------------------------
1 | """
2 | Django-Select2 Widgets.
3 |
4 | These components are responsible for rendering
5 | the necessary HTML data markups. Since this whole
6 | package is to render choices using Select2 JavaScript
7 | library, hence these components are meant to be used
8 | with choice fields.
9 |
10 | Widgets are generally of tree types:
11 | Light, Heavy and Model.
12 |
13 | Light
14 | ~~~~~
15 |
16 | They are not meant to be used when there
17 | are too many options, say, in thousands.
18 | This is because all those options would
19 | have to be pre-rendered onto the page
20 | and JavaScript would be used to search
21 | through them. Said that, they are also one
22 | the easiest to use. They are a
23 | drop-in-replacement for Django's default
24 | select widgets.
25 |
26 | Heavy
27 | ~~~~~
28 |
29 | They are suited for scenarios when the number of options
30 | are large and need complex queries (from maybe different
31 | sources) to get the options.
32 |
33 | This dynamic fetching of options undoubtedly requires
34 | Ajax communication with the server. Django-Select2 includes
35 | a helper JS file which is included automatically,
36 | so you need not worry about writing any Ajax related JS code.
37 | Although on the server side you do need to create a view
38 | specifically to respond to the queries.
39 |
40 | Model
41 | ~~~~~
42 |
43 | Model-widgets are a further specialized versions of Heavies.
44 | These do not require views to serve Ajax requests.
45 | When they are instantiated, they register themselves
46 | with one central view which handles Ajax requests for them.
47 |
48 | Heavy and Model widgets have respectively the word 'Heavy' and 'Model' in
49 | their name. Light widgets are normally named, i.e. there is no 'Light' word
50 | in their names.
51 |
52 | .. inheritance-diagram:: django_select2.forms
53 | :parts: 1
54 |
55 | """
56 |
57 | import operator
58 | import uuid
59 | from functools import reduce
60 | from itertools import chain
61 | from pickle import PicklingError # nosec
62 |
63 | from django import forms
64 | from django.contrib.admin.utils import lookup_spawns_duplicates
65 | from django.contrib.admin.widgets import AutocompleteMixin
66 | from django.core import signing
67 | from django.db.models import Q
68 | from django.forms.models import ModelChoiceIterator
69 | from django.urls import reverse
70 |
71 | from .cache import cache
72 | from .conf import settings
73 |
74 |
75 | class Select2Mixin:
76 | """
77 | The base mixin of all Select2 widgets.
78 |
79 | This mixin is responsible for rendering the necessary
80 | data attributes for select2 as well as adding the static
81 | form media.
82 | """
83 |
84 | css_class_name = "django-select2"
85 | theme = None
86 |
87 | empty_label = ""
88 |
89 | @property
90 | def i18n_name(self):
91 | """Name of the i18n file for the current language."""
92 | from django.contrib.admin.widgets import get_select2_language
93 |
94 | return get_select2_language()
95 |
96 | def build_attrs(self, base_attrs, extra_attrs=None):
97 | """Add select2 data attributes."""
98 | default_attrs = {
99 | "lang": self.i18n_name,
100 | "data-minimum-input-length": 0,
101 | "data-theme": self.theme or settings.SELECT2_THEME,
102 | }
103 | if self.is_required:
104 | default_attrs["data-allow-clear"] = "false"
105 | else:
106 | default_attrs["data-allow-clear"] = "true"
107 | default_attrs["data-placeholder"] = self.empty_label or ""
108 |
109 | default_attrs.update(base_attrs)
110 | attrs = super().build_attrs(default_attrs, extra_attrs=extra_attrs)
111 |
112 | if "class" in attrs:
113 | attrs["class"] += " " + self.css_class_name
114 | else:
115 | attrs["class"] = self.css_class_name
116 | return attrs
117 |
118 | def optgroups(self, name, value, attrs=None):
119 | """Add empty option for clearable selects."""
120 | if not self.is_required and not self.allow_multiple_selected:
121 | self.choices = list(chain([("", "")], self.choices))
122 | return super().optgroups(name, value, attrs=attrs)
123 |
124 | @property
125 | def media(self):
126 | """
127 | Construct Media as a dynamic property.
128 |
129 | .. Note:: For more information visit
130 | https://docs.djangoproject.com/en/stable/topics/forms/media/#media-as-a-dynamic-property
131 | """
132 | select2_js = settings.SELECT2_JS if settings.SELECT2_JS else []
133 | select2_css = settings.SELECT2_CSS if settings.SELECT2_CSS else []
134 |
135 | if isinstance(select2_js, str):
136 | select2_js = [select2_js]
137 | if isinstance(select2_css, str):
138 | select2_css = [select2_css]
139 |
140 | i18n_file = []
141 | if self.i18n_name in settings.SELECT2_I18N_AVAILABLE_LANGUAGES:
142 | i18n_file = [f"{settings.SELECT2_I18N_PATH}/{self.i18n_name}.js"]
143 |
144 | return forms.Media(
145 | js=select2_js + i18n_file + ["django_select2/django_select2.js"],
146 | css={"screen": select2_css + ["django_select2/django_select2.css"]},
147 | )
148 |
149 |
150 | class Select2AdminMixin:
151 | """Select2 mixin that uses Django's own select template."""
152 |
153 | theme = "admin-autocomplete"
154 |
155 | @property
156 | def media(self):
157 | css = {**AutocompleteMixin(None, None).media._css}
158 | css["screen"].append("django_select2/django_select2.css")
159 | js = [*Select2Mixin().media._js]
160 | js.insert(
161 | js.index("django_select2/django_select2.js"), "admin/js/jquery.init.js"
162 | )
163 | return forms.Media(
164 | js=js,
165 | css=css,
166 | )
167 |
168 |
169 | class Select2TagMixin:
170 | """Mixin to add select2 tag functionality."""
171 |
172 | def build_attrs(self, base_attrs, extra_attrs=None):
173 | """Add select2's tag attributes."""
174 | default_attrs = {
175 | "data-minimum-input-length": 1,
176 | "data-tags": "true",
177 | "data-token-separators": '[",", " "]',
178 | }
179 | default_attrs.update(base_attrs)
180 | return super().build_attrs(default_attrs, extra_attrs=extra_attrs)
181 |
182 |
183 | class Select2Widget(Select2Mixin, forms.Select):
184 | """
185 | Select2 drop in widget.
186 |
187 | Example usage::
188 |
189 | class MyModelForm(forms.ModelForm):
190 | class Meta:
191 | model = MyModel
192 | fields = ('my_field', )
193 | widgets = {
194 | 'my_field': Select2Widget
195 | }
196 |
197 | or::
198 |
199 | class MyForm(forms.Form):
200 | my_choice = forms.ChoiceField(widget=Select2Widget)
201 |
202 | """
203 |
204 |
205 | class Select2MultipleWidget(Select2Mixin, forms.SelectMultiple):
206 | """
207 | Select2 drop in widget for multiple select.
208 |
209 | Works just like :class:`.Select2Widget` but for multi select.
210 | """
211 |
212 |
213 | class Select2TagWidget(Select2TagMixin, Select2Mixin, forms.SelectMultiple):
214 | """
215 | Select2 drop in widget with tagging support. It allows to dynamically create new options from text input by the user.
216 |
217 | Example for :class:`.django.contrib.postgres.fields.ArrayField`::
218 |
219 | class MyWidget(Select2TagWidget):
220 |
221 | def value_from_datadict(self, data, files, name):
222 | values = super().value_from_datadict(data, files, name)
223 | return ",".join(values)
224 |
225 | def optgroups(self, name, value, attrs=None):
226 | values = value[0].split(',') if value[0] else []
227 | selected = set(values)
228 | subgroup = [self.create_option(name, v, v, selected, i) for i, v in enumerate(values)]
229 | return [(None, subgroup, 0)]
230 |
231 | """
232 |
233 |
234 | class HeavySelect2Mixin:
235 | """Mixin that adds select2's AJAX options and registers itself on Django's cache."""
236 |
237 | dependent_fields = {}
238 | data_view = None
239 | data_url = None
240 |
241 | def __init__(self, attrs=None, choices=(), **kwargs):
242 | """
243 | Return HeavySelect2Mixin.
244 |
245 | Args:
246 | data_view (str): URL pattern name
247 | data_url (str): URL
248 | dependent_fields (dict): Dictionary of dependent parent fields.
249 | The value of the dependent field will be passed as to :func:`.filter_queryset`.
250 | It can be used to further restrict the search results. For example, a city
251 | widget could be dependent on a country.
252 | Key is a name of a field in a form.
253 | Value is a name of a field in a model (used in `queryset`).
254 |
255 | """
256 | super().__init__(attrs, choices)
257 |
258 | self.data_view = kwargs.pop("data_view", self.data_view)
259 | self.data_url = kwargs.pop("data_url", self.data_url)
260 |
261 | dependent_fields = kwargs.pop("dependent_fields", None)
262 | if dependent_fields is not None:
263 | self.dependent_fields = dict(dependent_fields)
264 | if not (self.data_view or self.data_url):
265 | raise ValueError('You must either specify "data_view" or "data_url".')
266 | self.userGetValTextFuncName = kwargs.pop("userGetValTextFuncName", "null")
267 |
268 | def get_url(self):
269 | """Return URL from instance or by reversing :attr:`.data_view`."""
270 | if self.data_url:
271 | return self.data_url
272 | return reverse(self.data_view)
273 |
274 | def build_attrs(self, base_attrs, extra_attrs=None):
275 | """Set select2's AJAX attributes."""
276 | self.uuid = str(uuid.uuid4())
277 | self.field_id = signing.dumps(self.uuid)
278 | default_attrs = {
279 | "data-ajax--url": self.get_url(),
280 | "data-ajax--cache": "true",
281 | "data-ajax--type": "GET",
282 | "data-minimum-input-length": 2,
283 | }
284 |
285 | if self.dependent_fields:
286 | default_attrs["data-select2-dependent-fields"] = " ".join(
287 | self.dependent_fields
288 | )
289 |
290 | default_attrs.update(base_attrs)
291 |
292 | attrs = super().build_attrs(default_attrs, extra_attrs=extra_attrs)
293 |
294 | attrs["data-field_id"] = self.field_id
295 |
296 | attrs["class"] += " django-select2-heavy"
297 | return attrs
298 |
299 | def render(self, *args, **kwargs):
300 | """Render widget and register it in Django's cache."""
301 | output = super().render(*args, **kwargs)
302 | self.set_to_cache()
303 | return output
304 |
305 | def _get_cache_key(self):
306 | return f"{settings.SELECT2_CACHE_PREFIX}{self.uuid}"
307 |
308 | def set_to_cache(self):
309 | """
310 | Add widget object to Django's cache.
311 |
312 | You may need to overwrite this method, to pickle all information
313 | that is required to serve your JSON response view.
314 | """
315 | try:
316 | cache.set(self._get_cache_key(), {"widget": self, "url": self.get_url()})
317 | except (PicklingError, AttributeError):
318 | msg = 'You need to overwrite "set_to_cache" or ensure that %s is serialisable.'
319 | raise NotImplementedError(msg % self.__class__.__name__)
320 |
321 |
322 | class HeavySelect2Widget(HeavySelect2Mixin, Select2Widget):
323 | """
324 | Select2 widget with AJAX support that registers itself to Django's Cache.
325 |
326 | Usage example::
327 |
328 | class MyWidget(HeavySelect2Widget):
329 | data_view = 'my_view_name'
330 |
331 | or::
332 |
333 | class MyForm(forms.Form):
334 | my_field = forms.ChoiceField(
335 | widget=HeavySelect2Widget(
336 | data_url='/url/to/json/response'
337 | )
338 | )
339 |
340 | """
341 |
342 |
343 | class HeavySelect2MultipleWidget(HeavySelect2Mixin, Select2MultipleWidget):
344 | """Select2 multi select widget similar to :class:`.HeavySelect2Widget`."""
345 |
346 |
347 | class HeavySelect2TagWidget(HeavySelect2Mixin, Select2TagWidget):
348 | """Select2 tag widget."""
349 |
350 |
351 | # Auto Heavy widgets
352 |
353 |
354 | class ModelSelect2Mixin:
355 | """Widget mixin that provides attributes and methods for :class:`.AutoResponseView`."""
356 |
357 | model = None
358 | queryset = None
359 | search_fields = []
360 | """
361 | Model lookups that are used to filter the QuerySet.
362 |
363 | Example::
364 |
365 | search_fields = [
366 | 'title__icontains',
367 | ]
368 |
369 | """
370 |
371 | max_results = 25
372 | """Maximal results returned by :class:`.AutoResponseView`."""
373 |
374 | @property
375 | def empty_label(self):
376 | if isinstance(self.choices, ModelChoiceIterator):
377 | return self.choices.field.empty_label
378 | return ""
379 |
380 | def __init__(self, *args, **kwargs):
381 | """
382 | Overwrite class parameters if passed as keyword arguments.
383 |
384 | Args:
385 | model (django.db.models.Model): Model to select choices from.
386 | queryset (django.db.models.query.QuerySet): QuerySet to select choices from.
387 | search_fields (list): List of model lookup strings.
388 | max_results (int): Max. JsonResponse view page size.
389 |
390 | """
391 | self.model = kwargs.pop("model", self.model)
392 | self.queryset = kwargs.pop("queryset", self.queryset)
393 | self.search_fields = kwargs.pop("search_fields", self.search_fields)
394 | self.max_results = kwargs.pop("max_results", self.max_results)
395 | defaults = {"data_view": "django_select2:auto-json"}
396 | defaults.update(kwargs)
397 | super().__init__(*args, **defaults)
398 |
399 | def set_to_cache(self):
400 | """
401 | Add widget's attributes to Django's cache.
402 |
403 | Split the QuerySet, to not pickle the result set.
404 | """
405 | queryset = self.get_queryset()
406 | cache.set(
407 | self._get_cache_key(),
408 | {
409 | "queryset": [queryset.none(), queryset.query],
410 | "cls": self.__class__,
411 | "search_fields": tuple(self.search_fields),
412 | "max_results": int(self.max_results),
413 | "url": str(self.get_url()),
414 | "dependent_fields": dict(self.dependent_fields),
415 | },
416 | )
417 |
418 | def filter_queryset(self, request, term, queryset=None, **dependent_fields):
419 | """
420 | Return QuerySet filtered by search_fields matching the passed term.
421 |
422 | Args:
423 | request (django.http.request.HttpRequest): The request is being passed from
424 | the JSON view and can be used to dynamically alter the response queryset.
425 | term (str): Search term
426 | queryset (django.db.models.query.QuerySet): QuerySet to select choices from.
427 | **dependent_fields: Dependent fields and their values. If you want to inherit
428 | from ModelSelect2Mixin and later call to this method, be sure to pop
429 | everything from keyword arguments that is not a dependent field.
430 |
431 | Returns:
432 | QuerySet: Filtered QuerySet
433 |
434 | """
435 | if queryset is None:
436 | queryset = self.get_queryset()
437 | search_fields = self.get_search_fields()
438 | select = Q()
439 |
440 | use_distinct = False
441 | if search_fields and term:
442 | for bit in term.split():
443 | or_queries = [Q(**{orm_lookup: bit}) for orm_lookup in search_fields]
444 | select &= reduce(operator.or_, or_queries)
445 | or_queries = [Q(**{orm_lookup: term}) for orm_lookup in search_fields]
446 | select |= reduce(operator.or_, or_queries)
447 | use_distinct |= any(
448 | lookup_spawns_duplicates(queryset.model._meta, search_spec)
449 | for search_spec in search_fields
450 | )
451 |
452 | if dependent_fields:
453 | select &= Q(**dependent_fields)
454 |
455 | use_distinct |= any(
456 | lookup_spawns_duplicates(queryset.model._meta, search_spec)
457 | for search_spec in dependent_fields.keys()
458 | )
459 |
460 | if use_distinct:
461 | return queryset.filter(select).distinct()
462 | return queryset.filter(select)
463 |
464 | def get_queryset(self):
465 | """
466 | Return QuerySet based on :attr:`.queryset` or :attr:`.model`.
467 |
468 | Returns:
469 | QuerySet: QuerySet of available choices.
470 |
471 | """
472 | if self.queryset is not None:
473 | queryset = self.queryset
474 | elif hasattr(self.choices, "queryset"):
475 | queryset = self.choices.queryset
476 | elif self.model is not None:
477 | queryset = self.model._default_manager.all()
478 | else:
479 | raise NotImplementedError(
480 | "%(cls)s is missing a QuerySet. Define "
481 | "%(cls)s.model, %(cls)s.queryset, or override "
482 | "%(cls)s.get_queryset()." % {"cls": self.__class__.__name__}
483 | )
484 | return queryset
485 |
486 | def get_search_fields(self):
487 | """Return list of lookup names."""
488 | if self.search_fields:
489 | return self.search_fields
490 | raise NotImplementedError(
491 | '%s, must implement "search_fields".' % self.__class__.__name__
492 | )
493 |
494 | def optgroups(self, name, value, attrs=None):
495 | """Return only selected options and set QuerySet from `ModelChoicesIterator`."""
496 | default = (None, [], 0)
497 | groups = [default]
498 | has_selected = False
499 | selected_choices = {str(v) for v in value}
500 | if not self.is_required and not self.allow_multiple_selected:
501 | default[1].append(self.create_option(name, "", "", False, 0))
502 | if not isinstance(self.choices, ModelChoiceIterator):
503 | return super().optgroups(name, value, attrs=attrs)
504 | selected_choices = {
505 | c for c in selected_choices if c not in self.choices.field.empty_values
506 | }
507 | field_name = self.choices.field.to_field_name or "pk"
508 | query = Q(**{"%s__in" % field_name: selected_choices})
509 | for obj in self.choices.queryset.filter(query):
510 | option_value = self.choices.choice(obj)[0]
511 | option_label = self.label_from_instance(obj)
512 |
513 | selected = str(option_value) in value and (
514 | has_selected is False or self.allow_multiple_selected
515 | )
516 | if selected is True and has_selected is False:
517 | has_selected = True
518 | index = len(default[1])
519 | subgroup = default[1]
520 | subgroup.append(
521 | self.create_option(
522 | name, option_value, option_label, selected_choices, index
523 | )
524 | )
525 | return groups
526 |
527 | def label_from_instance(self, obj):
528 | """
529 | Return option label representation from instance.
530 |
531 | Can be overridden to change the representation of each choice.
532 |
533 | Example usage::
534 |
535 | class MyWidget(ModelSelect2Widget):
536 | def label_from_instance(obj):
537 | return str(obj.title).upper()
538 |
539 | Args:
540 | obj (django.db.models.Model): Instance of Django Model.
541 |
542 | Returns:
543 | str: Option label.
544 |
545 | """
546 | return str(obj)
547 |
548 | def result_from_instance(self, obj, request):
549 | """
550 | Return a dictionary representing the object.
551 |
552 | Can be overridden to change the result returned by
553 | :class:`.AutoResponseView` for each object.
554 |
555 | The request passed in will correspond to the request sent to the
556 | :class:`.AutoResponseView` by the widget.
557 |
558 | Example usage::
559 |
560 | class MyWidget(ModelSelect2Widget):
561 | def result_from_instance(obj, request):
562 | return {
563 | 'id': obj.pk,
564 | 'text': self.label_from_instance(obj),
565 | 'extra_data': obj.extra_data,
566 | }
567 | """
568 | return {"id": obj.pk, "text": self.label_from_instance(obj)}
569 |
570 |
571 | class ModelSelect2Widget(ModelSelect2Mixin, HeavySelect2Widget):
572 | """
573 | Select2 drop in model select widget.
574 |
575 | Example usage::
576 |
577 | class MyWidget(ModelSelect2Widget):
578 | search_fields = [
579 | 'title__icontains',
580 | ]
581 |
582 | class MyModelForm(forms.ModelForm):
583 | class Meta:
584 | model = MyModel
585 | fields = ('my_field', )
586 | widgets = {
587 | 'my_field': MyWidget,
588 | }
589 |
590 | or::
591 |
592 | class MyForm(forms.Form):
593 | my_choice = forms.ChoiceField(
594 | widget=ModelSelect2Widget(
595 | model=MyOtherModel,
596 | search_fields=['title__icontains']
597 | )
598 | )
599 |
600 | .. tip:: The ModelSelect2(Multiple)Widget will try
601 | to get the QuerySet from the fields choices.
602 | Therefore you don't need to define a QuerySet,
603 | if you just drop in the widget for a ForeignKey field.
604 | """
605 |
606 |
607 | class ModelSelect2MultipleWidget(ModelSelect2Mixin, HeavySelect2MultipleWidget):
608 | """
609 | Select2 drop in model multiple select widget.
610 |
611 | Works just like :class:`.ModelSelect2Widget` but for multi select.
612 | """
613 |
614 |
615 | class ModelSelect2TagWidget(ModelSelect2Mixin, HeavySelect2TagWidget):
616 | """
617 | Select2 model widget with tag support.
618 |
619 | This it not a simple drop in widget.
620 | It requires to implement you own :func:`.value_from_datadict`
621 | that adds missing tags to you QuerySet.
622 |
623 | Example::
624 |
625 | class MyModelSelect2TagWidget(ModelSelect2TagWidget):
626 | queryset = MyModel.objects.all()
627 |
628 | def value_from_datadict(self, data, files, name):
629 | '''Create objects for given non-pimary-key values. Return list of all primary keys.'''
630 | values = set(super().value_from_datadict(data, files, name))
631 | # This may only work for MyModel, if MyModel has title field.
632 | # You need to implement this method yourself, to ensure proper object creation.
633 | pks = self.queryset.filter(**{'pk__in': list(values)}).values_list('pk', flat=True)
634 | pks = set(map(str, pks))
635 | cleaned_values = list(pks)
636 | for val in values - pks:
637 | cleaned_values.append(self.queryset.create(title=val).pk)
638 | return cleaned_values
639 |
640 | """
641 |
--------------------------------------------------------------------------------
/tests/test_forms.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | from collections.abc import Iterable
4 |
5 | import pytest
6 | from django.db.models import QuerySet
7 | from django.urls import reverse
8 | from django.utils import translation
9 | from django.utils.encoding import force_str
10 | from selenium.common.exceptions import NoSuchElementException
11 | from selenium.webdriver.common.by import By
12 | from selenium.webdriver.support import expected_conditions
13 | from selenium.webdriver.support.wait import WebDriverWait
14 |
15 | from django_select2.cache import cache
16 | from django_select2.forms import (
17 | HeavySelect2MultipleWidget,
18 | HeavySelect2Widget,
19 | ModelSelect2TagWidget,
20 | ModelSelect2Widget,
21 | Select2AdminMixin,
22 | Select2Widget,
23 | )
24 | from tests.testapp import forms
25 | from tests.testapp.forms import (
26 | NUMBER_CHOICES,
27 | HeavySelect2MultipleWidgetForm,
28 | TitleModelSelect2Widget,
29 | )
30 | from tests.testapp.models import Artist, City, Country, Genre, Groupie
31 |
32 |
33 | class TestSelect2Mixin:
34 | url = reverse("select2_widget")
35 | form = forms.AlbumSelect2WidgetForm()
36 | multiple_form = forms.AlbumSelect2MultipleWidgetForm()
37 | widget_cls = Select2Widget
38 |
39 | def test_initial_data(self, genres):
40 | genre = genres[0]
41 | form = self.form.__class__(initial={"primary_genre": genre.pk})
42 | assert str(genre) in form.as_p()
43 |
44 | def test_initial_form_class(self):
45 | widget = self.widget_cls(attrs={"class": "my-class"})
46 | assert "my-class" in widget.render("name", None)
47 | assert "django-select2" in widget.render("name", None)
48 |
49 | def test_lang_attr(self):
50 | with translation.override("de"):
51 | widget = Select2Widget()
52 | assert 'lang="de"' in widget.render("name", None)
53 |
54 | # Regression test for #163
55 | widget = Select2Widget()
56 | with translation.override("en"):
57 | assert widget.i18n_name == "en"
58 | with translation.override("de"):
59 | assert widget.i18n_name == "de"
60 |
61 | def test_allow_clear(self, db):
62 | required_field = self.form.fields["artist"]
63 | assert required_field.required is True
64 | assert 'data-allow-clear="true"' not in required_field.widget.render(
65 | "artist", None
66 | )
67 | assert 'data-allow-clear="false"' in required_field.widget.render(
68 | "artist", None
69 | )
70 | assert '' not in required_field.widget.render(
71 | "artist", None
72 | )
73 |
74 | not_required_field = self.form.fields["primary_genre"]
75 | assert not_required_field.required is False
76 | assert 'data-allow-clear="true"' in not_required_field.widget.render(
77 | "primary_genre", None
78 | )
79 | assert 'data-allow-clear="false"' not in not_required_field.widget.render(
80 | "primary_genre", None
81 | )
82 | assert "data-placeholder" in not_required_field.widget.render(
83 | "primary_genre", None
84 | )
85 | assert '' in not_required_field.widget.render(
86 | "primary_genre", None
87 | )
88 |
89 | @pytest.mark.selenium
90 | def test_no_js_error(self, db, live_server, driver):
91 | driver.get(live_server + self.url)
92 | with pytest.raises(NoSuchElementException):
93 | error = driver.find_element(By.XPATH, "//body[@JSError]")
94 | pytest.fail(error.get_attribute("JSError"))
95 |
96 | @pytest.mark.selenium
97 | def test_selecting(self, db, live_server, driver):
98 | driver.get(live_server + self.url)
99 | with pytest.raises(NoSuchElementException):
100 | driver.find_element(By.CSS_SELECTOR, ".select2-results")
101 | elem = driver.find_element(By.CSS_SELECTOR, ".select2-selection")
102 | elem.click()
103 | results = driver.find_element(By.CSS_SELECTOR, ".select2-results")
104 | assert results.is_displayed() is True
105 | elem = results.find_element(By.CSS_SELECTOR, ".select2-results__option")
106 | elem.click()
107 |
108 | with pytest.raises(NoSuchElementException):
109 | error = driver.find_element(By.XPATH, "//body[@JSError]")
110 | pytest.fail(error.get_attribute("JSError"))
111 |
112 | def test_data_url(self):
113 | with pytest.raises(ValueError):
114 | HeavySelect2Widget()
115 |
116 | widget = HeavySelect2Widget(data_url="/foo/bar")
117 | assert widget.get_url() == "/foo/bar"
118 |
119 | def test_empty_option(self, db):
120 | # Empty options is only required for single selects
121 | # https://select2.github.io/options.html#allowClear
122 | single_select = self.form.fields["primary_genre"]
123 | assert single_select.required is False
124 | assert '' in single_select.widget.render(
125 | "primary_genre", None
126 | )
127 |
128 | multiple_select = self.multiple_form.fields["featured_artists"]
129 | assert multiple_select.required is False
130 | assert multiple_select.widget.allow_multiple_selected
131 | output = multiple_select.widget.render("featured_artists", None)
132 | assert '' not in output
133 | assert 'data-placeholder=""' in output
134 |
135 | def test_i18n(self):
136 | translation.activate("de")
137 | assert tuple(Select2Widget().media._js) == (
138 | "admin/js/vendor/select2/select2.full.min.js",
139 | "admin/js/vendor/select2/i18n/de.js",
140 | "django_select2/django_select2.js",
141 | )
142 |
143 | translation.activate("en")
144 | assert tuple(Select2Widget().media._js) == (
145 | "admin/js/vendor/select2/select2.full.min.js",
146 | "admin/js/vendor/select2/i18n/en.js",
147 | "django_select2/django_select2.js",
148 | )
149 |
150 | translation.activate("00")
151 | assert tuple(Select2Widget().media._js) == (
152 | "admin/js/vendor/select2/select2.full.min.js",
153 | "django_select2/django_select2.js",
154 | )
155 |
156 | translation.activate("sr-Cyrl")
157 | assert tuple(Select2Widget().media._js) == (
158 | "admin/js/vendor/select2/select2.full.min.js",
159 | "admin/js/vendor/select2/i18n/sr-Cyrl.js",
160 | "django_select2/django_select2.js",
161 | )
162 |
163 | pytest.importorskip("django", minversion="2.0.4")
164 |
165 | translation.activate("zh-hans")
166 | assert tuple(Select2Widget().media._js) == (
167 | "admin/js/vendor/select2/select2.full.min.js",
168 | "admin/js/vendor/select2/i18n/zh-CN.js",
169 | "django_select2/django_select2.js",
170 | )
171 |
172 | translation.activate("zh-hant")
173 | assert tuple(Select2Widget().media._js) == (
174 | "admin/js/vendor/select2/select2.full.min.js",
175 | "admin/js/vendor/select2/i18n/zh-TW.js",
176 | "django_select2/django_select2.js",
177 | )
178 |
179 | def test_theme_setting(self, settings):
180 | settings.SELECT2_THEME = "classic"
181 | widget = self.widget_cls()
182 | assert 'data-theme="classic"' in widget.render("name", None)
183 |
184 |
185 | class TestSelect2AdminMixin:
186 | def test_media(self):
187 | translation.activate("en")
188 | assert tuple(Select2AdminMixin().media._js) == (
189 | "admin/js/vendor/select2/select2.full.min.js",
190 | "admin/js/vendor/select2/i18n/en.js",
191 | "admin/js/jquery.init.js",
192 | "django_select2/django_select2.js",
193 | )
194 |
195 | assert dict(Select2AdminMixin().media._css) == {
196 | "screen": [
197 | "admin/css/vendor/select2/select2.min.css",
198 | "admin/css/autocomplete.css",
199 | "django_select2/django_select2.css",
200 | ]
201 | }
202 |
203 |
204 | class TestSelect2MixinSettings:
205 | def test_default_media(self):
206 | sut = Select2Widget()
207 | result = sut.media.render()
208 | assert "admin/js/vendor/select2/select2.full.min.js" in result
209 | assert "admin/css/vendor/select2/select2.min.css" in result
210 | assert "django_select2/django_select2.js" in result
211 |
212 | def test_js_setting(self, settings):
213 | settings.SELECT2_JS = "alternate.js"
214 | sut = Select2Widget()
215 | result = sut.media.render()
216 | assert "alternate.js" in result
217 | assert "django_select2/django_select2.js" in result
218 |
219 | def test_empty_js_setting(self, settings):
220 | settings.SELECT2_JS = ""
221 | sut = Select2Widget()
222 | result = sut.media.render()
223 | assert "django_select2/django_select2.js" in result
224 |
225 | def test_css_setting(self, settings):
226 | settings.SELECT2_CSS = "alternate.css"
227 | sut = Select2Widget()
228 | result = sut.media.render()
229 | assert "alternate.css" in result
230 |
231 | def test_empty_css_setting(self, settings):
232 | settings.SELECT2_CSS = ""
233 | sut = Select2Widget()
234 | result = sut.media.render()
235 | assert "/select2.css" not in result
236 |
237 | def test_multiple_css_setting(self, settings):
238 | settings.SELECT2_CSS = ["select2.css", "select2-theme.css"]
239 | sut = Select2Widget()
240 | result = sut.media.render()
241 | assert "select2.css" in result
242 | assert "select2-theme.css" in result
243 |
244 |
245 | class TestHeavySelect2Mixin(TestSelect2Mixin):
246 | url = reverse("heavy_select2_widget")
247 | form = forms.HeavySelect2WidgetForm(initial={"primary_genre": 1})
248 | widget_cls = HeavySelect2Widget
249 |
250 | def test_initial_data(self):
251 | assert "One" in self.form.as_p()
252 |
253 | def test_initial_form_class(self):
254 | widget = self.widget_cls(data_view="heavy_data_1", attrs={"class": "my-class"})
255 | assert "my-class" in widget.render("name", None)
256 | assert "django-select2" in widget.render("name", None)
257 | assert "django-select2-heavy" in widget.render("name", None), widget.render(
258 | "name", None
259 | )
260 |
261 | def test_lang_attr(self):
262 | with translation.override("fr"):
263 | widget = self.widget_cls(data_view="heavy_data_1")
264 | assert 'lang="fr"' in widget.render("name", None)
265 |
266 | def test_selected_option(self, db):
267 | not_required_field = self.form.fields["primary_genre"]
268 | assert not_required_field.required is False
269 | assert (
270 | ''
271 | in not_required_field.widget.render("primary_genre", 1)
272 | or ''
273 | in not_required_field.widget.render("primary_genre", 1)
274 | ), not_required_field.widget.render("primary_genre", 1)
275 |
276 | def test_many_selected_option(self, db, genres):
277 | field = HeavySelect2MultipleWidgetForm().fields["genres"]
278 | field.widget.choices = NUMBER_CHOICES
279 | widget_output = field.widget.render("genres", [1, 2])
280 | selected_option = (
281 | ''.format(
282 | pk=1, value="One"
283 | )
284 | )
285 | selected_option_a = ''.format(
286 | pk=1, value="One"
287 | )
288 | selected_option2 = (
289 | ''.format(
290 | pk=2, value="Two"
291 | )
292 | )
293 | selected_option2a = ''.format(
294 | pk=2, value="Two"
295 | )
296 |
297 | assert selected_option in widget_output or selected_option_a in widget_output, (
298 | widget_output
299 | )
300 | assert selected_option2 in widget_output or selected_option2a in widget_output
301 |
302 | @pytest.mark.selenium
303 | def test_multiple_widgets(self, db, live_server, driver):
304 | driver.get(live_server + self.url)
305 | with pytest.raises(NoSuchElementException):
306 | driver.find_element(By.CSS_SELECTOR, ".select2-results")
307 |
308 | elem1, elem2 = driver.find_elements(By.CSS_SELECTOR, ".select2-selection")
309 |
310 | elem1.click()
311 | search1 = driver.find_element(By.CSS_SELECTOR, ".select2-search__field")
312 | search1.send_keys("fo")
313 | search1.send_keys("\ue00c") # ESC key
314 |
315 | elem2.click()
316 | search2 = driver.find_element(By.CSS_SELECTOR, ".select2-search__field")
317 | search2.send_keys("fo")
318 |
319 | with pytest.raises(NoSuchElementException):
320 | error = driver.find_element(By.XPATH, "//body[@JSError]")
321 | pytest.fail(error.get_attribute("JSError"))
322 |
323 | def test_get_url(self):
324 | widget = self.widget_cls(data_view="heavy_data_1", attrs={"class": "my-class"})
325 | assert isinstance(widget.get_url(), str)
326 |
327 | def test_can_not_pickle(self):
328 | widget = self.widget_cls(data_view="heavy_data_1", attrs={"class": "my-class"})
329 |
330 | class NoPickle:
331 | pass
332 |
333 | widget.no_pickle = NoPickle()
334 | with pytest.raises(NotImplementedError):
335 | widget.set_to_cache()
336 |
337 | def test_theme_setting(self, settings):
338 | settings.SELECT2_THEME = "classic"
339 | widget = self.widget_cls(data_view="heavy_data_1")
340 | assert 'data-theme="classic"' in widget.render("name", None)
341 |
342 | def test_cache_key_leak(self):
343 | bob = self.widget_cls(data_url="/test/")
344 | alice = self.widget_cls(data_url="/test/")
345 | bob.render("name", "value")
346 | bob_key_request_1 = bob._get_cache_key()
347 | alice.render("name", "value")
348 | assert bob._get_cache_key() != alice._get_cache_key()
349 | bob.render("name", "value")
350 | bob_key_request_2 = bob._get_cache_key()
351 | assert bob_key_request_1 != bob_key_request_2
352 |
353 |
354 | class TestModelSelect2Mixin(TestHeavySelect2Mixin):
355 | form = forms.AlbumModelSelect2WidgetForm(initial={"primary_genre": 1})
356 | multiple_form = forms.ArtistModelSelect2MultipleWidgetForm()
357 |
358 | def test_initial_data(self, genres):
359 | genre = genres[0]
360 | form = self.form.__class__(initial={"primary_genre": genre.pk})
361 | assert str(genre) in form.as_p()
362 |
363 | def test_label_from_instance_initial(self, genres):
364 | genre = genres[0]
365 | genre.title = genre.title.lower()
366 | genre.save()
367 |
368 | form = self.form.__class__(initial={"primary_genre": genre.pk})
369 | assert genre.title not in form.as_p(), form.as_p()
370 | assert genre.title.upper() in form.as_p()
371 |
372 | @pytest.fixture(autouse=True)
373 | def genres(self, genres):
374 | return genres
375 |
376 | def test_selected_option(self, db, genres):
377 | genre = genres[0]
378 | genre2 = genres[1]
379 | not_required_field = self.form.fields["primary_genre"]
380 | assert not_required_field.required is False
381 | widget_output = not_required_field.widget.render("primary_genre", genre.pk)
382 | selected_option = (
383 | ''.format(
384 | pk=genre.pk, value=force_str(genre)
385 | )
386 | )
387 | selected_option_a = ''.format(
388 | pk=genre.pk, value=force_str(genre)
389 | )
390 | unselected_option = ''.format(
391 | pk=genre2.pk, value=force_str(genre2)
392 | )
393 |
394 | assert selected_option in widget_output or selected_option_a in widget_output, (
395 | widget_output
396 | )
397 | assert unselected_option not in widget_output
398 |
399 | def test_selected_option_label_from_instance(self, db, genres):
400 | genre = genres[0]
401 | genre.title = genre.title.lower()
402 | genre.save()
403 |
404 | field = self.form.fields["primary_genre"]
405 | widget_output = field.widget.render("primary_genre", genre.pk)
406 |
407 | def get_selected_options(genre):
408 | return (
409 | ''.format(
410 | pk=genre.pk, value=force_str(genre)
411 | ),
412 | ''.format(
413 | pk=genre.pk, value=force_str(genre)
414 | ),
415 | )
416 |
417 | assert all(o not in widget_output for o in get_selected_options(genre))
418 | genre.title = genre.title.upper()
419 |
420 | assert any(o in widget_output for o in get_selected_options(genre))
421 |
422 | def test_get_queryset(self):
423 | widget = ModelSelect2Widget()
424 | with pytest.raises(NotImplementedError):
425 | widget.get_queryset()
426 | widget.model = Genre
427 | assert isinstance(widget.get_queryset(), QuerySet)
428 | widget.model = None
429 | widget.queryset = Genre.objects.all()
430 | assert isinstance(widget.get_queryset(), QuerySet)
431 |
432 | def test_result_from_instance_ModelSelect2Widget(self, genres):
433 | widget = ModelSelect2Widget()
434 | widget.model = Genre
435 | genre = Genre.objects.first()
436 | assert widget.result_from_instance(genre, request=None) == {
437 | "id": genre.pk,
438 | "text": str(genre),
439 | }
440 |
441 | def test_tag_attrs_Select2Widget(self):
442 | widget = Select2Widget()
443 | output = widget.render("name", "value")
444 | assert 'data-minimum-input-length="0"' in output
445 |
446 | def test_custom_tag_attrs_Select2Widget(self):
447 | widget = Select2Widget(attrs={"data-minimum-input-length": "3"})
448 | output = widget.render("name", "value")
449 | assert 'data-minimum-input-length="3"' in output
450 |
451 | def test_tag_attrs_ModelSelect2Widget(self):
452 | widget = ModelSelect2Widget(
453 | queryset=Genre.objects.all(), search_fields=["title__icontains"]
454 | )
455 | output = widget.render("name", "value")
456 | assert 'data-minimum-input-length="2"' in output
457 |
458 | def test_tag_attrs_ModelSelect2TagWidget(self):
459 | widget = ModelSelect2TagWidget(
460 | queryset=Genre.objects.all(), search_fields=["title__icontains"]
461 | )
462 | output = widget.render("name", "value")
463 | assert 'data-minimum-input-length="2"' in output
464 |
465 | def test_tag_attrs_HeavySelect2Widget(self):
466 | widget = HeavySelect2Widget(data_url="/foo/bar/")
467 | output = widget.render("name", "value")
468 | assert 'data-minimum-input-length="2"' in output
469 |
470 | def test_custom_tag_attrs_ModelSelect2Widget(self):
471 | widget = ModelSelect2Widget(
472 | queryset=Genre.objects.all(),
473 | search_fields=["title__icontains"],
474 | attrs={"data-minimum-input-length": "3"},
475 | )
476 | output = widget.render("name", "value")
477 | assert 'data-minimum-input-length="3"' in output
478 |
479 | def test_get_search_fields(self):
480 | widget = ModelSelect2Widget()
481 | with pytest.raises(NotImplementedError):
482 | widget.get_search_fields()
483 |
484 | widget.search_fields = ["title__icontains"]
485 | assert isinstance(widget.get_search_fields(), Iterable)
486 | assert all(isinstance(x, str) for x in widget.get_search_fields())
487 |
488 | def test_filter_queryset(self, genres):
489 | widget = TitleModelSelect2Widget(queryset=Genre.objects.all())
490 | assert widget.filter_queryset(None, genres[0].title[:3]).exists()
491 |
492 | widget = TitleModelSelect2Widget(
493 | search_fields=["title__icontains"], queryset=Genre.objects.all()
494 | )
495 | qs = widget.filter_queryset(
496 | None, " ".join([genres[0].title[:3], genres[0].title[3:]])
497 | )
498 | assert qs.exists()
499 |
500 | def test_filter_queryset__empty(self, genres):
501 | widget = TitleModelSelect2Widget(queryset=Genre.objects.all())
502 | assert widget.filter_queryset(None, genres[0].title[:3]).exists()
503 |
504 | widget = TitleModelSelect2Widget(
505 | search_fields=["title__icontains"], queryset=Genre.objects.all()
506 | )
507 | qs = widget.filter_queryset(None, "")
508 | assert qs.exists()
509 |
510 | def test_filter_queryset__startswith(self, genres):
511 | genre = Genre.objects.create(title="Space Genre")
512 | widget = TitleModelSelect2Widget(queryset=Genre.objects.all())
513 | assert widget.filter_queryset(None, genre.title).exists()
514 |
515 | widget = TitleModelSelect2Widget(
516 | search_fields=["title__istartswith"], queryset=Genre.objects.all()
517 | )
518 | qs = widget.filter_queryset(None, "Space Gen")
519 | assert qs.exists()
520 |
521 | qs = widget.filter_queryset(None, "Gen")
522 | assert not qs.exists()
523 |
524 | def test_filter_queryset__contains(self, genres):
525 | genre = Genre.objects.create(title="Space Genre")
526 | widget = TitleModelSelect2Widget(queryset=Genre.objects.all())
527 | assert widget.filter_queryset(None, genre.title).exists()
528 |
529 | widget = TitleModelSelect2Widget(
530 | search_fields=["title__contains"], queryset=Genre.objects.all()
531 | )
532 | qs = widget.filter_queryset(None, "Space Gen")
533 | assert qs.exists()
534 |
535 | qs = widget.filter_queryset(None, "NOT Gen")
536 | assert not qs.exists(), "contains works even if all bits match"
537 |
538 | def test_filter_queryset__multiple_fields(self, genres):
539 | genre = Genre.objects.create(title="Space Genre")
540 | widget = TitleModelSelect2Widget(queryset=Genre.objects.all())
541 | assert widget.filter_queryset(None, genre.title).exists()
542 |
543 | widget = TitleModelSelect2Widget(
544 | search_fields=[
545 | "title__startswith",
546 | "title__endswith",
547 | ],
548 | queryset=Genre.objects.all(),
549 | )
550 | qs = widget.filter_queryset(None, "Space")
551 | assert qs.exists()
552 |
553 | qs = widget.filter_queryset(None, "Genre")
554 | assert qs.exists()
555 |
556 | def test_model_kwarg(self):
557 | widget = ModelSelect2Widget(model=Genre, search_fields=["title__icontains"])
558 | genre = Genre.objects.last()
559 | result = widget.filter_queryset(None, genre.title)
560 | assert result.exists()
561 |
562 | def test_queryset_kwarg(self):
563 | widget = ModelSelect2Widget(
564 | queryset=Genre.objects.all(), search_fields=["title__icontains"]
565 | )
566 | genre = Genre.objects.last()
567 | result = widget.filter_queryset(None, genre.title)
568 | assert result.exists()
569 |
570 | def test_ajax_view_registration(self, client):
571 | widget = ModelSelect2Widget(
572 | queryset=Genre.objects.all(), search_fields=["title__icontains"]
573 | )
574 | widget.render("name", "value")
575 | url = reverse("django_select2:auto-json")
576 | genre = Genre.objects.last()
577 | response = client.get(
578 | url, data=dict(field_id=widget.field_id, term=genre.title)
579 | )
580 | assert response.status_code == 200, response.content
581 | data = json.loads(response.content.decode("utf-8"))
582 | assert data["results"]
583 | assert genre.pk in [result["id"] for result in data["results"]]
584 |
585 | def test_render(self):
586 | widget = ModelSelect2Widget(queryset=Genre.objects.all())
587 | widget.render("name", "value")
588 | cached_widget = cache.get(widget._get_cache_key())
589 | assert cached_widget["max_results"] == widget.max_results
590 | assert cached_widget["search_fields"] == tuple(widget.search_fields)
591 | qs = widget.get_queryset()
592 | assert isinstance(cached_widget["queryset"][0], qs.__class__)
593 | assert str(cached_widget["queryset"][1]) == str(qs.query)
594 |
595 | def test_get_url(self):
596 | widget = ModelSelect2Widget(
597 | queryset=Genre.objects.all(), search_fields=["title__icontains"]
598 | )
599 | assert isinstance(widget.get_url(), str)
600 |
601 | def test_custom_to_field_name(self):
602 | the_best_band_in_the_world = Artist.objects.create(title="Take That")
603 | groupie = Groupie.objects.create(obsession=the_best_band_in_the_world)
604 | form = forms.GroupieForm(instance=groupie)
605 | assert '' in form.as_p()
606 |
607 | def test_empty_label(self, db):
608 | # Empty options is only required for single selects
609 | # https://select2.github.io/options.html#allowClear
610 | single_select = self.form.fields["primary_genre"]
611 | single_select.empty_label = "Hello World"
612 | assert single_select.required is False
613 | assert 'data-placeholder="Hello World"' in single_select.widget.render(
614 | "primary_genre", None
615 | )
616 |
617 |
618 | class TestHeavySelect2TagWidget(TestHeavySelect2Mixin):
619 | def test_tag_attrs(self):
620 | widget = ModelSelect2TagWidget(
621 | queryset=Genre.objects.all(), search_fields=["title__icontains"]
622 | )
623 | output = widget.render("name", "value")
624 | assert 'data-minimum-input-length="2"' in output
625 | assert 'data-tags="true"' in output
626 | assert "data-token-separators" in output
627 |
628 | def test_custom_tag_attrs(self):
629 | widget = ModelSelect2TagWidget(
630 | queryset=Genre.objects.all(),
631 | search_fields=["title__icontains"],
632 | attrs={"data-minimum-input-length": "3"},
633 | )
634 | output = widget.render("name", "value")
635 | assert 'data-minimum-input-length="3"' in output
636 |
637 |
638 | class TestHeavySelect2MultipleWidget:
639 | url = reverse("heavy_select2_multiple_widget")
640 | form = forms.HeavySelect2MultipleWidgetForm()
641 | widget_cls = HeavySelect2MultipleWidget
642 |
643 | @pytest.mark.xfail(
644 | bool(os.environ.get("CI", False)),
645 | reason="https://bugs.chromium.org/p/chromedriver/issues/detail?id=1772",
646 | )
647 | @pytest.mark.selenium
648 | def test_widgets_selected_after_validation_error(self, db, live_server, driver):
649 | driver.get(live_server + self.url)
650 | WebDriverWait(driver, 3).until(
651 | expected_conditions.presence_of_element_located((By.ID, "id_title"))
652 | )
653 | title = driver.find_element(By.ID, "id_title")
654 | title.send_keys("fo")
655 | genres, fartists = driver.find_elements(
656 | By.CSS_SELECTOR, ".select2-selection--multiple"
657 | )
658 | genres.click()
659 | genres.send_keys("o") # results are Zero One Two Four
660 | # select second element - One
661 | driver.find_element(By.CSS_SELECTOR, ".select2-results li:nth-child(2)").click()
662 | genres.submit()
663 | # there is a ValidationError raised, check for it
664 | errstring = (
665 | WebDriverWait(driver, 3)
666 | .until(
667 | expected_conditions.presence_of_element_located(
668 | (By.CSS_SELECTOR, "ul.errorlist li")
669 | )
670 | )
671 | .text
672 | )
673 | assert errstring == "Title must have more than 3 characters."
674 | # genres should still have One as selected option
675 | result_title = driver.find_element(
676 | By.CSS_SELECTOR, ".select2-selection--multiple li"
677 | ).get_attribute("title")
678 | assert result_title == "One"
679 |
680 |
681 | class TestAddressChainedSelect2Widget:
682 | url = reverse("model_chained_select2_widget")
683 | form = forms.AddressChainedSelect2WidgetForm()
684 |
685 | @pytest.mark.selenium
686 | def test_widgets_selected_after_validation_error(
687 | self, db, live_server, driver, countries, cities
688 | ):
689 | driver.get(live_server + self.url)
690 |
691 | WebDriverWait(driver, 60).until(
692 | expected_conditions.presence_of_element_located(
693 | (By.CSS_SELECTOR, ".select2-selection--single")
694 | )
695 | )
696 | (
697 | country_container,
698 | city_container,
699 | city2_container,
700 | ) = driver.find_elements(By.CSS_SELECTOR, ".select2-selection--single")
701 |
702 | # clicking city select2 lists all available cities
703 | city_container.click()
704 | city_options = WebDriverWait(driver, 60).until(
705 | expected_conditions.visibility_of_all_elements_located(
706 | (By.CSS_SELECTOR, ".select2-results li")
707 | )
708 | )
709 | assert len(city_options) == City.objects.count()
710 |
711 | # selecting a country really does it
712 | country_container.click()
713 | WebDriverWait(driver, 60).until(
714 | expected_conditions.presence_of_element_located(
715 | (By.CSS_SELECTOR, ".select2-results li:nth-child(2)")
716 | )
717 | )
718 | country_option = driver.find_element(
719 | By.CSS_SELECTOR, ".select2-results li:nth-child(2)"
720 | )
721 | country_name = country_option.text
722 | country_option.click()
723 | assert country_name == country_container.text
724 |
725 | # clicking city select2 lists reduced list of cities belonging to the country
726 | city_container.click()
727 | WebDriverWait(driver, 60).until(
728 | expected_conditions.presence_of_element_located(
729 | (By.CSS_SELECTOR, ".select2-results li")
730 | )
731 | )
732 | city_options = driver.find_elements(By.CSS_SELECTOR, ".select2-results li")
733 | assert len(city_options) != City.objects.count()
734 |
735 | # selecting a city really does it
736 | city_option = driver.find_element(
737 | By.CSS_SELECTOR, ".select2-results li:nth-child(2)"
738 | )
739 | city_name = city_option.text
740 | city_option.click()
741 | assert city_name == city_container.text
742 |
743 | # clicking country select2 lists reduced list to the only country available to the city
744 | country_container.click()
745 | country_options = WebDriverWait(driver, 60).until(
746 | expected_conditions.presence_of_all_elements_located(
747 | (By.CSS_SELECTOR, ".select2-results li")
748 | )
749 | )
750 | assert len(country_options) != Country.objects.count()
751 |
752 | @pytest.mark.selenium
753 | def test_dependent_fields_clear_after_change_parent(
754 | self, db, live_server, driver, countries, cities
755 | ):
756 | driver.get(live_server + self.url)
757 | (
758 | country_container,
759 | city_container,
760 | city2_container,
761 | ) = driver.find_elements(By.CSS_SELECTOR, ".select2-selection--single")
762 |
763 | # selecting a country really does it
764 | country_container.click()
765 | WebDriverWait(driver, 60).until(
766 | expected_conditions.presence_of_element_located(
767 | (By.CSS_SELECTOR, ".select2-results li:nth-child(2)")
768 | )
769 | )
770 | country_option = driver.find_element(
771 | By.CSS_SELECTOR, ".select2-results li:nth-child(2)"
772 | )
773 | country_name = country_option.text
774 | country_option.click()
775 | assert country_name == country_container.text
776 |
777 | # selecting a city2
778 | city2_container.click()
779 | WebDriverWait(driver, 60).until(
780 | expected_conditions.presence_of_element_located(
781 | (By.CSS_SELECTOR, ".select2-results li")
782 | )
783 | )
784 | city2_option = WebDriverWait(driver, 60).until(
785 | expected_conditions.presence_of_element_located(
786 | (By.CSS_SELECTOR, ".select2-results li:nth-child(2)")
787 | )
788 | )
789 | city2_name = city2_option.text
790 | city2_option.click()
791 | assert city2_name == city2_container.text
792 |
793 | # change a country
794 | country_container.click()
795 | WebDriverWait(driver, 60).until(
796 | expected_conditions.presence_of_element_located(
797 | (By.CSS_SELECTOR, ".select2-results li:nth-child(3)")
798 | )
799 | )
800 | country_option = driver.find_element(
801 | By.CSS_SELECTOR, ".select2-results li:nth-child(3)"
802 | )
803 | country_name = country_option.text
804 | country_option.click()
805 | assert country_name == country_container.text
806 |
807 | # check the value in city2
808 | city2_container.click()
809 | WebDriverWait(driver, 60).until(
810 | expected_conditions.visibility_of_all_elements_located(
811 | (By.CSS_SELECTOR, ".select2-results li")
812 | )
813 | )
814 | assert city2_container.text == ""
815 |
816 |
817 | @pytest.fixture(
818 | name="widget",
819 | params=[
820 | (Select2Widget, {}),
821 | (HeavySelect2Widget, {"data_view": "heavy_data_1"}),
822 | (HeavySelect2MultipleWidget, {"data_view": "heavy_data_1"}),
823 | (ModelSelect2Widget, {}),
824 | (ModelSelect2TagWidget, {}),
825 | ],
826 | ids=lambda p: p[0],
827 | )
828 | def widget_fixture(request):
829 | widget_class, widget_kwargs = request.param
830 | return widget_class(**widget_kwargs)
831 |
832 |
833 | @pytest.mark.parametrize(
834 | "locale,expected",
835 | [
836 | ("fr-FR", "fr"),
837 | # Some locales with a country code are natively supported by select2's i18n
838 | ("pt-BR", "pt-BR"),
839 | ("sr-Cyrl", "sr-Cyrl"),
840 | ],
841 | ids=repr,
842 | )
843 | def test_i18n_name_property_with_country_code_in_locale(widget, locale, expected):
844 | """Test we fall back to the language code if the locale contain an unsupported country code."""
845 | with translation.override(locale):
846 | assert widget.i18n_name == expected
847 |
848 |
849 | def test_i18n_media_js_with_country_code_in_locale(widget):
850 | translation.activate("fr-FR")
851 | assert tuple(widget.media._js) == (
852 | "admin/js/vendor/select2/select2.full.min.js",
853 | "admin/js/vendor/select2/i18n/fr.js",
854 | "django_select2/django_select2.js",
855 | )
856 |
--------------------------------------------------------------------------------