├── smart_selects ├── models.py ├── __init__.py ├── urls.py ├── tests.py ├── utils.py ├── static │ └── smart-selects │ │ └── admin │ │ └── js │ │ ├── bindfields.js │ │ ├── chainedfk.js │ │ └── chainedm2m.js ├── views.py ├── form_fields.py ├── widgets.py └── db_fields.py ├── test_app ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0005_merge.py │ ├── 0007_auto_20200207_1149.py │ ├── 0003_auto_20160129_1531.py │ ├── 0002_book1_location1.py │ ├── 0004_client_domain_website.py │ ├── 0004_auto_20170309_0016.py │ ├── 0006_auto_20170707_2000.py │ └── 0001_initial.py ├── urls.py ├── fixtures │ ├── user.json │ ├── grouped_select.json │ ├── chained_m2m_select.json │ └── chained_select.json ├── settings.py ├── admin.py ├── models.py └── tests.py ├── .pre-commit-config.yaml ├── .flake8 ├── AUTHORS ├── requirements.txt ├── setup.cfg ├── .coveragerc ├── .gitignore ├── manage.py ├── pytest.ini ├── CONTRIBUTING.md ├── MANIFEST.in ├── docs ├── index.rst ├── settings.md ├── installation.md ├── conf.py ├── usage.md └── Makefile ├── .travis.yml ├── tox.ini ├── setup.py ├── .github ├── workflows │ ├── release.yml │ └── test.yml └── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── CHANGES.rst └── CODE_OF_CONDUCT.md /smart_selects/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: [] -------------------------------------------------------------------------------- /test_app/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E501, W503 3 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | * Patrick Lauber 5 | -------------------------------------------------------------------------------- /smart_selects/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import version 2 | 3 | __version__ = version("django-smart-selects") 4 | 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | pytest==7.4.4 3 | pytest-cov==2.10.0 4 | pytest-django==4.6.0 5 | pytest-sugar==0.9.2 6 | recommonmark==0.6.0 7 | sphinx-rtd-theme==2.0.0 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [flake8] 5 | max-line-length = 180 6 | exclude = env/*,*/migrations/*,*/static/* 7 | max-complexity = 10 8 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | precision=2 3 | 4 | [run] 5 | include = smart_selects* 6 | omit = 7 | manage.py 8 | setup.py 9 | **/migrations/** 10 | test_app/* 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .tmp* 2 | .coverage 3 | .env 4 | *.pyc 5 | *.DS_Store 6 | .pydevproject 7 | .project 8 | build/ 9 | dist/ 10 | htmlcov/ 11 | *.egg* 12 | *.swp 13 | *~ 14 | *.orig 15 | docs/_build 16 | .tox/ 17 | coverage.xml 18 | -------------------------------------------------------------------------------- /test_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, re_path 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | re_path(r"^admin/", admin.site.urls), 6 | re_path(r"^chaining/", include("smart_selects.urls")), 7 | ] 8 | -------------------------------------------------------------------------------- /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", "test_app.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = test_app.settings 3 | django_find_project = false 4 | python_paths = ./ 5 | python_files = test*.py 6 | 7 | addopts = --showlocals --cov=. --no-cov-on-fail --cov-report term-missing --cov-report term:skip-covered --cov-report html 8 | 9 | norecursedirs = src 10 | -------------------------------------------------------------------------------- /test_app/migrations/0005_merge.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ("test_app", "0004_client_domain_website"), 8 | ("test_app", "0004_auto_20170309_0016"), 9 | ] 10 | 11 | operations = [] 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | [![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) 2 | 3 | This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft * 2 | prune .hg 3 | prune .hgignore 4 | prune .ropeproject 5 | prune .git 6 | prune .gitignore 7 | prune .github 8 | prune dist 9 | prune */__pycache__ 10 | prune */*/__pycache__ 11 | prune .coverage 12 | prune htmlcov 13 | include README.md 14 | include CHANGES.rst 15 | include smart_selects/static/smart-selects/admin/js/* 16 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. _index: 2 | 3 | django-smart-selects documentation 4 | ================================== 5 | 6 | Contents 7 | -------- 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :numbered: 1 12 | 13 | installation 14 | settings 15 | usage 16 | 17 | 18 | Indices and tables 19 | ------------------ 20 | 21 | * :ref:`search` 22 | -------------------------------------------------------------------------------- /docs/settings.md: -------------------------------------------------------------------------------- 1 | ## Settings 2 | 3 | `JQUERY_URL` 4 | : jQuery 2.2.0 is loaded from Google's CDN if this is set to `True`. If you would prefer to 5 | use a different version put the full URL here. Set `JQUERY_URL = False` 6 | to disable loading jQuery altogether. 7 | 8 | `USE_DJANGO_JQUERY` 9 | : By default, `smart_selects` loads jQuery from Google's CDN. However, it can use jQuery from Django's 10 | admin area. Set `USE_DJANGO_JQUERY = True` to enable this behaviour. 11 | -------------------------------------------------------------------------------- /test_app/fixtures/user.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "fields": { 4 | "date_joined": "2015-11-12T18:16:03.886Z", 5 | "email": "admin@admin.com", 6 | "first_name": "", 7 | "groups": [], 8 | "is_active": true, 9 | "is_staff": true, 10 | "is_superuser": true, 11 | "last_login": "2016-01-16T17:16:11.609Z", 12 | "last_name": "", 13 | "password": "pbkdf2_sha256$24000$Wx6h5GFwF85f$ghtDkGbfcm6jirxju4ewNgr0p8oLEO11yxAlmvCQMgk=", 14 | "user_permissions": [], 15 | "username": "admin" 16 | }, 17 | "model": "auth.user", 18 | "pk": 1 19 | } 20 | ] -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | 1. Install `django-smart-selects` using a tool like `pip`: 4 | 5 | ```console 6 | $ pip install django-smart-selects 7 | ``` 8 | 9 | 2. Add `smart_selects` to your `INSTALLED_APPS` 10 | 3. Add the `smart_selects` urls into your project's `urls.py`. This is needed for the `Chained Selects` and `Chained ManyToMany Selects`. For example: 11 | 12 | ```python 13 | urlpatterns = patterns('', 14 | url(r'^admin/', include(admin.site.urls)), 15 | url(r'^chaining/', include('smart_selects.urls')), 16 | ) 17 | ``` 18 | 19 | 4. You will also need to include jQuery in every page that includes a field from `smart_selects`, or set `JQUERY_URL = True` in your project's `settings.py`. 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | install: travis_retry pip install coveralls tox tox-travis 4 | script: tox -v 5 | python: 6 | - 3.8 7 | - 3.9 8 | - 3.10 9 | - 3.11 10 | - 3.12 11 | after_script: 12 | - coveralls 13 | jobs: 14 | fast_finish: true 15 | include: 16 | - stage: deploy 17 | env: 18 | python: 3.8 19 | script: skip 20 | deploy: 21 | provider: pypi 22 | user: jazzband 23 | server: https://jazzband.co/projects/django-smart-selects/upload 24 | distributions: sdist bdist_wheel 25 | password: 26 | secure: WRKX0JQavruAlXjKAMiv4pNYLNLs9YMiAts7lyp2BXWTPiVsTSN9TfkWsiFSKJ5u+aLCyYZDx7FFxO9sFrlRjSmvgoxP7EBA0vxkKRQHABo/aQjM0HdnkIOw6P0fXoxqHBAizytLBKyTQH1Ls876oPcvHt5Ehoxchdt8YEckxOw= 27 | skip_existing: true 28 | on: 29 | tags: true 30 | repo: jazzband/django-smart-selects 31 | -------------------------------------------------------------------------------- /test_app/fixtures/grouped_select.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "fields": { 4 | "name": "Grade 1" 5 | }, 6 | "model": "test_app.grade", 7 | "pk": 1 8 | }, 9 | { 10 | "fields": { 11 | "name": "Grade 2" 12 | }, 13 | "model": "test_app.grade", 14 | "pk": 2 15 | }, 16 | { 17 | "fields": { 18 | "name": "Team 1", 19 | "grade": 1 20 | }, 21 | "model": "test_app.team", 22 | "pk": 1 23 | }, 24 | { 25 | "fields": { 26 | "name": "Team 2", 27 | "grade": 2 28 | }, 29 | "model": "test_app.team", 30 | "pk": 2 31 | }, 32 | { 33 | "fields": { 34 | "name": "Student 1", 35 | "grade": 1, 36 | "team": 1 37 | }, 38 | "model": "test_app.student", 39 | "pk": 1 40 | } 41 | ] -------------------------------------------------------------------------------- /smart_selects/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | from smart_selects import views 3 | 4 | urlpatterns = [ 5 | re_path( 6 | r"^all/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-,]+)/$", # noqa: E501 7 | views.filterchain_all, 8 | name="chained_filter_all", 9 | ), 10 | re_path( 11 | r"^filter/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-,]+)/$", # noqa: E501 12 | views.filterchain, 13 | name="chained_filter", 14 | ), 15 | re_path( 16 | r"^filter/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-]+)/(?P[\w\-,]+)/$", # noqa: E501 17 | views.filterchain, 18 | name="chained_filter", 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310}-dj{32,40} 4 | py{38,39,310,311}-dj{41} 5 | py{38,39,310,311,312}-dj{42} 6 | py{310,311,312}-dj{50,main} 7 | flake8 8 | 9 | [testenv] 10 | usedevelop = True 11 | ignore_outcome = 12 | djmain: True 13 | deps = 14 | dj32: Django>=3.2,<4.0 15 | dj40: Django>=4.0,<4.1 16 | dj41: Django>=4.1,<4.2 17 | dj42: Django>=4.2,<5.0 18 | dj50: Django>=5.0,<5.1 19 | djmain: https://github.com/django/django/archive/main.tar.gz 20 | coverage 21 | commands = 22 | coverage run --branch --source=smart_selects manage.py test 23 | coverage report -m 24 | coverage xml 25 | pip freeze 26 | 27 | [testenv:flake8] 28 | deps = 29 | flake8 30 | commands = 31 | flake8 manage.py setup.py smart_selects test_app 32 | 33 | [gh-actions] 34 | python = 35 | 3.8: py38, flake8 36 | 3.9: py39 37 | 3.10: py310 38 | 3.11: py311 39 | 3.12: py312 40 | 41 | [gh-actions:env] 42 | DJANGO = 43 | 3.2: dj32 44 | 4.0: dj40 45 | 4.1: dj41 46 | 4.2: dj42 47 | 5.0: dj50 48 | main: djmain 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | with open("README.md", "r") as f: 5 | long_desc = f.read() 6 | 7 | setup( 8 | name="django-smart-selects", 9 | use_scm_version={"version_scheme": "post-release"}, 10 | setup_requires=["setuptools_scm"], 11 | description="Django application to handle chained model fields.", 12 | long_description=long_desc, 13 | long_description_content_type="text/markdown", 14 | author="Patrick Lauber", 15 | author_email="digi@treepy.com", 16 | url="https://github.com/jazzband/django-smart-selects", 17 | packages=find_packages(), 18 | include_package_data=True, 19 | python_requires=">=3.8", 20 | install_requires=["django>=3.2"], 21 | classifiers=[ 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.8", 24 | "Programming Language :: Python :: 3.9", 25 | "Programming Language :: Python :: 3.10", 26 | "Programming Language :: Python :: 3.11", 27 | "Programming Language :: Python :: 3.12", 28 | "Framework :: Django", 29 | "Framework :: Django :: 3.2", 30 | "Framework :: Django :: 4.0", 31 | "Framework :: Django :: 4.1", 32 | "Framework :: Django :: 4.2", 33 | "Framework :: Django :: 5.0", 34 | ], 35 | ) 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-smart-selects' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.11 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip 26 | python -m pip install -U setuptools twine wheel 27 | 28 | - name: Build package 29 | run: | 30 | python setup.py --version 31 | python setup.py sdist --format=gztar bdist_wheel 32 | twine check dist/* 33 | 34 | # - name: Upload packages to GitHub release 35 | # uses: svenstaro/upload-release-action@483c1e56f95e88835747b1c7c60581215016cbf2 36 | # with: 37 | # repo_token: ${{ secrets.GITHUB_TOKEN }} 38 | # file: dist/* 39 | # file_glob: true 40 | # tag: ${{ github.ref }} 41 | # overwrite: true 42 | 43 | - name: Upload packages to Jazzband 44 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 45 | uses: pypa/gh-action-pypi-publish@master 46 | with: 47 | user: jazzband 48 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 49 | repository_url: https://jazzband.co/projects/django-smart-selects/upload 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | You **MUST** use this template when reporting issues. Please make sure you follow the checklist and fill in all of the information sections below. 2 | 3 | ---------------- 4 | 5 | All versions of django-smart-selects prior to version 1.2.8 are vulnerable to an XSS attack as detailed in [issue 171](https://github.com/jazzband/django-smart-selects/issues/171#issuecomment-276774103). As a result, all previous versions have been removed from PyPI to prevent users from installing insecure versions. All users are urged to upgrade as soon as possible. 6 | 7 | ## Checklist 8 | 9 | Put an `x` in the bracket when you have completed each task, like this: `[x]` 10 | 11 | - [ ] This issue is not about installing previous versions of django-smart-selects older than 1.2.8. I understand that previous versions are insecure and will not receive any support whatsoever. 12 | - [ ] I have verified that that issue exists against the `master` branch of django-smart-selects. 13 | - [ ] I have searched for similar issues in both open and closed tickets and cannot find a duplicate. 14 | - [ ] I have debugged the issue to the `smart_selects` app. 15 | - [ ] I have reduced the issue to the simplest possible case. 16 | - [ ] I have included all relevant sections of `models.py`, `forms.py`, and `views.py` with problems. 17 | - [ ] I have used [GitHub Flavored Markdown](https://help.github.com/articles/creating-and-highlighting-code-blocks/) to style all of my posted code. 18 | 19 | ## Steps to reproduce 20 | 21 | 1. 22 | 2. 23 | 3. 24 | 25 | ## Actual behavior 26 | 27 | ## Expected behavior 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009, Patrick Lauber 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | * Neither the name of the author nor the names of other 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] 13 | django-version: ['3.2', '4.0', '4.1', '4.2', '5.0', 'main'] 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | 23 | - name: Get pip cache dir 24 | id: pip-cache 25 | run: | 26 | echo "::set-output name=dir::$(pip cache dir)" 27 | 28 | - name: Cache 29 | uses: actions/cache@v3 30 | with: 31 | path: ${{ steps.pip-cache.outputs.dir }} 32 | key: 33 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/requirements.txt') }}-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} 34 | restore-keys: | 35 | ${{ matrix.python-version }}-v1- 36 | 37 | - name: Install dependencies 38 | run: | 39 | python -m pip install --upgrade pip 40 | python -m pip install --upgrade setuptools wheel 41 | python -m pip install --upgrade tox tox-gh-actions 42 | 43 | - name: Tox tests 44 | run: | 45 | tox -v 46 | env: 47 | DJANGO: ${{ matrix.django-version }} 48 | 49 | - name: Upload coverage 50 | uses: codecov/codecov-action@v3 51 | with: 52 | name: Python ${{ matrix.python-version }} 53 | -------------------------------------------------------------------------------- /test_app/migrations/0007_auto_20200207_1149.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.15 on 2020-02-07 11:49 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import smart_selects.db_fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("test_app", "0006_auto_20170707_2000"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Area", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("name", models.CharField(max_length=255)), 28 | ( 29 | "country", 30 | models.ForeignKey( 31 | on_delete=django.db.models.deletion.CASCADE, 32 | to="test_app.Country", 33 | ), 34 | ), 35 | ], 36 | ), 37 | migrations.AddField( 38 | model_name="location", 39 | name="area", 40 | field=smart_selects.db_fields.ChainedForeignKey( 41 | auto_choose=True, 42 | chained_field="country", 43 | chained_model_field="country", 44 | default=1, 45 | on_delete=django.db.models.deletion.CASCADE, 46 | to="test_app.Area", 47 | ), 48 | preserve_default=False, 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /test_app/fixtures/chained_m2m_select.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "fields": { 4 | "name": "Publication 1" 5 | }, 6 | "model": "test_app.publication", 7 | "pk": 1 8 | }, 9 | { 10 | "fields": { 11 | "name": "Author 1", 12 | "publications": [] 13 | }, 14 | "model": "test_app.writer", 15 | "pk": 1 16 | }, 17 | { 18 | "fields": { 19 | "name": "Author 2", 20 | "publications": [] 21 | }, 22 | "model": "test_app.writer", 23 | "pk": 2 24 | }, 25 | { 26 | "fields": { 27 | "name": "Author 3", 28 | "publications": [ 29 | 1 30 | ] 31 | }, 32 | "model": "test_app.writer", 33 | "pk": 3 34 | }, 35 | { 36 | "fields": { 37 | "name": "Book 1", 38 | "publication": 1, 39 | "writer": [ 40 | 3 41 | ] 42 | }, 43 | "model": "test_app.book", 44 | "pk": 1 45 | }, 46 | { 47 | "fields": { 48 | "date_joined": "2015-11-12T18:16:03.886Z", 49 | "email": "admin@admin.com", 50 | "first_name": "", 51 | "groups": [], 52 | "is_active": true, 53 | "is_staff": true, 54 | "is_superuser": true, 55 | "last_login": "2016-01-16T17:16:11.609Z", 56 | "last_name": "", 57 | "password": "pbkdf2_sha256$24000$Wx6h5GFwF85f$ghtDkGbfcm6jirxju4ewNgr0p8oLEO11yxAlmvCQMgk=", 58 | "user_permissions": [], 59 | "username": "admin" 60 | }, 61 | "model": "auth.user", 62 | "pk": 1 63 | } 64 | ] 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Smart Selects 2 | 3 | [![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/) 4 | [![Build Status](https://github.com/jazzband/django-smart-selects/workflows/Test/badge.svg)](https://github.com/jazzband/django-smart-selects/actions) 5 | [![Coverage Status](https://codecov.io/gh/jazzband/django-smart-selects/branch/master/graph/badge.svg)](https://codecov.io/gh/jazzband/django-smart-selects) 6 | [![PyPI](https://img.shields.io/pypi/v/django-smart-selects.svg)](https://pypi.org/project/django-smart-selects/) 7 | 8 | This package allows you to quickly filter or group "chained" models by adding a custom foreign key or many to many field to your models. This will use an AJAX query to load only the applicable chained objects. 9 | 10 | Works with Django version 3.2 to 5.0. 11 | 12 | **Warning**: The AJAX endpoint enforces no permissions by default. This means that **any model with a chained field will be world readable**. If you would like more control over this permission, the [`django-autocomplete-light`](https://github.com/yourlabs/django-autocomplete-light) package is a great, high-quality package that enables the same functionality with permission checks. 13 | 14 | ## Documentation 15 | 16 | For more information on installation and configuration see the documentation at: 17 | 18 | https://django-smart-selects.readthedocs.io/ 19 | 20 | ## Reporting issues / sending PRs 21 | 22 | You can try the test_app example using: 23 | 24 | ```shell 25 | python manage.py migrate 26 | python manage.py loaddata test_app/fixtures/* 27 | python manage.py runserver 28 | ``` 29 | 30 | Then login with admin/admin at http://127.0.0.1:8000/admin/ 31 | 32 | 33 | ## TODO 34 | 35 | * Add permission checks to enable users to restrict who can use the chained fields. 36 | * Add a `ChainedCheckboxSelectMultiple` widget and adjust `chainedm2m.js` and `chainedfk.js` to build checkboxes in that case 37 | -------------------------------------------------------------------------------- /smart_selects/tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .db_fields import ChainedForeignKey, GroupedForeignKey 4 | 5 | 6 | class AssertReconstructibleMixin: 7 | def assert_reconstructible(self, *field_args, **field_kwargs): 8 | field_instance = self.field_class(*field_args, **field_kwargs) 9 | name, path, args, kwargs = field_instance.deconstruct() 10 | new_instance = self.field_class(*args, **kwargs) 11 | 12 | for attr_name in self.deconstruct_attrs: 13 | self.assertEqual( 14 | getattr(field_instance, attr_name), getattr(new_instance, attr_name) 15 | ) 16 | 17 | 18 | class ChainedForeignKeyTests(AssertReconstructibleMixin, unittest.TestCase): 19 | def setUp(self): 20 | self.field_class = ChainedForeignKey 21 | self.deconstruct_attrs = [ 22 | "chained_field", 23 | "chained_model_field", 24 | "show_all", 25 | "auto_choose", 26 | "view_name", 27 | ] 28 | 29 | def test_deconstruct_basic(self): 30 | self.assert_reconstructible( 31 | "myapp.MyModel", 32 | chained_field="a_chained_field", 33 | chained_model_field="the_chained_model_field", 34 | show_all=False, 35 | auto_choose=True, 36 | ) 37 | 38 | def test_deconstruct_mostly_default(self): 39 | self.assert_reconstructible("myapp.MyModel") 40 | 41 | def test_deconstruct_non_default(self): 42 | self.assert_reconstructible( 43 | "myapp.MyModel", 44 | chained_field="a_chained_field", 45 | chained_model_field="the_chained_model_field", 46 | show_all=True, 47 | auto_choose=True, 48 | ) 49 | 50 | 51 | class GroupedForeignKeyTests(AssertReconstructibleMixin, unittest.TestCase): 52 | def setUp(self): 53 | self.field_class = GroupedForeignKey 54 | self.deconstruct_attrs = ["group_field"] 55 | 56 | def test_deconstruct_basic(self): 57 | self.assert_reconstructible("myapp.MyModel", "the_group_field") 58 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | (unreleased) 5 | ------------------ 6 | 7 | 8 | 1.7.0 (2024-03-13) 9 | ------------------ 10 | 11 | - Dropped support for Python 3.6 and 3.7! [medbenmakhlouf] 12 | - Dropped support for Django 2.2, 3.0 and 3.1! [medbenmakhlouf] 13 | - Add support for Python 3.10, 3.11 and 3.12. [medbenmakhlouf] 14 | - Add Support for Django 4.1, 4.2 and 5.0! [medbenmakhlouf] 15 | 16 | 17 | 1.6.0 (2022-07-13) 18 | ------------------ 19 | 20 | - Dropped support for Python 2.7 and 3.5! [jezdez] 21 | - Dropped support for Django 1.11, 2.0 and 2.1! [jezdez] 22 | - Add support for Python 3.9. [jezdez] 23 | - Move CI to GitHub Actions: https://github.com/jazzband/django-smart-selects/actions [jezdez] 24 | - Docs: elaborate usage within templates [amiroff] 25 | - Ensure at least one option in the 192 | 193 | ``` 194 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoSmartSelects.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoSmartSelects.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoSmartSelects" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoSmartSelects" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /smart_selects/static/smart-selects/admin/js/chainedm2m.js: -------------------------------------------------------------------------------- 1 | (function ($) { 2 | "use strict"; 3 | 4 | window.chainedm2m = function () { 5 | return { 6 | fireEvent: function (element, event) { 7 | var evt, rtn; 8 | if (document.createEventObject) { 9 | // dispatch for IE 10 | evt = document.createEventObject(); 11 | rtn = element.fireEvent('on' + event, evt); 12 | } else { 13 | // dispatch for firefox + others 14 | evt = document.createEvent("HTMLEvents"); 15 | evt.initEvent(event, true, true); // event type,bubbling,cancelable 16 | rtn = !element.dispatchEvent(evt); 17 | } 18 | 19 | return rtn; 20 | }, 21 | 22 | dismissRelatedLookupPopup: function(win, chosenId) { 23 | var name = windowname_to_id(win.name), 24 | elem = document.getElementById(name); 25 | if (elem.className.indexOf('vManyToManyRawIdAdminField') !== -1 && elem.value) { 26 | elem.value += ',' + chosenId; 27 | } else { 28 | elem.value = chosenId; 29 | } 30 | fireEvent(elem, 'change'); 31 | win.close(); 32 | }, 33 | 34 | fill_field: function (val, initial_value, elem_id, url, initial_parent, auto_choose) { 35 | var $selectField = $(elem_id), 36 | $selectedto = $(elem_id + '_to'), 37 | cache_to = elem_id.replace('#', '') + '_to', 38 | cache_from = elem_id.replace('#', '') + '_from'; 39 | if (!$selectField.length) { 40 | $selectField = $(elem_id + '_from'); 41 | } 42 | function trigger_chosen_updated() { 43 | if ($.fn.chosen !== undefined) { 44 | $selectField.trigger('chosen:updated'); 45 | } 46 | } 47 | 48 | // SelectBox is a global var from djangojs "admin/js/SelectBox.js" 49 | // Clear cache to avoid the elements duplication 50 | if (typeof SelectBox !== 'undefined') { 51 | if (typeof SelectBox.cache[cache_to] !== 'undefined') { 52 | SelectBox.cache[cache_to].splice(0); 53 | } 54 | if (typeof SelectBox.cache[cache_from] !== 'undefined') { 55 | SelectBox.cache[cache_from].splice(0); 56 | } 57 | } 58 | 59 | if (!val || val === '') { 60 | $selectField.html(''); 61 | $selectedto.html(''); 62 | trigger_chosen_updated(); 63 | return; 64 | } 65 | 66 | // Make sure that these are always an arrays 67 | val = [].concat(val); 68 | initial_parent = [].concat(initial_parent); 69 | 70 | var target_url = url + "/" + val + "/", 71 | options = [], 72 | selectedoptions = []; 73 | 74 | $.getJSON(target_url, function (j) { 75 | var i, width; 76 | auto_choose = j.length === 1 && auto_choose; 77 | 78 | var selected_values = {}; 79 | // if val and initial_parent have any common values, we need to set selected options. 80 | if ($(val).filter(initial_parent).length >= 0 && initial_value) { 81 | for (i = 0; i < initial_value.length; i = i + 1) { 82 | selected_values[initial_value[i]] = true; 83 | } 84 | } 85 | 86 | // select values which were previously selected (for many2many - many2many chain) 87 | $(elem_id + ' option:selected').each(function () { 88 | selected_values[$(this).val()] = true; 89 | }); 90 | 91 | $.each(j, function (index, optionData) { 92 | var option = $('') 93 | .attr('value', optionData.value) 94 | .text(optionData.display) 95 | .attr('title', optionData.display); 96 | if (auto_choose === "true" || auto_choose === "True") { 97 | auto_choose = true; 98 | } else if (auto_choose === "false" || auto_choose === "False") { 99 | auto_choose = false; 100 | } 101 | if (auto_choose || selected_values[optionData.value] === true) { 102 | if ($selectedto.length) { 103 | selectedoptions.push(option); 104 | } else { 105 | option.prop('selected', true); 106 | options.push(option); 107 | } 108 | } else { 109 | options.push(option); 110 | } 111 | }); 112 | 113 | $selectField.html(options); 114 | if ($selectedto.length) { 115 | $selectedto.html(selectedoptions); 116 | var node; 117 | // SelectBox is a global var from djangojs "admin/js/SelectBox.js" 118 | for (i = 0, j = selectedoptions.length; i < j; i = i + 1) { 119 | node = selectedoptions[i]; 120 | SelectBox.cache[cache_to].push({value: node.prop("value"), text: node.prop("text"), displayed: 1}); 121 | } 122 | for (i = 0, j = options.length; i < j; i = i + 1) { 123 | node = options[i]; 124 | SelectBox.cache[cache_from].push({value: node.prop("value"), text: node.prop("text"), displayed: 1}); 125 | } 126 | } 127 | width = $selectField.outerWidth(); 128 | if (navigator.appVersion.indexOf("MSIE") !== -1) { 129 | $selectField.width(width + 'px'); 130 | } 131 | 132 | $selectField.trigger('change'); 133 | 134 | trigger_chosen_updated(); 135 | }); 136 | }, 137 | 138 | init: function (chainfield, url, id, value, auto_choose) { 139 | var fill_field, val, initial_parent = $(chainfield).val(), 140 | initial_value = value; 141 | 142 | if (!$(chainfield).hasClass("chained")) { 143 | val = $(chainfield).val(); 144 | this.fill_field(val, initial_value, id, url, initial_parent, auto_choose); 145 | } 146 | fill_field = this.fill_field; 147 | $(chainfield).change(function () { 148 | var prefix, start_value, this_val, localID = id; 149 | if (localID.indexOf("__prefix__") > -1) { 150 | prefix = $(this).attr("id").match(/\d+/)[0]; 151 | localID = localID.replace("__prefix__", prefix); 152 | } 153 | 154 | start_value = $(localID).val(); 155 | this_val = $(this).val(); 156 | fill_field(this_val, initial_value, localID, url, initial_parent, auto_choose); 157 | }); 158 | 159 | // allait en bas, hors du documentready 160 | if (typeof(dismissAddAnotherPopup) !== 'undefined') { 161 | var oldDismissAddAnotherPopup = dismissAddAnotherPopup; 162 | dismissAddAnotherPopup = function (win, newId, newRepr) { 163 | oldDismissAddAnotherPopup(win, newId, newRepr); 164 | if ("#" + windowname_to_id(win.name) === chainfield) { 165 | $(chainfield).change(); 166 | } 167 | }; 168 | } 169 | if (typeof(dismissRelatedLookupPopup) !== 'undefined') { 170 | var oldDismissRelatedLookupPopup = dismissRelatedLookupPopup; 171 | dismissRelatedLookupPopup = function (win, chosenId) { 172 | oldDismissRelatedLookupPopup(win, chosenId); 173 | if ("#" + windowname_to_id(win.name) === chainfield) { 174 | $(chainfield).change(); 175 | } 176 | }; 177 | } 178 | } 179 | }; 180 | }(); 181 | }(jQuery || django.jQuery)); 182 | -------------------------------------------------------------------------------- /test_app/tests.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.test import TestCase, RequestFactory 3 | from django.urls import reverse 4 | from .models import Book, Country, Location, Student 5 | from smart_selects.views import filterchain, filterchain_all, is_m2m 6 | 7 | 8 | class ModelTests(TestCase): 9 | fixtures = ["chained_select", "chained_m2m_select", "grouped_select", "user"] 10 | 11 | def test_reverse_relationship_manager(self): 12 | cr = Country.objects.get(name="Czech republic") 13 | self.assertEqual( 14 | list(cr.location_set.all().values_list("city", flat=True)), ["Praha"] 15 | ) 16 | 17 | 18 | class SecurityTests(TestCase): 19 | fixtures = ["user"] 20 | 21 | def test_models_arent_exposed_with_filter(self): 22 | # Make sure only models with ChainedManyToMany or ChainedForeignKey 23 | # fields are globally searchable 24 | response = self.client.get( 25 | "/chaining/filter/auth/User/is_superuser/auth/User/password/1/" 26 | ) 27 | self.assertEqual(response.status_code, 403) 28 | 29 | def test_models_arent_exposed_with_all(self): 30 | # Make sure only models with ChainedManyToMany or ChainedForeignKey 31 | # fields are globally searchable 32 | response = self.client.get( 33 | "/chaining/all/auth/User/is_superuser/auth/User/password/1/" 34 | ) 35 | self.assertEqual(response.status_code, 403) 36 | 37 | 38 | class ViewTests(TestCase): 39 | fixtures = ["chained_select", "chained_m2m_select", "grouped_select", "user"] 40 | 41 | def setUp(self): 42 | self.factory = RequestFactory() 43 | self.assertTrue(self.client.login(username="admin", password="admin")) 44 | 45 | def test_model_manager(self): 46 | # Make sure only models with ChainedManyToMany or ChainedForeignKey 47 | # fields are globally searchable 48 | expected_data = [ 49 | { 50 | "value": 1, 51 | "display": "Czech republic", 52 | }, 53 | { 54 | "value": 3, 55 | "display": "Germany", 56 | }, 57 | { 58 | "value": 4, 59 | "display": "Great Britain", 60 | }, 61 | ] 62 | 63 | response = self.client.get( 64 | "/chaining/filter/test_app/Country/objects/continent/test_app/Location/country/1/" 65 | ) 66 | if hasattr(response, "json"): 67 | self.assertEqual(response.json(), expected_data) 68 | else: 69 | import json 70 | 71 | json_data = json.loads(response.content.decode(response.charset)) 72 | self.assertEqual(json_data, expected_data) 73 | 74 | def test_null_value(self): 75 | # Make sure only models with ChainedManyToMany or ChainedForeignKey 76 | # fields are globally searchable 77 | response = self.client.get( 78 | "/chaining/filter/test_app/Country/objects/continent/test_app/Location/country/0/" 79 | ) 80 | if hasattr(response, "json"): 81 | self.assertEqual(response.json(), []) 82 | else: 83 | self.assertEqual(response.content.decode(response.charset), "[]") 84 | 85 | # chained foreignkey 86 | def test_location_add_get(self): 87 | response = self.client.get(reverse("admin:test_app_location_add"), follow=True) 88 | self.assertContains(response, "Europe") 89 | self.assertContains(response, "America") 90 | self.assertContains(response, 'data-value="null"') 91 | 92 | def test_location_add_post(self): 93 | post_data = { 94 | "continent": "1", 95 | "country": "2", 96 | "area": "2", 97 | "city": "New York", 98 | "street": "Wallstreet", 99 | } 100 | self.client.post(reverse("admin:test_app_location_add"), post_data, follow=True) 101 | location = Location.objects.get(country__pk=2, continent__pk=1) 102 | self.assertEqual(location.city, "New York") 103 | self.assertEqual(location.street, "Wallstreet") 104 | 105 | def test_location_add_post_no_data(self): 106 | post_data = { 107 | "continent": "1", 108 | "country": "", 109 | "city": "New York", 110 | "street": "Wallstreet", 111 | } 112 | response = self.client.post(reverse("admin:test_app_location_add"), post_data) 113 | self.assertContains(response, "This field is required.") 114 | self.assertContains(response, 'data-value="null"') 115 | 116 | def test_location_change_get(self): 117 | response = self.client.get(reverse("admin:test_app_location_change", args=(1,))) 118 | self.assertContains(response, "Europe") 119 | self.assertContains(response, 'data-value="1"') 120 | 121 | def test_filterchain_view_for_chained_foreignkey(self): 122 | request = self.factory.get("") 123 | response = filterchain( 124 | request, 125 | "test_app", 126 | "Country", 127 | "continent", 128 | "test_app", 129 | "Location", 130 | "country", 131 | 1, 132 | ) 133 | expected_value = '[{"value": 1, "display": "Czech republic"}, {"value": 3, "display": "Germany"}, {"value": 4, "display": "Great Britain"}]' 134 | self.assertEqual(response.status_code, 200) 135 | self.assertJSONEqual(response.content.decode(), expected_value) 136 | 137 | def test_filterchain_all_view_for_chained_foreignkey(self): 138 | request = self.factory.get("") 139 | response = filterchain_all( 140 | request, 141 | "test_app", 142 | "Country", 143 | "continent", 144 | "test_app", 145 | "Location", 146 | "country", 147 | 1, 148 | ) 149 | expected_value = ( 150 | '[{"display": "Czech republic", "value": 1}, {"display": "Germany", "value": 3},' 151 | ' {"display": "Great Britain", "value": 4}, {"display": "---------", "value": ""}, {"display": "New York", "value": 2}]' 152 | ) 153 | self.assertEqual(response.status_code, 200) 154 | self.assertJSONEqual(response.content.decode(), expected_value) 155 | 156 | def test_limit_to_choice_for_chained_foreignkey(self): 157 | request = self.factory.get("") 158 | # filterchain 159 | response = filterchain( 160 | request, 161 | "test_app", 162 | "Country", 163 | "continent", 164 | "test_app", 165 | "Location1", 166 | "country", 167 | 1, 168 | ) 169 | expected_value = '[{"value": 3, "display": "Germany"}, {"value": 4, "display": "Great Britain"}]' 170 | self.assertEqual(response.status_code, 200) 171 | self.assertJSONEqual(response.content.decode(), expected_value) 172 | # filterchain_all 173 | response = filterchain_all( 174 | request, 175 | "test_app", 176 | "Country", 177 | "continent", 178 | "test_app", 179 | "Location1", 180 | "country", 181 | 1, 182 | ) 183 | expected_value = '[{"value": 3, "display": "Germany"}, {"value": 4, "display": "Great Britain"}, {"display": "---------", "value": ""}]' 184 | self.assertEqual(response.status_code, 200) 185 | self.assertJSONEqual(response.content.decode(), expected_value) 186 | 187 | # chained manytomany 188 | def test_book_add_get(self): 189 | response = self.client.get(reverse("admin:test_app_book_add")) 190 | self.assertContains(response, "Publication 1") 191 | self.assertContains(response, 'data-value="null"') 192 | 193 | def test_book_add_post(self): 194 | post_data = { 195 | "publication": "1", 196 | "writer": "2", 197 | "name": "Book 2", 198 | } 199 | self.client.post(reverse("admin:test_app_book_add"), post_data, follow=True) 200 | book = Book.objects.get(writer__pk=2, publication__pk=1) 201 | self.assertEqual(book.name, "Book 2") 202 | 203 | def test_book_add_post_no_data(self): 204 | post_data = {"publication": "1", "name": "Book 2"} 205 | response = self.client.post(reverse("admin:test_app_book_add"), post_data) 206 | self.assertContains(response, "This field is required.") 207 | self.assertContains(response, 'data-value="[]"') 208 | 209 | def test_book_change_get(self): 210 | response = self.client.get( 211 | reverse("admin:test_app_book_change", args=(1,)), follow=True 212 | ) 213 | self.assertContains(response, 'data-value="[3]"') 214 | 215 | def test_filterchain_view_for_chained_manytomany(self): 216 | request = self.factory.get("") 217 | response = filterchain( 218 | request, 219 | "test_app", 220 | "Writer", 221 | "publications", 222 | "test_app", 223 | "Book", 224 | "writer", 225 | 1, 226 | ) 227 | expected_value = '[{"display": "Author 3", "value": 3}]' 228 | self.assertEqual(response.status_code, 200) 229 | self.assertJSONEqual(response.content.decode(), expected_value) 230 | 231 | def test_limit_to_choice_for_chained_manytomany(self): 232 | request = self.factory.get("") 233 | # filterchain 234 | response = filterchain( 235 | request, 236 | "test_app", 237 | "Writer", 238 | "publications", 239 | "test_app", 240 | "Book1", 241 | "writer", 242 | 1, 243 | ) 244 | expected_value = "[]" 245 | self.assertEqual(response.status_code, 200) 246 | self.assertJSONEqual(response.content.decode(), expected_value) 247 | 248 | # grouped foreignkey 249 | def test_student_add_get(self): 250 | response = self.client.get(reverse("admin:test_app_student_add")) 251 | self.assertContains( 252 | response, 253 | '\n\n', 254 | html=True, 255 | ) 256 | self.assertContains( 257 | response, 258 | '\n\n', 259 | html=True, 260 | ) 261 | 262 | def test_student_add_post(self): 263 | post_data = {"name": "Student 2", "grade": 2, "team": 2} 264 | self.client.post( 265 | reverse("admin:test_app_student_add"), post_data 266 | ) # noqa: F841 267 | student = Student.objects.get(grade=2, team=2) 268 | self.assertEqual(student.name, "Student 2") 269 | 270 | # chained without foreign key field 271 | def test_view_for_chained_charfield(self): 272 | request = self.factory.get("") 273 | # filterchain 274 | response = filterchain( 275 | request, 276 | "test_app", 277 | "Tag", 278 | "kind", 279 | "test_app", 280 | "TagResource", 281 | "kind", 282 | "music", 283 | ) 284 | expected_value = '[{"display": "reggae", "value": 2}, {"display": "rock-and-roll", "value": 1}]' 285 | self.assertEqual(response.status_code, 200) 286 | self.assertJSONEqual(response.content.decode(), expected_value) 287 | 288 | def test_is_m2m_for_chained_charfield(self): 289 | # should return false 290 | self.assertEqual(is_m2m(apps.get_model("test_app", "TagResource"), "kind"), False) 291 | -------------------------------------------------------------------------------- /smart_selects/widgets.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.apps import apps 4 | from django.conf import settings 5 | 6 | from django.urls import reverse 7 | from django.forms.widgets import Select, SelectMultiple, Media 8 | from django.utils.safestring import mark_safe 9 | from django.utils.encoding import force_str 10 | from django.utils.html import escape 11 | 12 | from smart_selects.utils import unicode_sorter, sort_results 13 | 14 | get_model = apps.get_model 15 | 16 | USE_DJANGO_JQUERY = getattr(settings, "USE_DJANGO_JQUERY", False) 17 | JQUERY_URL = getattr( 18 | settings, 19 | "JQUERY_URL", 20 | "https://ajax.googleapis.com/ajax/libs/jquery/2.2.0/jquery.min.js", 21 | ) 22 | 23 | URL_PREFIX = getattr(settings, "SMART_SELECTS_URL_PREFIX", "") 24 | 25 | 26 | class JqueryMediaMixin: 27 | @property 28 | def media(self): 29 | """Media defined as a dynamic property instead of an inner class.""" 30 | media = super(JqueryMediaMixin, self).media 31 | 32 | js = [] 33 | 34 | if JQUERY_URL: 35 | js.append(JQUERY_URL) 36 | elif JQUERY_URL is not False: 37 | vendor = "vendor/jquery/" 38 | extra = "" if settings.DEBUG else ".min" 39 | 40 | jquery_paths = [ 41 | "{}jquery{}.js".format(vendor, extra), 42 | "jquery.init.js", 43 | ] 44 | 45 | if USE_DJANGO_JQUERY: 46 | jquery_paths = ["admin/js/{}".format(path) for path in jquery_paths] 47 | 48 | js.extend(jquery_paths) 49 | 50 | media += Media(js=js) 51 | return media 52 | 53 | 54 | class ChainedSelect(JqueryMediaMixin, Select): 55 | def __init__( 56 | self, 57 | to_app_name, 58 | to_model_name, 59 | chained_field, 60 | chained_model_field, 61 | foreign_key_app_name, 62 | foreign_key_model_name, 63 | foreign_key_field_name, 64 | show_all, 65 | auto_choose, 66 | sort=True, 67 | manager=None, 68 | view_name=None, 69 | *args, 70 | **kwargs 71 | ): 72 | self.to_app_name = to_app_name 73 | self.to_model_name = to_model_name 74 | self.chained_field = chained_field 75 | self.chained_model_field = chained_model_field 76 | self.show_all = show_all 77 | self.auto_choose = auto_choose 78 | self.sort = sort 79 | self.manager = manager 80 | self.view_name = view_name 81 | self.foreign_key_app_name = foreign_key_app_name 82 | self.foreign_key_model_name = foreign_key_model_name 83 | self.foreign_key_field_name = foreign_key_field_name 84 | super(Select, self).__init__(*args, **kwargs) 85 | 86 | @property 87 | def media(self): 88 | """Media defined as a dynamic property instead of an inner class.""" 89 | media = super(ChainedSelect, self).media 90 | js = [ 91 | "smart-selects/admin/js/chainedfk.js", 92 | "smart-selects/admin/js/bindfields.js", 93 | ] 94 | media += Media(js=js) 95 | return media 96 | 97 | # TODO: Simplify this and remove the noqa tag 98 | def render(self, name, value, attrs=None, choices=(), renderer=None): # noqa: C901 99 | if len(name.split("-")) > 1: # formset 100 | chained_field = "-".join(name.split("-")[:-1] + [self.chained_field]) 101 | else: 102 | chained_field = self.chained_field 103 | 104 | if not self.view_name: 105 | if self.show_all: 106 | view_name = "chained_filter_all" 107 | else: 108 | view_name = "chained_filter" 109 | else: 110 | view_name = self.view_name 111 | kwargs = { 112 | "app": self.to_app_name, 113 | "model": self.to_model_name, 114 | "field": self.chained_model_field, 115 | "foreign_key_app_name": self.foreign_key_app_name, 116 | "foreign_key_model_name": self.foreign_key_model_name, 117 | "foreign_key_field_name": self.foreign_key_field_name, 118 | "value": "1", 119 | } 120 | if self.manager is not None: 121 | kwargs.update({"manager": self.manager}) 122 | url = URL_PREFIX + ("/".join(reverse(view_name, kwargs=kwargs).split("/")[:-2])) 123 | if self.auto_choose: 124 | auto_choose = "true" 125 | else: 126 | auto_choose = "false" 127 | if choices: 128 | iterator = iter(self.choices) 129 | if hasattr(iterator, "__next__"): 130 | empty_label = iterator.__next__()[1] 131 | else: 132 | # Hacky way to getting the correct empty_label from the field instead of a hardcoded '--------' 133 | empty_label = iterator.next()[1] 134 | else: 135 | empty_label = "--------" 136 | 137 | final_choices = [] 138 | 139 | if value: 140 | available_choices = self._get_available_choices(self.queryset, value) 141 | for choice in available_choices: 142 | final_choices.append((choice.pk, force_str(choice))) 143 | if len(final_choices) > 1: 144 | final_choices = [("", (empty_label))] + final_choices 145 | if self.show_all: 146 | final_choices.append(("", (empty_label))) 147 | self.choices = list(self.choices) 148 | if self.sort: 149 | self.choices.sort(key=lambda x: unicode_sorter(x[1])) 150 | for ch in self.choices: 151 | if ch not in final_choices: 152 | final_choices.append(ch) 153 | elif final_choices == []: 154 | final_choices.append(("", (empty_label))) 155 | self.choices = final_choices 156 | 157 | attrs.update(self.attrs) 158 | attrs["data-chainfield"] = chained_field 159 | attrs["data-url"] = url 160 | attrs["data-value"] = "null" if value is None or value == "" else value 161 | attrs["data-auto_choose"] = auto_choose 162 | attrs["data-empty_label"] = escape(empty_label) 163 | final_attrs = self.build_attrs(attrs) 164 | if "class" in final_attrs: 165 | final_attrs["class"] += " chained-fk" 166 | else: 167 | final_attrs["class"] = "chained-fk" 168 | 169 | if renderer: 170 | output = super(ChainedSelect, self).render( 171 | name, value, final_attrs, renderer 172 | ) 173 | else: 174 | output = super(ChainedSelect, self).render(name, value, final_attrs) 175 | 176 | return mark_safe(output) 177 | 178 | def _get_available_choices(self, queryset, value): 179 | """ 180 | get possible choices for selection 181 | """ 182 | item = queryset.filter(pk=value).first() 183 | if item: 184 | try: 185 | pk = getattr(item, self.chained_model_field + "_id") 186 | filter = {self.chained_model_field: pk} 187 | except AttributeError: 188 | try: # maybe m2m? 189 | pks = ( 190 | getattr(item, self.chained_model_field) 191 | .all() 192 | .values_list("pk", flat=True) 193 | ) 194 | filter = {self.chained_model_field + "__in": pks} 195 | except AttributeError: 196 | try: # maybe a set? 197 | pks = ( 198 | getattr(item, self.chained_model_field + "_set") 199 | .all() 200 | .values_list("pk", flat=True) 201 | ) 202 | filter = {self.chained_model_field + "__in": pks} 203 | except AttributeError: # give up 204 | filter = {} 205 | filtered = list( 206 | get_model(self.to_app_name, self.to_model_name) 207 | .objects.filter(**filter) 208 | .distinct() 209 | ) 210 | if self.sort: 211 | sort_results(filtered) 212 | else: 213 | # invalid value for queryset 214 | filtered = [] 215 | 216 | return filtered 217 | 218 | 219 | class ChainedSelectMultiple(JqueryMediaMixin, SelectMultiple): 220 | def __init__( 221 | self, 222 | to_app_name, 223 | to_model_name, 224 | chain_field, 225 | chained_model_field, 226 | foreign_key_app_name, 227 | foreign_key_model_name, 228 | foreign_key_field_name, 229 | auto_choose, 230 | horizontal, 231 | verbose_name="", 232 | manager=None, 233 | *args, 234 | **kwargs 235 | ): 236 | self.to_app_name = to_app_name 237 | self.to_model_name = to_model_name 238 | self.chain_field = chain_field 239 | self.chained_model_field = chained_model_field 240 | self.auto_choose = auto_choose 241 | self.horizontal = horizontal 242 | self.verbose_name = verbose_name 243 | self.manager = manager 244 | self.foreign_key_app_name = foreign_key_app_name 245 | self.foreign_key_model_name = foreign_key_model_name 246 | self.foreign_key_field_name = foreign_key_field_name 247 | super(SelectMultiple, self).__init__(*args, **kwargs) 248 | 249 | @property 250 | def media(self): 251 | """Media defined as a dynamic property instead of an inner class.""" 252 | media = super(ChainedSelectMultiple, self).media 253 | js = [] 254 | if self.horizontal: 255 | # For horizontal mode add django filter horizontal javascript code 256 | js.extend( 257 | [ 258 | "admin/js/core.js", 259 | "admin/js/SelectBox.js", 260 | "admin/js/SelectFilter2.js", 261 | ] 262 | ) 263 | js.extend( 264 | [ 265 | "smart-selects/admin/js/chainedm2m.js", 266 | "smart-selects/admin/js/bindfields.js", 267 | ] 268 | ) 269 | media += Media(js=js) 270 | return media 271 | 272 | def render(self, name, value, attrs=None, choices=(), renderer=None): 273 | if len(name.split("-")) > 1: # formset 274 | chain_field = "-".join(name.split("-")[:-1] + [self.chain_field]) 275 | else: 276 | chain_field = self.chain_field 277 | 278 | view_name = "chained_filter" 279 | 280 | kwargs = { 281 | "app": self.to_app_name, 282 | "model": self.to_model_name, 283 | "field": self.chained_model_field, 284 | "foreign_key_app_name": self.foreign_key_app_name, 285 | "foreign_key_model_name": self.foreign_key_model_name, 286 | "foreign_key_field_name": self.foreign_key_field_name, 287 | "value": "1", 288 | } 289 | if self.manager is not None: 290 | kwargs.update({"manager": self.manager}) 291 | url = URL_PREFIX + ("/".join(reverse(view_name, kwargs=kwargs).split("/")[:-2])) 292 | if self.auto_choose: 293 | auto_choose = "true" 294 | else: 295 | auto_choose = "false" 296 | 297 | # since we cannot deduce the value of the chained_field 298 | # so we just render empty choices here and let the js 299 | # fetch related choices later 300 | final_choices = [] 301 | self.choices = final_choices 302 | 303 | attrs.update(self.attrs) 304 | attrs["data-chainfield"] = chain_field 305 | attrs["data-url"] = url 306 | attrs["data-value"] = "null" if value is None else json.dumps(value) 307 | attrs["data-auto_choose"] = auto_choose 308 | attrs["name"] = name 309 | final_attrs = self.build_attrs(attrs) 310 | if "class" in final_attrs: 311 | final_attrs["class"] += " chained" 312 | else: 313 | final_attrs["class"] = "chained" 314 | if self.horizontal: 315 | # For hozontal mode add django filter horizontal javascript selector class 316 | final_attrs["class"] += " selectfilter" 317 | final_attrs["data-field-name"] = self.verbose_name 318 | if renderer: 319 | output = super(ChainedSelectMultiple, self).render( 320 | name, value, final_attrs, renderer 321 | ) 322 | else: 323 | output = super(ChainedSelectMultiple, self).render(name, value, final_attrs) 324 | 325 | return mark_safe(output) 326 | -------------------------------------------------------------------------------- /smart_selects/db_fields.py: -------------------------------------------------------------------------------- 1 | from django.db.models.fields.related import ( 2 | ForeignKey, 3 | ManyToManyField, 4 | RECURSIVE_RELATIONSHIP_CONSTANT, 5 | ) 6 | from django.db import models 7 | 8 | from smart_selects import form_fields 9 | 10 | 11 | class IntrospectiveFieldMixin: 12 | to_app_name = None 13 | to_model_name = None 14 | 15 | def __init__(self, to, *args, **kwargs): 16 | if isinstance(to, str): 17 | if to == RECURSIVE_RELATIONSHIP_CONSTANT: # to == 'self' 18 | # This will be handled in contribute_to_class(), when we have 19 | # enough information to set these properly 20 | self.to_app_name, self.to_model_name = (None, to) 21 | elif "." in to: # 'app_label.ModelName' 22 | self.to_app_name, self.to_model_name = to.split(".") 23 | else: # 'ModelName' 24 | self.to_app_name, self.to_model_name = (None, to) 25 | else: 26 | self.to_app_name = to._meta.app_label 27 | self.to_model_name = to._meta.object_name 28 | 29 | super(IntrospectiveFieldMixin, self).__init__(to, *args, **kwargs) 30 | 31 | def contribute_to_class(self, cls, *args, **kwargs): 32 | if self.to_model_name == RECURSIVE_RELATIONSHIP_CONSTANT: 33 | # Resolve the model name 34 | self.to_model_name = cls._meta.object_name 35 | if self.to_app_name is None: 36 | # Resolve the app name 37 | self.to_app_name = cls._meta.app_label 38 | super(IntrospectiveFieldMixin, self).contribute_to_class(cls, *args, **kwargs) 39 | 40 | 41 | class ChainedManyToManyField(IntrospectiveFieldMixin, ManyToManyField): 42 | """ 43 | chains the choices of a previous combo box with this ManyToMany 44 | """ 45 | 46 | def __init__( 47 | self, 48 | to, 49 | chained_field=None, 50 | chained_model_field=None, 51 | auto_choose=False, 52 | horizontal=False, 53 | **kwargs 54 | ): 55 | """ 56 | examples: 57 | 58 | class Publication(models.Model): 59 | name = models.CharField(max_length=255) 60 | 61 | class Writer(models.Model): 62 | name = models.CharField(max_length=255) 63 | publications = models.ManyToManyField('Publication', blank=True, null=True) 64 | 65 | class Book(models.Model): 66 | publication = models.ForeignKey(Publication) 67 | writer = ChainedManyToManyField( 68 | Writer, 69 | chained_field="publication", 70 | chained_model_field="publications", 71 | ) 72 | name = models.CharField(max_length=255) 73 | 74 | ``chained_field`` is the name of the ForeignKey field referenced by ChainedManyToManyField of the same Model. 75 | in the examples, chained_field is the name of field publication in Model Book. 76 | 77 | ``chained_model_field`` is the name of the ManyToMany field referenced in the 'to' Model. 78 | in the examples, chained_model_field is the name of field publications in Model Writer. 79 | 80 | ``auto_choose`` controls whether auto select the choice when there is only one available choice. 81 | 82 | """ 83 | self.chain_field = chained_field 84 | self.chained_model_field = chained_model_field 85 | self.auto_choose = auto_choose 86 | self.horizontal = horizontal 87 | self.verbose_name = kwargs.get("verbose_name", "") 88 | super(ChainedManyToManyField, self).__init__(to, **kwargs) 89 | 90 | def deconstruct(self): 91 | field_name, path, args, kwargs = super( 92 | ChainedManyToManyField, self 93 | ).deconstruct() 94 | 95 | # Maps attribute names to their default kwarg values. 96 | defaults = { 97 | "chain_field": None, 98 | "chained_model_field": None, 99 | "auto_choose": False, 100 | "horizontal": False, 101 | } 102 | 103 | # Maps attribute names to their __init__ kwarg names. 104 | attr_to_kwarg_names = { 105 | "chain_field": "chained_field", 106 | "chained_model_field": "chained_model_field", 107 | "auto_choose": "auto_choose", 108 | "horizontal": "horizontal", 109 | "verbose_name": "verbose_name", 110 | } 111 | 112 | for name, default in defaults.items(): 113 | value = getattr(self, name) 114 | kwarg_name = attr_to_kwarg_names[name] 115 | 116 | # None and Boolean defaults should use an 'is' comparison. 117 | if value is not default: 118 | kwargs[kwarg_name] = value 119 | else: 120 | # value is default, so don't include it in serialized kwargs. 121 | if kwarg_name in kwargs: 122 | del kwargs[kwarg_name] 123 | 124 | return field_name, path, args, kwargs 125 | 126 | def formfield(self, **kwargs): 127 | foreign_key_app_name = self.model._meta.app_label 128 | foreign_key_model_name = self.model._meta.object_name 129 | foreign_key_field_name = self.name 130 | defaults = { 131 | "form_class": form_fields.ChainedManyToManyField, 132 | "queryset": self.remote_field.model._default_manager.complex_filter( 133 | self.remote_field.limit_choices_to 134 | ), 135 | "to_app_name": self.to_app_name, 136 | "to_model_name": self.to_model_name, 137 | "chain_field": self.chain_field, 138 | "chained_model_field": self.chained_model_field, 139 | "auto_choose": self.auto_choose, 140 | "horizontal": self.horizontal, 141 | "verbose_name": self.verbose_name, 142 | "foreign_key_app_name": foreign_key_app_name, 143 | "foreign_key_model_name": foreign_key_model_name, 144 | "foreign_key_field_name": foreign_key_field_name, 145 | } 146 | defaults.update(kwargs) 147 | return super(ChainedManyToManyField, self).formfield(**defaults) 148 | 149 | 150 | class ChainedForeignKey(IntrospectiveFieldMixin, ForeignKey): 151 | """ 152 | chains the choices of a previous combo box with this one 153 | """ 154 | 155 | def __init__( 156 | self, 157 | to, 158 | chained_field=None, 159 | chained_model_field=None, 160 | show_all=False, 161 | auto_choose=False, 162 | sort=True, 163 | view_name=None, 164 | **kwargs 165 | ): 166 | """ 167 | examples: 168 | 169 | class Continent(models.Model): 170 | name = models.CharField(max_length=255) 171 | 172 | class Country(models.Model): 173 | continent = models.ForeignKey(Continent) 174 | 175 | class Location(models.Model): 176 | continent = models.ForeignKey(Continent) 177 | country = ChainedForeignKey( 178 | Country, 179 | chained_field="continent", 180 | chained_model_field="continent", 181 | show_all=True, 182 | auto_choose=True, 183 | sort=True, 184 | # limit_choices_to={'name':'test'} 185 | ) 186 | ``chained_field`` is the name of the ForeignKey field referenced by ChainedForeignKey of the same Model. 187 | in the examples, chained_field is the name of field continent in Model Location. 188 | 189 | ``chained_model_field`` is the name of the ForeignKey field referenced in the 'to' Model. 190 | in the examples, chained_model_field is the name of field continent in Model Country. 191 | 192 | ``show_all`` controls whether show other choices below the filtered choices, with separater '----------'. 193 | 194 | ``auto_choose`` controls whether auto select the choice when there is only one available choice. 195 | 196 | ``sort`` controls whether or not to sort results lexicographically or not. 197 | 198 | ``view_name`` controls which view to use, 'chained_filter' or 'chained_filter_all'. 199 | 200 | """ 201 | self.chained_field = chained_field 202 | self.chained_model_field = chained_model_field 203 | self.show_all = show_all 204 | self.auto_choose = auto_choose 205 | self.sort = sort 206 | self.view_name = view_name 207 | if kwargs: 208 | kwargs["on_delete"] = kwargs.get("on_delete", models.CASCADE) 209 | else: 210 | kwargs = {"on_delete": models.CASCADE} 211 | super(ChainedForeignKey, self).__init__(to, **kwargs) 212 | 213 | def deconstruct(self): 214 | field_name, path, args, kwargs = super(ChainedForeignKey, self).deconstruct() 215 | 216 | # Maps attribute names to their default kwarg values. 217 | defaults = { 218 | "chained_field": None, 219 | "chained_model_field": None, 220 | "show_all": False, 221 | "auto_choose": False, 222 | "sort": True, 223 | "view_name": None, 224 | } 225 | 226 | # Maps attribute names to their __init__ kwarg names. 227 | attr_to_kwarg_names = { 228 | "chained_field": "chained_field", 229 | "chained_model_field": "chained_model_field", 230 | "show_all": "show_all", 231 | "auto_choose": "auto_choose", 232 | "sort": "sort", 233 | "view_name": "view_name", 234 | } 235 | 236 | for name, default in defaults.items(): 237 | value = getattr(self, name) 238 | kwarg_name = attr_to_kwarg_names[name] 239 | 240 | # None and Boolean defaults should use an 'is' comparison. 241 | if value is not default: 242 | kwargs[kwarg_name] = value 243 | else: 244 | # value is default, so don't include it in serialized kwargs. 245 | if kwarg_name in kwargs: 246 | del kwargs[kwarg_name] 247 | 248 | return field_name, path, args, kwargs 249 | 250 | def formfield(self, **kwargs): 251 | foreign_key_app_name = self.model._meta.app_label 252 | foreign_key_model_name = self.model._meta.object_name 253 | foreign_key_field_name = self.name 254 | defaults = { 255 | "form_class": form_fields.ChainedModelChoiceField, 256 | "queryset": self.remote_field.model._default_manager.complex_filter( 257 | self.remote_field.limit_choices_to 258 | ), 259 | "to_field_name": self.remote_field.field_name, 260 | "to_app_name": self.to_app_name, 261 | "to_model_name": self.to_model_name, 262 | "chained_field": self.chained_field, 263 | "chained_model_field": self.chained_model_field, 264 | "show_all": self.show_all, 265 | "auto_choose": self.auto_choose, 266 | "sort": self.sort, 267 | "view_name": self.view_name, 268 | "foreign_key_app_name": foreign_key_app_name, 269 | "foreign_key_model_name": foreign_key_model_name, 270 | "foreign_key_field_name": foreign_key_field_name, 271 | } 272 | defaults.update(kwargs) 273 | return super(ChainedForeignKey, self).formfield(**defaults) 274 | 275 | 276 | class GroupedForeignKey(ForeignKey): 277 | """ 278 | Opt Grouped Field 279 | """ 280 | 281 | def __init__(self, to, group_field, **kwargs): 282 | self.group_field = group_field 283 | self._choices = True 284 | if kwargs: 285 | kwargs["on_delete"] = kwargs.get("on_delete", models.CASCADE) 286 | else: 287 | kwargs = {"on_delete": models.CASCADE} 288 | super(GroupedForeignKey, self).__init__(to, **kwargs) 289 | 290 | def deconstruct(self): 291 | field_name, path, args, kwargs = super(GroupedForeignKey, self).deconstruct() 292 | 293 | # Add positional arg group_field as a kwarg, since the 'to' positional 294 | # arg is serialized as a keyword arg by the superclass deconstruct(). 295 | kwargs.update(group_field=self.group_field) 296 | 297 | # Choices handling in Field.deconstruct() should suffice (if choices is 298 | # not default, serialize it as a kwarg). _choices is set in the 299 | # GroupedForeignKey constructor, but should be overwritten by the 300 | # Field constructor's handling of the 'choices' kwarg. 301 | 302 | return field_name, path, args, kwargs 303 | 304 | def formfield(self, **kwargs): 305 | defaults = { 306 | "form_class": form_fields.GroupedModelSelect, 307 | "queryset": self.remote_field.model._default_manager.complex_filter( 308 | self.remote_field.limit_choices_to 309 | ), 310 | "to_field_name": self.remote_field.field_name, 311 | "order_field": self.group_field, 312 | } 313 | defaults.update(kwargs) 314 | return super(ForeignKey, self).formfield(**defaults) 315 | --------------------------------------------------------------------------------