├── .bumpversion.cfg ├── .codeclimate.yml ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── ruff.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .ruff.toml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENCE ├── MANIFEST.in ├── README.md ├── Rakefile ├── SECURITY.md ├── pyproject.toml ├── requirements-dev.txt ├── screens ├── after-dalf.gif └── before-dalf.gif ├── src └── dalf │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── static │ └── admin │ │ ├── css │ │ └── django_admin_list_filter.css │ │ └── js │ │ └── django_admin_list_filter.js │ └── templates │ └── admin │ └── filter │ ├── django_admin_list_filter.html │ └── django_admin_list_filter_ajax.html └── tests └── testproject ├── manage.py ├── pytest.ini ├── testapp ├── __init__.py ├── admin.py ├── apps.py ├── conftest.py ├── factories.py ├── migrations │ └── __init__.py ├── models.py └── tests.py └── testproject ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.3.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:README.md] 7 | search = version-{current_version}-orange.svg 8 | replace = version-{new_version}-orange.svg 9 | 10 | [bumpversion:file:pyproject.toml] 11 | search = version = "{current_version}" 12 | replace = version = "{new_version}" 13 | 14 | [bumpversion:file:src/dalf/__init__.py] 15 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | checks: 4 | argument-count: 5 | enabled: false 6 | method-lines: 7 | enabled: true 8 | config: 9 | threshold: 100 10 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @vigo 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: vigo 2 | patreon: vigoo 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: 7 | - vigo 8 | - bahattincinic 9 | --- 10 | 11 | ## Describe the bug 12 | 13 | A clear and concise description of what the bug is. 14 | 15 | ## To Reproduce 16 | 17 | Steps to reproduce the behavior: 18 | 19 | 1. Provide the Django model code you are using for the filter. 20 | 1. Mention the Django version you are working with. 21 | 1. Include any relevant code snippets if necessary. 22 | 1. Check the Django server console and copy any error messages. 23 | 1. Check the web browser console and copy any error messages. 24 | 1. Perform the action that triggers the bug. 25 | 1. See error 26 | 27 | ## Expected behavior 28 | 29 | A clear and concise description of what you expected to happen. 30 | 31 | ## Screenshots 32 | 33 | If applicable, add screenshots to help explain your problem. 34 | 35 | ## Environment (please complete the following information): 36 | 37 | - OS: [e.g., macOS] 38 | - CPU: [e.g., M3] 39 | - Python version: [e.g., 3.11] 40 | - Django version: [e.g., 5.0.2] 41 | - Django Admin List Filter package version: [e.g., 0.1.0] 42 | - Browser Vendor and version: [e.g., Firefox 126.0 (64-bit)] 43 | 44 | ## Additional context 45 | 46 | Add any other context about the problem here. 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: GitHub Community Support 4 | url: https://github.com/orgs/community/discussions 5 | about: Please ask and answer questions here. 6 | - name: GitHub Security Bug Bounty 7 | url: https://bounty.github.com/ 8 | about: Please report security vulnerabilities here. 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest a new feature for this project 4 | title: "[FEATURE] " 5 | labels: feature request 6 | assignees: 7 | - vigo 8 | - bahattincinic 9 | --- 10 | 11 | ### Is your feature request related to a problem? Please describe: 12 | 13 | --- 14 | 15 | ### Describe the solution you'd like: 16 | 17 | --- 18 | 19 | ### Describe alternatives you've considered: 20 | 21 | --- 22 | 23 | ### Additional context: 24 | 25 | --- -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please provide a summary of the changes you have made. 4 | 5 | ## Related Issue(s) 6 | 7 | This pull request is related to issue(s) #. 8 | 9 | ## Type of change 10 | 11 | - [ ] New feature (non-breaking change which adds functionality) 12 | - [ ] Bug fix (non-breaking change which fixes an issue) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] Documentation update 15 | 16 | ## How Has This Been Tested? 17 | 18 | Please describe the tests that you ran to verify your changes. Provide 19 | instructions so we can reproduce. 20 | 21 | ## Checklist 22 | 23 | - [ ] I have performed a self-review of my own code. 24 | - [ ] I have commented my code, particularly in hard-to-understand areas. 25 | - [ ] I have made corresponding changes to the documentation. 26 | - [ ] My changes generate no new warnings. 27 | - [ ] I have added tests that prove my fix is effective or that my feature works. 28 | - [ ] Any dependent changes have been merged and published in downstream modules. 29 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff Linter 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | tags-ignore: 8 | - '**' 9 | paths: 10 | - '**/*.py' 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | max-parallel: 4 17 | matrix: 18 | python-version: ["3.11", "3.12", "3.13"] 19 | 20 | steps: 21 | - name: Check out repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install -r requirements-dev.txt 33 | pip install ruff 34 | echo "RUFF_VERSION=$(ruff --version)" >> "${GITHUB_ENV}" 35 | 36 | - name: Runing Ruff ${{ env.RUFF_VERSION }} 37 | run: ruff check --output-format=github . 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | tags-ignore: 8 | - '**' 9 | paths: 10 | - '**/*.py' 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | max-parallel: 4 17 | matrix: 18 | python-version: ["3.11", "3.12", "3.13"] 19 | 20 | steps: 21 | - name: Check out repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install -r requirements-dev.txt 33 | pip install codecov 34 | 35 | - name: Run Tests with coverage 36 | env: 37 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 38 | run: | 39 | pytest -s --cov=src/dalf --cov-report=xml tests/testproject 40 | codecov -f coverage.xml 41 | 42 | - name: Upload coverage to Codecov 43 | uses: codecov/codecov-action@v4.0.1 44 | with: 45 | token: ${{ secrets.CODECOV_TOKEN }} 46 | files: ./coverage.xml 47 | flags: unittests 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/django 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=django 3 | 4 | ### Django ### 5 | *.log 6 | *.pot 7 | *.pyc 8 | __pycache__/ 9 | local_settings.py 10 | db.sqlite3 11 | db.sqlite3-journal 12 | media 13 | 14 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 15 | # in your Git repository. Update and uncomment the following line accordingly. 16 | # /staticfiles/ 17 | 18 | ### Django.Python Stack ### 19 | # Byte-compiled / optimized / DLL files 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | share/python-wheels/ 41 | *.egg-info/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage.xml 65 | *.cover 66 | *.py,cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | cover/ 70 | 71 | # Translations 72 | *.mo 73 | 74 | # Django stuff: 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | .pybuilder/ 88 | target/ 89 | 90 | # Jupyter Notebook 91 | .ipynb_checkpoints 92 | 93 | # IPython 94 | profile_default/ 95 | ipython_config.py 96 | 97 | # pyenv 98 | # For a library or package, you might want to ignore these files since the code is 99 | # intended to run in multiple environments; otherwise, check them in: 100 | # .python-version 101 | 102 | # pipenv 103 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 104 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 105 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 106 | # install all needed dependencies. 107 | #Pipfile.lock 108 | 109 | # poetry 110 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 111 | # This is especially recommended for binary packages to ensure reproducibility, and is more 112 | # commonly ignored for libraries. 113 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 114 | #poetry.lock 115 | 116 | # pdm 117 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 118 | #pdm.lock 119 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 120 | # in version control. 121 | # https://pdm.fming.dev/#use-with-ide 122 | .pdm.toml 123 | 124 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 125 | __pypackages__/ 126 | 127 | # Celery stuff 128 | celerybeat-schedule 129 | celerybeat.pid 130 | 131 | # SageMath parsed files 132 | *.sage.py 133 | 134 | # Environments 135 | .env 136 | .venv 137 | env/ 138 | venv/ 139 | ENV/ 140 | env.bak/ 141 | venv.bak/ 142 | 143 | # Spyder project settings 144 | .spyderproject 145 | .spyproject 146 | 147 | # Rope project settings 148 | .ropeproject 149 | 150 | # mkdocs documentation 151 | /site 152 | 153 | # mypy 154 | .mypy_cache/ 155 | .dmypy.json 156 | dmypy.json 157 | 158 | # Pyre type checker 159 | .pyre/ 160 | 161 | # pytype static type analyzer 162 | .pytype/ 163 | 164 | # Cython debug symbols 165 | cython_debug/ 166 | 167 | # PyCharm 168 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 169 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 170 | # and can be added to the global gitignore or merged into this file. For a more nuclear 171 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 172 | #.idea/ 173 | 174 | # End of https://www.toptal.com/developers/gitignore/api/django 175 | 176 | # Ignore .egg-info directory created by pip install -e 177 | src/dalf.egg-info/ 178 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.0 4 | hooks: 5 | - id: ruff 6 | - repo: local 7 | hooks: 8 | - id: pytest 9 | name: pytest 10 | entry: pytest -s tests/testproject/testapp -c tests/testproject/pytest.ini 11 | language: system 12 | pass_filenames: false 13 | types_or: [python, html] 14 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | line-length = 119 2 | 3 | [format] 4 | quote-style = "single" 5 | 6 | [lint] 7 | ignore = [ 8 | "ANN", 9 | "D", 10 | "SIM105", 11 | "PGH004", 12 | "PTH", 13 | "ERA001", 14 | "ISC001", 15 | "COM812", 16 | ] 17 | select = ["ALL"] 18 | 19 | [lint.flake8-quotes] 20 | inline-quotes = "single" 21 | docstring-quotes = "double" 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | **2024-09-06** 4 | 5 | - Fix dark-mode text color. 6 | 7 | --- 8 | 9 | **2024-08-25** 10 | 11 | - Fix extend media in DALFModelAdmin without overriding default assets - [Bahattin][bahattincinic] 12 | 13 | --- 14 | 15 | **2024-08-16** 16 | 17 | - Add `gettextSafe` function to handle missing gettext in Django - [Bahattin][bahattincinic] 18 | 19 | --- 20 | 21 | **2024-07-14** 22 | 23 | - Fix choice.display last element issue 24 | 25 | --- 26 | 27 | **2024-07-03** 28 | 29 | - Now package is working fine :) Thanks to [Bahattin][bahattincinic]! 30 | 31 | --- 32 | 33 | **2024-06-01** 34 | 35 | - Update missing information in the README 36 | - Improve github action triggers 37 | 38 | --- 39 | 40 | **2024-05-23** 41 | 42 | - Add tests 43 | - Add GitHub actions (test, ruff linter) 44 | - Add pre-commit hooks 45 | 46 | --- 47 | 48 | **2024-05-20** 49 | 50 | - Initial release. 51 | 52 | --- 53 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at ugurozyilmazel@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contribute 2 | 3 | All PR’s are welcome! 4 | 5 | 1. `fork` (https://github.com/vigo/django-admin-list-filter/fork) 6 | 1. Create your `branch` (`git checkout -b my-feature`) 7 | 1. `commit` yours (`git commit -am 'add some functionality'`) 8 | 1. `push` your `branch` (`git push origin my-feature`) 9 | 1. Than create a new **Pull Request**! 10 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Uğur Özyılmazel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include src/dalf/static * 2 | recursive-include src/dalf/templates * 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Version](https://img.shields.io/badge/version-0.3.0-orange.svg?style=for-the-badge&logo=semver) 2 | ![Python](https://img.shields.io/badge/python-3.11+-green.svg?style=for-the-badge&logo=python) 3 | ![Django](https://img.shields.io/badge/django-5.0.2-green.svg?style=for-the-badge&logo=django) 4 | [![Ruff](https://img.shields.io/endpoint?style=for-the-badge&url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 5 | [![PyPI version](https://img.shields.io/pypi/v/dalf.svg?style=for-the-badge&logo=pypi)](https://pypi.org/project/dalf/) 6 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/dalf?style=for-the-badge&logo=pypi) 7 | [![Codecov](https://img.shields.io/codecov/c/github/vigo/django-admin-list-filter?token=6JRNSB6WN1&style=for-the-badge&logo=codecov)](https://codecov.io/gh/vigo/django-admin-list-filter) 8 | 9 | 10 | # Django Admin List Filter 11 | 12 | Dead simple autocompletion for Django admin `list_filter`. This was made using 13 | the libraries shipped with Django (`select2`, `jquery`), Django’s built-in 14 | list filters, and Django’s built-in `AutocompleteJsonView`. 15 | 16 | This package is an **improved** version of the previously created 17 | [django-admin-autocomplete-list-filter][1] package. It supports Django **version 5** and 18 | above. Please note that the *django-admin-autocomplete-list-filter* package is 19 | now **deprecated**. Since I am no longer part of the organization where it was 20 | initially developed, I cannot archive it. 21 | 22 | No extra package or install required! 23 | 24 | Before **Django Admin List Filter** 25 | 26 | ![Before Django Admin List Filter](screens/before-dalf.gif) 27 | 28 | After **Django Admin List Filter** 29 | 30 | ![After Django Admin List Filter](screens/after-dalf.gif?1) 31 | 32 | --- 33 | 34 | ## 2024-07-03 35 | 36 | Thanks to my dear friend [Bahattin Çiniç][bahattincinic]’s warning, He realized 37 | that the necessary HTML, CSS, and JavaScript files were missing from the 38 | published package! I quickly fixed this and published a new version. The `0.1.0` 39 | version is a faulty version. I apologize to the users for this confusion. 40 | Thank you. - vigo 41 | 42 | --- 43 | 44 | ## Installation 45 | 46 | ```bash 47 | pip install dalf 48 | ``` 49 | 50 | Add `dalf` to your `INSTALLED_APPS` in your `settings.py`: 51 | 52 | ```python 53 | INSTALLED_APPS = [ 54 | "django.contrib.admin", 55 | "django.contrib.auth", 56 | "django.contrib.contenttypes", 57 | "django.contrib.sessions", 58 | "django.contrib.messages", 59 | "django.contrib.staticfiles", 60 | "dalf", # <- add 61 | ] 62 | ``` 63 | 64 | --- 65 | 66 | ## Usage 67 | 68 | Use `DALFModelAdmin`, inherited from `admin.ModelAdmin` to inject media urls only. 69 | You have some filters; 70 | 71 | - `DALFRelatedField`: inherited from `admin.RelatedFieldListFilter`. 72 | - `DALFRelatedFieldAjax`: inherited from `admin.RelatedFieldListFilter` 73 | - `DALFRelatedOnlyField`: inherited from `admin.RelatedOnlyFieldListFilter`. 74 | - `DALFChoicesField`: inherited from `admin.ChoicesFieldListFilter`. 75 | 76 | Example `models.py` 77 | 78 | ```python 79 | # models.py 80 | import uuid 81 | 82 | from django.conf import settings 83 | from django.db import models 84 | 85 | 86 | class Category(models.Model): 87 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 88 | title = models.CharField(max_length=255) 89 | 90 | def __str__(self): 91 | return self.title 92 | 93 | 94 | class Tag(models.Model): 95 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 96 | name = models.CharField(max_length=255) 97 | 98 | def __str__(self): 99 | return self.name 100 | 101 | 102 | class Post(models.Model): 103 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 104 | category = models.ForeignKey(to='Category', on_delete=models.CASCADE, related_name='posts') 105 | author = models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='posts') 106 | title = models.CharField(max_length=255) 107 | body = models.TextField() 108 | tags = models.ManyToManyField(to='Tag', blank=True) 109 | 110 | def __str__(self): 111 | return self.title 112 | ``` 113 | 114 | Example `admin.py`: 115 | 116 | ```python 117 | # admin.py 118 | from dalf.admin import DALFModelAdmin, DALFRelatedOnlyField, DALFRelatedFieldAjax 119 | from django.contrib import admin 120 | from YOURAPP.models import Post 121 | 122 | @admin.register(Post) 123 | class PostAdmin(DALFModelAdmin): 124 | list_filter = ( 125 | ('author', DALFRelatedOnlyField), # if author has a post! 126 | ('category', DALFRelatedFieldAjax), # enable ajax completion for category field (FK) 127 | ('tags', DALFRelatedFieldAjax), # enable ajax completion for tags field (M2M) 128 | ) 129 | ``` 130 | 131 | That’s all... There is also `DALFChoicesField`, you can test it out: 132 | 133 | ```python 134 | # admin.py 135 | from django.contrib import admin 136 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 137 | from django.contrib.auth.models import User 138 | 139 | from dalf.admin import ( 140 | DALFModelAdmin, 141 | DALFChoicesField, 142 | DALFRelatedOnlyField, 143 | DALFRelatedFieldAjax, 144 | ) 145 | 146 | from YOURAPP.models import Post, Category, Tag 147 | 148 | # must be registered, must have search_fields, required for `author` field demo. 149 | # this is demo purpose only, you can register/import your own/custom User model 150 | class UserAdmin(BaseUserAdmin): 151 | search_fields = ['username'] 152 | ordering = ['username'] 153 | 154 | admin.site.unregister(User) 155 | admin.site.register(User, UserAdmin) 156 | 157 | @admin.register(Post) 158 | class PostAdmin(DALFModelAdmin): 159 | list_filter = ( 160 | ('author', DALFChoicesField), # enable autocomplete w/o ajax (FK) 161 | ('category', DALFRelatedFieldAjax), # enable ajax completion for category field (FK) 162 | ('tags', DALFRelatedOnlyField), # enable ajax completion for tags field (M2M) if posts has any tag! 163 | ) 164 | 165 | # must be registered, must have search_fields 166 | @admin.register(Category) 167 | class CategoryAdmin(admin.ModelAdmin): 168 | search_fields = ['title',] 169 | ordering = ['title'] 170 | 171 | 172 | # must be registered, must have search_fields 173 | @admin.register(Tag) 174 | class TagAdmin(admin.ModelAdmin): 175 | search_fields = ['name',] 176 | ordering = ['name'] 177 | 178 | ``` 179 | 180 | ### Extras 181 | 182 | I mostly use `django-timezone-field`, here is an illustration of timezone 183 | completion w/o **ajax**: 184 | 185 | ```bash 186 | pip install django-timezone-field 187 | ``` 188 | 189 | Now add `timezone` field to `Post` model: 190 | 191 | ```python 192 | # modify models.py, add new ones 193 | # ... 194 | 195 | from timezone_field import TimeZoneField # <- add this line 196 | 197 | class Post(models.Model): 198 | # all the other fiels 199 | timezone = TimeZoneField(default=settings.TIME_ZONE) # <- add this line 200 | 201 | # rest of the code 202 | ``` 203 | 204 | Now, just add `timezone` as regular `list_filter`: 205 | 206 | ```python 207 | # modify admin.py, add new ones 208 | 209 | @admin.register(Post) 210 | class PostAdmin(DALFModelAdmin): 211 | # previous codes 212 | list_filter = ( 213 | # previous filters 214 | ('timezone', DALFChoicesField), # <- add this line 215 | ) 216 | ``` 217 | 218 | That’s it! 219 | 220 | --- 221 | 222 | ## Contributor(s) 223 | 224 | * [Uğur Özyılmazel](https://github.com/vigo) - Creator, maintainer 225 | * [Ehco](https://github.com/Ehco1996) - Contributor 226 | * [Bahattin Çiniç][bahattincinic] - Contributor 227 | * [Nguyễn Hồng Quân](https://github.com/hongquan) - Contributor 228 | * [Stanislav Terliakov](https://github.com/sterliakov) - Contributor 229 | 230 | --- 231 | 232 | ## Contribute 233 | 234 | All PR’s are welcome! 235 | 236 | 1. `fork` (https://github.com/vigo/django-admin-list-filter/fork) 237 | 1. Create your `branch` (`git checkout -b my-feature`) 238 | 1. `commit` yours (`git commit -am 'add some functionality'`) 239 | 1. `push` your `branch` (`git push origin my-feature`) 240 | 1. Than create a new **Pull Request**! 241 | 242 | I am not very proficient in JavaScript. Therefore, any support, suggestions, 243 | and feedback are welcome to help improve the project. Feel free to open 244 | pull requests! 245 | 246 | --- 247 | 248 | ## Development 249 | 250 | Clone the repo somewhere, and install with: 251 | 252 | ```bash 253 | pip install -r requirements-dev.txt 254 | pip install -e /path/to/dalf 255 | pre-commit install 256 | ``` 257 | 258 | And play with the filters :) 259 | 260 | ## Publish 261 | 262 | Note to my self: 263 | 264 | ```bash 265 | pip install build twine 266 | rake -T 267 | 268 | rake build # Build package 269 | rake bump[revision] # Bump version: major,minor,patch 270 | rake clean # Remove/Delete build.. 271 | rake test # Run tests 272 | rake upload:main # Upload package to main distro (release) 273 | rake upload:test # Upload package to test distro 274 | ``` 275 | 276 | --- 277 | 278 | ## Change Log 279 | 280 | **2025-03-14** 281 | 282 | - Fix ForeignKey relation to `__id` issue. [Stanislav Terliakov](https://github.com/sterliakov) 283 | 284 | **2024-11-07** 285 | 286 | - Fix dark-mode ajax autocompletion colors - [Nguyễn Hồng Quân](https://github.com/hongquan) 287 | 288 | **2024-09-06** 289 | 290 | - Fix dark-mode text color. 291 | 292 | --- 293 | 294 | **2024-08-25** 295 | 296 | - Fix extend media in `DALFModelAdmin` without overriding default assets - [Bahattin][bahattincinic] 297 | 298 | --- 299 | 300 | **2024-08-16** 301 | 302 | - Add `gettextSafe` function to handle missing gettext in Django - [Bahattin][bahattincinic] 303 | 304 | --- 305 | 306 | **2024-07-14** 307 | 308 | - Fix choice.display last element issue 309 | 310 | --- 311 | 312 | **2024-07-03** 313 | 314 | - Now package is working fine :) Thanks to [Bahattin][bahattincinic]! 315 | 316 | --- 317 | 318 | You can read the whole story [here][changelog]. 319 | 320 | --- 321 | 322 | ## License 323 | 324 | This project is licensed under MIT 325 | 326 | --- 327 | 328 | This project is intended to be a safe, welcoming space for collaboration, and 329 | contributors are expected to adhere to the [code of conduct][coc]. 330 | 331 | [1]: https://github.com/demiroren-teknoloji/django-admin-autocomplete-list-filter "Deprecated, old package" 332 | [coc]: https://github.com/vigo/django-admin-list-filter/blob/main/CODE_OF_CONDUCT.md 333 | [changelog]: https://github.com/vigo/django-admin-list-filter/blob/main/CHANGELOG.md 334 | [bahattincinic]: https://github.com/bahattincinic 335 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | task :default => [:install] 2 | 3 | desc "Remove/Delete build..." 4 | task :clean do 5 | rm_rf %w(build dist) 6 | rm_rf Dir.glob("*.egg-info") 7 | puts "Build files are removed..." 8 | end 9 | 10 | desc "Build package" 11 | task :build => [:clean] do 12 | system "python -m build" 13 | end 14 | 15 | namespace :upload do 16 | desc "Upload package to main distro (release)" 17 | task :main => [:build] do 18 | puts "Uploading package to MAIN distro..." 19 | system "twine upload --repository pypi dist/*" 20 | end 21 | 22 | desc "Upload package to test distro" 23 | task :test => [:build] do 24 | puts "Uploading package to TEST distro..." 25 | system "twine upload --repository testpypi dist/*" 26 | end 27 | end 28 | 29 | AVAILABLE_REVISIONS = ["major", "minor", "patch"] 30 | desc "Bump version: #{AVAILABLE_REVISIONS.join(',')}" 31 | task :bump, [:revision] do |t, args| 32 | args.with_defaults(revision: "patch") 33 | abort "Please provide valid revision: #{AVAILABLE_REVISIONS.join(',')}" unless AVAILABLE_REVISIONS.include?(args.revision) 34 | system "bumpversion #{args.revision}" 35 | end 36 | 37 | desc "Run tests" 38 | task :test do 39 | system %{ 40 | pytest -s --cov=src/dalf --cov-report=xml tests/testproject 41 | } 42 | end 43 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | We take the security of our project seriously. If you find any security 6 | vulnerability in our TextMate bundle, please report it by emailing us directly 7 | at [ugurozyilmazel@gmail.com]. Please do not disclose these issues publicly 8 | until we have had a chance to address them. 9 | 10 | Thank you for helping keep this project safe and secure. 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "dalf" 3 | version = "0.3.0" 4 | authors = [ 5 | { name="Uğur Özyılmazel", email="ugurozyilmazel@gmail.com" }, 6 | ] 7 | description = "Dead simple autocompletion for Django admin list_filter with goodies." 8 | readme = "README.md" 9 | license = { file = "LICENSE" } 10 | requires-python = ">=3.11" 11 | classifiers = [ 12 | "Programming Language :: Python :: 3 :: Only", 13 | "Programming Language :: Python :: 3.11", 14 | "Programming Language :: Python :: 3.12", 15 | "Programming Language :: Python :: 3.13", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | "Framework :: Django :: 5.0", 19 | "Framework :: Django :: 5.1", 20 | "Intended Audience :: Developers", 21 | "Development Status :: 3 - Alpha", 22 | "Natural Language :: English", 23 | ] 24 | keywords = ["django", "django admin", "list filter"] 25 | 26 | [project.optional-dependencies] 27 | build = ["build", "twine"] 28 | dev = ["Django", "pytest", "pytest-django", "pytest-factoryboy", "pytest-cov"] 29 | 30 | [project.urls] 31 | Homepage = "https://github.com/vigo/django-admin-list-filter" 32 | Repository = "https://github.com/vigo/django-admin-list-filter" 33 | Issues = "https://github.com/vigo/django-admin-list-filter/issues" 34 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | Django==5.0.6 2 | pytest==8.2.1 3 | pytest-cov==5.0.0 4 | pytest-django==4.8.0 5 | pytest-factoryboy==2.7.0 -------------------------------------------------------------------------------- /screens/after-dalf.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigo/django-admin-list-filter/3a7e6d7966701a768cef2a969f8f2dcf80cc3308/screens/after-dalf.gif -------------------------------------------------------------------------------- /screens/before-dalf.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigo/django-admin-list-filter/3a7e6d7966701a768cef2a969f8f2dcf80cc3308/screens/before-dalf.gif -------------------------------------------------------------------------------- /src/dalf/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.3.0' 2 | -------------------------------------------------------------------------------- /src/dalf/admin.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: PLR0913,ARG002,SLF001 2 | 3 | from django import forms 4 | from django.contrib import admin 5 | from django.contrib.admin.widgets import get_select2_language 6 | from django.urls import reverse 7 | 8 | __all__ = [ 9 | 'DALFChoicesField', 10 | 'DALFModelAdmin', 11 | 'DALFRelatedField', 12 | 'DALFRelatedFieldAjax', 13 | 'DALFRelatedOnlyField', 14 | ] 15 | 16 | 17 | class DALFModelAdmin(admin.ModelAdmin): 18 | @property 19 | def media(self): 20 | i18n_name = get_select2_language() 21 | i18n_file = (f'admin/js/vendor/select2/i18n/{i18n_name}.js',) if i18n_name else () 22 | return super().media + forms.Media( 23 | js=( 24 | 'admin/js/vendor/jquery/jquery.min.js', 25 | 'admin/js/vendor/select2/select2.full.min.js', 26 | *i18n_file, 27 | 'admin/js/jquery.init.js', 28 | 'admin/js/django_admin_list_filter.js', 29 | ), 30 | css={ 31 | 'screen': ( 32 | 'admin/css/vendor/select2/select2.min.css', 33 | 'admin/css/autocomplete.css', 34 | 'admin/css/django_admin_list_filter.css', 35 | ), 36 | }, 37 | ) 38 | 39 | 40 | class DALFMixin: 41 | template = 'admin/filter/django_admin_list_filter.html' 42 | 43 | def __init__(self, field, request, params, model, model_admin, field_path): 44 | super().__init__(field, request, params, model, model_admin, field_path) 45 | self.custom_template_params = { 46 | 'app_label': model._meta.app_label, 47 | 'model_name': model._meta.model_name, 48 | 'field_name': field_path, 49 | 'lookup_kwarg': self.lookup_kwarg, 50 | } 51 | 52 | def choices(self, changelist): 53 | yield from super().choices(changelist) 54 | yield { 55 | **self.custom_template_params, 56 | } 57 | 58 | 59 | class DALFRelatedField(DALFMixin, admin.RelatedFieldListFilter): 60 | pass 61 | 62 | 63 | class DALFRelatedOnlyField(DALFMixin, admin.RelatedOnlyFieldListFilter): 64 | pass 65 | 66 | 67 | class DALFChoicesField(DALFMixin, admin.ChoicesFieldListFilter): 68 | pass 69 | 70 | 71 | class DALFRelatedFieldAjax(admin.RelatedFieldListFilter): 72 | template = 'admin/filter/django_admin_list_filter_ajax.html' 73 | 74 | def __init__(self, field, request, params, model, model_admin, field_path): 75 | originial_params = dict(params) 76 | super().__init__(field, request, params, model, model_admin, field_path) 77 | 78 | self.custom_template_params = { 79 | 'app_label': model._meta.app_label, 80 | 'model_name': model._meta.model_name, 81 | 'field_name': field_path, 82 | 'ajax_url': reverse('admin:autocomplete'), 83 | 'lookup_kwarg': self.lookup_kwarg, 84 | } 85 | selected_value = originial_params.get(self.lookup_kwarg, []) 86 | self.selected_value = selected_value[0] if selected_value else None 87 | 88 | def field_choices(self, field, request, model_admin): 89 | return [] 90 | 91 | def has_output(self): 92 | return True 93 | 94 | def choices(self, changelist): 95 | yield from super().choices(changelist) 96 | yield { 97 | **self.custom_template_params, 98 | 'selected_value': self.selected_value, 99 | } 100 | -------------------------------------------------------------------------------- /src/dalf/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DalfConfig(AppConfig): 5 | name = 'dalf' 6 | -------------------------------------------------------------------------------- /src/dalf/static/admin/css/django_admin_list_filter.css: -------------------------------------------------------------------------------- 1 | select.django-admin-list-filter, 2 | select.django-admin-list-filter-ajax { 3 | width: 100%; 4 | } 5 | 6 | html[data-theme="dark"] ul.select2-results__options { 7 | color: var(--primary); 8 | } 9 | -------------------------------------------------------------------------------- /src/dalf/static/admin/js/django_admin_list_filter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | { 3 | const $ = django.jQuery; 4 | 5 | $.fn.djangoAdminListFilterSelect2 = function() { 6 | $.each(this, function(i, element) { 7 | const ajaxURL = $(element).data("ajax--url"); 8 | const appLabel = element.dataset.appLabel; 9 | const modelName = element.dataset.modelName; 10 | const fieldName = element.dataset.fieldName; 11 | const lookupKwarg = element.dataset.lookupKwarg; 12 | const selectedValue = $(element).prev('.djal-selected-value').val(); 13 | 14 | $(element).select2({ 15 | ajax: { 16 | data: (params) => { 17 | return { 18 | term: params.term, 19 | page: params.page, 20 | app_label: appLabel, 21 | model_name: modelName, 22 | field_name: fieldName 23 | }; 24 | } 25 | } 26 | }).on('select2:select', function(e){ 27 | var data = e.params.data; 28 | var navURL = new URL(window.location.href); 29 | 30 | navURL.searchParams.set(lookupKwarg, decodeURIComponent(data.id)); 31 | window.location.href = navURL.href; 32 | }).on("select2:unselect", function(e){ 33 | var navURL = new URL(window.location.href); 34 | navURL.searchParams.delete(lookupKwarg); 35 | window.location.href = navURL.href; 36 | }); 37 | 38 | if (selectedValue){ 39 | $.ajax({ 40 | url: ajaxURL, 41 | dataType: "json", 42 | data: { 43 | term: "", 44 | app_label: appLabel, 45 | model_name: modelName, 46 | field_name: fieldName 47 | }, 48 | success: function(data){ 49 | if (data.results.length > 0) { 50 | const selectedItem = data.results.find(item => item.id === selectedValue); 51 | if (selectedItem) { 52 | const selectedOption = new Option(selectedItem.text, selectedItem.id, true, true); 53 | $(element).append(selectedOption).trigger("change"); 54 | } 55 | }; 56 | } 57 | }); 58 | } 59 | 60 | }); 61 | return this; 62 | }; 63 | 64 | function getQueryParams(e) { 65 | var fieldQueryParam = $(e.target).data('lookupKwarg'); 66 | var data = e.params.data; 67 | var selected = data.id.replace(/\?/, ""); 68 | var queryParams = selected.split("&"); 69 | if (queryParams.length === 1 && queryParams[0] === "") { 70 | queryParams = [] 71 | } 72 | return [fieldQueryParam, queryParams]; 73 | } 74 | 75 | function getTextSafe(text) { 76 | /** 77 | * Safely retrieves the translated text using gettext if available. 78 | * Django doesn't always load the admin:jsi18n URL, for instance, when 79 | * has_delete_permission is set to false. In these cases, the gettext 80 | * function may be unavailable. 81 | * Reference: https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/change_list.html#L10-L12 82 | * 83 | */ 84 | if (typeof gettext !== 'undefined') { 85 | return gettext(text); 86 | } else { 87 | return text; 88 | } 89 | } 90 | 91 | $(document).ready(function() { 92 | $('.django-admin-list-filter').select2({ 93 | allowClear: true, 94 | placeholder: getTextSafe("Filter") 95 | }).on("select2:select", function(e){ 96 | var navURL = new URL(window.location.href); 97 | let [fieldQueryParam, queryParams] = getQueryParams(e); 98 | var isAllorEmptyChoice = true; 99 | 100 | queryParams.forEach(function(item){ 101 | var [field, value] = item.split("="); 102 | if (field == fieldQueryParam) { 103 | isAllorEmptyChoice = false; 104 | navURL.searchParams.set(field, decodeURIComponent(value)); 105 | } 106 | }); 107 | if (isAllorEmptyChoice) { 108 | navURL.searchParams.delete(fieldQueryParam); 109 | } 110 | window.location.href = navURL.href; 111 | 112 | }).on("select2:unselect", function(e){ 113 | var navURL = new URL(window.location.href); 114 | let [fieldQueryParam, queryParams] = getQueryParams(e); 115 | 116 | queryParams.forEach(function(item){ 117 | var [field, value] = item.split("="); 118 | if (field == fieldQueryParam) { 119 | navURL.searchParams.delete(field); 120 | } 121 | }); 122 | 123 | window.location.href = navURL.href; 124 | }); 125 | 126 | $('.django-admin-list-filter-ajax').djangoAdminListFilterSelect2(); 127 | }); 128 | } 129 | -------------------------------------------------------------------------------- /src/dalf/templates/admin/filter/django_admin_list_filter.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
3 | 4 | {% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %} 5 | 6 |
7 |
    8 |
  • 9 | {% with params=choices|last %} 10 | 20 | {% endwith %} 21 |
  • 22 |
23 |
24 |
25 | -------------------------------------------------------------------------------- /src/dalf/templates/admin/filter/django_admin_list_filter_ajax.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
3 | 4 | {% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %} 5 | 6 |
7 |
    8 |
  • 9 | {% with params=choices|last %} 10 | 11 | 24 | {% endwith %} 25 |
  • 26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /tests/testproject/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # ruff: noqa: TRY003,EM101 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main(): 9 | """Run administrative tasks.""" 10 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.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 | -------------------------------------------------------------------------------- /tests/testproject/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = testproject.settings 3 | python_files = tests.py test_*.py *_tests.py 4 | pythonpath = ../../src 5 | addopts = -p no:warnings --strict-markers --no-migrations --reuse-db --capture=no 6 | -------------------------------------------------------------------------------- /tests/testproject/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigo/django-admin-list-filter/3a7e6d7966701a768cef2a969f8f2dcf80cc3308/tests/testproject/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testproject/testapp/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from dalf.admin import ( 4 | DALFChoicesField, 5 | DALFModelAdmin, 6 | DALFRelatedField, 7 | DALFRelatedFieldAjax, 8 | DALFRelatedOnlyField, 9 | ) 10 | 11 | from .models import Category, CategoryRenamed, Post, Tag 12 | 13 | 14 | @admin.register(Post) 15 | class PostAdmin(DALFModelAdmin): 16 | list_display = ('title',) 17 | list_filter = ( 18 | ('author', DALFRelatedField), 19 | ('audience', DALFChoicesField), 20 | ('category', DALFRelatedFieldAjax), 21 | ('category_renamed', DALFRelatedFieldAjax), 22 | ('tags', DALFRelatedOnlyField), 23 | ) 24 | 25 | 26 | @admin.register(Category) 27 | class CategoryAdmin(admin.ModelAdmin): 28 | search_fields = ('name',) 29 | ordering = ('name',) 30 | 31 | 32 | @admin.register(CategoryRenamed) 33 | class CategoryRenamedAdmin(admin.ModelAdmin): 34 | search_fields = ('name',) 35 | ordering = ('name',) 36 | 37 | 38 | @admin.register(Tag) 39 | class TagAdmin(admin.ModelAdmin): 40 | search_fields = ('name',) 41 | ordering = ('name',) 42 | -------------------------------------------------------------------------------- /tests/testproject/testapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestappConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'testapp' 7 | -------------------------------------------------------------------------------- /tests/testproject/testapp/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest_factoryboy import register 3 | 4 | from .factories import PostFactory, TagFactory 5 | 6 | register(TagFactory) 7 | register(PostFactory) 8 | 9 | 10 | @pytest.fixture 11 | def posts(): 12 | return PostFactory.create_batch(10) 13 | 14 | @pytest.fixture 15 | def unused_tag(): 16 | return TagFactory(name='Unused') 17 | -------------------------------------------------------------------------------- /tests/testproject/testapp/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from django.contrib.auth import get_user_model 3 | from factory import fuzzy 4 | 5 | from .models import AudienceChoices, Category, CategoryRenamed, Post, Tag 6 | 7 | FAKE_USERNAMES = [ 8 | 'vigo', 9 | 'turbo', 10 | 'move', 11 | ] 12 | 13 | FAKE_CATEGORIES = [ 14 | 'Python', 15 | 'Ruby', 16 | 'Go', 17 | 'Bash', 18 | 'AppleScript', 19 | 'C', 20 | 'Perl', 21 | ] 22 | 23 | FAKE_TAGS = [ 24 | 'django', 25 | 'django rest', 26 | 'linux', 27 | 'macos', 28 | 'stdlib', 29 | ] 30 | 31 | 32 | class UserFactory(factory.django.DjangoModelFactory): 33 | class Meta: 34 | model = get_user_model() 35 | django_get_or_create = ('username',) 36 | 37 | username = fuzzy.FuzzyChoice(FAKE_USERNAMES) 38 | email = factory.Faker('email') 39 | password = factory.PostGenerationMethodCall('set_password', 'defaultpassword') 40 | 41 | 42 | class CategoryFactory(factory.django.DjangoModelFactory): 43 | class Meta: 44 | model = Category 45 | django_get_or_create = ('name',) 46 | 47 | name = factory.Iterator(FAKE_CATEGORIES) 48 | 49 | 50 | class CategoryRenamedFactory(factory.django.DjangoModelFactory): 51 | class Meta: 52 | model = CategoryRenamed 53 | django_get_or_create = ('name',) 54 | 55 | name = factory.Iterator(FAKE_CATEGORIES) 56 | 57 | 58 | class TagFactory(factory.django.DjangoModelFactory): 59 | class Meta: 60 | model = Tag 61 | django_get_or_create = ('name',) 62 | 63 | name = fuzzy.FuzzyChoice(FAKE_TAGS) 64 | 65 | 66 | class PostFactory(factory.django.DjangoModelFactory): 67 | class Meta: 68 | model = Post 69 | django_get_or_create = ('title',) 70 | 71 | author = factory.SubFactory(UserFactory) 72 | category = factory.SubFactory(CategoryFactory) 73 | category_renamed = factory.SubFactory(CategoryRenamedFactory) 74 | title = factory.Sequence(lambda n: f'Book about {FAKE_CATEGORIES[n % len(FAKE_CATEGORIES)]} - {n}') 75 | audience = fuzzy.FuzzyChoice(AudienceChoices.choices, getter=lambda c: c[0]) 76 | 77 | @factory.post_generation 78 | def tags(self, create, extracted, **kwargs): # noqa: ARG002 79 | if not create: 80 | return 81 | 82 | if extracted: 83 | self.tags.add(*extracted) 84 | else: 85 | tags = TagFactory.create_batch(len(FAKE_TAGS)) 86 | self.tags.add(*tags) 87 | -------------------------------------------------------------------------------- /tests/testproject/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigo/django-admin-list-filter/3a7e6d7966701a768cef2a969f8f2dcf80cc3308/tests/testproject/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /tests/testproject/testapp/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | 6 | 7 | class Category(models.Model): 8 | name = models.CharField(max_length=255) 9 | 10 | def __str__(self): 11 | return self.name 12 | 13 | 14 | class CategoryRenamed(models.Model): 15 | renamed_id = models.UUIDField(default=uuid.uuid4, primary_key=True) 16 | name = models.CharField(max_length=255) 17 | 18 | def __str__(self): 19 | return self.name 20 | 21 | 22 | class Tag(models.Model): 23 | name = models.CharField(max_length=255) 24 | 25 | def __str__(self): 26 | return self.name 27 | 28 | 29 | class AudienceChoices(models.TextChoices): 30 | BEGINNER = 'beginner', 'Beginer' 31 | INTERMEDIATE = 'intermediate', 'Intermediate' 32 | PRO = 'pro', 'Pro' 33 | 34 | 35 | class Post(models.Model): 36 | author = models.ForeignKey( 37 | to=settings.AUTH_USER_MODEL, 38 | on_delete=models.CASCADE, 39 | related_name='posts', 40 | ) 41 | category = models.ForeignKey( 42 | to='Category', 43 | on_delete=models.CASCADE, 44 | related_name='posts', 45 | ) 46 | category_renamed = models.ForeignKey( 47 | to='CategoryRenamed', 48 | on_delete=models.CASCADE, 49 | related_name='posts', 50 | ) 51 | tags = models.ManyToManyField(to='Tag', blank=True) 52 | audience = models.CharField( 53 | max_length=100, 54 | choices=AudienceChoices.choices, 55 | default=AudienceChoices.BEGINNER, 56 | ) 57 | 58 | title = models.CharField(max_length=255) 59 | 60 | def __str__(self): 61 | return self.title 62 | -------------------------------------------------------------------------------- /tests/testproject/testapp/tests.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: S101 2 | import re 3 | from html.parser import HTMLParser 4 | from http import HTTPStatus 5 | 6 | import pytest 7 | from django.urls import reverse 8 | 9 | from dalf.admin import DALFChoicesField, DALFRelatedField, DALFRelatedFieldAjax, DALFRelatedOnlyField 10 | 11 | from .models import Post, Tag 12 | 13 | csrf_token_pattern = re.compile(r'name="csrfmiddlewaretoken" value="([^"]+)"') 14 | 15 | 16 | class MatchingTagValidator(HTMLParser): 17 | """Assert that a tag matching the given spec is found in the document. 18 | 19 | Instances of this class are not reusable. Create a new one for every ``check`` call. 20 | """ 21 | 22 | def __init__(self, tag, matcher_attrs, expected_attrs, expected_content=None): 23 | super().__init__() 24 | self.matcher_attrs = matcher_attrs 25 | self.target_tag = tag 26 | self.expected_attrs = expected_attrs 27 | self.expected_content = expected_content 28 | self.seen_target_tag = False 29 | self.inside_target_tag = False 30 | 31 | def check(self, content): 32 | self.feed(content) 33 | assert self.seen_target_tag 34 | 35 | def handle_starttag(self, tag, attrs): 36 | if tag != self.target_tag: 37 | return 38 | attrs = dict(attrs) 39 | if self.matcher_attrs.items() <= attrs.items(): 40 | self.inside_target_tag = True 41 | assert not self.seen_target_tag, 'Multiple matching tags found' 42 | self.seen_target_tag = True 43 | assert self.expected_attrs.items() <= attrs.items() 44 | 45 | def handle_endtag(self, tag): 46 | if tag == self.target_tag: 47 | # Yes, this will be incorrect with nested tags of same kind. We don't need 48 | # nested tags at all. 49 | self.inside_target_tag = False 50 | 51 | def handle_data(self, data): 52 | if self.inside_target_tag and self.expected_content is not None: 53 | assert data.strip() == self.expected_content 54 | 55 | 56 | 57 | @pytest.mark.django_db 58 | @pytest.mark.usefixtures('posts') 59 | def test_post_admin_filters_basics(admin_client, unused_tag): 60 | posts_count = 10 61 | post_authors = dict(Post.objects.values_list('author__id', 'author__username')) 62 | post_audiences = {p.audience: p.get_audience_display() for p in Post.objects.all()} 63 | post_tags = dict(Tag.objects.filter(post__isnull=False).distinct().values_list('id', 'name')) 64 | 65 | assert post_authors 66 | assert post_audiences 67 | assert post_tags 68 | target_options = {'author': post_authors, 'audience': post_audiences, 'tags': post_tags} 69 | 70 | response = admin_client.get(reverse('admin:testapp_post_changelist')) 71 | assert response.status_code == HTTPStatus.OK 72 | assert len(response.context['results']) == posts_count 73 | assert response.context.get('cl', None) 74 | assert hasattr(response.context.get('cl', {}), 'filter_specs') 75 | 76 | content = response.content.decode() 77 | filter_specs = response.context['cl'].filter_specs 78 | 79 | assert len(filter_specs) > 0 80 | 81 | expected_lookup_kwargs = { 82 | 'author': 'author__id__exact', 83 | 'audience': 'audience__exact', 84 | 'category': 'category__id__exact', 85 | 'category_renamed': 'category_renamed__renamed_id__exact', 86 | 'tags': 'tags__id__exact', 87 | } 88 | 89 | for spec in filter_specs: 90 | if isinstance(spec, (DALFRelatedField, DALFChoicesField, DALFRelatedFieldAjax, DALFRelatedOnlyField)): 91 | filter_choices = list(spec.choices(response.context['cl'])) 92 | filter_custom_options = filter_choices.pop() 93 | option_field_name = filter_custom_options['field_name'] 94 | 95 | lookup_kwarg = filter_custom_options['lookup_kwarg'] 96 | assert lookup_kwarg == expected_lookup_kwargs[option_field_name] 97 | 98 | if option_field_name in ['author', 'audience', 'tags']: 99 | validator = MatchingTagValidator( 100 | 'select', 101 | {'name': option_field_name}, 102 | { 103 | 'class': 'django-admin-list-filter admin-autocomplete', 104 | 'data-theme': 'admin-autocomplete', 105 | 'name': option_field_name, 106 | 'data-lookup-kwarg': lookup_kwarg, 107 | }, 108 | ) 109 | validator.check(content) 110 | 111 | for internal, human in target_options[option_field_name].items(): 112 | validator = MatchingTagValidator( 113 | 'option', 114 | {'value': f'?{lookup_kwarg}={internal}'}, 115 | {}, 116 | human 117 | ) 118 | validator.check(content) 119 | 120 | elif option_field_name in ['category', 'category_renamed']: 121 | validator = MatchingTagValidator( 122 | 'select', 123 | {'data-field-name': option_field_name}, 124 | { 125 | 'class': 'django-admin-list-filter-ajax', 126 | 'data-theme': 'admin-autocomplete', 127 | 'data-allow-clear': 'true', 128 | 'data-lookup-kwarg': lookup_kwarg, 129 | 'data-app-label': 'testapp', 130 | 'data-model-name': 'post', 131 | 'data-field-name': option_field_name, 132 | }, 133 | ) 134 | validator.check(content) 135 | 136 | url_params = '&'.join( 137 | [f'{key}={value}' for key, value in filter_custom_options.items() if key != 'selected_value'] 138 | ) 139 | url_params += '&term=Py' 140 | ajax_resonse = admin_client.get(f'/admin/autocomplete/?{url_params}') 141 | 142 | assert ajax_resonse['Content-Type'] == 'application/json' 143 | json_response = ajax_resonse.json() 144 | assert json_response 145 | 146 | results = json_response.get('results') 147 | assert len(results) == 1 148 | # Even when not named `id`, autocomplete AJAX will helpfully call it so: 149 | assert 'id' in results[0] 150 | 151 | pagination = json_response.get('pagination', {}).get('more', None) 152 | assert pagination is False 153 | else: 154 | pytest.fail(f'Unexpected field: {option_field_name}') 155 | 156 | # Must not include tags that have no associated Posts. 157 | validator = MatchingTagValidator( 158 | 'option', 159 | {'value': f'?tags__id__exact={unused_tag.pk}'}, 160 | {} 161 | ) 162 | validator.feed(content) 163 | assert not validator.seen_target_tag 164 | -------------------------------------------------------------------------------- /tests/testproject/testproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vigo/django-admin-list-filter/3a7e6d7966701a768cef2a969f8f2dcf80cc3308/tests/testproject/testproject/__init__.py -------------------------------------------------------------------------------- /tests/testproject/testproject/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.asgi import get_asgi_application 4 | 5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings') 6 | 7 | application = get_asgi_application() 8 | -------------------------------------------------------------------------------- /tests/testproject/testproject/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | BASE_DIR = Path(__file__).resolve().parent.parent 4 | SECRET_KEY = 'django-insecure-testprojectst' # noqa: S105 5 | DEBUG = True 6 | ALLOWED_HOSTS = [] 7 | INSTALLED_APPS = [ 8 | 'django.contrib.admin', 9 | 'django.contrib.auth', 10 | 'django.contrib.contenttypes', 11 | 'django.contrib.sessions', 12 | 'django.contrib.messages', 13 | 'django.contrib.staticfiles', 14 | 'testapp.apps.TestappConfig', 15 | 'dalf', 16 | ] 17 | 18 | MIDDLEWARE = [ 19 | 'django.middleware.security.SecurityMiddleware', 20 | 'django.contrib.sessions.middleware.SessionMiddleware', 21 | 'django.middleware.common.CommonMiddleware', 22 | 'django.middleware.csrf.CsrfViewMiddleware', 23 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 24 | 'django.contrib.messages.middleware.MessageMiddleware', 25 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 26 | ] 27 | 28 | ROOT_URLCONF = 'testproject.urls' 29 | 30 | TEMPLATES = [ 31 | { 32 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 33 | 'DIRS': [], 34 | 'APP_DIRS': True, 35 | 'OPTIONS': { 36 | 'context_processors': [ 37 | 'django.template.context_processors.debug', 38 | 'django.template.context_processors.request', 39 | 'django.contrib.auth.context_processors.auth', 40 | 'django.contrib.messages.context_processors.messages', 41 | ], 42 | }, 43 | }, 44 | ] 45 | 46 | WSGI_APPLICATION = 'testproject.wsgi.application' 47 | DATABASES = { 48 | 'default': { 49 | 'ENGINE': 'django.db.backends.sqlite3', 50 | 'NAME': ':memory:', 51 | } 52 | } 53 | 54 | LANGUAGE_CODE = 'en-us' 55 | TIME_ZONE = 'UTC' 56 | USE_I18N = True 57 | USE_TZ = True 58 | STATIC_URL = 'static/' 59 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 60 | -------------------------------------------------------------------------------- /tests/testproject/testproject/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | urlpatterns = [ 5 | path('admin/', admin.site.urls), 6 | ] 7 | -------------------------------------------------------------------------------- /tests/testproject/testproject/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings') 6 | 7 | application = get_wsgi_application() 8 | --------------------------------------------------------------------------------