├── .cruft.json ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release.yml │ ├── test.yml │ └── update.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTORS ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── example_changelist.png ├── example_inline.png ├── inline_actions ├── __init__.py ├── actions.py ├── admin.py ├── static │ └── inline_actions │ │ └── css │ │ └── inline_actions.css └── templatetags │ ├── __init__.py │ └── inline_action_tags.py ├── poetry.lock ├── poetry.toml ├── pyproject.toml ├── setup.cfg ├── test_proj ├── __init__.py ├── blog │ ├── __init__.py │ ├── admin.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ └── change_title.html │ └── tests │ │ ├── __init__.py │ │ ├── test_admin.py │ │ ├── test_form.py │ │ ├── test_inline_admin.py │ │ ├── test_model_admin.py │ │ └── test_templatetags.py ├── conftest.py ├── manage.py ├── settings.py ├── urls.py └── wsgi.py └── tox.ini /.cruft.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "https://github.com/escaped/cookiecutter-pypackage.git", 3 | "commit": "201e2aa005db39b8b3ab854658dc5d8da0822e3a", 4 | "context": { 5 | "cookiecutter": { 6 | "author": "Alexander Frenzel", 7 | "author_email": "alex@relatedworks.com", 8 | "github_username": "escaped", 9 | "project_name": "django-inline-actions", 10 | "project_slug": "django_inline_actions", 11 | "short_description": "django-inline-actions adds actions to each row of the ModelAdmin or InlineModelAdmin.", 12 | "version": "2.3.0", 13 | "line_length": "88", 14 | "uses_django": "n", 15 | "_template": "https://github.com/escaped/cookiecutter-pypackage.git" 16 | } 17 | }, 18 | "directory": null 19 | } -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the proposed changes. 4 | 5 | Fixes #(issue) 6 | 7 | ## Checklist 8 | 9 | - [ ] Tests covering the new functionality have been added 10 | - [ ] Code builds clean without any errors or warnings 11 | - [ ] Documentation has been updated 12 | - [ ] Changes have been added to the `CHANGELOG.md` 13 | - [ ] You added yourself to the `CONTRIBUTORS.md` 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | name: Create release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | - name: Get version from tag 16 | id: tag_name 17 | run: | 18 | echo ::set-output name=current_version::${GITHUB_REF#refs/tags/} 19 | shell: bash 20 | - name: Get Changelog Entry 21 | id: changelog_reader 22 | uses: mindsers/changelog-reader-action@v2 23 | with: 24 | version: ${{ steps.tag_name.outputs.current_version }} 25 | path: ./CHANGELOG.md 26 | - name: Create Release 27 | id: create_release 28 | uses: actions/create-release@v1 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | with: 32 | tag_name: ${{ steps.changelog_reader.outputs.version }} 33 | release_name: Release ${{ steps.changelog_reader.outputs.version }} 34 | body: ${{ steps.changelog_reader.outputs.changes }} 35 | prerelease: ${{ steps.changelog_reader.outputs.status == 'prereleased' }} 36 | draft: ${{ steps.changelog_reader.outputs.status == 'unreleased' }} 37 | 38 | publish: 39 | needs: [release] 40 | name: Build and publish Python distributions to PyPI 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@master 44 | - name: Set up Python 3.7 45 | uses: actions/setup-python@v1 46 | with: 47 | python-version: 3.7 48 | - name: Install pep517 49 | run: | 50 | python -m pip install pep517 51 | - name: Build a binary wheel and a source tarball 52 | run: | 53 | python -m pep517.build . --source --binary --out-dir dist/ 54 | - name: Publish distribution to PyPI 55 | uses: pypa/gh-action-pypi-publish@master 56 | with: 57 | password: ${{ secrets.pypi_token }} 58 | 59 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test & Lint 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | - main 9 | 10 | jobs: 11 | lint_cruft: 12 | name: Check if automatic project update was successful 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | with: 18 | ref: ${{ github.event.pull_request.head.sha }} 19 | - name: Fail if .rej files exist as structure update was not successful 20 | run: test -z "$(find . -iname '*.rej')" 21 | 22 | lint: 23 | name: Lint 24 | needs: [lint_cruft] 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v2 29 | with: 30 | ref: ${{ github.event.pull_request.head.sha }} 31 | - name: Set up Python 32 | uses: actions/setup-python@v2 33 | with: 34 | python-version: 3.8.5 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install poetry 39 | poetry install 40 | - name: Lint 41 | run: poetry run pre-commit run -a 42 | 43 | test: 44 | name: Test 45 | needs: [lint_cruft] 46 | runs-on: ${{ matrix.platform }} 47 | strategy: 48 | max-parallel: 4 49 | matrix: 50 | platform: [ubuntu-latest] 51 | python-version: [3.6, 3.7, 3.8, 3.9] 52 | steps: 53 | - uses: actions/checkout@v2 54 | with: 55 | ref: ${{ github.event.pull_request.head.sha }} 56 | - name: Set up Python ${{ matrix.python-version }} 57 | uses: actions/setup-python@v2 58 | with: 59 | python-version: ${{ matrix.python-version }} 60 | - name: Install dependencies 61 | run: | 62 | python -m pip install --upgrade pip 63 | pip install tox tox-gh-actions coveralls 64 | - name: Test with tox 65 | run: tox 66 | env: 67 | PLATFORM: ${{ matrix.platform }} 68 | - name: Coveralls 69 | uses: AndreMiras/coveralls-python-action@develop 70 | with: 71 | github-token: ${{ secrets.GITHUB_TOKEN }} 72 | parallel: true 73 | 74 | coveralls_finish: 75 | needs: [test] 76 | runs-on: ubuntu-latest 77 | steps: 78 | - name: Coveralls Finished 79 | uses: AndreMiras/coveralls-python-action@develop 80 | with: 81 | github-token: ${{ secrets.GITHUB_TOKEN }} 82 | parallel-finished: true 83 | 84 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: Update project structure 2 | on: 3 | schedule: 4 | - cron: "0 0 * * *" # at the end of every day 5 | 6 | jobs: 7 | autoUpdateProject: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: 3.8.5 15 | 16 | - name: Install dependencies 17 | run: pip install cruft poetry jello 18 | 19 | - name: Update project structure 20 | run: | 21 | cruft update -y 22 | 23 | - name: Check if there are changes 24 | id: changes 25 | uses: UnicornGlobal/has-changes-action@v1.0.11 26 | 27 | - name: apply additional changes and fixes 28 | if: steps.changes.outputs.changed == 1 29 | run: | 30 | poetry lock --no-update # add new dependencies 31 | poetry install 32 | poetry run pre-commit run -a || true # we have to fix other issue manually 33 | 34 | - name: Get new template version 35 | if: steps.changes.outputs.changed == 1 36 | # extract new cooiecutter template version 37 | run: | 38 | echo "TEMPLATE_COMMIT=$(cat .cruft.json | jello -r "_['commit'][:8]")" >> $GITHUB_ENV 39 | 40 | # behaviour if PR already exists: https://github.com/marketplace/actions/create-pull-request#action-behaviour 41 | - name: Create Pull Request 42 | if: steps.changes.outputs.changed == 1 43 | uses: peter-evans/create-pull-request@v3 44 | with: 45 | token: ${{ secrets.AUTO_UPDATE_GITHUB_TOKEN }} 46 | commit-message: >- 47 | chore: update project structure to ${{ env.TEMPLATE_COMMIT }} 48 | title: "[Actions] Auto-Sync cookiecutter template" 49 | body: "" 50 | branch: chore/cookiecutter-pypackage 51 | delete-branch: true 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/vim,osx,node,linux,python,windows,visualstudiocode,git 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=vim,osx,node,linux,python,windows,visualstudiocode,git 4 | 5 | ### Git ### 6 | # Created by git for backups. To disable backups in Git: 7 | # $ git config --global mergetool.keepBackup false 8 | *.orig 9 | 10 | # Created by git when using merge tools for conflicts 11 | *.BACKUP.* 12 | *.BASE.* 13 | *.LOCAL.* 14 | *.REMOTE.* 15 | *_BACKUP_*.txt 16 | *_BASE_*.txt 17 | *_LOCAL_*.txt 18 | *_REMOTE_*.txt 19 | 20 | ### Linux ### 21 | *~ 22 | 23 | # temporary files which can be created if a process still has a handle open of a deleted file 24 | .fuse_hidden* 25 | 26 | # KDE directory preferences 27 | .directory 28 | 29 | # Linux trash folder which might appear on any partition or disk 30 | .Trash-* 31 | 32 | # .nfs files are created when an open file is removed but is still being accessed 33 | .nfs* 34 | 35 | ### Node ### 36 | # Logs 37 | logs 38 | *.log 39 | npm-debug.log* 40 | yarn-debug.log* 41 | yarn-error.log* 42 | lerna-debug.log* 43 | 44 | # Diagnostic reports (https://nodejs.org/api/report.html) 45 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 46 | 47 | # Runtime data 48 | pids 49 | *.pid 50 | *.seed 51 | *.pid.lock 52 | 53 | # Directory for instrumented libs generated by jscoverage/JSCover 54 | lib-cov 55 | 56 | # Coverage directory used by tools like istanbul 57 | coverage 58 | *.lcov 59 | 60 | # nyc test coverage 61 | .nyc_output 62 | 63 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 64 | .grunt 65 | 66 | # Bower dependency directory (https://bower.io/) 67 | bower_components 68 | 69 | # node-waf configuration 70 | .lock-wscript 71 | 72 | # Compiled binary addons (https://nodejs.org/api/addons.html) 73 | build/Release 74 | 75 | # Dependency directories 76 | node_modules/ 77 | jspm_packages/ 78 | 79 | # TypeScript v1 declaration files 80 | typings/ 81 | 82 | # TypeScript cache 83 | *.tsbuildinfo 84 | 85 | # Optional npm cache directory 86 | .npm 87 | 88 | # Optional eslint cache 89 | .eslintcache 90 | 91 | # Microbundle cache 92 | .rpt2_cache/ 93 | .rts2_cache_cjs/ 94 | .rts2_cache_es/ 95 | .rts2_cache_umd/ 96 | 97 | # Optional REPL history 98 | .node_repl_history 99 | 100 | # Output of 'npm pack' 101 | *.tgz 102 | 103 | # Yarn Integrity file 104 | .yarn-integrity 105 | 106 | # dotenv environment variables file 107 | .env 108 | .env.test 109 | .env*.local 110 | 111 | # parcel-bundler cache (https://parceljs.org/) 112 | .cache 113 | .parcel-cache 114 | 115 | # Next.js build output 116 | .next 117 | 118 | # Nuxt.js build / generate output 119 | .nuxt 120 | dist 121 | 122 | # Gatsby files 123 | .cache/ 124 | # Comment in the public line in if your project uses Gatsby and not Next.js 125 | # https://nextjs.org/blog/next-9-1#public-directory-support 126 | # public 127 | 128 | # vuepress build output 129 | .vuepress/dist 130 | 131 | # Serverless directories 132 | .serverless/ 133 | 134 | # FuseBox cache 135 | .fusebox/ 136 | 137 | # DynamoDB Local files 138 | .dynamodb/ 139 | 140 | # TernJS port file 141 | .tern-port 142 | 143 | # Stores VSCode versions used for testing VSCode extensions 144 | .vscode-test 145 | 146 | ### OSX ### 147 | # General 148 | .DS_Store 149 | .AppleDouble 150 | .LSOverride 151 | 152 | # Icon must end with two \r 153 | Icon 154 | 155 | # Thumbnails 156 | ._* 157 | 158 | # Files that might appear in the root of a volume 159 | .DocumentRevisions-V100 160 | .fseventsd 161 | .Spotlight-V100 162 | .TemporaryItems 163 | .Trashes 164 | .VolumeIcon.icns 165 | .com.apple.timemachine.donotpresent 166 | 167 | # Directories potentially created on remote AFP share 168 | .AppleDB 169 | .AppleDesktop 170 | Network Trash Folder 171 | Temporary Items 172 | .apdisk 173 | 174 | ### Python ### 175 | # Byte-compiled / optimized / DLL files 176 | __pycache__/ 177 | *.py[cod] 178 | *$py.class 179 | 180 | # C extensions 181 | *.so 182 | 183 | # Distribution / packaging 184 | .Python 185 | build/ 186 | develop-eggs/ 187 | dist/ 188 | downloads/ 189 | eggs/ 190 | .eggs/ 191 | lib/ 192 | lib64/ 193 | parts/ 194 | sdist/ 195 | var/ 196 | wheels/ 197 | pip-wheel-metadata/ 198 | share/python-wheels/ 199 | *.egg-info/ 200 | .installed.cfg 201 | *.egg 202 | MANIFEST 203 | 204 | # PyInstaller 205 | # Usually these files are written by a python script from a template 206 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 207 | *.manifest 208 | *.spec 209 | 210 | # Installer logs 211 | pip-log.txt 212 | pip-delete-this-directory.txt 213 | 214 | # Unit test / coverage reports 215 | htmlcov/ 216 | .tox/ 217 | .nox/ 218 | .coverage 219 | .coverage.* 220 | nosetests.xml 221 | coverage.xml 222 | *.cover 223 | *.py,cover 224 | .hypothesis/ 225 | .pytest_cache/ 226 | pytestdebug.log 227 | 228 | # Translations 229 | *.mo 230 | *.pot 231 | 232 | # Django stuff: 233 | local_settings.py 234 | db.sqlite3 235 | db.sqlite3-journal 236 | 237 | # Flask stuff: 238 | instance/ 239 | .webassets-cache 240 | 241 | # Scrapy stuff: 242 | .scrapy 243 | 244 | # Sphinx documentation 245 | docs/_build/ 246 | doc/_build/ 247 | 248 | # PyBuilder 249 | target/ 250 | 251 | # Jupyter Notebook 252 | .ipynb_checkpoints 253 | 254 | # IPython 255 | profile_default/ 256 | ipython_config.py 257 | 258 | # pyenv 259 | .python-version 260 | 261 | # pipenv 262 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 263 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 264 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 265 | # install all needed dependencies. 266 | #Pipfile.lock 267 | 268 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 269 | __pypackages__/ 270 | 271 | # Celery stuff 272 | celerybeat-schedule 273 | celerybeat.pid 274 | 275 | # SageMath parsed files 276 | *.sage.py 277 | 278 | # Environments 279 | .venv 280 | env/ 281 | venv/ 282 | ENV/ 283 | env.bak/ 284 | venv.bak/ 285 | pythonenv* 286 | 287 | # Spyder project settings 288 | .spyderproject 289 | .spyproject 290 | 291 | # Rope project settings 292 | .ropeproject 293 | 294 | # mkdocs documentation 295 | /site 296 | 297 | # mypy 298 | .mypy_cache/ 299 | .dmypy.json 300 | dmypy.json 301 | 302 | # Pyre type checker 303 | .pyre/ 304 | 305 | # pytype static type analyzer 306 | .pytype/ 307 | 308 | # profiling data 309 | .prof 310 | 311 | ### Vim ### 312 | # Swap 313 | [._]*.s[a-v][a-z] 314 | !*.svg # comment out if you don't need vector files 315 | [._]*.sw[a-p] 316 | [._]s[a-rt-v][a-z] 317 | [._]ss[a-gi-z] 318 | [._]sw[a-p] 319 | 320 | # Session 321 | Session.vim 322 | Sessionx.vim 323 | 324 | # Temporary 325 | .netrwhist 326 | # Auto-generated tag files 327 | tags 328 | # Persistent undo 329 | [._]*.un~ 330 | 331 | ### VisualStudioCode ### 332 | .vscode/* 333 | !.vscode/settings.json 334 | !.vscode/tasks.json 335 | !.vscode/launch.json 336 | !.vscode/extensions.json 337 | *.code-workspace 338 | 339 | ### VisualStudioCode Patch ### 340 | # Ignore all local history of files 341 | .history 342 | .ionide 343 | 344 | ### Windows ### 345 | # Windows thumbnail cache files 346 | Thumbs.db 347 | Thumbs.db:encryptable 348 | ehthumbs.db 349 | ehthumbs_vista.db 350 | 351 | # Dump file 352 | *.stackdump 353 | 354 | # Folder config file 355 | [Dd]esktop.ini 356 | 357 | # Recycle Bin used on file shares 358 | $RECYCLE.BIN/ 359 | 360 | # Windows Installer files 361 | *.cab 362 | *.msi 363 | *.msix 364 | *.msm 365 | *.msp 366 | 367 | # Windows shortcuts 368 | *.lnk 369 | 370 | # End of https://www.toptal.com/developers/gitignore/api/vim,osx,node,linux,python,windows,visualstudiocode,git 371 | 372 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: black 6 | name: black 7 | language: system 8 | entry: poetry run black 9 | types: [python] 10 | 11 | - repo: local 12 | hooks: 13 | - id: autoflake 14 | name: autoflake 15 | language: system 16 | entry: poetry run autoflake --expand-star-imports --remove-all-unused-imports --remove-unused-variables --in-place --recursive test_proj/ inline_actions/ 17 | types: [python] 18 | 19 | - repo: local 20 | hooks: 21 | - id: isort 22 | name: isort 23 | language: system 24 | entry: poetry run isort 25 | types: [python] 26 | 27 | - repo: local 28 | hooks: 29 | - id: mypy 30 | name: mypy 31 | language: system 32 | entry: poetry run mypy 33 | types: [python] 34 | 35 | - repo: local 36 | hooks: 37 | - id: flake8 38 | name: flake8 39 | language: system 40 | entry: poetry run flake8 41 | types: [python] 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Changed 11 | 12 | * removed RemovedInDjango40Warning warning message, thanks to @Ivan-Feofanov 13 | 14 | ## [2.4.0] - 2021-02-08 15 | 16 | ### Fixed 17 | 18 | * Do not break if `render_inline_actions` field is missing, thanks to @tony 19 | 20 | ### Added 21 | 22 | * support for django 3.x 23 | 24 | ### Changed 25 | 26 | * dropped support for python 3.5 27 | 28 | ## [2.3.0] - 2019-09-27 29 | 30 | * feat: support for intermediat forms, #25 31 | * feat: support for Django 2.2 32 | * feat: support for Python 3.8 33 | 34 | ## [2.2.0] - 2018-06-13 35 | 36 | * feat: support for Django 2.2 37 | * feat: Support multiple inlines for the same related model 38 | * fix: keep original exception, when an action could not be executed 39 | 40 | ## [2.1.0] - 2018-09-09 41 | 42 | * feat: support for django 2.1 43 | 44 | ## [2.0.2] - 2018-04-12 45 | 46 | * fix: load css files on admin pages (thanks @tripliks) 47 | 48 | ## [2.0.1] - 2018-01-20 49 | 50 | * fix: don't show actions on unsaved inlines (thanks @tripliks) 51 | 52 | ## [2.0.0] - 2018-01-04 53 | 54 | * Add support for django 2.0 55 | * *BREAKING CHANGE* Drop support for python 2.x 56 | * *BREAKING CHANGE* Drop support for django<2.0 57 | 58 | ## [1.2.0] - 2018-01-04 59 | 60 | * Added support for django 1.11 61 | * Dropped support for django 1.7 62 | 63 | ## [1.1.0] - 2017-07-21 64 | 65 | * Add support for per-object label and css classes (thanks to @DimmuR) 66 | 67 | ## [1.0.0] - 1016-11-12 68 | 69 | * *BREAKING CHANGE* support for admin changelist 70 | 71 | ## [0.1.1] - 2016-09-27 72 | 73 | * Fixed duplication of field Actions in inlines (thanks to @torwald-sergesson) 74 | 75 | ## [0.1] - 2016-06-09 76 | 77 | Initial release 78 | 79 | [Unreleased]: https://github.com/escaped/django-inline-actions/compare/2.4.0...HEAD 80 | [2.4.0]: https://github.com/escaped/django-inline-actions/compare/2.3.0...2.4.0 81 | [2.3.0]: https://github.com/escaped/django-inline-actions/compare/2.2.0...2.3.0 82 | [2.2.0]: https://github.com/escaped/django-inline-actions/compare/2.1.0...2.2.0 83 | [2.1.0]: https://github.com/escaped/django-inline-actions/compare/2.0.2...2.1.0 84 | [2.0.2]: https://github.com/escaped/django-inline-actions/compare/2.0.2...2.1.0 85 | [2.0.1]: https://github.com/escaped/django-inline-actions/compare/2.0.0...2.0.1 86 | [2.0.0]: https://github.com/escaped/django-inline-actions/compare/1.2.0...2.0.0 87 | [1.2.0]: https://github.com/escaped/django-inline-actions/compare/1.1.0...1.2.0 88 | [1.1.0]: https://github.com/escaped/django-inline-actions/compare/1.0.0...1.1.0 89 | [1.0.0]: https://github.com/escaped/django-inline-actions/compare/0.1...1.0.0 90 | [0.1]: https://github.com/escaped/django-inline-actions/tree/0.1 91 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-inline-actions/d02441339a1b5ec3d34c10a29da41157afe9df2c/CONTRIBUTORS -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | - [@ashishnitinpatil](https://github.com/ashishnitinpatil) 4 | - [@DimmuR](https://github.com/DimmuR) 5 | - [@escaped](https://github.com/escaped) 6 | - [@nikbora](https://github.com/nikbora) 7 | - [@orwald-sergesson](https://github.com/torwald-sergesson) 8 | - [@sobolevn](https://github.com/sobolevn) 9 | - [@tony](https://github.com/tony) 10 | - [@tripliks](https://github.com/tripliks) 11 | - [@Ivan-Feofanov](https://github.com/Ivan-Feofanov) 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Alexander Frenzel . 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Alexander Frenzel nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-inline-actions 2 | 3 | ![PyPI](https://img.shields.io/pypi/v/django-inline-actions?style=flat-square) 4 | ![GitHub Workflow Status (master)](https://img.shields.io/github/workflow/status/escaped/django-inline-actions/Test%20&%20Lint/master?style=flat-square) 5 | ![Coveralls github branch](https://img.shields.io/coveralls/github/escaped/django-inline-actions/master?style=flat-square) 6 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-inline-actions?style=flat-square) 7 | ![PyPI - License](https://img.shields.io/pypi/l/django-inline-actions?style=flat-square) 8 | 9 | django-inline-actions adds actions to each row of the ModelAdmin or InlineModelAdmin. 10 | 11 | ## Requirements 12 | 13 | * Python 3.6.1 or newer 14 | 15 | ## Screenshot 16 | 17 | ![Changelist example](https://raw.githubusercontent.com/escaped/django-inline-actions/master/example_changelist.png) 18 | ![Inline example](https://raw.githubusercontent.com/escaped/django-inline-actions/master/example_inline.png) 19 | 20 | ## Installation 21 | 22 | 1. Install django-inline-actions 23 | 24 | ```sh 25 | pip install django-inline-actions 26 | ``` 27 | 28 | 2. Add `inline_actions` to your `INSTALLED_APPS`. 29 | 30 | ## Integration 31 | 32 | Add the `InlineActionsModelAdminMixin` to your `ModelAdmin`. 33 | If you want to have actions on your inlines, add the `InlineActionsMixin` to your `InlineModelAdmin`. 34 | Each action is implemented as a method on the `ModelAdmin`/`InlineModelAdmin` and **must have** the following signature. 35 | 36 | ```python 37 | def action_name(self, request, obj, parent_obj=None): 38 | ``` 39 | 40 | | Argument | Description | 41 | |--------------|---------------------------------------------------| 42 | | `request` | current request | 43 | | `obj` | instance on which the action was triggered | 44 | | `parent_obj` | instance of the parent model, only set on inlines | 45 | 46 | and should return `None` to return to the current changeform or a `HttpResponse`. 47 | Finally, add your method name to list of actions `inline_actions` defined on the corresponding `ModelAdmin`. 48 | If you want to disable the *actions* column, you have to explicitly set `inline_actions = None`. 49 | To add your actions dynamically, you can use the method `get_inline_actions(self, request, obj=None)` instead. 50 | 51 | This module is bundled with two actions for viewing (`inline_actions.actions.ViewAction`) and deleting (`inline_actions.actions.DeleteAction`). 52 | Just add these classes to your admin and you're done. 53 | 54 | Additionally, you can add methods to generate a custom label and CSS classes per object. 55 | If you have an inline action called `action_name` then you can define 56 | 57 | ```python 58 | def get_action_name_label(self, obj): 59 | return 'some string' 60 | 61 | def get_action_name_css(self, obj): 62 | return 'some string' 63 | ``` 64 | 65 | | Argument | Description | 66 | |----------|--------------------------------------------| 67 | | `obj` | instance on which the action was triggered | 68 | 69 | Each defined method has to return a string. 70 | 71 | ### Example 1 72 | 73 | Imagine a simple news application with the following `admin.py`. 74 | 75 | ```python 76 | from django.contrib import admin 77 | from inline_actions.admin import InlineActionsMixin 78 | from inline_actions.admin import InlineActionsModelAdminMixin 79 | 80 | from .models import Article, Author 81 | 82 | 83 | class ArticleInline(InlineActionsMixin, 84 | admin.TabularInline): 85 | model = Article 86 | inline_actions = [] 87 | 88 | def has_add_permission(self, request, obj=None): 89 | return False 90 | 91 | 92 | @admin.register(Author) 93 | class AuthorAdmin(InlineActionsModelAdminMixin, 94 | admin.ModelAdmin): 95 | inlines = [ArticleInline] 96 | list_display = ('name',) 97 | 98 | 99 | @admin.register(Article) 100 | class AuthorAdmin(admin.ModelAdmin): 101 | list_display = ('title', 'status', 'author') 102 | ``` 103 | 104 | We now want to add two simple actions (`view`, `unpublish`) to each article within the `AuthorAdmin`. 105 | The `view` action redirects to the changeform of the selected instance. 106 | 107 | ```python 108 | from django.core.urlresolvers import reverse 109 | from django.shortcuts import redirect 110 | 111 | 112 | class ArticleInline(InlineActionsMixin, 113 | admin.TabularInline): 114 | # ... 115 | inline_actions = ['view'] 116 | # ... 117 | 118 | def view(self, request, obj, parent_obj=None): 119 | url = reverse( 120 | 'admin:{}_{}_change'.format( 121 | obj._meta.app_label, 122 | obj._meta.model_name, 123 | ), 124 | args=(obj.pk,) 125 | ) 126 | return redirect(url) 127 | view.short_description = _("View") 128 | ``` 129 | 130 | Since `unpublish` depends on `article.status` we must use `get_inline_actions` to add this action dynamically. 131 | 132 | ```python 133 | from django.contrib import admin, messages 134 | from django.utils.translation import gettext_lazy as _ 135 | 136 | 137 | class ArticleInline(InlineActionsMixin, 138 | admin.TabularInline): 139 | # ... 140 | def get_inline_actions(self, request, obj=None): 141 | actions = super(ArticleInline, self).get_inline_actions(request, obj) 142 | if obj: 143 | if obj.status == Article.PUBLISHED: 144 | actions.append('unpublish') 145 | return actions 146 | 147 | def unpublish(self, request, obj, parent_obj=None): 148 | obj.status = Article.DRAFT 149 | obj.save() 150 | messages.info(request, _("Article unpublished")) 151 | unpublish.short_description = _("Unpublish") 152 | ``` 153 | 154 | Adding `inline_actions` to the changelist works similar. See the sample project for further details (`test_proj/blog/admin.py`). 155 | 156 | ### Example 2 157 | 158 | Instead of creating separate actions for publishing and unpublishing, we might prefer an action, which toggles between those two states. 159 | `toggle_publish` implements the behaviour described above. 160 | 161 | ```python 162 | def toggle_publish(self, request, obj, parent_obj=None): 163 | if obj.status == Article.DRAFT: 164 | obj.status = Article.PUBLISHED 165 | else: 166 | obj.status = Article.DRAFT 167 | 168 | obj.save() 169 | 170 | if obj.status == Article.DRAFT: 171 | messages.info(request, _("Article unpublished.")) 172 | else: 173 | messages.info(request, _("Article published.")) 174 | ``` 175 | 176 | This might leave the user with an ambiguous button label as it will be called `Toggle publish` regardless of the internal state. 177 | We can specify a dynamic label by adding a special method `get_ACTIONNAME_label`. 178 | 179 | ```python 180 | def get_toggle_publish_label(self, obj): 181 | if obj.status == Article.DRAFT: 182 | return 'Publish' 183 | return 'Unpublish' 184 | ``` 185 | 186 | So assuming an object in a row has `DRAFT` status, then the button label will be `Toggle publish` and `Toggle unpublish` otherwise. 187 | 188 | We can go even fancier when we create a method that will add css classes for each object depending on a status like: 189 | 190 | ```python 191 | def get_toggle_publish_css(self, obj): 192 | if obj.status == Article.DRAFT: 193 | return 'btn-red' 194 | return 'btn-green' 195 | ``` 196 | 197 | You can make it more eye-candy by using `btn-green` that makes your button green and `btn-red` that makes your button red. 198 | Or you can use those classes to add some javascript logic (i.e. confirmation box). 199 | 200 | ### Tip on confirmation alerts 201 | 202 | When performing a certain critical action or ones which may not be easily reversible it's good to have a confirmation prompt before submitting the action form. To achieve this, one way would be to override `templates/admin/change_list.html` with the following. 203 | 204 | ```html 205 | {% extends "admin/change_list.html" %} 206 | 207 | {% block extrahead %} 208 | {{ block.super }} 209 | 223 | {% endblock %} 224 | ``` 225 | 226 | If a staff user has clicked any inline action accidentally, they can safely click no in the confirmation prompt & the inline action form would not be submitted. 227 | 228 | ## Intermediate forms 229 | 230 | The current implementation for using intermediate forms involves some manual handling. 231 | This will be simplified in the next major release! 232 | 233 | 234 | In order to have an intermediate form, you must add some information about the triggered action. 235 | `django-inline-actions` provides a handy templatetag `render_inline_action_fields`, 236 | which adds these information as hidden fields to a form. 237 | 238 | ```html 239 | {% extends "admin/base_site.html" %} 240 | {% load inline_action_tags %} 241 | 242 | {% block content %} 243 |
244 | {% csrf_token %} 245 | {% render_inline_action_fields %} 246 | 247 | {{ form.as_p }} 248 | 249 | 250 | 251 |
252 | {% endblock %} 253 | ``` 254 | 255 | As the action does not know that an intermediate form is used, we have to include some special handling. 256 | In the case above we have to consider 3 cases: 257 | 258 | 1. The form has been submitted and we want to redirect to the previous view. 259 | 2. Back button has been clicked. 260 | 3. Initial access to the intermediate page/form. 261 | 262 | The corresponding action could look like 263 | 264 | ```python 265 | def change_title(self, request, obj, parent_obj=None): 266 | 267 | # 1. has the form been submitted? 268 | if '_save' in request.POST: 269 | form = forms.ChangeTitleForm(request.POST, instance=obj) 270 | form.save() 271 | return None # return back to list view 272 | # 2. has the back button been pressed? 273 | elif '_back' in request.POST: 274 | return None # return back to list view 275 | # 3. simply display the form 276 | else: 277 | form = forms.ChangeTitleForm(instance=obj) 278 | 279 | return render( 280 | request, 281 | 'change_title.html', 282 | context={'form': form} 283 | ) 284 | ``` 285 | 286 | ## Example Application 287 | 288 | You can see `django-inline-actions` in action using the bundled test application `test_proj`. 289 | Use [`poetry`](https://poetry.eustace.io/) to run it. 290 | 291 | ```bash 292 | git clone https://github.com/escaped/django-inline-actions.git 293 | cd django-inline-actions/ 294 | poetry install 295 | poetry run pip install Django 296 | cd test_proj 297 | poetry run ./manage.py migrate 298 | poetry run ./manage.py createsuperuser 299 | poetry run ./manage.py runserver 300 | ``` 301 | 302 | Open [`http://localhost:8000/admin/`](http://localhost:8000/admin/) in your browser and create an author and some articles. 303 | 304 | ## How to test your actions? 305 | 306 | There are two ways on how to write tests for your actions. 307 | We will use [pytest](https://docs.pytest.org/en/latest/) for the following examples. 308 | 309 | ### Test the action itself 310 | 311 | Before we can call our action on the admin class itself, we have to instantiate the admin environment and pass it to the `ModelAdmin` together with an instance of our model. 312 | Therefore, we implement a fixture called `admin_site`, which is used on each test. 313 | 314 | ```python 315 | import pytest 316 | from django.contrib.admin import AdminSite 317 | 318 | from yourapp.module.admin import MyAdmin 319 | 320 | 321 | @pytest.fixture 322 | def admin_site(): 323 | return AdminSite() 324 | 325 | @pytest.mark.django_db 326 | def test_action_XXX(admin_site): 327 | """Test action XXX""" 328 | fake_request = {} # you might need to use a RequestFactory here 329 | obj = ... # create an instance 330 | 331 | admin = MyAdmin(obj, admin_site) 332 | 333 | admin.render_inline_actions(article) 334 | response = admin.action_XXX(fake_request, obj) 335 | # assert the state of the application 336 | ``` 337 | 338 | ### Test the admin integration 339 | 340 | Alternatively, you can test your actions on the real Django admin page. 341 | You will have to log in, navigate to the corresponding admin and trigger a click on the action. 342 | To simplify this process you can use [django-webtest](https://github.com/django-webtest/django-webtest). 343 | Example can be found [here](https://github.com/escaped/django-inline-actions/blob/76b6f6b83c6d1830c2ad71512cd1e85362936dbd/test_proj/blog/tests/test_inline_admin.py#L146). 344 | 345 | ## Development 346 | 347 | This project uses [poetry](https://poetry.eustace.io/) for packaging and 348 | managing all dependencies and [pre-commit](https://pre-commit.com/) to run 349 | [flake8](http://flake8.pycqa.org/), [isort](https://pycqa.github.io/isort/), 350 | [mypy](http://mypy-lang.org/) and [black](https://github.com/python/black). 351 | 352 | Additionally, [pdbpp](https://github.com/pdbpp/pdbpp) and [better-exceptions](https://github.com/qix-/better-exceptions) are installed to provide a better debugging experience. 353 | To enable `better-exceptions` you have to run `export BETTER_EXCEPTIONS=1` in your current session/terminal. 354 | 355 | Clone this repository and run 356 | 357 | ```bash 358 | poetry install 359 | poetry run pre-commit install 360 | ``` 361 | 362 | to create a virtual enviroment containing all dependencies. 363 | Afterwards, You can run the test suite using 364 | 365 | ```bash 366 | poetry run pytest 367 | ``` 368 | 369 | This repository follows the [Conventional Commits](https://www.conventionalcommits.org/) 370 | style. 371 | 372 | ### Cookiecutter template 373 | 374 | This project was created using [cruft](https://github.com/cruft/cruft) and the 375 | [cookiecutter-pyproject](https://github.com/escaped/cookiecutter-pypackage) template. 376 | In order to update this repository to the latest template version run 377 | 378 | ```sh 379 | cruft update 380 | ``` 381 | 382 | in the root of this repository. 383 | -------------------------------------------------------------------------------- /example_changelist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-inline-actions/d02441339a1b5ec3d34c10a29da41157afe9df2c/example_changelist.png -------------------------------------------------------------------------------- /example_inline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-inline-actions/d02441339a1b5ec3d34c10a29da41157afe9df2c/example_inline.png -------------------------------------------------------------------------------- /inline_actions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-inline-actions/d02441339a1b5ec3d34c10a29da41157afe9df2c/inline_actions/__init__.py -------------------------------------------------------------------------------- /inline_actions/actions.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Optional, Union 2 | 3 | from django.contrib import messages 4 | from django.shortcuts import redirect 5 | from django.urls import reverse 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | 9 | class ViewAction: 10 | inline_actions: Optional[List[Union[str, Callable]]] = ['view_action'] 11 | 12 | def view_action(self, request, obj, parent_obj=None): 13 | """Redirect to changeform of selcted inline instance""" 14 | url = reverse( 15 | 'admin:{}_{}_change'.format( 16 | obj._meta.app_label, 17 | obj._meta.model_name, 18 | ), 19 | args=(obj.pk,), 20 | ) 21 | return redirect(url) 22 | 23 | view_action.short_description = _("View") # type: ignore 24 | 25 | 26 | class DeleteAction: 27 | def get_inline_actions(self, request, obj=None): 28 | actions = super().get_inline_actions(request, obj) 29 | if self.has_delete_permission(request, obj): 30 | actions.append('delete_action') 31 | return actions 32 | 33 | def delete_action(self, request, obj, parent_obj=None): 34 | """Remove selected inline instance if permission is sufficient""" 35 | if self.has_delete_permission(request): 36 | obj.delete() 37 | messages.info(request, "`{}` deleted.".format(obj)) 38 | 39 | delete_action.short_description = _("Delete") # type: ignore 40 | 41 | 42 | class DefaultActionsMixin(ViewAction, DeleteAction): 43 | inline_actions: Optional[List[Union[str, Callable]]] = [] 44 | -------------------------------------------------------------------------------- /inline_actions/admin.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Optional, Union 2 | 3 | from django.apps import apps 4 | from django.contrib import admin 5 | from django.http import HttpResponse 6 | from django.shortcuts import redirect 7 | from django.urls import reverse 8 | from django.utils.safestring import mark_safe 9 | from django.utils.text import capfirst 10 | from django.utils.translation import gettext_lazy as _ 11 | 12 | 13 | class InlineActionException(Exception): 14 | pass 15 | 16 | 17 | class ActionNotCallable(InlineActionException): 18 | def __init__(self, model_admin, action, *args, **kwargs): 19 | super().__init__(*args, **kwargs) 20 | self.model_admin = model_admin 21 | self.action = action 22 | 23 | 24 | class BaseInlineActionsMixin: 25 | INLINE_MODEL_ADMIN = 'inline' 26 | MODEL_ADMIN = 'admin' 27 | 28 | inline_actions: Optional[List[Union[str, Callable]]] = [] 29 | 30 | def get_inline_actions(self, request, obj=None): 31 | """ 32 | Returns a list of all actions for this Admin. 33 | """ 34 | # If self.actions is explicitly set to None that means that we don't 35 | # want *any* actions enabled on this page. 36 | if self.inline_actions is None: 37 | return [] 38 | 39 | actions = [] 40 | 41 | # Gather actions from the inline admin and all parent classes, 42 | # starting with self and working back up. 43 | for klass in self.__class__.mro()[::-1]: 44 | class_actions = getattr(klass, 'inline_actions', []) 45 | # Avoid trying to iterate over None 46 | if not class_actions: 47 | continue 48 | 49 | for action in class_actions: 50 | if action not in actions: 51 | actions.append(action) 52 | 53 | return actions 54 | 55 | def get_readonly_fields(self, request, obj=None): 56 | fields = super().get_readonly_fields(request, obj) 57 | fields = list(fields) 58 | 59 | if 'render_inline_actions' not in fields: 60 | fields.append('render_inline_actions') 61 | return fields 62 | 63 | def _get_admin_type(self, model_admin=None): 64 | """ 65 | Returns wether this is an InlineAdmin or not. 66 | """ 67 | model_admin = model_admin or self 68 | 69 | if isinstance(model_admin, admin.options.InlineModelAdmin): 70 | return self.INLINE_MODEL_ADMIN 71 | return self.MODEL_ADMIN 72 | 73 | def render_inline_actions(self, obj=None): # NOQA: C901 74 | """ 75 | Renders all defined inline actions as html. 76 | """ 77 | if not (obj and obj.pk): 78 | return '' 79 | 80 | buttons = [] 81 | for action_name in self.get_inline_actions(self._request, obj): 82 | action_func = getattr(self, action_name, None) 83 | if not action_func: 84 | raise RuntimeError("Could not find action `{}`".format(action_name)) 85 | 86 | # Add per-object label support 87 | action_name = action_func.__name__ 88 | label_handler = getattr(self, 'get_{}_label'.format(action_name), None) 89 | if callable(label_handler): 90 | description = label_handler(obj=obj) 91 | else: 92 | try: 93 | description = action_func.short_description 94 | except AttributeError: 95 | description = capfirst(action_name.replace('_', ' ')) 96 | 97 | # Add per-object css classes support 98 | css_handler = getattr(self, 'get_{}_css'.format(action_name), None) 99 | if callable(css_handler): 100 | css_classes = css_handler(obj=obj) 101 | else: 102 | try: 103 | css_classes = action_func.css_classes 104 | except AttributeError: 105 | css_classes = '' 106 | 107 | # If the form is submitted, we have no information about the 108 | # requested action. 109 | # Hence we need all data to be encoded using the action name. 110 | action_data = [ 111 | # required to distinguish between multiple inlines for the same model 112 | self.__class__.__name__.lower(), 113 | self._get_admin_type(), 114 | action_name, 115 | obj._meta.app_label, 116 | obj._meta.model_name, 117 | str(obj.pk), 118 | ] 119 | buttons.append( 120 | ''.format( 121 | '_action__{}'.format('__'.join(action_data)), 122 | description, 123 | css_classes, 124 | ) 125 | ) 126 | return mark_safe( 127 | '
{}
'.format(''.join(buttons)) 128 | ) 129 | 130 | render_inline_actions.short_description = _("Actions") # type: ignore 131 | render_inline_actions.allow_tags = True # type: ignore 132 | 133 | 134 | class InlineActionsMixin(BaseInlineActionsMixin): 135 | def render_inline_actions(self, obj=None): 136 | html = super().render_inline_actions(obj=obj) 137 | # we have to add

tags as a workaround for invalid html 138 | return mark_safe('

{}

'.format(html)) 139 | 140 | render_inline_actions.short_description = _("Actions") # type: ignore 141 | render_inline_actions.allow_tags = True # type: ignore 142 | 143 | def get_fields(self, request, obj=None): 144 | # store `request` for `get_inline_actions` 145 | self._request = request 146 | 147 | fields = super().get_fields(request, obj) 148 | if self.inline_actions is not None: # is it explicitly disabled? 149 | fields = list(fields) 150 | if 'render_inline_actions' not in fields: 151 | fields.append('render_inline_actions') 152 | return fields 153 | 154 | 155 | class InlineActionsModelAdminMixin(BaseInlineActionsMixin): 156 | class Media: 157 | css = {"all": ("inline_actions/css/inline_actions.css",)} 158 | 159 | def get_list_display(self, request): 160 | # store `request` for `get_inline_actions` 161 | self._request = request 162 | 163 | fields = super().get_list_display(request) 164 | if self.inline_actions is not None: # is it explicitly disabled? 165 | fields = list(fields) 166 | if 'render_inline_actions' not in fields: 167 | fields.append('render_inline_actions') 168 | return fields 169 | 170 | def get_fields(self, request, obj=None): 171 | # store `request` for `get_inline_actions` 172 | self._request = request 173 | 174 | fields = super().get_fields(request, obj=obj) 175 | if not self.fields: 176 | # django adds all readonly fields by default 177 | # if `self.fields` is not defined we don't want to include 178 | # `render_inline_actions 179 | if 'render_inline_actions' in fields: 180 | fields.remove('render_inline_actions') 181 | return fields 182 | 183 | def _execute_action(self, request, model_admin, action, obj, parent_obj=None): 184 | """ 185 | Tries to execute the requested action and returns a `HttpResponse`. 186 | 187 | raises 188 | ActionNotCallable - When action is not a function 189 | """ 190 | # execute action 191 | func = getattr(model_admin, action, None) 192 | try: 193 | response = func(request, obj, parent_obj=parent_obj) 194 | except TypeError as e: 195 | raise ActionNotCallable(model_admin, action) from e 196 | 197 | # we should receive an HttpResponse 198 | if isinstance(response, HttpResponse): 199 | return response 200 | 201 | # otherwise redirect back 202 | if parent_obj is None: # InlineActionsMixin.MODEL_ADMIN: 203 | # redirect to `changelist` 204 | url = reverse( 205 | 'admin:{}_{}_changelist'.format( 206 | obj._meta.app_label, 207 | obj._meta.model_name, 208 | ), 209 | ) 210 | else: 211 | # redirect to `changeform` 212 | url = reverse( 213 | 'admin:{}_{}_change'.format( 214 | parent_obj._meta.app_label, 215 | parent_obj._meta.model_name, 216 | ), 217 | args=(parent_obj.pk,), 218 | ) 219 | 220 | # readd query string 221 | query = request.META['QUERY_STRING'] or request.GET.urlencode() 222 | if query: 223 | url = '{}?{}'.format(url, query) 224 | 225 | return redirect(url) 226 | 227 | def _handle_action(self, request, object_id=None): 228 | """ 229 | Resolve and executes the action issued by the current request. 230 | If no action was triggered, it does nothing. 231 | 232 | Returns `HttpResponse` or `None` 233 | """ 234 | all_actions = [ 235 | key for key in list(request.POST.keys()) if key.startswith('_action__') 236 | ] 237 | 238 | if request.method == 'POST' and all_actions: 239 | assert len(all_actions) == 1 240 | raw_action_name = all_actions[0].replace('_action__', '', 1) 241 | 242 | # resolve action and target models 243 | raw_action_parts = raw_action_name.split('__') 244 | admin_class_name, admin_type = raw_action_parts[:2] 245 | action, app_label, model_name, object_pk = raw_action_parts[2:] 246 | 247 | model = apps.get_model(app_label=app_label, model_name=model_name) 248 | parent_obj = self.get_object(request, object_id) 249 | 250 | # find action and execute 251 | if admin_type == self.MODEL_ADMIN: 252 | model_admin = self 253 | obj = model_admin.get_queryset(request).get(pk=object_pk) 254 | # parent_obj is None because `object_id` is None 255 | 256 | else: 257 | for inline in self.get_inline_instances(request): 258 | inline_class_name = inline.__class__.__name__.lower() 259 | matches_inline_class = inline_class_name == admin_class_name 260 | matches_model = inline.model == model 261 | 262 | if not matches_model or not matches_inline_class: 263 | continue 264 | model_admin = inline 265 | obj = model_admin.get_queryset(request).get(pk=object_pk) 266 | 267 | if model_admin: 268 | return self._execute_action( 269 | request, model_admin, action, obj, parent_obj 270 | ) 271 | return None 272 | 273 | def changeform_view(self, request, object_id=None, form_url='', extra_context=None): 274 | # handle requested action if required 275 | response = self._handle_action(request, object_id=object_id) 276 | if response: 277 | return response 278 | 279 | # continue normally 280 | return super().changeform_view(request, object_id, form_url, extra_context) 281 | 282 | def changelist_view(self, request, extra_context=None): 283 | # handle requested action if required 284 | response = self._handle_action(request) 285 | if response: 286 | return response 287 | 288 | # continue normally 289 | return super().changelist_view(request, extra_context) 290 | -------------------------------------------------------------------------------- /inline_actions/static/inline_actions/css/inline_actions.css: -------------------------------------------------------------------------------- 1 | .field-render_inline_actions > p, 2 | .field-render_inline_actions label + p { 3 | display: none; 4 | } 5 | 6 | #changelist table .submit_row.inline_actions input, 7 | .submit_row.inline_actions input { 8 | padding: 0px 5px; 9 | margin: 0 10px 0 0; 10 | cursor: pointer; 11 | } 12 | -------------------------------------------------------------------------------- /inline_actions/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-inline-actions/d02441339a1b5ec3d34c10a29da41157afe9df2c/inline_actions/templatetags/__init__.py -------------------------------------------------------------------------------- /inline_actions/templatetags/inline_action_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.safestring import mark_safe 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.simple_tag(takes_context=True) 8 | def render_inline_action_fields(context): 9 | """ 10 | Render hidden fields, which are required for identifying an action. 11 | """ 12 | request = context.get('request', {'POST': {}}) 13 | all_actions = [ 14 | key for key in list(request.POST.keys()) if key.startswith('_action__') 15 | ] 16 | if len(all_actions) == 0: 17 | raise RuntimeError("No inline action has been triggered.") 18 | if len(all_actions) > 1: 19 | raise RuntimeError( 20 | "Multiple inline actions have been triggered simultaneously." 21 | ) 22 | 23 | action_key = all_actions[0] 24 | fields = ''.format(action_key) 25 | return mark_safe(fields) 26 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "appdirs" 3 | version = "1.4.4" 4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "asgiref" 11 | version = "3.3.1" 12 | description = "ASGI specs, helper code, and adapters" 13 | category = "main" 14 | optional = false 15 | python-versions = ">=3.5" 16 | 17 | [package.extras] 18 | tests = ["pytest", "pytest-asyncio"] 19 | 20 | [[package]] 21 | name = "atomicwrites" 22 | version = "1.4.0" 23 | description = "Atomic file writes." 24 | category = "dev" 25 | optional = false 26 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 27 | 28 | [[package]] 29 | name = "attrs" 30 | version = "20.3.0" 31 | description = "Classes Without Boilerplate" 32 | category = "dev" 33 | optional = false 34 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 35 | 36 | [package.extras] 37 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] 38 | docs = ["furo", "sphinx", "zope.interface"] 39 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 40 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] 41 | 42 | [[package]] 43 | name = "autoflake" 44 | version = "1.4" 45 | description = "Removes unused imports and unused variables" 46 | category = "dev" 47 | optional = false 48 | python-versions = "*" 49 | 50 | [package.dependencies] 51 | pyflakes = ">=1.1.0" 52 | 53 | [[package]] 54 | name = "beautifulsoup4" 55 | version = "4.9.3" 56 | description = "Screen-scraping library" 57 | category = "dev" 58 | optional = false 59 | python-versions = "*" 60 | 61 | [package.dependencies] 62 | soupsieve = {version = ">1.2", markers = "python_version >= \"3.0\""} 63 | 64 | [package.extras] 65 | html5lib = ["html5lib"] 66 | lxml = ["lxml"] 67 | 68 | [[package]] 69 | name = "better-exceptions" 70 | version = "0.3.3" 71 | description = "Pretty and helpful exceptions, automatically" 72 | category = "dev" 73 | optional = false 74 | python-versions = "*" 75 | 76 | [package.dependencies] 77 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 78 | 79 | [[package]] 80 | name = "black" 81 | version = "20.8b1" 82 | description = "The uncompromising code formatter." 83 | category = "dev" 84 | optional = false 85 | python-versions = ">=3.6" 86 | 87 | [package.dependencies] 88 | appdirs = "*" 89 | click = ">=7.1.2" 90 | dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} 91 | mypy-extensions = ">=0.4.3" 92 | pathspec = ">=0.6,<1" 93 | regex = ">=2020.1.8" 94 | toml = ">=0.10.1" 95 | typed-ast = ">=1.4.0" 96 | typing-extensions = ">=3.7.4" 97 | 98 | [package.extras] 99 | colorama = ["colorama (>=0.4.3)"] 100 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 101 | 102 | [[package]] 103 | name = "cfgv" 104 | version = "3.2.0" 105 | description = "Validate configuration and produce human readable error messages." 106 | category = "dev" 107 | optional = false 108 | python-versions = ">=3.6.1" 109 | 110 | [[package]] 111 | name = "click" 112 | version = "7.1.2" 113 | description = "Composable command line interface toolkit" 114 | category = "dev" 115 | optional = false 116 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 117 | 118 | [[package]] 119 | name = "colorama" 120 | version = "0.4.4" 121 | description = "Cross-platform colored terminal text." 122 | category = "dev" 123 | optional = false 124 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 125 | 126 | [[package]] 127 | name = "coverage" 128 | version = "5.3.1" 129 | description = "Code coverage measurement for Python" 130 | category = "dev" 131 | optional = false 132 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 133 | 134 | [package.extras] 135 | toml = ["toml"] 136 | 137 | [[package]] 138 | name = "dataclasses" 139 | version = "0.8" 140 | description = "A backport of the dataclasses module for Python 3.6" 141 | category = "dev" 142 | optional = false 143 | python-versions = ">=3.6, <3.7" 144 | 145 | [[package]] 146 | name = "distlib" 147 | version = "0.3.1" 148 | description = "Distribution utilities" 149 | category = "dev" 150 | optional = false 151 | python-versions = "*" 152 | 153 | [[package]] 154 | name = "django" 155 | version = "3.1.5" 156 | description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." 157 | category = "main" 158 | optional = false 159 | python-versions = ">=3.6" 160 | 161 | [package.dependencies] 162 | asgiref = ">=3.2.10,<4" 163 | pytz = "*" 164 | sqlparse = ">=0.2.2" 165 | 166 | [package.extras] 167 | argon2 = ["argon2-cffi (>=16.1.0)"] 168 | bcrypt = ["bcrypt"] 169 | 170 | [[package]] 171 | name = "django-webtest" 172 | version = "1.9.7" 173 | description = "Instant integration of Ian Bicking's WebTest (http://docs.pylonsproject.org/projects/webtest/) with django's testing framework." 174 | category = "dev" 175 | optional = false 176 | python-versions = "*" 177 | 178 | [package.dependencies] 179 | webtest = ">=1.3.3" 180 | 181 | [[package]] 182 | name = "fancycompleter" 183 | version = "0.9.1" 184 | description = "colorful TAB completion for Python prompt" 185 | category = "dev" 186 | optional = false 187 | python-versions = "*" 188 | 189 | [package.dependencies] 190 | pyreadline = {version = "*", markers = "platform_system == \"Windows\""} 191 | pyrepl = ">=0.8.2" 192 | 193 | [[package]] 194 | name = "filelock" 195 | version = "3.0.12" 196 | description = "A platform independent file lock." 197 | category = "dev" 198 | optional = false 199 | python-versions = "*" 200 | 201 | [[package]] 202 | name = "flake8" 203 | version = "3.8.4" 204 | description = "the modular source code checker: pep8 pyflakes and co" 205 | category = "dev" 206 | optional = false 207 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 208 | 209 | [package.dependencies] 210 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 211 | mccabe = ">=0.6.0,<0.7.0" 212 | pycodestyle = ">=2.6.0a1,<2.7.0" 213 | pyflakes = ">=2.2.0,<2.3.0" 214 | 215 | [[package]] 216 | name = "flake8-bugbear" 217 | version = "20.11.1" 218 | description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." 219 | category = "dev" 220 | optional = false 221 | python-versions = ">=3.6" 222 | 223 | [package.dependencies] 224 | attrs = ">=19.2.0" 225 | flake8 = ">=3.0.0" 226 | 227 | [package.extras] 228 | dev = ["coverage", "black", "hypothesis", "hypothesmith"] 229 | 230 | [[package]] 231 | name = "flake8-builtins" 232 | version = "1.5.3" 233 | description = "Check for python builtins being used as variables or parameters." 234 | category = "dev" 235 | optional = false 236 | python-versions = "*" 237 | 238 | [package.dependencies] 239 | flake8 = "*" 240 | 241 | [package.extras] 242 | test = ["coverage", "coveralls", "mock", "pytest", "pytest-cov"] 243 | 244 | [[package]] 245 | name = "flake8-comprehensions" 246 | version = "3.3.1" 247 | description = "A flake8 plugin to help you write better list/set/dict comprehensions." 248 | category = "dev" 249 | optional = false 250 | python-versions = ">=3.6" 251 | 252 | [package.dependencies] 253 | flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" 254 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 255 | 256 | [[package]] 257 | name = "flake8-debugger" 258 | version = "4.0.0" 259 | description = "ipdb/pdb statement checker plugin for flake8" 260 | category = "dev" 261 | optional = false 262 | python-versions = ">=3.6" 263 | 264 | [package.dependencies] 265 | flake8 = ">=3.0" 266 | pycodestyle = "*" 267 | six = "*" 268 | 269 | [[package]] 270 | name = "flake8-polyfill" 271 | version = "1.0.2" 272 | description = "Polyfill package for Flake8 plugins" 273 | category = "dev" 274 | optional = false 275 | python-versions = "*" 276 | 277 | [package.dependencies] 278 | flake8 = "*" 279 | 280 | [[package]] 281 | name = "identify" 282 | version = "1.5.12" 283 | description = "File identification library for Python" 284 | category = "dev" 285 | optional = false 286 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 287 | 288 | [package.extras] 289 | license = ["editdistance"] 290 | 291 | [[package]] 292 | name = "importlib-metadata" 293 | version = "3.4.0" 294 | description = "Read metadata from Python packages" 295 | category = "dev" 296 | optional = false 297 | python-versions = ">=3.6" 298 | 299 | [package.dependencies] 300 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 301 | zipp = ">=0.5" 302 | 303 | [package.extras] 304 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 305 | testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 306 | 307 | [[package]] 308 | name = "importlib-resources" 309 | version = "5.0.0" 310 | description = "Read resources from Python packages" 311 | category = "dev" 312 | optional = false 313 | python-versions = ">=3.6" 314 | 315 | [package.dependencies] 316 | zipp = {version = ">=0.4", markers = "python_version < \"3.8\""} 317 | 318 | [package.extras] 319 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 320 | testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "pytest-black (>=0.3.7)", "pytest-mypy"] 321 | 322 | [[package]] 323 | name = "iniconfig" 324 | version = "1.1.1" 325 | description = "iniconfig: brain-dead simple config-ini parsing" 326 | category = "dev" 327 | optional = false 328 | python-versions = "*" 329 | 330 | [[package]] 331 | name = "isort" 332 | version = "5.7.0" 333 | description = "A Python utility / library to sort Python imports." 334 | category = "dev" 335 | optional = false 336 | python-versions = ">=3.6,<4.0" 337 | 338 | [package.extras] 339 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 340 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 341 | colors = ["colorama (>=0.4.3,<0.5.0)"] 342 | 343 | [[package]] 344 | name = "lxml" 345 | version = "4.6.2" 346 | description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." 347 | category = "dev" 348 | optional = false 349 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" 350 | 351 | [package.extras] 352 | cssselect = ["cssselect (>=0.7)"] 353 | html5 = ["html5lib"] 354 | htmlsoup = ["beautifulsoup4"] 355 | source = ["Cython (>=0.29.7)"] 356 | 357 | [[package]] 358 | name = "mccabe" 359 | version = "0.6.1" 360 | description = "McCabe checker, plugin for flake8" 361 | category = "dev" 362 | optional = false 363 | python-versions = "*" 364 | 365 | [[package]] 366 | name = "mypy" 367 | version = "0.800" 368 | description = "Optional static typing for Python" 369 | category = "dev" 370 | optional = false 371 | python-versions = ">=3.5" 372 | 373 | [package.dependencies] 374 | mypy-extensions = ">=0.4.3,<0.5.0" 375 | typed-ast = ">=1.4.0,<1.5.0" 376 | typing-extensions = ">=3.7.4" 377 | 378 | [package.extras] 379 | dmypy = ["psutil (>=4.0)"] 380 | 381 | [[package]] 382 | name = "mypy-extensions" 383 | version = "0.4.3" 384 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 385 | category = "dev" 386 | optional = false 387 | python-versions = "*" 388 | 389 | [[package]] 390 | name = "nodeenv" 391 | version = "1.5.0" 392 | description = "Node.js virtual environment builder" 393 | category = "dev" 394 | optional = false 395 | python-versions = "*" 396 | 397 | [[package]] 398 | name = "packaging" 399 | version = "20.8" 400 | description = "Core utilities for Python packages" 401 | category = "dev" 402 | optional = false 403 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 404 | 405 | [package.dependencies] 406 | pyparsing = ">=2.0.2" 407 | 408 | [[package]] 409 | name = "pathspec" 410 | version = "0.8.1" 411 | description = "Utility library for gitignore style pattern matching of file paths." 412 | category = "dev" 413 | optional = false 414 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 415 | 416 | [[package]] 417 | name = "pdbpp" 418 | version = "0.10.2" 419 | description = "pdb++, a drop-in replacement for pdb" 420 | category = "dev" 421 | optional = false 422 | python-versions = "*" 423 | 424 | [package.dependencies] 425 | fancycompleter = ">=0.8" 426 | pygments = "*" 427 | wmctrl = "*" 428 | 429 | [package.extras] 430 | funcsigs = ["funcsigs"] 431 | testing = ["funcsigs", "pytest"] 432 | 433 | [[package]] 434 | name = "pep8-naming" 435 | version = "0.11.1" 436 | description = "Check PEP-8 naming conventions, plugin for flake8" 437 | category = "dev" 438 | optional = false 439 | python-versions = "*" 440 | 441 | [package.dependencies] 442 | flake8-polyfill = ">=1.0.2,<2" 443 | 444 | [[package]] 445 | name = "pluggy" 446 | version = "0.13.1" 447 | description = "plugin and hook calling mechanisms for python" 448 | category = "dev" 449 | optional = false 450 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 451 | 452 | [package.dependencies] 453 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 454 | 455 | [package.extras] 456 | dev = ["pre-commit", "tox"] 457 | 458 | [[package]] 459 | name = "pre-commit" 460 | version = "2.9.3" 461 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 462 | category = "dev" 463 | optional = false 464 | python-versions = ">=3.6.1" 465 | 466 | [package.dependencies] 467 | cfgv = ">=2.0.0" 468 | identify = ">=1.0.0" 469 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 470 | importlib-resources = {version = "*", markers = "python_version < \"3.7\""} 471 | nodeenv = ">=0.11.1" 472 | pyyaml = ">=5.1" 473 | toml = "*" 474 | virtualenv = ">=20.0.8" 475 | 476 | [[package]] 477 | name = "py" 478 | version = "1.10.0" 479 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 480 | category = "dev" 481 | optional = false 482 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 483 | 484 | [[package]] 485 | name = "pycodestyle" 486 | version = "2.6.0" 487 | description = "Python style guide checker" 488 | category = "dev" 489 | optional = false 490 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 491 | 492 | [[package]] 493 | name = "pyflakes" 494 | version = "2.2.0" 495 | description = "passive checker of Python programs" 496 | category = "dev" 497 | optional = false 498 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 499 | 500 | [[package]] 501 | name = "pygments" 502 | version = "2.8.0" 503 | description = "Pygments is a syntax highlighting package written in Python." 504 | category = "dev" 505 | optional = false 506 | python-versions = ">=3.5" 507 | 508 | [[package]] 509 | name = "pyparsing" 510 | version = "2.4.7" 511 | description = "Python parsing module" 512 | category = "dev" 513 | optional = false 514 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 515 | 516 | [[package]] 517 | name = "pyreadline" 518 | version = "2.1" 519 | description = "A python implmementation of GNU readline." 520 | category = "dev" 521 | optional = false 522 | python-versions = "*" 523 | 524 | [[package]] 525 | name = "pyrepl" 526 | version = "0.9.0" 527 | description = "A library for building flexible command line interfaces" 528 | category = "dev" 529 | optional = false 530 | python-versions = "*" 531 | 532 | [[package]] 533 | name = "pytest" 534 | version = "6.2.1" 535 | description = "pytest: simple powerful testing with Python" 536 | category = "dev" 537 | optional = false 538 | python-versions = ">=3.6" 539 | 540 | [package.dependencies] 541 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 542 | attrs = ">=19.2.0" 543 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 544 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 545 | iniconfig = "*" 546 | packaging = "*" 547 | pluggy = ">=0.12,<1.0.0a1" 548 | py = ">=1.8.2" 549 | toml = "*" 550 | 551 | [package.extras] 552 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 553 | 554 | [[package]] 555 | name = "pytest-cov" 556 | version = "2.10.1" 557 | description = "Pytest plugin for measuring coverage." 558 | category = "dev" 559 | optional = false 560 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 561 | 562 | [package.dependencies] 563 | coverage = ">=4.4" 564 | pytest = ">=4.6" 565 | 566 | [package.extras] 567 | testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] 568 | 569 | [[package]] 570 | name = "pytest-django" 571 | version = "3.10.0" 572 | description = "A Django plugin for pytest." 573 | category = "dev" 574 | optional = false 575 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 576 | 577 | [package.dependencies] 578 | pytest = ">=3.6" 579 | 580 | [package.extras] 581 | docs = ["sphinx", "sphinx-rtd-theme"] 582 | testing = ["django", "django-configurations (>=2.0)", "six"] 583 | 584 | [[package]] 585 | name = "pytest-mock" 586 | version = "3.5.1" 587 | description = "Thin-wrapper around the mock package for easier use with pytest" 588 | category = "dev" 589 | optional = false 590 | python-versions = ">=3.5" 591 | 592 | [package.dependencies] 593 | pytest = ">=5.0" 594 | 595 | [package.extras] 596 | dev = ["pre-commit", "tox", "pytest-asyncio"] 597 | 598 | [[package]] 599 | name = "pytz" 600 | version = "2020.5" 601 | description = "World timezone definitions, modern and historical" 602 | category = "main" 603 | optional = false 604 | python-versions = "*" 605 | 606 | [[package]] 607 | name = "pyyaml" 608 | version = "5.3.1" 609 | description = "YAML parser and emitter for Python" 610 | category = "dev" 611 | optional = false 612 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 613 | 614 | [[package]] 615 | name = "regex" 616 | version = "2020.11.13" 617 | description = "Alternative regular expression module, to replace re." 618 | category = "dev" 619 | optional = false 620 | python-versions = "*" 621 | 622 | [[package]] 623 | name = "six" 624 | version = "1.15.0" 625 | description = "Python 2 and 3 compatibility utilities" 626 | category = "dev" 627 | optional = false 628 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 629 | 630 | [[package]] 631 | name = "soupsieve" 632 | version = "2.1" 633 | description = "A modern CSS selector implementation for Beautiful Soup." 634 | category = "dev" 635 | optional = false 636 | python-versions = ">=3.5" 637 | 638 | [[package]] 639 | name = "sqlparse" 640 | version = "0.4.1" 641 | description = "A non-validating SQL parser." 642 | category = "main" 643 | optional = false 644 | python-versions = ">=3.5" 645 | 646 | [[package]] 647 | name = "toml" 648 | version = "0.10.2" 649 | description = "Python Library for Tom's Obvious, Minimal Language" 650 | category = "dev" 651 | optional = false 652 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 653 | 654 | [[package]] 655 | name = "tox" 656 | version = "3.21.0" 657 | description = "tox is a generic virtualenv management and test command line tool" 658 | category = "dev" 659 | optional = false 660 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 661 | 662 | [package.dependencies] 663 | colorama = {version = ">=0.4.1", markers = "platform_system == \"Windows\""} 664 | filelock = ">=3.0.0" 665 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 666 | packaging = ">=14" 667 | pluggy = ">=0.12.0" 668 | py = ">=1.4.17" 669 | six = ">=1.14.0" 670 | toml = ">=0.9.4" 671 | virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" 672 | 673 | [package.extras] 674 | docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] 675 | testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)", "pathlib2 (>=2.3.3)"] 676 | 677 | [[package]] 678 | name = "tox-gh-actions" 679 | version = "1.3.0" 680 | description = "Seamless integration of tox into GitHub Actions" 681 | category = "dev" 682 | optional = false 683 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 684 | 685 | [package.dependencies] 686 | tox = ">=3.12" 687 | 688 | [package.extras] 689 | testing = ["flake8 (>=3,<4)", "pytest (>=4.0.0,<6)", "pytest-mock (>=2,<3)", "pytest-randomly (>=3)"] 690 | 691 | [[package]] 692 | name = "typed-ast" 693 | version = "1.4.2" 694 | description = "a fork of Python 2 and 3 ast modules with type comment support" 695 | category = "dev" 696 | optional = false 697 | python-versions = "*" 698 | 699 | [[package]] 700 | name = "typing-extensions" 701 | version = "3.7.4.3" 702 | description = "Backported and Experimental Type Hints for Python 3.5+" 703 | category = "dev" 704 | optional = false 705 | python-versions = "*" 706 | 707 | [[package]] 708 | name = "virtualenv" 709 | version = "20.3.0" 710 | description = "Virtual Python Environment builder" 711 | category = "dev" 712 | optional = false 713 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 714 | 715 | [package.dependencies] 716 | appdirs = ">=1.4.3,<2" 717 | distlib = ">=0.3.1,<1" 718 | filelock = ">=3.0.0,<4" 719 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 720 | importlib-resources = {version = ">=1.0", markers = "python_version < \"3.7\""} 721 | six = ">=1.9.0,<2" 722 | 723 | [package.extras] 724 | docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] 725 | testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] 726 | 727 | [[package]] 728 | name = "waitress" 729 | version = "1.4.4" 730 | description = "Waitress WSGI server" 731 | category = "dev" 732 | optional = false 733 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 734 | 735 | [package.extras] 736 | docs = ["Sphinx (>=1.8.1)", "docutils", "pylons-sphinx-themes (>=1.0.9)"] 737 | testing = ["pytest", "pytest-cover", "coverage (>=5.0)"] 738 | 739 | [[package]] 740 | name = "webob" 741 | version = "1.8.6" 742 | description = "WSGI request and response object" 743 | category = "dev" 744 | optional = false 745 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*" 746 | 747 | [package.extras] 748 | docs = ["Sphinx (>=1.7.5)", "pylons-sphinx-themes"] 749 | testing = ["pytest (>=3.1.0)", "coverage", "pytest-cov", "pytest-xdist"] 750 | 751 | [[package]] 752 | name = "webtest" 753 | version = "2.0.35" 754 | description = "Helper to test WSGI applications" 755 | category = "dev" 756 | optional = false 757 | python-versions = "*" 758 | 759 | [package.dependencies] 760 | beautifulsoup4 = "*" 761 | six = "*" 762 | waitress = ">=0.8.5" 763 | WebOb = ">=1.2" 764 | 765 | [package.extras] 766 | docs = ["Sphinx (>=1.8.1)", "docutils", "pylons-sphinx-themes (>=1.0.8)"] 767 | tests = ["nose (<1.3.0)", "coverage", "mock", "pastedeploy", "wsgiproxy2", "pyquery"] 768 | 769 | [[package]] 770 | name = "wmctrl" 771 | version = "0.3" 772 | description = "A tool to programmatically control windows inside X" 773 | category = "dev" 774 | optional = false 775 | python-versions = "*" 776 | 777 | [[package]] 778 | name = "zipp" 779 | version = "3.4.0" 780 | description = "Backport of pathlib-compatible object wrapper for zip files" 781 | category = "dev" 782 | optional = false 783 | python-versions = ">=3.6" 784 | 785 | [package.extras] 786 | docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] 787 | testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 788 | 789 | [metadata] 790 | lock-version = "1.1" 791 | python-versions = ">=3.6.1, <4.0" 792 | content-hash = "0c88ef4e4396a854ab36e6de6d67a1ced694d0c1aaf3a6fc920a14690a2630bc" 793 | 794 | [metadata.files] 795 | appdirs = [ 796 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 797 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 798 | ] 799 | asgiref = [ 800 | {file = "asgiref-3.3.1-py3-none-any.whl", hash = "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17"}, 801 | {file = "asgiref-3.3.1.tar.gz", hash = "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0"}, 802 | ] 803 | atomicwrites = [ 804 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 805 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 806 | ] 807 | attrs = [ 808 | {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, 809 | {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, 810 | ] 811 | autoflake = [ 812 | {file = "autoflake-1.4.tar.gz", hash = "sha256:61a353012cff6ab94ca062823d1fb2f692c4acda51c76ff83a8d77915fba51ea"}, 813 | ] 814 | beautifulsoup4 = [ 815 | {file = "beautifulsoup4-4.9.3-py2-none-any.whl", hash = "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35"}, 816 | {file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"}, 817 | {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, 818 | ] 819 | better-exceptions = [ 820 | {file = "better_exceptions-0.3.3-py3-none-any.whl", hash = "sha256:9c70b1c61d5a179b84cd2c9d62c3324b667d74286207343645ed4306fdaad976"}, 821 | {file = "better_exceptions-0.3.3-py3.8.egg", hash = "sha256:bf111d0c9994ac1123f29c24907362bed2320a86809c85f0d858396000667ce2"}, 822 | {file = "better_exceptions-0.3.3.tar.gz", hash = "sha256:e4e6bc18444d5f04e6e894b10381e5e921d3d544240418162c7db57e9eb3453b"}, 823 | ] 824 | black = [ 825 | {file = "black-20.8b1-py3-none-any.whl", hash = "sha256:70b62ef1527c950db59062cda342ea224d772abdf6adc58b86a45421bab20a6b"}, 826 | {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, 827 | ] 828 | cfgv = [ 829 | {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, 830 | {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, 831 | ] 832 | click = [ 833 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 834 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 835 | ] 836 | colorama = [ 837 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 838 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 839 | ] 840 | coverage = [ 841 | {file = "coverage-5.3.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:fabeeb121735d47d8eab8671b6b031ce08514c86b7ad8f7d5490a7b6dcd6267d"}, 842 | {file = "coverage-5.3.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:7e4d159021c2029b958b2363abec4a11db0ce8cd43abb0d9ce44284cb97217e7"}, 843 | {file = "coverage-5.3.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:378ac77af41350a8c6b8801a66021b52da8a05fd77e578b7380e876c0ce4f528"}, 844 | {file = "coverage-5.3.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:e448f56cfeae7b1b3b5bcd99bb377cde7c4eb1970a525c770720a352bc4c8044"}, 845 | {file = "coverage-5.3.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:cc44e3545d908ecf3e5773266c487ad1877be718d9dc65fc7eb6e7d14960985b"}, 846 | {file = "coverage-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:08b3ba72bd981531fd557f67beee376d6700fba183b167857038997ba30dd297"}, 847 | {file = "coverage-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:8dacc4073c359f40fcf73aede8428c35f84639baad7e1b46fce5ab7a8a7be4bb"}, 848 | {file = "coverage-5.3.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ee2f1d1c223c3d2c24e3afbb2dd38be3f03b1a8d6a83ee3d9eb8c36a52bee899"}, 849 | {file = "coverage-5.3.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:9a9d4ff06804920388aab69c5ea8a77525cf165356db70131616acd269e19b36"}, 850 | {file = "coverage-5.3.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:782a5c7df9f91979a7a21792e09b34a658058896628217ae6362088b123c8500"}, 851 | {file = "coverage-5.3.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:fda29412a66099af6d6de0baa6bd7c52674de177ec2ad2630ca264142d69c6c7"}, 852 | {file = "coverage-5.3.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:f2c6888eada180814b8583c3e793f3f343a692fc802546eed45f40a001b1169f"}, 853 | {file = "coverage-5.3.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:8f33d1156241c43755137288dea619105477961cfa7e47f48dbf96bc2c30720b"}, 854 | {file = "coverage-5.3.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b239711e774c8eb910e9b1ac719f02f5ae4bf35fa0420f438cdc3a7e4e7dd6ec"}, 855 | {file = "coverage-5.3.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:f54de00baf200b4539a5a092a759f000b5f45fd226d6d25a76b0dff71177a714"}, 856 | {file = "coverage-5.3.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:be0416074d7f253865bb67630cf7210cbc14eb05f4099cc0f82430135aaa7a3b"}, 857 | {file = "coverage-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:c46643970dff9f5c976c6512fd35768c4a3819f01f61169d8cdac3f9290903b7"}, 858 | {file = "coverage-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9a4f66259bdd6964d8cf26142733c81fb562252db74ea367d9beb4f815478e72"}, 859 | {file = "coverage-5.3.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c6e5174f8ca585755988bc278c8bb5d02d9dc2e971591ef4a1baabdf2d99589b"}, 860 | {file = "coverage-5.3.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3911c2ef96e5ddc748a3c8b4702c61986628bb719b8378bf1e4a6184bbd48fe4"}, 861 | {file = "coverage-5.3.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:c5ec71fd4a43b6d84ddb88c1df94572479d9a26ef3f150cef3dacefecf888105"}, 862 | {file = "coverage-5.3.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f51dbba78d68a44e99d484ca8c8f604f17e957c1ca09c3ebc2c7e3bbd9ba0448"}, 863 | {file = "coverage-5.3.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a2070c5affdb3a5e751f24208c5c4f3d5f008fa04d28731416e023c93b275277"}, 864 | {file = "coverage-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:535dc1e6e68fad5355f9984d5637c33badbdc987b0c0d303ee95a6c979c9516f"}, 865 | {file = "coverage-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:a4857f7e2bc6921dbd487c5c88b84f5633de3e7d416c4dc0bb70256775551a6c"}, 866 | {file = "coverage-5.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fac3c432851038b3e6afe086f777732bcf7f6ebbfd90951fa04ee53db6d0bcdd"}, 867 | {file = "coverage-5.3.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd556c79ad665faeae28020a0ab3bda6cd47d94bec48e36970719b0b86e4dcf4"}, 868 | {file = "coverage-5.3.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:a66ca3bdf21c653e47f726ca57f46ba7fc1f260ad99ba783acc3e58e3ebdb9ff"}, 869 | {file = "coverage-5.3.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:ab110c48bc3d97b4d19af41865e14531f300b482da21783fdaacd159251890e8"}, 870 | {file = "coverage-5.3.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:e52d3d95df81c8f6b2a1685aabffadf2d2d9ad97203a40f8d61e51b70f191e4e"}, 871 | {file = "coverage-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:fa10fee7e32213f5c7b0d6428ea92e3a3fdd6d725590238a3f92c0de1c78b9d2"}, 872 | {file = "coverage-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ce6f3a147b4b1a8b09aae48517ae91139b1b010c5f36423fa2b866a8b23df879"}, 873 | {file = "coverage-5.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:93a280c9eb736a0dcca19296f3c30c720cb41a71b1f9e617f341f0a8e791a69b"}, 874 | {file = "coverage-5.3.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:3102bb2c206700a7d28181dbe04d66b30780cde1d1c02c5f3c165cf3d2489497"}, 875 | {file = "coverage-5.3.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8ffd4b204d7de77b5dd558cdff986a8274796a1e57813ed005b33fd97e29f059"}, 876 | {file = "coverage-5.3.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:a607ae05b6c96057ba86c811d9c43423f35e03874ffb03fbdcd45e0637e8b631"}, 877 | {file = "coverage-5.3.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:3a3c3f8863255f3c31db3889f8055989527173ef6192a283eb6f4db3c579d830"}, 878 | {file = "coverage-5.3.1-cp38-cp38-win32.whl", hash = "sha256:ff1330e8bc996570221b450e2d539134baa9465f5cb98aff0e0f73f34172e0ae"}, 879 | {file = "coverage-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:3498b27d8236057def41de3585f317abae235dd3a11d33e01736ffedb2ef8606"}, 880 | {file = "coverage-5.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ceb499d2b3d1d7b7ba23abe8bf26df5f06ba8c71127f188333dddcf356b4b63f"}, 881 | {file = "coverage-5.3.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:3b14b1da110ea50c8bcbadc3b82c3933974dbeea1832e814aab93ca1163cd4c1"}, 882 | {file = "coverage-5.3.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:76b2775dda7e78680d688daabcb485dc87cf5e3184a0b3e012e1d40e38527cc8"}, 883 | {file = "coverage-5.3.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:cef06fb382557f66d81d804230c11ab292d94b840b3cb7bf4450778377b592f4"}, 884 | {file = "coverage-5.3.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6f61319e33222591f885c598e3e24f6a4be3533c1d70c19e0dc59e83a71ce27d"}, 885 | {file = "coverage-5.3.1-cp39-cp39-win32.whl", hash = "sha256:cc6f8246e74dd210d7e2b56c76ceaba1cc52b025cd75dbe96eb48791e0250e98"}, 886 | {file = "coverage-5.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:2757fa64e11ec12220968f65d086b7a29b6583d16e9a544c889b22ba98555ef1"}, 887 | {file = "coverage-5.3.1-pp36-none-any.whl", hash = "sha256:723d22d324e7997a651478e9c5a3120a0ecbc9a7e94071f7e1954562a8806cf3"}, 888 | {file = "coverage-5.3.1-pp37-none-any.whl", hash = "sha256:c89b558f8a9a5a6f2cfc923c304d49f0ce629c3bd85cb442ca258ec20366394c"}, 889 | {file = "coverage-5.3.1.tar.gz", hash = "sha256:38f16b1317b8dd82df67ed5daa5f5e7c959e46579840d77a67a4ceb9cef0a50b"}, 890 | ] 891 | dataclasses = [ 892 | {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, 893 | {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, 894 | ] 895 | distlib = [ 896 | {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, 897 | {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, 898 | ] 899 | django = [ 900 | {file = "Django-3.1.5-py3-none-any.whl", hash = "sha256:efa2ab96b33b20c2182db93147a0c3cd7769d418926f9e9f140a60dca7c64ca9"}, 901 | {file = "Django-3.1.5.tar.gz", hash = "sha256:2d78425ba74c7a1a74b196058b261b9733a8570782f4e2828974777ccca7edf7"}, 902 | ] 903 | django-webtest = [ 904 | {file = "django-webtest-1.9.7.tar.gz", hash = "sha256:c5a1e486a3d8d3623aa615b6db2f27de848aa7079303a84721e9a685f839796c"}, 905 | {file = "django_webtest-1.9.7-py2.py3-none-any.whl", hash = "sha256:b9b4b94670c0ce533efc456d02dd55a0d0a7a8f7912eb30728dca2d59d7948b4"}, 906 | ] 907 | fancycompleter = [ 908 | {file = "fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080"}, 909 | {file = "fancycompleter-0.9.1.tar.gz", hash = "sha256:09e0feb8ae242abdfd7ef2ba55069a46f011814a80fe5476be48f51b00247272"}, 910 | ] 911 | filelock = [ 912 | {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, 913 | {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, 914 | ] 915 | flake8 = [ 916 | {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, 917 | {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, 918 | ] 919 | flake8-bugbear = [ 920 | {file = "flake8-bugbear-20.11.1.tar.gz", hash = "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538"}, 921 | {file = "flake8_bugbear-20.11.1-py36.py37.py38-none-any.whl", hash = "sha256:f35b8135ece7a014bc0aee5b5d485334ac30a6da48494998cc1fabf7ec70d703"}, 922 | ] 923 | flake8-builtins = [ 924 | {file = "flake8-builtins-1.5.3.tar.gz", hash = "sha256:09998853b2405e98e61d2ff3027c47033adbdc17f9fe44ca58443d876eb00f3b"}, 925 | {file = "flake8_builtins-1.5.3-py2.py3-none-any.whl", hash = "sha256:7706babee43879320376861897e5d1468e396a40b8918ed7bccf70e5f90b8687"}, 926 | ] 927 | flake8-comprehensions = [ 928 | {file = "flake8-comprehensions-3.3.1.tar.gz", hash = "sha256:e734bf03806bb562886d9bf635d23a65a1a995c251b67d7e007a7b608af9bd22"}, 929 | {file = "flake8_comprehensions-3.3.1-py3-none-any.whl", hash = "sha256:6d80dfafda0d85633f88ea5bc7de949485f71f1e28db7af7719563fe5f62dcb1"}, 930 | ] 931 | flake8-debugger = [ 932 | {file = "flake8-debugger-4.0.0.tar.gz", hash = "sha256:e43dc777f7db1481db473210101ec2df2bd39a45b149d7218a618e954177eda6"}, 933 | {file = "flake8_debugger-4.0.0-py3-none-any.whl", hash = "sha256:82e64faa72e18d1bdd0000407502ebb8ecffa7bc027c62b9d4110ce27c091032"}, 934 | ] 935 | flake8-polyfill = [ 936 | {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, 937 | {file = "flake8_polyfill-1.0.2-py2.py3-none-any.whl", hash = "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9"}, 938 | ] 939 | identify = [ 940 | {file = "identify-1.5.12-py2.py3-none-any.whl", hash = "sha256:18994e850ba50c37bcaed4832be8b354d6a06c8fb31f54e0e7ece76d32f69bc8"}, 941 | {file = "identify-1.5.12.tar.gz", hash = "sha256:892473bf12e655884132a3a32aca737a3cbefaa34a850ff52d501773a45837bc"}, 942 | ] 943 | importlib-metadata = [ 944 | {file = "importlib_metadata-3.4.0-py3-none-any.whl", hash = "sha256:ace61d5fc652dc280e7b6b4ff732a9c2d40db2c0f92bc6cb74e07b73d53a1771"}, 945 | {file = "importlib_metadata-3.4.0.tar.gz", hash = "sha256:fa5daa4477a7414ae34e95942e4dd07f62adf589143c875c133c1e53c4eff38d"}, 946 | ] 947 | importlib-resources = [ 948 | {file = "importlib_resources-5.0.0-py3-none-any.whl", hash = "sha256:ea17df80a0ff04b5dbd3d96dbeab1842acfd1c6c902eaeb8c8858abf2720161e"}, 949 | {file = "importlib_resources-5.0.0.tar.gz", hash = "sha256:4743f090ed8946e713745ec0e660249ef9fb0b9843eacc5b5ff931d2fd5aa67f"}, 950 | ] 951 | iniconfig = [ 952 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 953 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 954 | ] 955 | isort = [ 956 | {file = "isort-5.7.0-py3-none-any.whl", hash = "sha256:fff4f0c04e1825522ce6949973e83110a6e907750cd92d128b0d14aaaadbffdc"}, 957 | {file = "isort-5.7.0.tar.gz", hash = "sha256:c729845434366216d320e936b8ad6f9d681aab72dc7cbc2d51bedc3582f3ad1e"}, 958 | ] 959 | lxml = [ 960 | {file = "lxml-4.6.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a9d6bc8642e2c67db33f1247a77c53476f3a166e09067c0474facb045756087f"}, 961 | {file = "lxml-4.6.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:791394449e98243839fa822a637177dd42a95f4883ad3dec2a0ce6ac99fb0a9d"}, 962 | {file = "lxml-4.6.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:68a5d77e440df94011214b7db907ec8f19e439507a70c958f750c18d88f995d2"}, 963 | {file = "lxml-4.6.2-cp27-cp27m-win32.whl", hash = "sha256:fc37870d6716b137e80d19241d0e2cff7a7643b925dfa49b4c8ebd1295eb506e"}, 964 | {file = "lxml-4.6.2-cp27-cp27m-win_amd64.whl", hash = "sha256:69a63f83e88138ab7642d8f61418cf3180a4d8cd13995df87725cb8b893e950e"}, 965 | {file = "lxml-4.6.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:42ebca24ba2a21065fb546f3e6bd0c58c3fe9ac298f3a320147029a4850f51a2"}, 966 | {file = "lxml-4.6.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:f83d281bb2a6217cd806f4cf0ddded436790e66f393e124dfe9731f6b3fb9afe"}, 967 | {file = "lxml-4.6.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:535f067002b0fd1a4e5296a8f1bf88193080ff992a195e66964ef2a6cfec5388"}, 968 | {file = "lxml-4.6.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:366cb750140f221523fa062d641393092813b81e15d0e25d9f7c6025f910ee80"}, 969 | {file = "lxml-4.6.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:97db258793d193c7b62d4e2586c6ed98d51086e93f9a3af2b2034af01450a74b"}, 970 | {file = "lxml-4.6.2-cp35-cp35m-win32.whl", hash = "sha256:648914abafe67f11be7d93c1a546068f8eff3c5fa938e1f94509e4a5d682b2d8"}, 971 | {file = "lxml-4.6.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4e751e77006da34643ab782e4a5cc21ea7b755551db202bc4d3a423b307db780"}, 972 | {file = "lxml-4.6.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:681d75e1a38a69f1e64ab82fe4b1ed3fd758717bed735fb9aeaa124143f051af"}, 973 | {file = "lxml-4.6.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:127f76864468d6630e1b453d3ffbbd04b024c674f55cf0a30dc2595137892d37"}, 974 | {file = "lxml-4.6.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4fb85c447e288df535b17ebdebf0ec1cf3a3f1a8eba7e79169f4f37af43c6b98"}, 975 | {file = "lxml-4.6.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:5be4a2e212bb6aa045e37f7d48e3e1e4b6fd259882ed5a00786f82e8c37ce77d"}, 976 | {file = "lxml-4.6.2-cp36-cp36m-win32.whl", hash = "sha256:8c88b599e226994ad4db29d93bc149aa1aff3dc3a4355dd5757569ba78632bdf"}, 977 | {file = "lxml-4.6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:6e4183800f16f3679076dfa8abf2db3083919d7e30764a069fb66b2b9eff9939"}, 978 | {file = "lxml-4.6.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d8d3d4713f0c28bdc6c806a278d998546e8efc3498949e3ace6e117462ac0a5e"}, 979 | {file = "lxml-4.6.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:8246f30ca34dc712ab07e51dc34fea883c00b7ccb0e614651e49da2c49a30711"}, 980 | {file = "lxml-4.6.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:923963e989ffbceaa210ac37afc9b906acebe945d2723e9679b643513837b089"}, 981 | {file = "lxml-4.6.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:1471cee35eba321827d7d53d104e7b8c593ea3ad376aa2df89533ce8e1b24a01"}, 982 | {file = "lxml-4.6.2-cp37-cp37m-win32.whl", hash = "sha256:2363c35637d2d9d6f26f60a208819e7eafc4305ce39dc1d5005eccc4593331c2"}, 983 | {file = "lxml-4.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:f4822c0660c3754f1a41a655e37cb4dbbc9be3d35b125a37fab6f82d47674ebc"}, 984 | {file = "lxml-4.6.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0448576c148c129594d890265b1a83b9cd76fd1f0a6a04620753d9a6bcfd0a4d"}, 985 | {file = "lxml-4.6.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:60a20bfc3bd234d54d49c388950195d23a5583d4108e1a1d47c9eef8d8c042b3"}, 986 | {file = "lxml-4.6.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2e5cc908fe43fe1aa299e58046ad66981131a66aea3129aac7770c37f590a644"}, 987 | {file = "lxml-4.6.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:50c348995b47b5a4e330362cf39fc503b4a43b14a91c34c83b955e1805c8e308"}, 988 | {file = "lxml-4.6.2-cp38-cp38-win32.whl", hash = "sha256:94d55bd03d8671686e3f012577d9caa5421a07286dd351dfef64791cf7c6c505"}, 989 | {file = "lxml-4.6.2-cp38-cp38-win_amd64.whl", hash = "sha256:7a7669ff50f41225ca5d6ee0a1ec8413f3a0d8aa2b109f86d540887b7ec0d72a"}, 990 | {file = "lxml-4.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e0bfe9bb028974a481410432dbe1b182e8191d5d40382e5b8ff39cdd2e5c5931"}, 991 | {file = "lxml-4.6.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:6fd8d5903c2e53f49e99359b063df27fdf7acb89a52b6a12494208bf61345a03"}, 992 | {file = "lxml-4.6.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7e9eac1e526386df7c70ef253b792a0a12dd86d833b1d329e038c7a235dfceb5"}, 993 | {file = "lxml-4.6.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:7ee8af0b9f7de635c61cdd5b8534b76c52cd03536f29f51151b377f76e214a1a"}, 994 | {file = "lxml-4.6.2-cp39-cp39-win32.whl", hash = "sha256:2e6fd1b8acd005bd71e6c94f30c055594bbd0aa02ef51a22bbfa961ab63b2d75"}, 995 | {file = "lxml-4.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:535332fe9d00c3cd455bd3dd7d4bacab86e2d564bdf7606079160fa6251caacf"}, 996 | {file = "lxml-4.6.2.tar.gz", hash = "sha256:cd11c7e8d21af997ee8079037fff88f16fda188a9776eb4b81c7e4c9c0a7d7fc"}, 997 | ] 998 | mccabe = [ 999 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 1000 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 1001 | ] 1002 | mypy = [ 1003 | {file = "mypy-0.800-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:e1c84c65ff6d69fb42958ece5b1255394714e0aac4df5ffe151bc4fe19c7600a"}, 1004 | {file = "mypy-0.800-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:947126195bfe4709c360e89b40114c6746ae248f04d379dca6f6ab677aa07641"}, 1005 | {file = "mypy-0.800-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:b95068a3ce3b50332c40e31a955653be245666a4bc7819d3c8898aa9fb9ea496"}, 1006 | {file = "mypy-0.800-cp35-cp35m-win_amd64.whl", hash = "sha256:ca7ad5aed210841f1e77f5f2f7d725b62c78fa77519312042c719ed2ab937876"}, 1007 | {file = "mypy-0.800-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e32b7b282c4ed4e378bba8b8dfa08e1cfa6f6574067ef22f86bee5b1039de0c9"}, 1008 | {file = "mypy-0.800-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e497a544391f733eca922fdcb326d19e894789cd4ff61d48b4b195776476c5cf"}, 1009 | {file = "mypy-0.800-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:5615785d3e2f4f03ab7697983d82c4b98af5c321614f51b8f1034eb9ebe48363"}, 1010 | {file = "mypy-0.800-cp36-cp36m-win_amd64.whl", hash = "sha256:2b216eacca0ec0ee124af9429bfd858d5619a0725ee5f88057e6e076f9eb1a7b"}, 1011 | {file = "mypy-0.800-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e3b8432f8df19e3c11235c4563a7250666dc9aa7cdda58d21b4177b20256ca9f"}, 1012 | {file = "mypy-0.800-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d16c54b0dffb861dc6318a8730952265876d90c5101085a4bc56913e8521ba19"}, 1013 | {file = "mypy-0.800-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:0d2fc8beb99cd88f2d7e20d69131353053fbecea17904ee6f0348759302c52fa"}, 1014 | {file = "mypy-0.800-cp37-cp37m-win_amd64.whl", hash = "sha256:aa9d4901f3ee1a986a3a79fe079ffbf7f999478c281376f48faa31daaa814e86"}, 1015 | {file = "mypy-0.800-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:319ee5c248a7c3f94477f92a729b7ab06bf8a6d04447ef3aa8c9ba2aa47c6dcf"}, 1016 | {file = "mypy-0.800-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:74f5aa50d0866bc6fb8e213441c41e466c86678c800700b87b012ed11c0a13e0"}, 1017 | {file = "mypy-0.800-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:a301da58d566aca05f8f449403c710c50a9860782148332322decf73a603280b"}, 1018 | {file = "mypy-0.800-cp38-cp38-win_amd64.whl", hash = "sha256:b9150db14a48a8fa114189bfe49baccdff89da8c6639c2717750c7ae62316738"}, 1019 | {file = "mypy-0.800-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5fdf935a46aa20aa937f2478480ebf4be9186e98e49cc3843af9a5795a49a25"}, 1020 | {file = "mypy-0.800-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6f8425fecd2ba6007e526209bb985ce7f49ed0d2ac1cc1a44f243380a06a84fb"}, 1021 | {file = "mypy-0.800-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5ff616787122774f510caeb7b980542a7cc2222be3f00837a304ea85cd56e488"}, 1022 | {file = "mypy-0.800-cp39-cp39-win_amd64.whl", hash = "sha256:90b6f46dc2181d74f80617deca611925d7e63007cf416397358aa42efb593e07"}, 1023 | {file = "mypy-0.800-py3-none-any.whl", hash = "sha256:3e0c159a7853e3521e3f582adb1f3eac66d0b0639d434278e2867af3a8c62653"}, 1024 | {file = "mypy-0.800.tar.gz", hash = "sha256:e0202e37756ed09daf4b0ba64ad2c245d357659e014c3f51d8cd0681ba66940a"}, 1025 | ] 1026 | mypy-extensions = [ 1027 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 1028 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 1029 | ] 1030 | nodeenv = [ 1031 | {file = "nodeenv-1.5.0-py2.py3-none-any.whl", hash = "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9"}, 1032 | {file = "nodeenv-1.5.0.tar.gz", hash = "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"}, 1033 | ] 1034 | packaging = [ 1035 | {file = "packaging-20.8-py2.py3-none-any.whl", hash = "sha256:24e0da08660a87484d1602c30bb4902d74816b6985b93de36926f5bc95741858"}, 1036 | {file = "packaging-20.8.tar.gz", hash = "sha256:78598185a7008a470d64526a8059de9aaa449238f280fc9eb6b13ba6c4109093"}, 1037 | ] 1038 | pathspec = [ 1039 | {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, 1040 | {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, 1041 | ] 1042 | pdbpp = [ 1043 | {file = "pdbpp-0.10.2.tar.gz", hash = "sha256:73ff220d5006e0ecdc3e2705d8328d8aa5ac27fef95cc06f6e42cd7d22d55eb8"}, 1044 | ] 1045 | pep8-naming = [ 1046 | {file = "pep8-naming-0.11.1.tar.gz", hash = "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724"}, 1047 | {file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"}, 1048 | ] 1049 | pluggy = [ 1050 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 1051 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 1052 | ] 1053 | pre-commit = [ 1054 | {file = "pre_commit-2.9.3-py2.py3-none-any.whl", hash = "sha256:6c86d977d00ddc8a60d68eec19f51ef212d9462937acf3ea37c7adec32284ac0"}, 1055 | {file = "pre_commit-2.9.3.tar.gz", hash = "sha256:ee784c11953e6d8badb97d19bc46b997a3a9eded849881ec587accd8608d74a4"}, 1056 | ] 1057 | py = [ 1058 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, 1059 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, 1060 | ] 1061 | pycodestyle = [ 1062 | {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, 1063 | {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, 1064 | ] 1065 | pyflakes = [ 1066 | {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, 1067 | {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, 1068 | ] 1069 | pygments = [ 1070 | {file = "Pygments-2.8.0-py3-none-any.whl", hash = "sha256:b21b072d0ccdf29297a82a2363359d99623597b8a265b8081760e4d0f7153c88"}, 1071 | {file = "Pygments-2.8.0.tar.gz", hash = "sha256:37a13ba168a02ac54cc5891a42b1caec333e59b66addb7fa633ea8a6d73445c0"}, 1072 | ] 1073 | pyparsing = [ 1074 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 1075 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 1076 | ] 1077 | pyreadline = [ 1078 | {file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"}, 1079 | {file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"}, 1080 | {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, 1081 | ] 1082 | pyrepl = [ 1083 | {file = "pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775"}, 1084 | ] 1085 | pytest = [ 1086 | {file = "pytest-6.2.1-py3-none-any.whl", hash = "sha256:1969f797a1a0dbd8ccf0fecc80262312729afea9c17f1d70ebf85c5e76c6f7c8"}, 1087 | {file = "pytest-6.2.1.tar.gz", hash = "sha256:66e419b1899bc27346cb2c993e12c5e5e8daba9073c1fbce33b9807abc95c306"}, 1088 | ] 1089 | pytest-cov = [ 1090 | {file = "pytest-cov-2.10.1.tar.gz", hash = "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"}, 1091 | {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"}, 1092 | ] 1093 | pytest-django = [ 1094 | {file = "pytest-django-3.10.0.tar.gz", hash = "sha256:4de6dbd077ed8606616958f77655fed0d5e3ee45159475671c7fa67596c6dba6"}, 1095 | {file = "pytest_django-3.10.0-py2.py3-none-any.whl", hash = "sha256:c33e3d3da14d8409b125d825d4e74da17bb252191bf6fc3da6856e27a8b73ea4"}, 1096 | ] 1097 | pytest-mock = [ 1098 | {file = "pytest-mock-3.5.1.tar.gz", hash = "sha256:a1e2aba6af9560d313c642dae7e00a2a12b022b80301d9d7fc8ec6858e1dd9fc"}, 1099 | {file = "pytest_mock-3.5.1-py3-none-any.whl", hash = "sha256:379b391cfad22422ea2e252bdfc008edd08509029bcde3c25b2c0bd741e0424e"}, 1100 | ] 1101 | pytz = [ 1102 | {file = "pytz-2020.5-py2.py3-none-any.whl", hash = "sha256:16962c5fb8db4a8f63a26646d8886e9d769b6c511543557bc84e9569fb9a9cb4"}, 1103 | {file = "pytz-2020.5.tar.gz", hash = "sha256:180befebb1927b16f6b57101720075a984c019ac16b1b7575673bea42c6c3da5"}, 1104 | ] 1105 | pyyaml = [ 1106 | {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, 1107 | {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"}, 1108 | {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"}, 1109 | {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"}, 1110 | {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"}, 1111 | {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"}, 1112 | {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"}, 1113 | {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"}, 1114 | {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"}, 1115 | {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"}, 1116 | {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, 1117 | ] 1118 | regex = [ 1119 | {file = "regex-2020.11.13-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85"}, 1120 | {file = "regex-2020.11.13-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70"}, 1121 | {file = "regex-2020.11.13-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee"}, 1122 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5"}, 1123 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7"}, 1124 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31"}, 1125 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa"}, 1126 | {file = "regex-2020.11.13-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6"}, 1127 | {file = "regex-2020.11.13-cp36-cp36m-win32.whl", hash = "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e"}, 1128 | {file = "regex-2020.11.13-cp36-cp36m-win_amd64.whl", hash = "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884"}, 1129 | {file = "regex-2020.11.13-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b"}, 1130 | {file = "regex-2020.11.13-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88"}, 1131 | {file = "regex-2020.11.13-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0"}, 1132 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1"}, 1133 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0"}, 1134 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512"}, 1135 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba"}, 1136 | {file = "regex-2020.11.13-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538"}, 1137 | {file = "regex-2020.11.13-cp37-cp37m-win32.whl", hash = "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4"}, 1138 | {file = "regex-2020.11.13-cp37-cp37m-win_amd64.whl", hash = "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444"}, 1139 | {file = "regex-2020.11.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f"}, 1140 | {file = "regex-2020.11.13-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d"}, 1141 | {file = "regex-2020.11.13-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af"}, 1142 | {file = "regex-2020.11.13-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f"}, 1143 | {file = "regex-2020.11.13-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b"}, 1144 | {file = "regex-2020.11.13-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8"}, 1145 | {file = "regex-2020.11.13-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5"}, 1146 | {file = "regex-2020.11.13-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b"}, 1147 | {file = "regex-2020.11.13-cp38-cp38-win32.whl", hash = "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c"}, 1148 | {file = "regex-2020.11.13-cp38-cp38-win_amd64.whl", hash = "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683"}, 1149 | {file = "regex-2020.11.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc"}, 1150 | {file = "regex-2020.11.13-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364"}, 1151 | {file = "regex-2020.11.13-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e"}, 1152 | {file = "regex-2020.11.13-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e"}, 1153 | {file = "regex-2020.11.13-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917"}, 1154 | {file = "regex-2020.11.13-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b"}, 1155 | {file = "regex-2020.11.13-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9"}, 1156 | {file = "regex-2020.11.13-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c"}, 1157 | {file = "regex-2020.11.13-cp39-cp39-win32.whl", hash = "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f"}, 1158 | {file = "regex-2020.11.13-cp39-cp39-win_amd64.whl", hash = "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d"}, 1159 | {file = "regex-2020.11.13.tar.gz", hash = "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562"}, 1160 | ] 1161 | six = [ 1162 | {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, 1163 | {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, 1164 | ] 1165 | soupsieve = [ 1166 | {file = "soupsieve-2.1-py3-none-any.whl", hash = "sha256:4bb21a6ee4707bf43b61230e80740e71bfe56e55d1f1f50924b087bb2975c851"}, 1167 | {file = "soupsieve-2.1.tar.gz", hash = "sha256:6dc52924dc0bc710a5d16794e6b3480b2c7c08b07729505feab2b2c16661ff6e"}, 1168 | ] 1169 | sqlparse = [ 1170 | {file = "sqlparse-0.4.1-py3-none-any.whl", hash = "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0"}, 1171 | {file = "sqlparse-0.4.1.tar.gz", hash = "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8"}, 1172 | ] 1173 | toml = [ 1174 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 1175 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 1176 | ] 1177 | tox = [ 1178 | {file = "tox-3.21.0-py2.py3-none-any.whl", hash = "sha256:5efda30ad73e662c3844ac51ce1381bf28f61063773e06996aa8b6277133a7c0"}, 1179 | {file = "tox-3.21.0.tar.gz", hash = "sha256:8cccede64802e78aa6c69f81051b25f0706639d1cbbb34d9366ce00c70ee054f"}, 1180 | ] 1181 | tox-gh-actions = [ 1182 | {file = "tox-gh-actions-1.3.0.tar.gz", hash = "sha256:85d61e5f6176746497692f1ae17854656dbc1d4badfd97c6e5218f91804de176"}, 1183 | {file = "tox_gh_actions-1.3.0-py2.py3-none-any.whl", hash = "sha256:4ffcdaffd271b678ff77f90eee8b59247197f8faab2f5d19b6375f62a7545318"}, 1184 | ] 1185 | typed-ast = [ 1186 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"}, 1187 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"}, 1188 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"}, 1189 | {file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"}, 1190 | {file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"}, 1191 | {file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"}, 1192 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"}, 1193 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"}, 1194 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"}, 1195 | {file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"}, 1196 | {file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"}, 1197 | {file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"}, 1198 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"}, 1199 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"}, 1200 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"}, 1201 | {file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"}, 1202 | {file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"}, 1203 | {file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"}, 1204 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"}, 1205 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"}, 1206 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"}, 1207 | {file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"}, 1208 | {file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"}, 1209 | {file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"}, 1210 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"}, 1211 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"}, 1212 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"}, 1213 | {file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"}, 1214 | {file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"}, 1215 | {file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"}, 1216 | ] 1217 | typing-extensions = [ 1218 | {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, 1219 | {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, 1220 | {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, 1221 | ] 1222 | virtualenv = [ 1223 | {file = "virtualenv-20.3.0-py2.py3-none-any.whl", hash = "sha256:205a7577275dd0d9223c730dd498e21a8910600085c3dee97412b041fc4b853b"}, 1224 | {file = "virtualenv-20.3.0.tar.gz", hash = "sha256:7992b8de87e544a4ab55afc2240bf8388c4e3b5765d03784dad384bfdf9097ee"}, 1225 | ] 1226 | waitress = [ 1227 | {file = "waitress-1.4.4-py2.py3-none-any.whl", hash = "sha256:3d633e78149eb83b60a07dfabb35579c29aac2d24bb803c18b26fb2ab1a584db"}, 1228 | {file = "waitress-1.4.4.tar.gz", hash = "sha256:1bb436508a7487ac6cb097ae7a7fe5413aefca610550baf58f0940e51ecfb261"}, 1229 | ] 1230 | webob = [ 1231 | {file = "WebOb-1.8.6-py2.py3-none-any.whl", hash = "sha256:a3c89a8e9ba0aeb17382836cdb73c516d0ecf6630ec40ec28288f3ed459ce87b"}, 1232 | {file = "WebOb-1.8.6.tar.gz", hash = "sha256:aa3a917ed752ba3e0b242234b2a373f9c4e2a75d35291dcbe977649bd21fd108"}, 1233 | ] 1234 | webtest = [ 1235 | {file = "WebTest-2.0.35-py2.py3-none-any.whl", hash = "sha256:44ddfe99b5eca4cf07675e7222c81dd624d22f9a26035d2b93dc8862dc1153c6"}, 1236 | {file = "WebTest-2.0.35.tar.gz", hash = "sha256:aac168b5b2b4f200af4e35867cf316712210e3d5db81c1cbdff38722647bb087"}, 1237 | ] 1238 | wmctrl = [ 1239 | {file = "wmctrl-0.3.tar.gz", hash = "sha256:d806f65ac1554366b6e31d29d7be2e8893996c0acbb2824bbf2b1f49cf628a13"}, 1240 | ] 1241 | zipp = [ 1242 | {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"}, 1243 | {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"}, 1244 | ] 1245 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-inline-actions" 3 | version = "2.4.0" 4 | description = "django-inline-actions adds actions to each row of the ModelAdmin or InlineModelAdmin." 5 | authors = [ 6 | "Alexander Frenzel ", 7 | ] 8 | 9 | license = "BSD-3-Clause" 10 | readme = "README.md" 11 | 12 | documentation = "https://github.com/escaped/django-inline-actions/blob/master/README.md" 13 | homepage = "https://github.com/escaped/django-inline-actions" 14 | repository = "https://github.com/escaped/django-inline-actions" 15 | 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Environment :: Web Environment", 19 | "Intended Audience :: Developers", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.6", 24 | "Programming Language :: Python :: 3.7", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | ] 29 | 30 | packages = [ 31 | { include = "inline_actions" }, 32 | ] 33 | 34 | [tool.poetry.dependencies] 35 | python = ">=3.6.1, <4.0" 36 | 37 | django = ">=2.0" 38 | 39 | [tool.poetry.dev-dependencies] 40 | autoflake = "^1.4" 41 | better-exceptions = "^0.3.2" 42 | black = "^20.8b1" 43 | django-webtest = "^1.9" 44 | flake8 = "^3.8.3" 45 | flake8-bugbear = "^20.11.1" 46 | flake8-builtins = "^1.5.3" 47 | flake8-comprehensions = "^3.3.1" 48 | flake8-debugger = "^4.0.0" 49 | isort = "^5.5.2" 50 | lxml = "^4.3" 51 | mypy = "^0.800" 52 | pdbpp = "^0.10.2" 53 | pep8-naming = "^0.11.1" 54 | pre-commit = "^2.7.1" 55 | pytest = "^6.0.1" 56 | pytest-cov = "^2.10.1" 57 | pytest-django = "^3.4" 58 | pytest-mock = "^3.3.1" 59 | tox = "^3.20.0" 60 | tox-gh-actions = "^1.3.0" 61 | 62 | [tool.black] 63 | line-length = 88 64 | skip-string-normalization = true 65 | target_version = ['py36', 'py37', 'py38'] 66 | include = '\.pyi?$' 67 | exclude = ''' 68 | ( 69 | /( 70 | \.eggs # exclude a few common directories in the 71 | | \.git # root of the project 72 | | \.hg 73 | | \.mypy_cache 74 | | \.tox 75 | | \.venv 76 | | _build 77 | | buck-out 78 | | build 79 | | dist 80 | )/ 81 | ) 82 | ''' 83 | 84 | [build-system] 85 | requires = ["poetry-core>=1.0.0"] 86 | build-backend = "poetry.core.masonry.api" 87 | 88 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | line_length = 88 3 | profile = black 4 | 5 | 6 | [flake8] 7 | exclude = 8 | .git 9 | __pycache__ 10 | dist 11 | build 12 | 13 | ignore = 14 | E501 # code is reformatted using black 15 | max-line-length = 88 16 | max-complexity = 9 17 | 18 | 19 | [coverage:run] 20 | relative_files = True 21 | branch = True 22 | include = inline_actions/* 23 | omit = 24 | */test_proj/* 25 | 26 | [coverage:report] 27 | show_missing = True 28 | exclude_lines = 29 | pragma: no cover 30 | 31 | # Don't complain about missing debug-only code: 32 | def __unicode__ 33 | def __repr__ 34 | def __str__ 35 | 36 | # Don't complain if tests don't hit defensive assertion code: 37 | raise AssertionError 38 | raise NotImplementedError 39 | 40 | # Don't complain if non-runnable code isn't run: 41 | if __name__ == __main__: 42 | 43 | # No need to check type checking imports 44 | if TYPE_CHECKING: 45 | 46 | 47 | [tool:pytest] 48 | addopts = 49 | --durations=10 50 | --cov=inline_actions 51 | --cov-report term 52 | norecursedirs = build dist 53 | testpaths = 54 | inline_actions 55 | test_proj 56 | 57 | 58 | [mypy] 59 | # Specify the target platform details in config, so your developers are 60 | # free to run mypy on Windows, Linux, or macOS and get consistent 61 | # results. 62 | python_version = 3.6 63 | platform = Linux 64 | 65 | ignore_missing_imports = True 66 | -------------------------------------------------------------------------------- /test_proj/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import django 4 | 5 | os.environ['DJANGO_SETTINGS_MODULE'] = 'test_proj.settings' 6 | django.setup() 7 | -------------------------------------------------------------------------------- /test_proj/blog/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-inline-actions/d02441339a1b5ec3d34c10a29da41157afe9df2c/test_proj/blog/__init__.py -------------------------------------------------------------------------------- /test_proj/blog/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin, messages 2 | from django.shortcuts import render 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from inline_actions.actions import DefaultActionsMixin, ViewAction 6 | from inline_actions.admin import InlineActionsMixin, InlineActionsModelAdminMixin 7 | 8 | from . import forms 9 | from .models import Article, Author, AuthorProxy 10 | 11 | 12 | class UnPublishActionsMixin(object): 13 | def get_inline_actions(self, request, obj=None): 14 | actions = super(UnPublishActionsMixin, self).get_inline_actions(request, obj) 15 | if obj: 16 | if obj.status == Article.DRAFT: 17 | actions.append('publish') 18 | elif obj.status == Article.PUBLISHED: 19 | actions.append('unpublish') 20 | return actions 21 | 22 | def publish(self, request, obj, parent_obj=None): 23 | obj.status = Article.PUBLISHED 24 | obj.save() 25 | messages.info(request, _("Article published.")) 26 | 27 | publish.short_description = _("Publish") # type: ignore 28 | 29 | def unpublish(self, request, obj, parent_obj=None): 30 | obj.status = Article.DRAFT 31 | obj.save() 32 | messages.info(request, _("Article unpublished.")) 33 | 34 | unpublish.short_description = _("Unpublish") # type: ignore 35 | 36 | 37 | class TogglePublishActionsMixin(object): 38 | def get_inline_actions(self, request, obj=None): 39 | actions = super(TogglePublishActionsMixin, self).get_inline_actions( 40 | request=request, obj=obj 41 | ) 42 | actions.append('toggle_publish') 43 | return actions 44 | 45 | def toggle_publish(self, request, obj, parent_obj=None): 46 | if obj.status == Article.DRAFT: 47 | obj.status = Article.PUBLISHED 48 | else: 49 | obj.status = Article.DRAFT 50 | 51 | obj.save() 52 | status = 'unpublished' if obj.status == Article.DRAFT else 'published' 53 | messages.info(request, _("Article {}.".format(status))) 54 | 55 | def get_toggle_publish_label(self, obj): 56 | label = 'publish' if obj.status == Article.DRAFT else 'unpublish' 57 | return 'Toggle {}'.format(label) 58 | 59 | def get_toggle_publish_css(self, obj): 60 | return 'button object-tools' if obj.status == Article.DRAFT else 'default' 61 | 62 | 63 | class ChangeTitleActionsMixin(object): 64 | def get_inline_actions(self, request, obj=None): 65 | actions = super(ChangeTitleActionsMixin, self).get_inline_actions(request, obj) 66 | actions.append('change_title') 67 | return actions 68 | 69 | def change_title(self, request, obj, parent_obj=None): 70 | 71 | # explictly check whether the submit button has been pressed 72 | if '_save' in request.POST: 73 | form = forms.ChangeTitleForm(request.POST, instance=obj) 74 | form.save() 75 | return None # return back to list view 76 | elif '_back' in request.POST: 77 | return None # return back to list view 78 | else: 79 | form = forms.ChangeTitleForm(instance=obj) 80 | 81 | return render(request, 'change_title.html', context={'form': form}) 82 | 83 | 84 | class ArticleInline( 85 | DefaultActionsMixin, 86 | UnPublishActionsMixin, 87 | TogglePublishActionsMixin, 88 | InlineActionsMixin, 89 | admin.TabularInline, 90 | ): 91 | model = Article 92 | fields = ( 93 | 'title', 94 | 'status', 95 | ) 96 | readonly_fields = ( 97 | 'title', 98 | 'status', 99 | ) 100 | 101 | def has_add_permission(self, request, obj=None): 102 | return False 103 | 104 | 105 | class ArticleNoopInline(InlineActionsMixin, admin.TabularInline): 106 | model = Article 107 | fields = ( 108 | 'title', 109 | 'status', 110 | ) 111 | readonly_fields = ( 112 | 'title', 113 | 'status', 114 | ) 115 | 116 | def get_inline_actions(self, request, obj=None): 117 | actions = super(ArticleNoopInline, self).get_inline_actions( 118 | request=request, obj=obj 119 | ) 120 | actions.append('noop_action') 121 | return actions 122 | 123 | def noop_action(self, request, obj, parent_obj=None): 124 | pass 125 | 126 | 127 | @admin.register(AuthorProxy) 128 | class AuthorMultipleInlinesAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): 129 | inlines = [ArticleInline, ArticleNoopInline] 130 | list_display = ('name',) 131 | inline_actions = None 132 | 133 | 134 | @admin.register(Author) 135 | class AuthorAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): 136 | inlines = [ArticleInline] 137 | list_display = ('name',) 138 | inline_actions = None 139 | 140 | 141 | @admin.register(Article) 142 | class ArticleAdmin( 143 | UnPublishActionsMixin, 144 | TogglePublishActionsMixin, 145 | ChangeTitleActionsMixin, 146 | ViewAction, 147 | InlineActionsModelAdminMixin, 148 | admin.ModelAdmin, 149 | ): 150 | list_display = ('title', 'status', 'author') 151 | -------------------------------------------------------------------------------- /test_proj/blog/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from . import models 4 | 5 | 6 | class ChangeTitleForm(forms.ModelForm): 7 | class Meta: 8 | model = models.Article 9 | fields = ('title',) 10 | -------------------------------------------------------------------------------- /test_proj/blog/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [] # type: ignore 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name='Article', 14 | fields=[ 15 | ( 16 | 'id', 17 | models.AutoField( 18 | verbose_name='ID', 19 | serialize=False, 20 | auto_created=True, 21 | primary_key=True, 22 | ), 23 | ), 24 | ('title', models.CharField(max_length=100)), 25 | ('body', models.TextField()), 26 | ( 27 | 'status', 28 | models.CharField( 29 | default=b'draft', 30 | max_length=10, 31 | choices=[(b'draft', 'Draft'), (b'published', 'Published')], 32 | ), 33 | ), 34 | ], 35 | ), 36 | migrations.CreateModel( 37 | name='Author', 38 | fields=[ 39 | ( 40 | 'id', 41 | models.AutoField( 42 | verbose_name='ID', 43 | serialize=False, 44 | auto_created=True, 45 | primary_key=True, 46 | ), 47 | ), 48 | ('name', models.CharField(max_length=50)), 49 | ], 50 | ), 51 | migrations.AddField( 52 | model_name='article', 53 | name='author', 54 | field=models.ForeignKey(to='blog.Author', on_delete=models.CASCADE), 55 | ), 56 | ] 57 | -------------------------------------------------------------------------------- /test_proj/blog/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-inline-actions/d02441339a1b5ec3d34c10a29da41157afe9df2c/test_proj/blog/migrations/__init__.py -------------------------------------------------------------------------------- /test_proj/blog/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class Author(models.Model): 6 | name = models.CharField( 7 | max_length=50, 8 | ) 9 | 10 | def __str__(self): 11 | return self.name 12 | 13 | 14 | class Article(models.Model): 15 | DRAFT = 'draft' 16 | PUBLISHED = 'published' 17 | 18 | STATUS_CHOICES = ( 19 | (DRAFT, _("Draft")), 20 | (PUBLISHED, _("Published")), 21 | ) 22 | 23 | author = models.ForeignKey(Author, on_delete=models.CASCADE) 24 | title = models.CharField( 25 | max_length=100, 26 | ) 27 | body = models.TextField() 28 | status = models.CharField( 29 | choices=STATUS_CHOICES, 30 | default=DRAFT, 31 | max_length=10, 32 | ) 33 | 34 | def __str__(self): 35 | return self.title 36 | 37 | 38 | class AuthorProxy(Author): 39 | class Meta: 40 | proxy = True 41 | -------------------------------------------------------------------------------- /test_proj/blog/templates/change_title.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load inline_action_tags %} 3 | 4 | {% block content %} 5 |

6 | {% csrf_token %} 7 | {% render_inline_action_fields %} 8 | 9 | {{ form.as_p }} 10 | 11 | 12 | 13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /test_proj/blog/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/escaped/django-inline-actions/d02441339a1b5ec3d34c10a29da41157afe9df2c/test_proj/blog/tests/__init__.py -------------------------------------------------------------------------------- /test_proj/blog/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib.admin.sites import AdminSite 3 | from django.urls import reverse 4 | 5 | from test_proj.blog.models import Article 6 | 7 | 8 | @pytest.fixture 9 | def admin_site(): 10 | return AdminSite() 11 | 12 | 13 | def test_for_mediafiles(admin_client, author): 14 | """Test weather the css is added to page.""" 15 | url = reverse('admin:blog_author_change', args=(author.pk,)) 16 | changeview = admin_client.get(url) 17 | xpath_to_css = './/head/link[contains(@href, "inline_actions.css")]' 18 | assert len(changeview.lxml.xpath(xpath_to_css)) == 1 19 | 20 | 21 | def test_actions_available(admin_client, author): 22 | """Test weather the action column is rendered.""" 23 | url = reverse('admin:blog_author_change', args=(author.pk,)) 24 | changeview = admin_client.get(url) 25 | path = ( 26 | './/div[@id="article_set-group"]//table' 27 | '//thead//th[starts-with(text(), "Actions")]' 28 | ) 29 | assert len(changeview.lxml.xpath(path)) == 1 30 | 31 | url = reverse('admin:blog_author_add') 32 | addview = admin_client.get(url) 33 | assert len(addview.lxml.xpath(path)) == 1 34 | 35 | 36 | @pytest.mark.django_db 37 | def test_non_existing_action(admin_site, article): 38 | """Test for appropriate exception, when `action` is not found.""" 39 | from test_proj.blog.admin import ArticleAdmin 40 | 41 | ArticleAdmin.inline_actions = ['non_existing'] 42 | fake_request = {} 43 | 44 | admin = ArticleAdmin(article, admin_site) 45 | admin._request = fake_request 46 | 47 | with pytest.raises(RuntimeError): 48 | admin.render_inline_actions(article) 49 | 50 | # reset 51 | ArticleAdmin.inline_actions = [] 52 | 53 | 54 | @pytest.mark.django_db 55 | def test_wrong_action_type(admin_client, article): 56 | """Test for appropriate exception, when the action is not callable.""" 57 | from inline_actions.admin import ActionNotCallable 58 | from test_proj.blog.admin import ArticleAdmin 59 | 60 | admin = ArticleAdmin(article, admin_site) 61 | admin.inline_actions = ['property_action'] 62 | admin.property_action = 'test' 63 | 64 | fake_request = {} 65 | 66 | with pytest.raises(ActionNotCallable): 67 | admin._execute_action(fake_request, admin, 'property_action', article) 68 | 69 | 70 | def test_actions_methods_called(admin_client, mocker, article): 71 | """Test is all required methods are called.""" 72 | from inline_actions.admin import InlineActionsMixin 73 | 74 | mocker.spy(InlineActionsMixin, 'render_inline_actions') 75 | mocker.spy(InlineActionsMixin, 'get_inline_actions') 76 | author = article.author 77 | 78 | url = reverse('admin:blog_author_change', args=(author.pk,)) 79 | admin_client.get(url) 80 | 81 | assert InlineActionsMixin.render_inline_actions.call_count > 0 82 | assert InlineActionsMixin.get_inline_actions.call_count > 0 83 | 84 | 85 | @pytest.mark.parametrize("action", ['view_action', 'publish', 'delete_action']) 86 | def test_actions_rendered(admin_client, article, action): 87 | """Test wether all action buttons are rendered.""" 88 | author = article.author 89 | 90 | url = reverse('admin:blog_author_change', args=(author.pk,)) 91 | changeview = admin_client.get(url) 92 | 93 | input_name = '_action__articleinline__inline__{}__blog__article__{}'.format( 94 | action, 95 | article.pk, 96 | ) 97 | assert input_name in dict(changeview.form.fields) 98 | 99 | 100 | def test_publish_action(admin_client, mocker, article): 101 | """Test dynamically added actions using `get_actions()`""" 102 | from ..admin import UnPublishActionsMixin 103 | 104 | mocker.spy(UnPublishActionsMixin, 'get_inline_actions') 105 | mocker.spy(UnPublishActionsMixin, 'publish') 106 | mocker.spy(UnPublishActionsMixin, 'unpublish') 107 | author = article.author 108 | assert article.status == Article.DRAFT 109 | 110 | author_url = reverse('admin:blog_author_change', args=(author.pk,)) 111 | publish_input_name = ( 112 | '_action__articleinline__inline__publish__blog__article__{}'.format( 113 | article.pk, 114 | ) 115 | ) 116 | unpublish_input_name = ( 117 | '_action__articleinline__inline__unpublish__blog__article__{}'.format( 118 | article.pk, 119 | ) 120 | ) 121 | 122 | # open changeform 123 | changeview = admin_client.get(author_url) 124 | assert UnPublishActionsMixin.get_inline_actions.call_count > 0 125 | assert publish_input_name in dict(changeview.form.fields) 126 | 127 | # execute and test publish action 128 | changeview = changeview.form.submit(name=publish_input_name).follow() 129 | # not available in django 1.7 130 | # article.refresh_from_db() 131 | article = Article.objects.get(pk=article.pk) 132 | assert publish_input_name not in dict(changeview.form.fields) 133 | assert unpublish_input_name in dict(changeview.form.fields) 134 | assert UnPublishActionsMixin.publish.call_count == 1 135 | assert article.status == Article.PUBLISHED 136 | 137 | # execute and test unpublish action 138 | changeview = changeview.form.submit(name=unpublish_input_name).follow() 139 | # article.refresh_from_db() 140 | article = Article.objects.get(pk=article.pk) 141 | assert publish_input_name in dict(changeview.form.fields) 142 | assert unpublish_input_name not in dict(changeview.form.fields) 143 | assert UnPublishActionsMixin.unpublish.call_count == 1 144 | assert article.status == Article.DRAFT 145 | 146 | 147 | def test_view_action(admin_client, mocker, article): 148 | """Test view action.""" 149 | from inline_actions.actions import ViewAction 150 | 151 | mocker.spy(ViewAction, 'view_action') 152 | author = article.author 153 | 154 | author_url = reverse('admin:blog_author_change', args=(author.pk,)) 155 | changeview = admin_client.get(author_url) 156 | 157 | # execute and test view action 158 | input_name = ( 159 | '_action__articleinline__inline__view_action__blog__article__{}'.format( 160 | article.pk, 161 | ) 162 | ) 163 | response = changeview.form.submit(name=input_name).follow() 164 | assert ViewAction.view_action.call_count == 1 165 | article_url = reverse('admin:blog_article_change', args=(article.pk,)) 166 | assert response.request.path == article_url 167 | 168 | 169 | def test_delete_action_without_permission(admin_client, mocker, article): 170 | """Delete action should not be visible without permission.""" 171 | from ..admin import ArticleInline 172 | 173 | mocker.patch.object(ArticleInline, 'has_delete_permission', return_value=False) 174 | author = article.author 175 | 176 | # mock delete_permission 177 | author_url = reverse('admin:blog_author_change', args=(author.pk,)) 178 | changeview = admin_client.get(author_url) 179 | 180 | input_name = ( 181 | '_action__articleinline__inline__delete_action__blog__article__{}'.format( 182 | article.pk, 183 | ) 184 | ) 185 | assert input_name not in dict(changeview.form.fields) 186 | 187 | 188 | def test_delete_action(admin_client, mocker, article): 189 | """Test delete action.""" 190 | from inline_actions.actions import DeleteAction 191 | 192 | mocker.spy(DeleteAction, 'delete_action') 193 | author = article.author 194 | 195 | # mock delete_permission 196 | author_url = reverse('admin:blog_author_change', args=(author.pk,)) 197 | changeview = admin_client.get(author_url) 198 | 199 | # execute and test delete action 200 | input_name = ( 201 | '_action__articleinline__inline__delete_action__blog__article__{}'.format( 202 | article.pk, 203 | ) 204 | ) 205 | response = changeview.form.submit(name=input_name).follow() 206 | assert DeleteAction.delete_action.call_count == 1 207 | assert response.request.path == author_url 208 | with pytest.raises(Article.DoesNotExist): 209 | Article.objects.get(pk=article.pk) 210 | 211 | 212 | def test_skip_rendering_actions_for_unsaved_objects(admin_client, mocker, article): 213 | from test_proj.blog.admin import ArticleAdmin 214 | 215 | unsaved_article = Article() 216 | admin = ArticleAdmin(unsaved_article, admin_site) 217 | 218 | assert admin.render_inline_actions(unsaved_article) == '' 219 | 220 | 221 | @pytest.mark.django_db 222 | def test_missing_render_inline_actions_from_readonly_fields( 223 | rf, admin_user, admin_site, article 224 | ): 225 | """ 226 | Make sure that customization does not break the app. 227 | """ 228 | from test_proj.blog import admin 229 | 230 | class ArticleAdmin(admin.InlineActionsModelAdminMixin, admin.admin.ModelAdmin): 231 | list_display = ('name',) 232 | inline_actions = None 233 | 234 | def get_readonly_fields(self, *args, **kwargs): 235 | """ 236 | Do some fancy logic to return a list of fields, which does not include `render_inline_actions`. 237 | """ 238 | return [] 239 | 240 | request = rf.get(f'/admin/blog/articles/{article.id}/') 241 | request.user = admin_user 242 | 243 | admin = ArticleAdmin(Article, admin_site) 244 | 245 | # even though `render_inline_actions` is not part of the fields, 246 | # it should not fail :) 247 | admin.changeform_view(request) 248 | -------------------------------------------------------------------------------- /test_proj/blog/tests/test_form.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_changetitle_action(admin_client, mocker, article): 7 | """Test action with intermediate form.""" 8 | new_title = 'Fooo bar!' 9 | action_name = ( 10 | '_action__articleadmin__admin__change_title__blog__article__{}'.format( 11 | article.pk 12 | ) 13 | ) 14 | 15 | article_url = reverse('admin:blog_article_changelist') 16 | changeview = admin_client.get(article_url) 17 | 18 | changetitle_view = changeview.form.submit(name=action_name) 19 | assert changetitle_view.status_code == 200 20 | 21 | # action should be available as hidden field 22 | expected_field = ''.format(action_name) 23 | assert expected_field in changetitle_view.text 24 | 25 | # change title and save 26 | changetitle_view.form['title'] = new_title 27 | response = changetitle_view.form.submit(name='_save') 28 | response = response.follow() 29 | assert response.status_code == 200 30 | 31 | article.refresh_from_db() 32 | assert article.title == new_title 33 | -------------------------------------------------------------------------------- /test_proj/blog/tests/test_inline_admin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | from django_webtest import DjangoTestApp, WebTestMixin 4 | 5 | from ..models import Article, Author 6 | 7 | 8 | @pytest.fixture(scope='function') 9 | def app(request): 10 | """WebTest's TestApp. 11 | Patch and unpatch settings before and after each test. 12 | WebTestMixin, when used in a unittest.TestCase, automatically calls 13 | _patch_settings() and _unpatchsettings. 14 | """ 15 | wtm = WebTestMixin() 16 | wtm._patch_settings() 17 | request.addfinalizer(wtm._unpatch_settings) 18 | return DjangoTestApp() 19 | 20 | 21 | @pytest.fixture() 22 | def admin_client(app, admin_user): 23 | app.set_user(admin_user) 24 | return app 25 | 26 | 27 | @pytest.fixture 28 | def author(): 29 | author, __ = Author.objects.get_or_create( 30 | name='Author', 31 | ) 32 | return author 33 | 34 | 35 | @pytest.fixture 36 | def article(author): 37 | return Article.objects.create( 38 | author=author, 39 | body='Body lorem ipson dolor', 40 | title='Lorem ipson dolor', 41 | ) 42 | 43 | 44 | def test_actions_available(admin_client, author): 45 | """Test weather the action column is rendered.""" 46 | url = reverse('admin:blog_author_change', args=(author.pk,)) 47 | changeview = admin_client.get(url) 48 | path = ( 49 | './/div[@id="article_set-group"]//table' 50 | '//thead//th[starts-with(text(), "Actions")]' 51 | ) 52 | assert len(changeview.lxml.xpath(path)) == 1 53 | 54 | url = reverse('admin:blog_author_add') 55 | addview = admin_client.get(url) 56 | assert len(addview.lxml.xpath(path)) == 1 57 | 58 | 59 | def test_no_actions_on_none(admin_client, author): 60 | """If `inline_actions=None` no actions should be visible""" 61 | from ..admin import ArticleInline 62 | 63 | url = reverse('admin:blog_article_changelist') 64 | 65 | # save 66 | old_inlinec_actions = ArticleInline.inline_actions 67 | ArticleInline.inline_actions = None 68 | 69 | url = reverse('admin:blog_author_change', args=(author.pk,)) 70 | changeview = admin_client.get(url) 71 | path = ( 72 | './/div[@id="article_set-group"]//table' 73 | '//thead//th[starts-with(text(), "Actions")]' 74 | ) 75 | assert len(changeview.lxml.xpath(path)) == 0 76 | 77 | url = reverse('admin:blog_author_add') 78 | addview = admin_client.get(url) 79 | assert len(addview.lxml.xpath(path)) == 0 80 | 81 | # restore 82 | ArticleInline.inline_actions = old_inlinec_actions 83 | 84 | 85 | def test_actions_methods_called(admin_client, mocker, article): 86 | """Test is all required methods are called.""" 87 | from inline_actions.admin import InlineActionsMixin 88 | 89 | mocker.spy(InlineActionsMixin, 'render_inline_actions') 90 | mocker.spy(InlineActionsMixin, 'get_inline_actions') 91 | author = article.author 92 | 93 | url = reverse('admin:blog_author_change', args=(author.pk,)) 94 | admin_client.get(url) 95 | 96 | assert InlineActionsMixin.render_inline_actions.call_count > 0 97 | assert InlineActionsMixin.get_inline_actions.call_count > 0 98 | 99 | 100 | @pytest.mark.parametrize("action", ['view_action', 'publish', 'delete_action']) 101 | def test_actions_rendered(admin_client, article, action): 102 | """Test wether all action buttons are rendered.""" 103 | author = article.author 104 | 105 | url = reverse('admin:blog_author_change', args=(author.pk,)) 106 | changeview = admin_client.get(url) 107 | 108 | input_name = '_action__articleinline__inline__{}__blog__article__{}'.format( 109 | action, article.pk 110 | ) 111 | assert input_name in dict(changeview.form.fields) 112 | 113 | 114 | def test_publish_action(admin_client, mocker, article): 115 | """Test dynamically added actions using `get_actions()`""" 116 | from ..admin import UnPublishActionsMixin 117 | 118 | mocker.spy(UnPublishActionsMixin, 'get_inline_actions') 119 | mocker.spy(UnPublishActionsMixin, 'publish') 120 | mocker.spy(UnPublishActionsMixin, 'unpublish') 121 | author = article.author 122 | assert article.status == Article.DRAFT 123 | 124 | author_url = reverse('admin:blog_author_change', args=(author.pk,)) 125 | publish_input_name = ( 126 | '_action__articleinline__inline__publish__blog__article__{}'.format(article.pk) 127 | ) 128 | unpublish_input_name = ( 129 | '_action__articleinline__inline__unpublish__blog__article__{}'.format( 130 | article.pk 131 | ) 132 | ) 133 | 134 | # open changeform 135 | changeview = admin_client.get(author_url) 136 | assert UnPublishActionsMixin.get_inline_actions.call_count > 0 137 | assert publish_input_name in dict(changeview.form.fields) 138 | 139 | # execute and test publish action 140 | changeview = changeview.form.submit(name=publish_input_name).follow() 141 | # not available in django 1.7 142 | # article.refresh_from_db() 143 | article = Article.objects.get(pk=article.pk) 144 | assert publish_input_name not in dict(changeview.form.fields) 145 | assert unpublish_input_name in dict(changeview.form.fields) 146 | assert UnPublishActionsMixin.publish.call_count == 1 147 | assert article.status == Article.PUBLISHED 148 | 149 | # execute and test unpublish action 150 | changeview = changeview.form.submit(name=unpublish_input_name).follow() 151 | # article.refresh_from_db() 152 | article = Article.objects.get(pk=article.pk) 153 | assert publish_input_name in dict(changeview.form.fields) 154 | assert unpublish_input_name not in dict(changeview.form.fields) 155 | assert UnPublishActionsMixin.unpublish.call_count == 1 156 | assert article.status == Article.DRAFT 157 | 158 | 159 | def test_view_action(admin_client, mocker, article): 160 | """Test view action.""" 161 | from inline_actions.actions import ViewAction 162 | 163 | mocker.spy(ViewAction, 'view_action') 164 | author = article.author 165 | 166 | author_url = reverse('admin:blog_author_change', args=(author.pk,)) 167 | changeview = admin_client.get(author_url) 168 | 169 | # execute and test view action 170 | input_name = ( 171 | '_action__articleinline__inline__view_action__blog__article__{}'.format( 172 | article.pk, 173 | ) 174 | ) 175 | response = changeview.form.submit(name=input_name).follow() 176 | assert ViewAction.view_action.call_count == 1 177 | article_url = reverse('admin:blog_article_change', args=(article.pk,)) 178 | assert response.request.path == article_url 179 | 180 | 181 | def test_delete_action_without_permission(admin_client, mocker, article): 182 | """Delete action should not be visible without permission.""" 183 | from ..admin import ArticleInline 184 | 185 | mocker.patch.object(ArticleInline, 'has_delete_permission', return_value=False) 186 | author = article.author 187 | 188 | # mock delete_permission 189 | author_url = reverse('admin:blog_author_change', args=(author.pk,)) 190 | changeview = admin_client.get(author_url) 191 | 192 | input_name = ( 193 | '_action__articleinline__inline__delete_action__blog__article__{}'.format( 194 | article.pk, 195 | ) 196 | ) 197 | assert input_name not in dict(changeview.form.fields) 198 | 199 | 200 | def test_delete_action(admin_client, mocker, article): 201 | """Test delete action.""" 202 | from inline_actions.actions import DeleteAction 203 | 204 | mocker.spy(DeleteAction, 'delete_action') 205 | author = article.author 206 | 207 | # mock delete_permission 208 | author_url = reverse('admin:blog_author_change', args=(author.pk,)) 209 | changeview = admin_client.get(author_url) 210 | 211 | # execute and test delete action 212 | input_name = ( 213 | '_action__articleinline__inline__delete_action__blog__article__{}'.format( 214 | article.pk, 215 | ) 216 | ) 217 | response = changeview.form.submit(name=input_name).follow() 218 | assert DeleteAction.delete_action.call_count == 1 219 | assert response.request.path == author_url 220 | with pytest.raises(Article.DoesNotExist): 221 | Article.objects.get(pk=article.pk) 222 | 223 | 224 | def test_handle_multiple_inlines(admin_client, mocker, article): 225 | """ 226 | Test that we can have multiple inlines for the same model. 227 | """ 228 | from ..admin import ArticleNoopInline 229 | 230 | mocker.spy(ArticleNoopInline, 'noop_action') 231 | author = article.author 232 | 233 | # mock delete_permission 234 | author_url = reverse('admin:blog_authorproxy_change', args=(author.pk,)) 235 | changeview = admin_client.get(author_url) 236 | 237 | # run action on second inline 238 | input_name = ( 239 | '_action__articlenoopinline__inline__noop_action__blog__article__{}'.format( 240 | article.pk, 241 | ) 242 | ) 243 | changeview.form.submit(name=input_name).follow() 244 | assert ArticleNoopInline.noop_action.call_count == 1 245 | -------------------------------------------------------------------------------- /test_proj/blog/tests/test_model_admin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | 4 | from ..models import Article 5 | 6 | 7 | def test_actions_available(admin_client, article): 8 | """Test weather the action column is rendered.""" 9 | url = reverse('admin:blog_article_changelist') 10 | changeview = admin_client.get(url) 11 | path = ( 12 | './/table[@id="result_list"]' '//thead//th//*[starts-with(text(), "Actions")]' 13 | ) 14 | assert len(changeview.lxml.xpath(path)) == 1 15 | 16 | 17 | def test_no_actions_on_none(admin_client, article): 18 | """If `inline_actions=None` no actions should be visible""" 19 | from ..admin import ArticleAdmin 20 | 21 | url = reverse('admin:blog_article_changelist') 22 | 23 | # save 24 | old_inlinec_actions = ArticleAdmin.inline_actions 25 | ArticleAdmin.inline_actions = None 26 | 27 | changeview = admin_client.get(url) 28 | path = ( 29 | './/table[@id="result_list"]' '//thead//th//*[starts-with(text(), "Actions")]' 30 | ) 31 | assert len(changeview.lxml.xpath(path)) == 0 32 | 33 | # restore 34 | ArticleAdmin.inline_actions = old_inlinec_actions 35 | 36 | 37 | def test_actions_methods_called(admin_client, mocker, article): 38 | """Test is all required methods are called.""" 39 | from inline_actions.admin import BaseInlineActionsMixin 40 | 41 | mocker.spy(BaseInlineActionsMixin, 'render_inline_actions') 42 | mocker.spy(BaseInlineActionsMixin, 'get_inline_actions') 43 | 44 | url = reverse('admin:blog_article_changelist') 45 | admin_client.get(url) 46 | 47 | assert BaseInlineActionsMixin.render_inline_actions.call_count > 0 48 | assert BaseInlineActionsMixin.get_inline_actions.call_count > 0 49 | 50 | 51 | def test_actions_dynamic_label_called(admin_client, mocker, article): 52 | """Test that if for model admin contains 'get__label 53 | then this method will be called 54 | """ 55 | 56 | from ..admin import TogglePublishActionsMixin 57 | 58 | mocker.spy(TogglePublishActionsMixin, 'get_toggle_publish_label') 59 | 60 | url = reverse('admin:blog_article_changelist') 61 | admin_client.get(url) 62 | 63 | assert TogglePublishActionsMixin.get_toggle_publish_label.call_count > 0 64 | 65 | 66 | def test_actions_dynamic_css_called(admin_client, mocker, article): 67 | """Test that if for model admin contains 'get__css 68 | then this method will be called 69 | """ 70 | 71 | from ..admin import TogglePublishActionsMixin 72 | 73 | mocker.spy(TogglePublishActionsMixin, 'get_toggle_publish_css') 74 | 75 | url = reverse('admin:blog_article_changelist') 76 | admin_client.get(url) 77 | 78 | assert TogglePublishActionsMixin.get_toggle_publish_css.call_count > 0 79 | 80 | 81 | @pytest.mark.parametrize('action', ['view_action', 'publish']) 82 | def test_actions_rendered(admin_client, article, action): 83 | """Test wether all action buttons are rendered.""" 84 | url = reverse('admin:blog_article_changelist') 85 | changelist = admin_client.get(url) 86 | 87 | input_name = '_action__articleadmin__admin__{}__blog__article__{}'.format( 88 | action, article.pk 89 | ) 90 | assert input_name in dict(changelist.form.fields) 91 | 92 | 93 | def test_publish_action(admin_client, mocker, article): 94 | """Test dynamically added actions using `get_actions()`""" 95 | from ..admin import UnPublishActionsMixin 96 | 97 | mocker.spy(UnPublishActionsMixin, 'get_inline_actions') 98 | mocker.spy(UnPublishActionsMixin, 'publish') 99 | mocker.spy(UnPublishActionsMixin, 'unpublish') 100 | assert article.status == Article.DRAFT 101 | 102 | article_url = reverse('admin:blog_article_changelist') 103 | publish_input_name = ( 104 | '_action__articleadmin__admin__publish__blog__article__{}'.format(article.pk) 105 | ) 106 | unpublish_input_name = ( 107 | '_action__articleadmin__admin__unpublish__blog__article__{}'.format( 108 | article.pk, 109 | ) 110 | ) 111 | 112 | # open changelist 113 | changelist = admin_client.get(article_url) 114 | assert UnPublishActionsMixin.get_inline_actions.call_count > 0 115 | assert publish_input_name in dict(changelist.form.fields) 116 | 117 | # execute and test publish action 118 | changelist = changelist.form.submit(name=publish_input_name).follow() 119 | # not available in django 1.7 120 | # article.refresh_from_db() 121 | article = Article.objects.get(pk=article.pk) 122 | assert publish_input_name not in dict(changelist.form.fields) 123 | assert unpublish_input_name in dict(changelist.form.fields) 124 | assert UnPublishActionsMixin.publish.call_count == 1 125 | assert article.status == Article.PUBLISHED 126 | 127 | # execute and test unpublish action 128 | changelist = changelist.form.submit(name=unpublish_input_name).follow() 129 | # article.refresh_from_db() 130 | article = Article.objects.get(pk=article.pk) 131 | assert publish_input_name in dict(changelist.form.fields) 132 | assert unpublish_input_name not in dict(changelist.form.fields) 133 | assert UnPublishActionsMixin.unpublish.call_count == 1 134 | assert article.status == Article.DRAFT 135 | 136 | 137 | def test_view_action(admin_client, mocker, article): 138 | """Test view action.""" 139 | from inline_actions.actions import ViewAction 140 | 141 | mocker.spy(ViewAction, 'view_action') 142 | 143 | article_url = reverse('admin:blog_article_changelist') 144 | changeview = admin_client.get(article_url) 145 | 146 | # execute and test view action 147 | input_name = '_action__articleadmin__admin__view_action__blog__article__{}'.format( 148 | article.pk 149 | ) 150 | response = changeview.form.submit(name=input_name).follow() 151 | assert ViewAction.view_action.call_count == 1 152 | article_change_url = reverse('admin:blog_article_change', args=(article.pk,)) 153 | assert response.request.path == article_change_url 154 | 155 | 156 | def test_no_actions_on_changelist(admin_client, article): 157 | """Test wether the actions fields is hidden by default""" 158 | url = reverse('admin:blog_article_change', args=(article.pk,)) 159 | changelist = admin_client.get(url) 160 | 161 | assert 'field-render_inline_actions' not in changelist.content.decode('utf8') 162 | -------------------------------------------------------------------------------- /test_proj/blog/tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from inline_actions.templatetags.inline_action_tags import render_inline_action_fields 4 | 5 | 6 | def test_mulitple_actions_are_triggered(rf): 7 | request = rf.post( 8 | '/some/url/', 9 | data={ 10 | '_action__admin__NAME1__blog__blog__1': "", 11 | '_action__admin__NAME2__blog__blog__1': "", 12 | }, 13 | ) 14 | context = {'request': request} 15 | 16 | with pytest.raises(RuntimeError) as exc_info: 17 | render_inline_action_fields(context) 18 | exception = exc_info.value 19 | assert ( 20 | str(exception) == "Multiple inline actions have been triggered simultaneously." 21 | ) 22 | 23 | 24 | def test_no_action_is_triggered(rf): 25 | request = rf.post( 26 | '/some/url/', 27 | ) 28 | context = {'request': request} 29 | 30 | with pytest.raises(RuntimeError) as exc_info: 31 | render_inline_action_fields(context) 32 | exception = exc_info.value 33 | assert str(exception) == "No inline action has been triggered." 34 | 35 | 36 | def test_render_action(rf): 37 | action_name = '_action__admin__NAME1__blog__blog__1' 38 | request = rf.post( 39 | '/some/url/', 40 | data={ 41 | '{}'.format(action_name): "", 42 | }, 43 | ) 44 | context = {'request': request} 45 | 46 | content = render_inline_action_fields(context) 47 | expected_content = ''.format(action_name) 48 | assert content == expected_content 49 | -------------------------------------------------------------------------------- /test_proj/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django_webtest import DjangoTestApp, WebTestMixin 3 | 4 | from test_proj.blog.models import Article, Author 5 | 6 | 7 | @pytest.fixture(scope='function') 8 | def app(request): 9 | """WebTest's TestApp. 10 | Patch and unpatch settings before and after each test. 11 | WebTestMixin, when used in a unittest.TestCase, automatically calls 12 | _patch_settings() and _unpatchsettings. 13 | """ 14 | wtm = WebTestMixin() 15 | wtm._patch_settings() 16 | request.addfinalizer(wtm._unpatch_settings) 17 | return DjangoTestApp() 18 | 19 | 20 | @pytest.fixture() 21 | def admin_client(app, admin_user): 22 | app.set_user(admin_user) 23 | return app 24 | 25 | 26 | @pytest.fixture 27 | def author(): 28 | author, __ = Author.objects.get_or_create( 29 | name='Author', 30 | ) 31 | return author 32 | 33 | 34 | @pytest.fixture 35 | def article(author): 36 | return Article.objects.create( 37 | author=author, 38 | body='Body lorem ipson dolor', 39 | title='Lorem ipson dolor', 40 | ) 41 | -------------------------------------------------------------------------------- /test_proj/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | sys.path.append(os.pardir) 8 | 9 | from django.core.management import execute_from_command_line 10 | 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /test_proj/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List 3 | 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | 7 | # Quick-start development settings - unsuitable for production 8 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 9 | 10 | # SECURITY WARNING: keep the secret key used in production secret! 11 | SECRET_KEY = '=ycop_5evy)i4s@_5cuw(5q*$e(03kj!ajeg92$g8$42a_nw_w' 12 | 13 | # SECURITY WARNING: don't run with debug turned on in production! 14 | DEBUG = True 15 | 16 | ALLOWED_HOSTS: List[str] = [] 17 | 18 | 19 | # Application definition 20 | 21 | INSTALLED_APPS = ( 22 | 'django.contrib.admin', 23 | 'django.contrib.auth', 24 | 'django.contrib.contenttypes', 25 | 'django.contrib.sessions', 26 | 'django.contrib.messages', 27 | 'django.contrib.staticfiles', 28 | 'inline_actions', 29 | 'test_proj.blog', 30 | ) 31 | 32 | MIDDLEWARE = ( 33 | 'django.contrib.sessions.middleware.SessionMiddleware', 34 | 'django.middleware.common.CommonMiddleware', 35 | 'django.middleware.csrf.CsrfViewMiddleware', 36 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 37 | 'django.contrib.messages.middleware.MessageMiddleware', 38 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 39 | ) 40 | 41 | ROOT_URLCONF = 'test_proj.urls' 42 | 43 | TEMPLATES = [ 44 | { 45 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 46 | 'DIRS': [], 47 | 'APP_DIRS': True, 48 | 'OPTIONS': { 49 | 'context_processors': [ 50 | 'django.template.context_processors.debug', 51 | 'django.template.context_processors.request', 52 | 'django.contrib.auth.context_processors.auth', 53 | 'django.contrib.messages.context_processors.messages', 54 | ], 55 | }, 56 | }, 57 | ] 58 | 59 | WSGI_APPLICATION = 'test_proj.wsgi.application' 60 | 61 | 62 | # Database 63 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 64 | 65 | DATABASES = { 66 | 'default': { 67 | 'ENGINE': 'django.db.backends.sqlite3', 68 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 69 | } 70 | } 71 | 72 | 73 | # Internationalization 74 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 75 | 76 | LANGUAGE_CODE = 'en-us' 77 | 78 | TIME_ZONE = 'UTC' 79 | 80 | USE_I18N = True 81 | 82 | USE_L10N = True 83 | 84 | USE_TZ = True 85 | 86 | 87 | # Static files (CSS, JavaScript, Images) 88 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 89 | 90 | STATIC_URL = '/static/' 91 | -------------------------------------------------------------------------------- /test_proj/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 | -------------------------------------------------------------------------------- /test_proj/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for bla project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [gh-actions] 2 | python = 3 | 3.6: py36 4 | 3.7: py37 5 | 3.8: py38 6 | 3.9: py39 7 | 8 | [tox] 9 | skipsdist = True 10 | isolated_build = True 11 | envlist = 12 | py36-{2.0,2.1,2.2,3.0,3.1} 13 | py37-{2.0,2.1,2.2,3.0,3.1} 14 | py38-{2.0,2.1,2.2,3.0,3.1} 15 | py39-{2.0,2.1,2.2,3.0,3.1} 16 | 17 | [testenv] 18 | skip_install = True 19 | whitelist_externals = 20 | bash 21 | env 22 | grep 23 | deps = 24 | poetry 25 | 2.0: Django>=2.0,<2.1 26 | 2.1: Django>=2.1,<2.2 27 | 2.2: Django>=2.2,<2.3 28 | 3.0: Django>=3.0,<3.1 29 | 3.1: Django>=3.1,<3.2 30 | commands = 31 | # Poetry install automatically install the specific versions from the `poetry.lock` 32 | # file regardless whether a different version is already present or not. 33 | # Since we want to test specific versions of Django, which is installed by tox, 34 | # we need to manually install all other dependencies. 35 | # see here for more information: https://github.com/python-poetry/poetry/issues/1745 36 | bash -c 'poetry export --dev --without-hashes -f requirements.txt | grep -v "^[dD]jango==" > .requirements.txt' 37 | poetry run pip install --no-deps -r .requirements.txt 38 | poetry run pytest --cov-append 39 | coverage report 40 | 41 | --------------------------------------------------------------------------------