├── .bumpversion.cfg ├── .cookiecutterrc ├── .coveragerc ├── .editorconfig ├── .github └── workflows │ └── github-actions.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── ci ├── appveyor-bootstrap.ps1 ├── bootstrap.py ├── requirements.txt └── templates │ └── .github │ └── workflows │ └── github-actions.yml ├── docs ├── FoldableListFilterAdminMixin.png └── FullWidthAdminMixin.png ├── pyproject.toml ├── pytest.ini ├── setup.py ├── src └── admin_utils │ ├── __init__.py │ ├── brief.py │ ├── mixins.py │ ├── mock.py │ └── static │ └── admin_utils │ ├── foldable-list-filter.css │ ├── foldable-list-filter.js │ └── full-width.css ├── tests ├── test_app │ ├── __init__.py │ ├── admin.py │ ├── models.py │ ├── tests.py │ └── views.py └── test_project │ ├── __init__.py │ ├── settings.py │ ├── templates │ ├── 404.html │ └── 500.html │ └── urls.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.1.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file (badge):README.rst] 11 | search = /v{current_version}.svg 12 | replace = /v{new_version}.svg 13 | 14 | [bumpversion:file (link):README.rst] 15 | search = /v{current_version}...master 16 | replace = /v{new_version}...master 17 | 18 | [bumpversion:file:src/admin_utils/__init__.py] 19 | search = __version__ = '{current_version}' 20 | replace = __version__ = '{new_version}' 21 | 22 | [bumpversion:file:.cookiecutterrc] 23 | search = version: {current_version} 24 | replace = version: {new_version} 25 | -------------------------------------------------------------------------------- /.cookiecutterrc: -------------------------------------------------------------------------------- 1 | # Generated by cookiepatcher, a small shim around cookiecutter (pip install cookiepatcher) 2 | 3 | default_context: 4 | c_extension_optional: 'no' 5 | c_extension_support: 'no' 6 | codacy: 'no' 7 | codacy_projectid: '[Get ID from https://app.codacy.com/gh/ionelmc/django-admin-utils/settings]' 8 | codeclimate: 'no' 9 | codecov: 'yes' 10 | command_line_interface: 'no' 11 | command_line_interface_bin_name: '-' 12 | coveralls: 'yes' 13 | distribution_name: django-admin-utils 14 | email: contact@ionelmc.ro 15 | formatter_quote_style: single 16 | full_name: Ionel Cristian Mărieș 17 | function_name: compute 18 | github_actions: 'yes' 19 | github_actions_osx: 'no' 20 | github_actions_windows: 'no' 21 | license: BSD 2-Clause License 22 | module_name: core 23 | package_name: admin_utils 24 | pre_commit: 'yes' 25 | project_name: Django Admin Utils 26 | project_short_description: Utility code and patterns. 27 | pypi_badge: 'yes' 28 | pypi_disable_upload: 'no' 29 | release_date: '2021-07-19' 30 | repo_hosting: github.com 31 | repo_hosting_domain: github.com 32 | repo_main_branch: master 33 | repo_name: django-admin-utils 34 | repo_username: ionelmc 35 | scrutinizer: 'no' 36 | setup_py_uses_setuptools_scm: 'no' 37 | sphinx_docs: 'no' 38 | sphinx_docs_hosting: https://django-admin-utils.readthedocs.io/ 39 | sphinx_doctest: 'no' 40 | sphinx_theme: sphinx-rtd-theme 41 | test_matrix_separate_coverage: 'no' 42 | tests_inside_package: 'no' 43 | version: 2.1.0 44 | version_manager: bump2version 45 | website: https://blog.ionelmc.ro 46 | year_from: '2012' 47 | year_to: '2022' 48 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | src 4 | */site-packages 5 | 6 | [run] 7 | branch = true 8 | source = 9 | admin_utils 10 | tests 11 | parallel = true 12 | 13 | [report] 14 | show_missing = true 15 | precision = 2 16 | omit = *migrations* 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see https://editorconfig.org/ 2 | root = true 3 | 4 | [*] 5 | # Use Unix-style newlines for most files (except Windows files, see below). 6 | end_of_line = lf 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | insert_final_newline = true 10 | indent_size = 4 11 | charset = utf-8 12 | 13 | [*.{bat,cmd,ps1}] 14 | end_of_line = crlf 15 | 16 | [*.{yml,yaml}] 17 | indent_size = 2 18 | 19 | [*.tsv] 20 | indent_style = tab 21 | -------------------------------------------------------------------------------- /.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request, workflow_dispatch] 3 | jobs: 4 | test: 5 | name: ${{ matrix.name }} 6 | runs-on: ${{ matrix.os }} 7 | timeout-minutes: 30 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - name: 'check' 13 | python: '3.11' 14 | toxpython: 'python3.11' 15 | tox_env: 'check' 16 | os: 'ubuntu-latest' 17 | - name: 'py39 (ubuntu)' 18 | python: '3.9' 19 | toxpython: 'python3.9' 20 | python_arch: 'x64' 21 | tox_env: 'py39' 22 | os: 'ubuntu-latest' 23 | - name: 'py310 (ubuntu)' 24 | python: '3.10' 25 | toxpython: 'python3.10' 26 | python_arch: 'x64' 27 | tox_env: 'py310' 28 | os: 'ubuntu-latest' 29 | - name: 'py311 (ubuntu)' 30 | python: '3.11' 31 | toxpython: 'python3.11' 32 | python_arch: 'x64' 33 | tox_env: 'py311' 34 | os: 'ubuntu-latest' 35 | - name: 'py312 (ubuntu)' 36 | python: '3.12' 37 | toxpython: 'python3.12' 38 | python_arch: 'x64' 39 | tox_env: 'py312' 40 | os: 'ubuntu-latest' 41 | - name: 'py313 (ubuntu)' 42 | python: '3.13' 43 | toxpython: 'python3.13' 44 | python_arch: 'x64' 45 | tox_env: 'py313' 46 | os: 'ubuntu-latest' 47 | - name: 'pypy39 (ubuntu)' 48 | python: 'pypy-3.9' 49 | toxpython: 'pypy3.9' 50 | python_arch: 'x64' 51 | tox_env: 'pypy39' 52 | os: 'ubuntu-latest' 53 | - name: 'pypy310 (ubuntu)' 54 | python: 'pypy-3.10' 55 | toxpython: 'pypy3.10' 56 | python_arch: 'x64' 57 | tox_env: 'pypy310' 58 | os: 'ubuntu-latest' 59 | steps: 60 | - uses: actions/checkout@v4 61 | with: 62 | fetch-depth: 0 63 | - uses: actions/setup-python@v5 64 | with: 65 | python-version: ${{ matrix.python }} 66 | architecture: ${{ matrix.python_arch }} 67 | - name: install dependencies 68 | run: | 69 | python -mpip install --progress-bar=off -r ci/requirements.txt 70 | virtualenv --version 71 | pip --version 72 | tox --version 73 | pip list --format=freeze 74 | - name: test 75 | env: 76 | TOXPYTHON: '${{ matrix.toxpython }}' 77 | run: > 78 | tox -e ${{ matrix.tox_env }} -v 79 | finish: 80 | needs: test 81 | if: ${{ always() }} 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: coverallsapp/github-action@v2 85 | with: 86 | parallel-finished: true 87 | - uses: codecov/codecov-action@v3 88 | with: 89 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # Temp files 5 | .*.sw[po] 6 | *~ 7 | *.bak 8 | .DS_Store 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Build and package files 14 | *.egg 15 | *.egg-info 16 | .bootstrap 17 | .build 18 | .cache 19 | .eggs 20 | .env 21 | .installed.cfg 22 | .ve 23 | bin 24 | build 25 | develop-eggs 26 | dist 27 | eggs 28 | lib 29 | lib64 30 | parts 31 | pip-wheel-metadata/ 32 | pyvenv*/ 33 | sdist 34 | var 35 | venv*/ 36 | wheelhouse 37 | 38 | # Installer logs 39 | pip-log.txt 40 | 41 | # Unit test / coverage reports 42 | .benchmarks 43 | .coverage 44 | .coverage.* 45 | .pytest 46 | .pytest_cache/ 47 | .tox 48 | coverage.xml 49 | htmlcov 50 | nosetests.xml 51 | 52 | # Translations 53 | *.mo 54 | 55 | # Buildout 56 | .mr.developer.cfg 57 | 58 | # IDE project files 59 | *.iml 60 | *.komodoproject 61 | .idea 62 | .project 63 | .pydevproject 64 | .vscode 65 | 66 | # Complexity 67 | output/*.html 68 | output/*/index.html 69 | 70 | # Sphinx 71 | docs/_build 72 | 73 | # Mypy Cache 74 | .mypy_cache/ 75 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # To install the git pre-commit hooks run: 2 | # pre-commit install --install-hooks 3 | # To update the versions: 4 | # pre-commit autoupdate 5 | exclude: '^(\.tox|ci/templates|\.bumpversion\.cfg)(/|$)' 6 | # Note the order is intentional to avoid multiple passes of the hooks 7 | repos: 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.11.2 10 | hooks: 11 | - id: ruff 12 | args: [--fix, --exit-non-zero-on-fix, --show-fixes, --unsafe-fixes] 13 | - id: ruff-format 14 | - repo: https://github.com/pre-commit/pre-commit-hooks 15 | rev: v5.0.0 16 | hooks: 17 | - id: trailing-whitespace 18 | - id: end-of-file-fixer 19 | - id: debug-statements 20 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | 2 | Authors 3 | ======= 4 | 5 | * Ionel Cristian Mărieș - https://blog.ionelmc.ro 6 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2 | Changelog 3 | ========= 4 | 5 | 2.1.0 (2025-03-22) 6 | ------------------ 7 | 8 | * Added support for Django 5.2. 9 | * Increased minimum versions to Django 4.2 and Python 3.9. 10 | 11 | 2.0.4 (2021-07-19) 12 | ------------------ 13 | 14 | * Fixed mock admin regression and add test. 15 | 16 | 2.0.3 (2021-07-19) 17 | ------------------ 18 | 19 | * Made the mock admin behave as without editable permissions (implemented the missing ``has_view_permission`` method). 20 | 21 | 2.0.2 (2021-07-18) 22 | ------------------ 23 | 24 | * Exposed the fake hidden model as a ``fake_model`` attribute. 25 | 26 | 2.0.1 (2021-07-18) 27 | ------------------ 28 | 29 | * Added missing import for ``admin_utils.register_view``. 30 | 31 | 2.0.0 (2021-07-18) 32 | ------------------ 33 | 34 | * Dropped support for Python 2.7 and Django 1.11. 35 | * Added the ``register_view`` decorator. 36 | * Update examples/readme. 37 | * Various bugfixes. 38 | 39 | 1.0.0 (2021-07-14) 40 | ------------------ 41 | 42 | * Fixed a bunch of regressions with Django 3.2. 43 | 44 | 0.3.0 (2014-02-02) 45 | ------------------ 46 | 47 | * Forgot to add any details. 48 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | Bug reports 9 | =========== 10 | 11 | When `reporting a bug `_ please include: 12 | 13 | * Your operating system name and version. 14 | * Any details about your local setup that might be helpful in troubleshooting. 15 | * Detailed steps to reproduce the bug. 16 | 17 | Documentation improvements 18 | ========================== 19 | 20 | Django Admin Utils could always use more documentation, whether as part of the 21 | official Django Admin Utils docs, in docstrings, or even on the web in blog posts, 22 | articles, and such. 23 | 24 | Feature requests and feedback 25 | ============================= 26 | 27 | The best way to send feedback is to file an issue at https://github.com/ionelmc/django-admin-utils/issues. 28 | 29 | If you are proposing a feature: 30 | 31 | * Explain in detail how it would work. 32 | * Keep the scope as narrow as possible, to make it easier to implement. 33 | * Remember that this is a volunteer-driven project, and that code contributions are welcome :) 34 | 35 | Development 36 | =========== 37 | 38 | To set up `django-admin-utils` for local development: 39 | 40 | 1. Fork `django-admin-utils `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:YOURGITHUBNAME/django-admin-utils.git 45 | 46 | 3. Create a branch for local development:: 47 | 48 | git checkout -b name-of-your-bugfix-or-feature 49 | 50 | Now you can make your changes locally. 51 | 52 | 4. When you're done making changes run all the checks and docs builder with one command:: 53 | 54 | tox 55 | 56 | 5. Commit your changes and push your branch to GitHub:: 57 | 58 | git add . 59 | git commit -m "Your detailed description of your changes." 60 | git push origin name-of-your-bugfix-or-feature 61 | 62 | 6. Submit a pull request through the GitHub website. 63 | 64 | Pull Request Guidelines 65 | ----------------------- 66 | 67 | If you need some code review or feedback while you're developing the code just make the pull request. 68 | 69 | For merging, you should: 70 | 71 | 1. Include passing tests (run ``tox``). 72 | 2. Update documentation when there's new API, functionality etc. 73 | 3. Add a note to ``CHANGELOG.rst`` about the changes. 74 | 4. Add yourself to ``AUTHORS.rst``. 75 | 76 | Tips 77 | ---- 78 | 79 | To run a subset of tests:: 80 | 81 | tox -e envname -- pytest -k test_myfeature 82 | 83 | To run all the test environments in *parallel*:: 84 | 85 | tox -p auto 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2012-2022, Ionel Cristian Mărieș. All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the 6 | following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following 9 | disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 15 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 17 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 18 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 19 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 20 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft docs 2 | graft src 3 | graft ci 4 | graft tests 5 | 6 | include .bumpversion.cfg 7 | include .cookiecutterrc 8 | include .coveragerc 9 | include .editorconfig 10 | include .github/workflows/github-actions.yml 11 | include .pre-commit-config.yaml 12 | include pytest.ini 13 | include tox.ini 14 | 15 | include AUTHORS.rst 16 | include CHANGELOG.rst 17 | include CONTRIBUTING.rst 18 | include LICENSE 19 | include README.rst 20 | 21 | global-exclude *.py[cod] __pycache__/* *.so *.dylib 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Overview 3 | ======== 4 | 5 | .. start-badges 6 | 7 | .. list-table:: 8 | :stub-columns: 1 9 | 10 | * - tests 11 | - |github-actions| |coveralls| |codecov| 12 | * - package 13 | - |version| |wheel| |supported-versions| |supported-implementations| |commits-since| 14 | 15 | .. |github-actions| image:: https://github.com/ionelmc/django-admin-utils/actions/workflows/github-actions.yml/badge.svg 16 | :alt: GitHub Actions Build Status 17 | :target: https://github.com/ionelmc/django-admin-utils/actions 18 | 19 | .. |coveralls| image:: https://coveralls.io/repos/github/ionelmc/django-admin-utils/badge.svg?branch=master 20 | :alt: Coverage Status 21 | :target: https://coveralls.io/github/ionelmc/django-admin-utils?branch=master 22 | 23 | .. |codecov| image:: https://codecov.io/gh/ionelmc/django-admin-utils/branch/master/graphs/badge.svg?branch=master 24 | :alt: Coverage Status 25 | :target: https://app.codecov.io/github/ionelmc/django-admin-utils 26 | 27 | .. |version| image:: https://img.shields.io/pypi/v/django-admin-utils.svg 28 | :alt: PyPI Package latest release 29 | :target: https://pypi.org/project/django-admin-utils 30 | 31 | .. |wheel| image:: https://img.shields.io/pypi/wheel/django-admin-utils.svg 32 | :alt: PyPI Wheel 33 | :target: https://pypi.org/project/django-admin-utils 34 | 35 | .. |supported-versions| image:: https://img.shields.io/pypi/pyversions/django-admin-utils.svg 36 | :alt: Supported versions 37 | :target: https://pypi.org/project/django-admin-utils 38 | 39 | .. |supported-implementations| image:: https://img.shields.io/pypi/implementation/django-admin-utils.svg 40 | :alt: Supported implementations 41 | :target: https://pypi.org/project/django-admin-utils 42 | 43 | .. |commits-since| image:: https://img.shields.io/github/commits-since/ionelmc/django-admin-utils/v2.1.0.svg 44 | :alt: Commits since latest release 45 | :target: https://github.com/ionelmc/django-admin-utils/compare/v2.1.0...master 46 | 47 | 48 | 49 | .. end-badges 50 | 51 | Utility code and patterns. 52 | 53 | * Free software: BSD 2-Clause License 54 | 55 | Installation 56 | ============ 57 | 58 | :: 59 | 60 | pip install django-admin-utils 61 | 62 | You can also install the in-development version with:: 63 | 64 | pip install https://github.com/ionelmc/django-admin-utils/archive/master.zip 65 | 66 | 67 | Documentation 68 | ============= 69 | 70 | Terse admin.py 71 | -------------- 72 | 73 | :: 74 | 75 | from django.contrib import admin 76 | from admin_utils import register, inline 77 | 78 | from .models import MyModel, OtherModel 79 | 80 | @register(MyModel) 81 | class MyModelAdmin(admin.ModelAdmin): 82 | inlines = inline(OtherModel), 83 | 84 | If you want custom admin sites:: 85 | 86 | customsite = admin.AdminSite() 87 | 88 | @register(MyModel, site=customsite) 89 | class MyModelAdmin(admin.ModelAdmin): 90 | inlines = inline(OherModel), 91 | 92 | 93 | Mock admin (mount your views in admin using model wrappers) 94 | ----------------------------------------------------------- 95 | 96 | Have you ever wanted a page in the admin that appears in the app list but you don't have any 97 | models ? Now you can have that without patching up the admin Site or the templates. Just put this 98 | in your admin.py:: 99 | 100 | from django.urls import path 101 | from admin_utils import make_admin_class 102 | 103 | make_admin_class( 104 | app_label="test_app", 105 | model_name="Test1", 106 | urls=[ 107 | path('', views.root, name='test_app_test1_changelist'), 108 | path('level1/', views.level1, name='level-1'), 109 | path('level1/level2/', views.level2, name='level-2'), 110 | ], 111 | ) 112 | 113 | To use different admin site:: 114 | 115 | make_admin_class( 116 | site=customsite, 117 | app_label="test_app", 118 | model_name="Test1", 119 | urls=[ 120 | path('', views.root, name='test_app_test1_changelist'), 121 | path('level1/', views.level1, name='level-1'), 122 | path('level1/level2/', views.level2, name='level-2'), 123 | ], 124 | ) 125 | 126 | Alternatively you can mount a single view with a decorator:: 127 | 128 | from admin_utils import register_view 129 | 130 | @register_view( 131 | site=customsite, 132 | app_label="test_app", 133 | model_name="Test1", 134 | ) 135 | def root(request): 136 | ... 137 | 138 | 139 | Admin mixins 140 | ------------ 141 | 142 | admin_utils.mixins.FoldableListFilterAdminMixin 143 | ``````````````````````````````````````````````` 144 | 145 | Adds nice filter toggling with cookie support. Largely based on `django-foldable-list-filter 146 | `_ but without the transition effect and no pictures. 147 | 148 | Example:: 149 | 150 | from admin_utils.mixins import FoldableListFilterAdminMixin 151 | 152 | class MyModelAdmin(FoldableListFilterAdminMixin, admin.ModelAdmin): 153 | pass 154 | 155 | Looks like this: 156 | 157 | .. image:: https://raw.githubusercontent.com/ionelmc/django-admin-utils/master/docs/FoldableListFilterAdminMixin.png 158 | :alt: Screenshort of FoldableListFilterAdminMixin 159 | 160 | admin_utils.mixins.FullWidthAdminMixin 161 | `````````````````````````````````````` 162 | 163 | Make the changelist expand instead of having the width of the windows and having that nasty inner scrollbar. You never gonna notice that if 164 | your table is long ! 165 | 166 | Example:: 167 | 168 | from admin_utils.mixins import FoldableListFilterAdminMixin 169 | 170 | class MyModelAdmin(FoldableListFilterAdminMixin, admin.ModelAdmin): 171 | pass 172 | 173 | You probably didn't even notice you had this problem: 174 | 175 | .. image:: https://raw.githubusercontent.com/ionelmc/django-admin-utils/master/docs/FullWidthAdminMixin.png 176 | :alt: Screenshort of FullWidthAdminMixin 177 | -------------------------------------------------------------------------------- /ci/appveyor-bootstrap.ps1: -------------------------------------------------------------------------------- 1 | # Source: https://github.com/pypa/python-packaging-user-guide/blob/master/source/code/install.ps1 2 | # Sample script to install Python and pip under Windows 3 | # Authors: Olivier Grisel and Kyle Kastner 4 | # License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ 5 | 6 | $BASE_URL = "https://www.python.org/ftp/python/" 7 | $GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" 8 | $GET_PIP_PATH = "C:\get-pip.py" 9 | 10 | 11 | function DownloadPython ($python_version, $platform_suffix) { 12 | $webclient = New-Object System.Net.WebClient 13 | $filename = "python-" + $python_version + $platform_suffix + ".msi" 14 | $url = $BASE_URL + $python_version + "/" + $filename 15 | 16 | $basedir = $pwd.Path + "\" 17 | $filepath = $basedir + $filename 18 | if (Test-Path $filename) { 19 | Write-Host "Reusing" $filepath 20 | return $filepath 21 | } 22 | 23 | # Download and retry up to 5 times in case of network transient errors. 24 | Write-Host "Downloading" $filename "from" $url 25 | $retry_attempts = 3 26 | for($i=0; $i -lt $retry_attempts; $i++){ 27 | try { 28 | $webclient.DownloadFile($url, $filepath) 29 | break 30 | } 31 | Catch [Exception]{ 32 | Start-Sleep 1 33 | } 34 | } 35 | Write-Host "File saved at" $filepath 36 | return $filepath 37 | } 38 | 39 | 40 | function InstallPython ($python_version, $architecture, $python_home) { 41 | Write-Host "Installing Python" $python_version "for" $architecture "bit architecture to" $python_home 42 | if (Test-Path $python_home) { 43 | Write-Host $python_home "already exists, skipping." 44 | return $false 45 | } 46 | if ($architecture -eq "32") { 47 | $platform_suffix = "" 48 | } else { 49 | $platform_suffix = ".amd64" 50 | } 51 | $filepath = DownloadPython $python_version $platform_suffix 52 | Write-Host "Installing" $filepath "to" $python_home 53 | $args = "/qn /i $filepath TARGETDIR=$python_home" 54 | Write-Host "msiexec.exe" $args 55 | Start-Process -FilePath "msiexec.exe" -ArgumentList $args -Wait -Passthru 56 | Write-Host "Python $python_version ($architecture) installation complete" 57 | return $true 58 | } 59 | 60 | 61 | function InstallPip ($python_home) { 62 | $pip_path = $python_home + "/Scripts/pip.exe" 63 | $python_path = $python_home + "/python.exe" 64 | if (-not(Test-Path $pip_path)) { 65 | Write-Host "Installing pip..." 66 | $webclient = New-Object System.Net.WebClient 67 | $webclient.DownloadFile($GET_PIP_URL, $GET_PIP_PATH) 68 | Write-Host "Executing:" $python_path $GET_PIP_PATH 69 | Start-Process -FilePath "$python_path" -ArgumentList "$GET_PIP_PATH" -Wait -Passthru 70 | } else { 71 | Write-Host "pip already installed." 72 | } 73 | } 74 | 75 | function InstallPackage ($python_home, $pkg) { 76 | $pip_path = $python_home + "/Scripts/pip.exe" 77 | & $pip_path install $pkg 78 | } 79 | 80 | function main () { 81 | InstallPython $env:PYTHON_VERSION $env:PYTHON_ARCH $env:PYTHON_HOME 82 | InstallPip $env:PYTHON_HOME 83 | InstallPackage $env:PYTHON_HOME setuptools 84 | InstallPackage $env:PYTHON_HOME wheel 85 | InstallPackage $env:PYTHON_HOME tox 86 | } 87 | 88 | main 89 | -------------------------------------------------------------------------------- /ci/bootstrap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import pathlib 4 | import subprocess 5 | import sys 6 | 7 | base_path: pathlib.Path = pathlib.Path(__file__).resolve().parent.parent 8 | templates_path = base_path / 'ci' / 'templates' 9 | 10 | 11 | def check_call(args): 12 | print('+', *args) 13 | subprocess.check_call(args) 14 | 15 | 16 | def exec_in_env(): 17 | env_path = base_path / '.tox' / 'bootstrap' 18 | if sys.platform == 'win32': 19 | bin_path = env_path / 'Scripts' 20 | else: 21 | bin_path = env_path / 'bin' 22 | if not env_path.exists(): 23 | import subprocess 24 | 25 | print(f'Making bootstrap env in: {env_path} ...') 26 | try: 27 | check_call([sys.executable, '-m', 'venv', env_path]) 28 | except subprocess.CalledProcessError: 29 | try: 30 | check_call([sys.executable, '-m', 'virtualenv', env_path]) 31 | except subprocess.CalledProcessError: 32 | check_call(['virtualenv', env_path]) 33 | print('Installing `jinja2` into bootstrap environment...') 34 | check_call([bin_path / 'pip', 'install', 'jinja2', 'tox']) 35 | python_executable = bin_path / 'python' 36 | if not python_executable.exists(): 37 | python_executable = python_executable.with_suffix('.exe') 38 | 39 | print(f'Re-executing with: {python_executable}') 40 | print('+ exec', python_executable, __file__, '--no-env') 41 | os.execv(python_executable, [python_executable, __file__, '--no-env']) 42 | 43 | 44 | def main(): 45 | import jinja2 46 | 47 | print(f'Project path: {base_path}') 48 | 49 | jinja = jinja2.Environment( 50 | loader=jinja2.FileSystemLoader(str(templates_path)), 51 | trim_blocks=True, 52 | lstrip_blocks=True, 53 | keep_trailing_newline=True, 54 | ) 55 | tox_environments = [ 56 | line.strip() 57 | # 'tox' need not be installed globally, but must be importable 58 | # by the Python that is running this script. 59 | # This uses sys.executable the same way that the call in 60 | # cookiecutter-pylibrary/hooks/post_gen_project.py 61 | # invokes this bootstrap.py itself. 62 | for line in subprocess.check_output([sys.executable, '-m', 'tox', '--listenvs'], universal_newlines=True).splitlines() 63 | ] 64 | tox_environments = [line for line in tox_environments if line.startswith('py')] 65 | for template in templates_path.rglob('*'): 66 | if template.is_file(): 67 | template_path = template.relative_to(templates_path).as_posix() 68 | destination = base_path / template_path 69 | destination.parent.mkdir(parents=True, exist_ok=True) 70 | destination.write_text(jinja.get_template(template_path).render(tox_environments=tox_environments)) 71 | print(f'Wrote {template_path}') 72 | print('DONE.') 73 | 74 | 75 | if __name__ == '__main__': 76 | args = sys.argv[1:] 77 | if args == ['--no-env']: 78 | main() 79 | elif not args: 80 | exec_in_env() 81 | else: 82 | print(f'Unexpected arguments: {args}', file=sys.stderr) 83 | sys.exit(1) 84 | -------------------------------------------------------------------------------- /ci/requirements.txt: -------------------------------------------------------------------------------- 1 | virtualenv>=16.6.0 2 | pip>=19.1.1 3 | setuptools>=18.0.1 4 | tox 5 | twine 6 | -------------------------------------------------------------------------------- /ci/templates/.github/workflows/github-actions.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request, workflow_dispatch] 3 | jobs: 4 | test: 5 | name: {{ '${{ matrix.name }}' }} 6 | runs-on: {{ '${{ matrix.os }}' }} 7 | timeout-minutes: 30 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - name: 'check' 13 | python: '3.11' 14 | toxpython: 'python3.11' 15 | tox_env: 'check' 16 | os: 'ubuntu-latest' 17 | {% for env in tox_environments %} 18 | {% set prefix = env.split('-')[0] -%} 19 | {% if prefix.startswith('pypy') %} 20 | {% set python %}pypy-{{ prefix[4] }}.{{ prefix[5:] }}{% endset %} 21 | {% set cpython %}pp{{ prefix[4:5] }}{% endset %} 22 | {% set toxpython %}pypy{{ prefix[4] }}.{{ prefix[5:] }}{% endset %} 23 | {% else %} 24 | {% set python %}{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} 25 | {% set cpython %}cp{{ prefix[2:] }}{% endset %} 26 | {% set toxpython %}python{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} 27 | {% endif %} 28 | {% for os, python_arch in [ 29 | ['ubuntu', 'x64'], 30 | ] %} 31 | - name: '{{ env }} ({{ os }})' 32 | python: '{{ python }}' 33 | toxpython: '{{ toxpython }}' 34 | python_arch: '{{ python_arch }}' 35 | tox_env: '{{ env }}' 36 | os: '{{ os }}-latest' 37 | {% endfor %} 38 | {% endfor %} 39 | steps: 40 | - uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 43 | - uses: actions/setup-python@v5 44 | with: 45 | python-version: {{ '${{ matrix.python }}' }} 46 | architecture: {{ '${{ matrix.python_arch }}' }} 47 | - name: install dependencies 48 | run: | 49 | python -mpip install --progress-bar=off -r ci/requirements.txt 50 | virtualenv --version 51 | pip --version 52 | tox --version 53 | pip list --format=freeze 54 | - name: test 55 | env: 56 | TOXPYTHON: '{{ '${{ matrix.toxpython }}' }}' 57 | run: > 58 | tox -e {{ '${{ matrix.tox_env }}' }} -v 59 | finish: 60 | needs: test 61 | if: {{ '${{ always() }}' }} 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: coverallsapp/github-action@v2 65 | with: 66 | parallel-finished: true 67 | - uses: codecov/codecov-action@v3 68 | with: 69 | CODECOV_TOKEN: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} 70 | -------------------------------------------------------------------------------- /docs/FoldableListFilterAdminMixin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/django-admin-utils/565e3d054a3a9b2f59b1a42f01b7a4d413a1d030/docs/FoldableListFilterAdminMixin.png -------------------------------------------------------------------------------- /docs/FullWidthAdminMixin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/django-admin-utils/565e3d054a3a9b2f59b1a42f01b7a4d413a1d030/docs/FullWidthAdminMixin.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=30.3.0", 4 | ] 5 | 6 | [tool.ruff] 7 | extend-exclude = ["static", "ci/templates"] 8 | line-length = 140 9 | src = ["src", "tests"] 10 | target-version = "py39" 11 | 12 | [tool.ruff.lint.per-file-ignores] 13 | "ci/*" = ["S"] 14 | 15 | [tool.ruff.lint] 16 | ignore = [ 17 | "RUF001", # ruff-specific rules ambiguous-unicode-character-string 18 | "RUF012", 19 | "S101", # flake8-bandit assert 20 | "S308", # flake8-bandit suspicious-mark-safe-usage 21 | "S603", # flake8-bandit subprocess-without-shell-equals-true 22 | "S607", # flake8-bandit start-process-with-partial-path 23 | "E501", # pycodestyle line-too-long 24 | ] 25 | select = [ 26 | "B", # flake8-bugbear 27 | "C4", # flake8-comprehensions 28 | "DTZ", # flake8-datetimez 29 | "E", # pycodestyle errors 30 | "EXE", # flake8-executable 31 | "F", # pyflakes 32 | "I", # isort 33 | "INT", # flake8-gettext 34 | "PIE", # flake8-pie 35 | "PLC", # pylint convention 36 | "PLE", # pylint errors 37 | "PT", # flake8-pytest-style 38 | "PTH", # flake8-use-pathlib 39 | "RSE", # flake8-raise 40 | "RUF", # ruff-specific rules 41 | "S", # flake8-bandit 42 | "UP", # pyupgrade 43 | "W", # pycodestyle warnings 44 | ] 45 | 46 | [tool.ruff.lint.flake8-pytest-style] 47 | fixture-parentheses = false 48 | mark-parentheses = false 49 | 50 | [tool.ruff.lint.isort] 51 | forced-separate = ["conftest"] 52 | force-single-line = true 53 | 54 | [tool.ruff.format] 55 | quote-style = "single" 56 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = test_project.settings 3 | 4 | # If a pytest section is found in one of the possible config files 5 | # (pytest.ini, tox.ini or setup.cfg), then pytest will not look for any others, 6 | # so if you add a pytest config section elsewhere, 7 | # you will need to delete this section from setup.cfg. 8 | norecursedirs = 9 | migrations 10 | 11 | python_files = 12 | test_*.py 13 | *_test.py 14 | tests.py 15 | addopts = 16 | -ra 17 | --strict-markers 18 | --doctest-modules 19 | --doctest-glob=\*.rst 20 | --tb=short 21 | testpaths = 22 | tests 23 | # If you want to switch back to tests outside package just remove --pyargs 24 | # and edit testpaths to have "tests/" instead of "admin_utils". 25 | 26 | # Idea from: https://til.simonwillison.net/pytest/treat-warnings-as-errors 27 | filterwarnings = 28 | error 29 | # You can add exclusions, some examples: 30 | # ignore:'admin_utils' defines default_app_config:PendingDeprecationWarning:: 31 | # ignore:The {{% if::: 32 | # ignore:Coverage disabled via --no-cov switch! 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import re 3 | from pathlib import Path 4 | 5 | from setuptools import find_packages 6 | from setuptools import setup 7 | 8 | 9 | def read(*names, **kwargs): 10 | with Path(__file__).parent.joinpath(*names).open(encoding=kwargs.get('encoding', 'utf8')) as fh: 11 | return fh.read() 12 | 13 | 14 | setup( 15 | name='django-admin-utils', 16 | version='2.1.0', 17 | license='BSD-2-Clause', 18 | description='Utility code and patterns.', 19 | long_description='{}\n{}'.format( 20 | re.compile('^.. start-badges.*^.. end-badges', re.M | re.S).sub('', read('README.rst')), 21 | re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst')), 22 | ), 23 | author='Ionel Cristian Mărieș', 24 | author_email='contact@ionelmc.ro', 25 | url='https://github.com/ionelmc/django-admin-utils', 26 | packages=find_packages('src'), 27 | package_dir={'': 'src'}, 28 | py_modules=[path.stem for path in Path('src').glob('*.py')], 29 | include_package_data=True, 30 | zip_safe=False, 31 | classifiers=[ 32 | # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers 33 | 'Development Status :: 5 - Production/Stable', 34 | 'Intended Audience :: Developers', 35 | 'License :: OSI Approved :: BSD License', 36 | 'Operating System :: Unix', 37 | 'Operating System :: POSIX', 38 | 'Operating System :: Microsoft :: Windows', 39 | 'Programming Language :: Python', 40 | 'Programming Language :: Python :: 3', 41 | 'Programming Language :: Python :: 3 :: Only', 42 | 'Programming Language :: Python :: 3.9', 43 | 'Programming Language :: Python :: 3.10', 44 | 'Programming Language :: Python :: 3.11', 45 | 'Programming Language :: Python :: 3.12', 46 | 'Programming Language :: Python :: 3.13', 47 | 'Programming Language :: Python :: Implementation :: CPython', 48 | 'Programming Language :: Python :: Implementation :: PyPy', 49 | # uncomment if you test on these interpreters: 50 | # "Programming Language :: Python :: Implementation :: IronPython", 51 | # "Programming Language :: Python :: Implementation :: Jython", 52 | # "Programming Language :: Python :: Implementation :: Stackless", 53 | 'Topic :: Utilities', 54 | ], 55 | project_urls={ 56 | 'Changelog': 'https://github.com/ionelmc/django-admin-utils/blob/master/CHANGELOG.rst', 57 | 'Issue Tracker': 'https://github.com/ionelmc/django-admin-utils/issues', 58 | }, 59 | keywords=[ 60 | # eg: "keyword1", "keyword2", "keyword3", 61 | ], 62 | python_requires='>=3.9', 63 | install_requires=[ 64 | 'django>=4.2', 65 | ], 66 | extras_require={ 67 | # eg: 68 | # "rst": ["docutils>=0.11"], 69 | # ":python_version=='3.8'": ["backports.zoneinfo"], 70 | }, 71 | ) 72 | -------------------------------------------------------------------------------- /src/admin_utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .brief import inline 2 | from .brief import register 3 | from .mock import make_admin_class 4 | from .mock import register_view 5 | 6 | __version__ = '2.1.0' 7 | __all__ = [ 8 | 'inline', 9 | 'make_admin_class', 10 | 'register', 11 | 'register_view', 12 | ] 13 | -------------------------------------------------------------------------------- /src/admin_utils/brief.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | 4 | def register(model, site=admin.site): 5 | def decorator(klass): 6 | site.register(model, klass) 7 | return klass 8 | 9 | return decorator 10 | 11 | 12 | def inline(model, klass=admin.TabularInline, **options): 13 | return type(f'{model.__name__}InlineAdmin', (klass,), dict(model=model, **options)) 14 | -------------------------------------------------------------------------------- /src/admin_utils/mixins.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import ModelAdmin 2 | 3 | 4 | class FoldableListFilterAdminMixin(ModelAdmin): 5 | class Media: 6 | js = ['admin_utils/foldable-list-filter.js'] 7 | css = {'all': ['admin_utils/foldable-list-filter.css']} 8 | 9 | 10 | class FullWidthAdminMixin(ModelAdmin): 11 | class Media: 12 | css = {'all': ['admin_utils/full-width.css']} 13 | -------------------------------------------------------------------------------- /src/admin_utils/mock.py: -------------------------------------------------------------------------------- 1 | from functools import update_wrapper 2 | 3 | from django.contrib import admin 4 | from django.urls import path 5 | from django.urls.resolvers import URLPattern 6 | 7 | 8 | class InvalidAdmin(RuntimeError): 9 | pass 10 | 11 | 12 | def fake_model_factory(**kwargs): 13 | type_name = f'Fake{kwargs["model_name"]}Model' 14 | 15 | class _meta: 16 | abstract = kwargs.pop('abstract') 17 | app_label = kwargs.pop('app_label') 18 | app_config = kwargs.pop('app_config') 19 | module_name = kwargs.pop('module_name') 20 | verbose_name_plural = kwargs.pop('verbose_name_plural') 21 | verbose_name = kwargs.pop('verbose_name') 22 | model_name = kwargs.pop('model_name') 23 | object_name = kwargs.pop('object_name') 24 | swapped = kwargs.pop('swapped') 25 | is_composite_pk = False 26 | 27 | if kwargs: 28 | raise InvalidAdmin(f'Unexpected arguments: {kwargs}') 29 | return type(type_name, (object,), {'_meta': _meta}) 30 | 31 | 32 | def register_view(app_label, model_name, **kwargs): 33 | def register_admin_decorator(view_func): 34 | urls = [ 35 | path('', view_func, name=f'{app_label}_{model_name.lower()}_changelist'), 36 | ] 37 | return make_admin_class(app_label, model_name, urls, **kwargs) 38 | 39 | return register_admin_decorator 40 | 41 | 42 | def make_admin_class( 43 | app_label, 44 | model_name, 45 | urls, 46 | register=True, 47 | site=admin.site, 48 | **kwargs, 49 | ): 50 | required_name = f'{app_label}_{model_name.lower()}_changelist' 51 | for url in urls: 52 | if getattr(url, 'name', None) == required_name: 53 | break 54 | else: 55 | raise InvalidAdmin(f'You must have an url with the name {required_name!r} otherwise the admin will fail to reverse it.') 56 | if 'app_label' in kwargs: 57 | raise InvalidAdmin(f'Got multiple values for app_label ({app_label}/{kwargs["app_label"]})') 58 | if 'model_name' in kwargs: 59 | raise InvalidAdmin(f'Got multiple values for model_name ({model_name}/{kwargs["model_name"]})') 60 | for url in urls: 61 | if not isinstance(url, URLPattern): 62 | raise InvalidAdmin(f'Unexpected url {url}') 63 | 64 | kwargs['model_name'] = model_name.lower() 65 | kwargs['app_label'] = app_label 66 | kwargs.setdefault('abstract', False) 67 | kwargs.setdefault('app_config', None) 68 | kwargs.setdefault('module_name', model_name.lower()) 69 | kwargs.setdefault('verbose_name_plural', model_name) 70 | kwargs.setdefault('verbose_name', model_name) 71 | kwargs.setdefault('object_name', model_name) 72 | kwargs.setdefault('swapped', False) 73 | 74 | FakeModel = fake_model_factory(**kwargs) 75 | 76 | class FakeModelAdminClass(admin.ModelAdmin): 77 | fake_model = FakeModel 78 | 79 | def has_view_permission(*args, **kwargs): 80 | return True 81 | 82 | def has_add_permission(*args, **kwargs): 83 | return False 84 | 85 | def has_change_permission(*args, **kwargs): 86 | return False 87 | 88 | def has_delete_permission(*args, **kwargs): 89 | return False 90 | 91 | def get_urls(self): 92 | def wrap(view): 93 | def wrapper(*args, **kwargs): 94 | return self.admin_site.admin_view(view)(*args, **kwargs) 95 | 96 | wrapper.model_admin = self 97 | return update_wrapper(wrapper, view) 98 | 99 | return [ 100 | URLPattern( 101 | pattern=url.pattern, 102 | callback=wrap(url.callback), 103 | default_args=url.default_args, 104 | name=url.name, 105 | ) 106 | for url in urls 107 | ] 108 | 109 | @classmethod 110 | def register(cls): 111 | site.register((FakeModel,), cls) 112 | 113 | if register: 114 | FakeModelAdminClass.register() 115 | return FakeModelAdminClass 116 | -------------------------------------------------------------------------------- /src/admin_utils/static/admin_utils/foldable-list-filter.css: -------------------------------------------------------------------------------- 1 | #changelist-filter h3 { 2 | cursor: pointer; 3 | color: #5B80B2; 4 | } 5 | #changelist-filter h3:hover { 6 | color: #036; 7 | } 8 | #changelist-filter h3:before { 9 | content: "[-]"; 10 | font-family: "Courier", monospace; 11 | color: #333; 12 | } 13 | #changelist-filter h3.closed:before { 14 | content: "[+]"; 15 | } 16 | #changelist-filter h3.closed + ul li:not(.selected) { 17 | display: none; 18 | } 19 | -------------------------------------------------------------------------------- /src/admin_utils/static/admin_utils/foldable-list-filter.js: -------------------------------------------------------------------------------- 1 | /* Django Foldable List. 2 | * 3 | * https://bitbucket.org/Stanislas/django-foldable-list-filter 4 | * 5 | * Copyright 2012, Stanislas Guerra 6 | * Licensed under the BSD 2-Clause licence. 7 | * http://www.opensource.org/licenses/bsd-license.php 8 | * 9 | * */ 10 | 11 | (function($) { 12 | /*! 13 | * jQuery Cookie Plugin v1.4.0 14 | * https://github.com/carhartl/jquery-cookie 15 | * 16 | * Copyright 2013 Klaus Hartl 17 | * Released under the MIT license 18 | */ 19 | var pluses = /\+/g; 20 | 21 | function encode(s) { 22 | return config.raw ? s : encodeURIComponent(s); 23 | } 24 | 25 | function decode(s) { 26 | return config.raw ? s : decodeURIComponent(s); 27 | } 28 | 29 | function stringifyCookieValue(value) { 30 | return encode(config.json ? JSON.stringify(value) : String(value)); 31 | } 32 | 33 | function parseCookieValue(s) { 34 | if (s.indexOf('"') === 0) { 35 | // This is a quoted cookie as according to RFC2068, unescape... 36 | s = s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); 37 | } 38 | 39 | try { 40 | // Replace server-side written pluses with spaces. 41 | // If we can't decode the cookie, ignore it, it's unusable. 42 | s = decodeURIComponent(s.replace(pluses, ' ')); 43 | } catch (e) { 44 | return; 45 | } 46 | 47 | try { 48 | // If we can't parse the cookie, ignore it, it's unusable. 49 | return config.json ? JSON.parse(s) : s; 50 | } catch (e) {} 51 | } 52 | 53 | function read(s, converter) { 54 | var value = config.raw ? s : parseCookieValue(s); 55 | return $.isFunction(converter) ? converter(value) : value; 56 | } 57 | 58 | var config = $.cookie = function(key, value, options) { 59 | 60 | // Write 61 | if (value !== undefined && !$.isFunction(value)) { 62 | options = $.extend({}, config.defaults, options); 63 | 64 | if (typeof options.expires === 'number') { 65 | var days = options.expires, 66 | t = options.expires = new Date(); 67 | t.setDate(t.getDate() + days); 68 | } 69 | 70 | return (document.cookie = [ 71 | encode(key), '=', stringifyCookieValue(value), 72 | options.expires ? '; expires=' + options.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE 73 | options.path ? '; path=' + options.path : '', 74 | options.domain ? '; domain=' + options.domain : '', 75 | options.secure ? '; secure' : '' 76 | ].join('')); 77 | } 78 | 79 | // Read 80 | 81 | var result = key ? undefined : {}; 82 | 83 | // To prevent the for loop in the first place assign an empty array 84 | // in case there are no cookies at all. Also prevents odd result when 85 | // calling $.cookie(). 86 | var cookies = document.cookie ? document.cookie.split('; ') : []; 87 | 88 | for (var i = 0, l = cookies.length; i < l; i++) { 89 | var parts = cookies[i].split('='); 90 | var name = decode(parts.shift()); 91 | var cookie = parts.join('='); 92 | 93 | if (key && key === name) { 94 | // If second argument (value) is a function it's a converter... 95 | result = read(cookie, value); 96 | break; 97 | } 98 | 99 | // Prevent storing a cookie that we couldn't decode. 100 | if (!key && (cookie = read(cookie)) !== undefined) { 101 | result[name] = cookie; 102 | } 103 | } 104 | 105 | return result; 106 | }; 107 | 108 | config.defaults = {}; 109 | 110 | $.removeCookie = function(key, options) { 111 | if ($.cookie(key) !== undefined) { 112 | // Must not alter options, thus extending a fresh object... 113 | $.cookie(key, '', $.extend({}, options, { 114 | expires: -1 115 | })); 116 | return true; 117 | } 118 | return false; 119 | }; 120 | 121 | $(document).ready(function() { 122 | var flf = { 123 | filters: $("#changelist-filter h3"), 124 | cookie_name: "list_filter_closed", 125 | delim: "|", 126 | opened_class: "opened", 127 | closed_class: "closed", 128 | list_filter_closed: [], 129 | update_cookie: function(action, index) { 130 | if ($.isFunction($.cookie)) { 131 | var list_filter_closed = flf.get_list_filter_closed(); 132 | if (action === flf.closed_class) { 133 | list_filter_closed.push(index.toString()); 134 | } else { 135 | list_filter_closed.splice(list_filter_closed.indexOf(index.toString()), 1); 136 | } 137 | $.cookie(flf.cookie_name, 138 | list_filter_closed.join(flf.delim)); 139 | } 140 | }, 141 | get_list_filter_closed: function() { 142 | return ($.cookie(flf.cookie_name) || "") 143 | .split(flf.delim) 144 | } 145 | }; 146 | 147 | if ($.isFunction($.cookie)) { 148 | flf.list_filter_closed = flf.get_list_filter_closed(); 149 | } 150 | 151 | flf.filters.each(function(i, elt) { 152 | var h3 = $(this), 153 | status_class = flf.opened_class; 154 | if (flf.list_filter_closed.indexOf(i.toString()) !== -1) { 155 | status_class = flf.closed_class; 156 | } 157 | h3.addClass("filter " + status_class); 158 | }); 159 | 160 | flf.filters.click(function() { 161 | var filter = $(this); 162 | if (filter.hasClass(flf.opened_class)) { // Closing. 163 | filter.removeClass(flf.opened_class); 164 | filter.addClass(flf.closed_class); 165 | flf.update_cookie(flf.closed_class, flf.filters.index(filter)); 166 | } else { // Opening. 167 | filter.addClass(flf.opened_class); 168 | filter.removeClass(flf.closed_class); 169 | flf.update_cookie(flf.opened_class, flf.filters.index(filter)); 170 | } 171 | }); 172 | }); 173 | 174 | })(django.jQuery); 175 | -------------------------------------------------------------------------------- /src/admin_utils/static/admin_utils/full-width.css: -------------------------------------------------------------------------------- 1 | #content-main { 2 | width: auto !important; 3 | } 4 | -------------------------------------------------------------------------------- /tests/test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/django-admin-utils/565e3d054a3a9b2f59b1a42f01b7a4d413a1d030/tests/test_app/__init__.py -------------------------------------------------------------------------------- /tests/test_app/admin.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from admin_utils import make_admin_class 4 | from test_app import views 5 | 6 | make_admin_class( 7 | 'test_app', 8 | 'Test1', 9 | [ 10 | path('', views.root, name='test_app_test1_changelist'), 11 | path('level1/', views.level1, name='level-1'), 12 | path('level1/level2/', views.level2, name='level-2'), 13 | ], 14 | ) 15 | 16 | make_admin_class( 17 | 'test_app', 18 | 'Test2', 19 | [ 20 | path('', views.root, name='test_app_test2_changelist'), 21 | ], 22 | ) 23 | -------------------------------------------------------------------------------- /tests/test_app/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/django-admin-utils/565e3d054a3a9b2f59b1a42f01b7a4d413a1d030/tests/test_app/models.py -------------------------------------------------------------------------------- /tests/test_app/tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.auth.models import User 3 | from django.test import TestCase 4 | 5 | try: 6 | from django.core.urlresolvers import reverse 7 | except ImportError: 8 | from django.urls import reverse 9 | 10 | 11 | class MockAdminTestCase(TestCase): 12 | def setUp(self): 13 | self.user = User( 14 | username='test', 15 | email='test@example.com', 16 | is_active=True, 17 | is_staff=True, 18 | is_superuser=True, 19 | ) 20 | self.user.set_password('test') 21 | self.user.save() 22 | self.client.login(username='test', password='test') # noqa:S106 23 | 24 | def test_admin_not_broken(self): 25 | response = self.client.get('/admin/') 26 | self.assertContains(response, '/admin/password_change/') 27 | self.assertNotContains(response, "You don't have permission to edit anything") 28 | 29 | def test_admin_auth_not_broken(self): 30 | response = self.client.get('/admin/auth/') 31 | assert response.status_code == 200, response 32 | 33 | def test_admin_auth_user_not_broken(self): 34 | url = reverse('admin:auth_user_changelist') 35 | assert url == '/admin/auth/user/' 36 | response = self.client.get(url) 37 | assert response.status_code == 200, response 38 | 39 | def test_admin_shows_in_applist(self): 40 | url = reverse('admin:app_list', kwargs={'app_label': 'test_app'}) 41 | assert url == '/admin/test_app/' 42 | response = self.client.get(url) 43 | assert response.status_code == 200, response 44 | assert b'>Test1<' in response.content, response 45 | assert b'href="/admin/test_app/test1/"' in response.content, response 46 | assert b'>Test2<' in response.content, response 47 | assert b'href="/admin/test_app/test2/"' in response.content, response 48 | 49 | def test_admin_shows_in_index(self): 50 | url = reverse('admin:index') 51 | assert url == '/admin/' 52 | response = self.client.get(url) 53 | assert response.status_code == 200, response 54 | assert b'>Test1<' in response.content, response 55 | assert b'href="/admin/test_app/test1/"' in response.content, response 56 | assert b'>Test2<' in response.content, response 57 | assert b'href="/admin/test_app/test2/"' in response.content, response 58 | 59 | def test_admin_1_root(self): 60 | url = reverse('admin:test_app_test1_changelist') 61 | assert url == '/admin/test_app/test1/' 62 | self.assertRedirects(self.client.get(url.rstrip('/')), url, 301) 63 | response = self.client.get(url) 64 | assert response.status_code == 200, response 65 | assert response.content == b'root', response 66 | 67 | def test_admin_1_level1(self): 68 | url = reverse('admin:level-1') 69 | assert url == '/admin/test_app/test1/level1/' 70 | self.assertRedirects(self.client.get(url.rstrip('/')), url, 301) 71 | response = self.client.get(url) 72 | assert response.status_code == 200, response 73 | assert response.content == b'level1', response 74 | 75 | def test_admin_1_level1_level2(self): 76 | url = reverse('admin:level-2') 77 | assert url == '/admin/test_app/test1/level1/level2/' 78 | self.assertRedirects(self.client.get(url.rstrip('/')), url, 301) 79 | response = self.client.get(url) 80 | assert response.status_code == 200, response 81 | assert response.content == b'level2', response 82 | 83 | def test_admin_2_root(self): 84 | url = reverse('admin:test_app_test2_changelist') 85 | assert url == '/admin/test_app/test2/' 86 | self.assertRedirects(self.client.get(url.rstrip('/')), url, 301) 87 | response = self.client.get(url) 88 | assert response.status_code == 200, response 89 | assert response.content == b'root', response 90 | 91 | def test_runtime_error(self): 92 | from admin_utils.mock import InvalidAdmin 93 | from test_app import views 94 | 95 | from .admin import make_admin_class 96 | from .admin import path 97 | 98 | with pytest.raises(InvalidAdmin): 99 | make_admin_class( 100 | 'test_app', 101 | 'Test', 102 | [ 103 | path('', views.root, name='whatever'), 104 | ], 105 | ) 106 | -------------------------------------------------------------------------------- /tests/test_app/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | 4 | def root(request): 5 | return HttpResponse('root') 6 | 7 | 8 | def level1(request): 9 | return HttpResponse('level1') 10 | 11 | 12 | def level2(request): 13 | return HttpResponse('level2') 14 | -------------------------------------------------------------------------------- /tests/test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionelmc/django-admin-utils/565e3d054a3a9b2f59b1a42f01b7a4d413a1d030/tests/test_project/__init__.py -------------------------------------------------------------------------------- /tests/test_project/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | BASE_DIR = Path(__file__).parent 4 | DEBUG = True 5 | DATABASE_ENGINE = 'sqlite3' 6 | DATABASE_NAME = str(BASE_DIR / 'database.sqlite') 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | 'NAME': ':memory:', 11 | # 'NAME': 'test.sqlite', 12 | }, 13 | } 14 | APPEND_SLASH = True 15 | USE_TZ = True 16 | ALLOWED_HOSTS = ('*',) 17 | INSTALLED_APPS = ( 18 | 'django.contrib.admin', 19 | 'django.contrib.admindocs', 20 | 'django.contrib.auth', 21 | 'django.contrib.contenttypes', 22 | 'django.contrib.messages', 23 | 'django.contrib.sessions', 24 | 'django.contrib.sites', 25 | 'django.contrib.staticfiles', 26 | 'test_app', 27 | ) 28 | MIDDLEWARE = ( 29 | 'django.contrib.sessions.middleware.SessionMiddleware', 30 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 31 | 'django.contrib.messages.middleware.MessageMiddleware', 32 | 'django.middleware.common.CommonMiddleware', 33 | ) 34 | SITE_ID = 1 35 | ROOT_URLCONF = 'test_project.urls' 36 | TEMPLATES = [ 37 | { 38 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 39 | 'DIRS': [str(BASE_DIR / 'templates')], 40 | 'APP_DIRS': True, 41 | 'OPTIONS': { 42 | 'debug': DEBUG, 43 | 'context_processors': [ 44 | 'django.template.context_processors.debug', 45 | 'django.template.context_processors.request', 46 | 'django.contrib.auth.context_processors.auth', 47 | 'django.contrib.messages.context_processors.messages', 48 | ], 49 | }, 50 | } 51 | ] 52 | SECRET_KEY = "DON'T MATTER" # noqa: S105 53 | STATIC_URL = '/static/' 54 | LOGGING = { 55 | 'version': 1, 56 | 'disable_existing_loggers': True, 57 | 'formatters': { 58 | 'verbose': { 59 | 'format': '[%(asctime)s: %(levelname)s/%(processName)s/%(process)s] %(name)s - %(message)s ' 60 | '\t\t\t in %(funcName)s@%(pathname)s:%(lineno)d' 61 | }, 62 | }, 63 | 'handlers': { 64 | 'console': {'level': 'DEBUG', 'class': 'logging.StreamHandler', 'formatter': 'verbose', 'stream': 'ext://sys.stderr'}, 65 | }, 66 | 'loggers': { 67 | 'django': { 68 | 'handlers': ['console'], 69 | 'propagate': True, 70 | 'level': 'DEBUG', 71 | }, 72 | }, 73 | 'root': { 74 | 'handlers': ['console'], 75 | 'level': 'DEBUG', 76 | 'propagate': True, 77 | }, 78 | } 79 | -------------------------------------------------------------------------------- /tests/test_project/templates/404.html: -------------------------------------------------------------------------------- 1 |

404 NOT FOUND

2 | -------------------------------------------------------------------------------- /tests/test_project/templates/500.html: -------------------------------------------------------------------------------- 1 |

500 INTERNAL SERVER ERROR

2 | -------------------------------------------------------------------------------- /tests/test_project/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.urls import re_path as url 3 | except ImportError: 4 | from django.conf.urls import url 5 | from django.contrib import admin 6 | 7 | urlpatterns = [ 8 | url(r'^admin/', admin.site.urls), 9 | ] 10 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv:bootstrap] 2 | deps = 3 | jinja2 4 | tox 5 | skip_install = true 6 | commands = 7 | python ci/bootstrap.py --no-env 8 | passenv = 9 | * 10 | 11 | ; a generative tox configuration, see: https://tox.wiki/en/latest/user_guide.html#generative-environments 12 | [tox] 13 | envlist = 14 | clean, 15 | check, 16 | {py39,py310,py311,py312,py313,pypy39,pypy310}-{dj42,dj51,dj52}, 17 | report 18 | ignore_basepython_conflict = true 19 | 20 | [testenv] 21 | basepython = 22 | pypy39: {env:TOXPYTHON:pypy3.9} 23 | pypy310: {env:TOXPYTHON:pypy3.10} 24 | py39: {env:TOXPYTHON:python3.9} 25 | py310: {env:TOXPYTHON:python3.10} 26 | py311: {env:TOXPYTHON:python3.11} 27 | py312: {env:TOXPYTHON:python3.12} 28 | py313: {env:TOXPYTHON:python3.13} 29 | {bootstrap,clean,check,report,codecov,coveralls}: {env:TOXPYTHON:python3} 30 | setenv = 31 | PYTHONPATH={toxinidir}/tests 32 | PYTHONUNBUFFERED=yes 33 | passenv = 34 | * 35 | usedevelop = false 36 | deps = 37 | pytest 38 | pytest-cov 39 | pytest-django 40 | dj42: django==4.2.20 41 | dj51: django==5.1.7 42 | dj52: django==5.2rc1 43 | commands = 44 | {posargs:pytest --cov --cov-report=term-missing --cov-report=xml -vv tests} 45 | 46 | [testenv:check] 47 | deps = 48 | docutils 49 | check-manifest 50 | pre-commit 51 | readme-renderer 52 | pygments 53 | isort 54 | skip_install = true 55 | commands = 56 | python setup.py check --strict --metadata --restructuredtext 57 | check-manifest . 58 | pre-commit run --all-files --show-diff-on-failure 59 | 60 | [testenv:report] 61 | deps = 62 | coverage 63 | skip_install = true 64 | commands = 65 | coverage report 66 | coverage html 67 | 68 | [testenv:clean] 69 | commands = 70 | python setup.py clean 71 | coverage erase 72 | skip_install = true 73 | deps = 74 | setuptools 75 | coverage 76 | --------------------------------------------------------------------------------