pax_global_header00006660000000000000000000000064152057534400014517gustar00rootroot0000000000000052 comment=dcc88b7a5f82c8b0a4c32f68a55edb0162114944 crccheck-django-object-actions-5966877/000077500000000000000000000000001520575344000176475ustar00rootroot00000000000000crccheck-django-object-actions-5966877/.claude/000077500000000000000000000000001520575344000211625ustar00rootroot00000000000000crccheck-django-object-actions-5966877/.claude/skills/000077500000000000000000000000001520575344000224635ustar00rootroot00000000000000crccheck-django-object-actions-5966877/.claude/skills/maintenance.md000066400000000000000000000024751520575344000252770ustar00rootroot00000000000000--- name: maintenance description: "Runs the full maintenance workflow: upgrade GitHub Actions and deps" allowed-tools: Bash, Read, Edit, Write, Agent, WebFetch, WebSearch --- ## Workflow Run each step in order. Stop and report if any step fails. 1. Ensure on latest `main`: `git pull` 2. Create a maintenance branch: `git checkout -b chore/maintenance` 3. Upgrade GitHub Actions using the `upgrade-github-workflows` skill 4. Check the CI matrix in `.github/workflows/ci.yml` — verify new Python/Django versions are covered. Old EOL versions stay until a major release drops them. 5. `make install` — sync and upgrade dependencies 6. `source .venv/bin/activate && make lint` — check for lint errors; fix any issues found 7. `source .venv/bin/activate && make test` — run test suite 8. Commit any changes to the branch (separate commits per logical change) Report a summary of which steps passed or failed and what was changed. ## When the Django version matrix changes If a new Django version is added or an old one is dropped, audit `example_project/` for modernization opportunities: - Remove compatibility shims for dropped versions - Adopt newer Django APIs when the minimum supported version provides them - Keep the example project bare-bones — remove anything not needed to demonstrate django-object-actions features crccheck-django-object-actions-5966877/.dockerignore000066400000000000000000000000761520575344000223260ustar00rootroot00000000000000*.pyc .coverage .env *.db *.sqlite build/ dist/ *.egg-info crccheck-django-object-actions-5966877/.github/000077500000000000000000000000001520575344000212075ustar00rootroot00000000000000crccheck-django-object-actions-5966877/.github/workflows/000077500000000000000000000000001520575344000232445ustar00rootroot00000000000000crccheck-django-object-actions-5966877/.github/workflows/ci.yml000066400000000000000000000030131520575344000243570ustar00rootroot00000000000000name: CI on: pull_request: push: branches: - main jobs: prepare: runs-on: ubuntu-latest steps: - name: Create matrix id: create_matrix # https://github.com/fabiocaccamo/create-matrix-action/releases uses: fabiocaccamo/create-matrix-action@v7 with: matrix: | python-version {3.9}, django-version {4.2} python-version {3.10}, django-version {4.2,5.0,5.1,5.2} python-version {3.11}, django-version {4.2,5.0,5.1,5.2} python-version {3.12}, django-version {4.2,5.0,5.1,5.2,6.0} python-version {3.13}, django-version {5.1,5.2,6.0} python-version {3.14}, django-version {5.2,6.0} outputs: matrix: ${{ steps.create_matrix.outputs.matrix }} test: needs: prepare strategy: fail-fast: false matrix: include: ${{ fromJson(needs.prepare.outputs.matrix) }} name: "Python ${{ matrix.python-version }} + Django ${{ matrix.django-version }}" runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - run: pip install -e '.[dev]' - run: pip install "Django==${{ matrix.django-version }}.*" - run: make test lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: "3.13" - run: pip install -e '.[dev]' - run: make lint crccheck-django-object-actions-5966877/.github/workflows/release.yml000066400000000000000000000034031520575344000254070ustar00rootroot00000000000000# https://python-semantic-release.readthedocs.io/en/latest/automatic-releases/github-actions.html#examples name: Release on: workflow_dispatch: # Disabled to be able to roll multiple breaking releases into one release # push: # branches: # - main jobs: release: runs-on: ubuntu-latest concurrency: release permissions: id-token: write contents: write steps: - uses: actions/checkout@v6 with: ref: ${{ github.ref_name }} fetch-depth: 0 persist-credentials: false - name: Setup | Force release branch to be at workflow sha run: | git reset --hard ${{ github.sha }} - name: Action | Python Semantic Release id: release # https://github.com/python-semantic-release/python-semantic-release/releases # https://python-semantic-release.readthedocs.io/en/latest/github-action.html uses: python-semantic-release/python-semantic-release@v10.5.3 with: github_token: ${{ secrets.BOT_GITHUB_TOKEN }} - name: Publish | Upload package to PyPI # https://github.com/pypa/gh-action-pypi-publish/releases uses: pypa/gh-action-pypi-publish@release/v1 # NOTE: DO NOT wrap the conditional in ${{ }} as it will always evaluate to true. # See https://github.com/actions/runner/issues/1173 if: steps.release.outputs.released == 'true' - name: Publish | Upload to GitHub Release Assets # https://github.com/python-semantic-release/publish-action/releases uses: python-semantic-release/publish-action@v10.5.3 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.BOT_GITHUB_TOKEN }} tag: ${{ steps.release.outputs.tag }} crccheck-django-object-actions-5966877/.github/workflows/semantic-pr.yml000066400000000000000000000005431520575344000262130ustar00rootroot00000000000000name: Lint PR on: pull_request_target: types: - opened - reopened - edited - synchronize permissions: pull-requests: read jobs: main: name: Validate PR title runs-on: ubuntu-latest steps: - uses: amannn/action-semantic-pull-request@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} crccheck-django-object-actions-5966877/.gitignore000066400000000000000000000002111520575344000216310ustar00rootroot00000000000000*.pyc __pycache__ .DS_Store .coverage .tox/ *.db *.sqlite .venv .idea # Private files .env # build MANIFEST build/* dist/* *.egg-info crccheck-django-object-actions-5966877/CHANGELOG.md000066400000000000000000000576441520575344000215000ustar00rootroot00000000000000# CHANGELOG ## v5.1.2 (2026-05-28) ### Bug Fixes - Unquote object_id before passing to get_change_actions ([#199](https://github.com/crccheck/django-object-actions/pull/199), [`f9099eb`](https://github.com/crccheck/django-object-actions/commit/f9099eb7f06fe91595eb431b136be7f388754c32)) ### Chores - **tests**: Fix ineffective regression test for #199 ([#200](https://github.com/crccheck/django-object-actions/pull/200), [`c54eaf4`](https://github.com/crccheck/django-object-actions/commit/c54eaf41016aa00180a8c7568434b39d5aca5450)) ## v5.1.1 (2026-05-22) ### Bug Fixes - **docs**: Fix missing changelog entries ([#198](https://github.com/crccheck/django-object-actions/pull/198), [`2799179`](https://github.com/crccheck/django-object-actions/commit/279917951083f69f755ddfe049c1674c3299c104)) ## v5.1.0 (2026-05-22) _This release is published under the Apache-2.0 License._ ### Features - Add DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD setting ([#190](https://github.com/crccheck/django-object-actions/pull/190), [`3c466b8`](https://github.com/crccheck/django-object-actions/commit/3c466b81cd33d50acfb97c960ba49fc533da0f53)) --- **Detailed Changes**: [v5.0.1...v5.1.0](https://github.com/crccheck/django-object-actions/compare/v5.0.1...v5.1.0) ## v5.0.1 (2026-04-29) _This release is published under the Apache-2.0 License._ ### Bug Fixes - Render form-type object actions as semantic button[submit] ([#196](https://github.com/crccheck/django-object-actions/pull/196), [`df54fa3`](https://github.com/crccheck/django-object-actions/commit/df54fa35bb7c8936a71b09f7dfce1e46b05be534)) ### Chores - Add a standard apps.py ([#197](https://github.com/crccheck/django-object-actions/pull/197), [`460f1c8`](https://github.com/crccheck/django-object-actions/commit/460f1c8b19b8f1d788902547fbe0cc438f3fbae4)) - Add Python 3.14 and Django 6 to testing matrix ([#187](https://github.com/crccheck/django-object-actions/pull/187), [`256b8f3`](https://github.com/crccheck/django-object-actions/commit/256b8f375fc51858df293b40e6163759d7df42b7)) - Basic maintenance and document a skill ([#195](https://github.com/crccheck/django-object-actions/pull/195), [`22825b0`](https://github.com/crccheck/django-object-actions/commit/22825b045f380a9284fbee360f96b11c31a551bd)) - Only maintain one Docker path and doc tweaks ([#188](https://github.com/crccheck/django-object-actions/pull/188), [`4193da8`](https://github.com/crccheck/django-object-actions/commit/4193da84b2d2c18736313846b5edf8088bfa3645)) - Remove 'black' lint references in favor of 'ruff' ([#186](https://github.com/crccheck/django-object-actions/pull/186), [`8adb227`](https://github.com/crccheck/django-object-actions/commit/8adb227fa2954c09a1b64ec942b018f8d3081138)) ### Documentation - Document intermediate confirmation and form page pattern ([#193](https://github.com/crccheck/django-object-actions/pull/193), [`fd3db28`](https://github.com/crccheck/django-object-actions/commit/fd3db28a72d7590a7ec08cad641aab9e9fdd81b1)) ### Refactoring - Simpler deps and use dependency groups ([#189](https://github.com/crccheck/django-object-actions/pull/189), [`3a7d34a`](https://github.com/crccheck/django-object-actions/commit/3a7d34a1fc712e1f86f96b92f4c78ebd973ee217)) --- **Detailed Changes**: [v5.0.0...v5.0.1](https://github.com/crccheck/django-object-actions/compare/v5.0.0...v5.0.1) ## v5.0.0 (2025-04-05) ### Bug Fixes - **ci**: Fix release should use python-semantic-release/publish-action ([#183](https://github.com/crccheck/django-object-actions/pull/183), [`ce75176`](https://github.com/crccheck/django-object-actions/commit/ce75176329fda9e9776a77525bf98d55c0a4150f)) - **ci**: Fix renamed branch s/master/main ([#184](https://github.com/crccheck/django-object-actions/pull/184), [`6e1a451`](https://github.com/crccheck/django-object-actions/commit/6e1a451cbf4e5818c10d637856884b1c88b4c4f3)) - **ci**: Typo in publish-action tag ([#185](https://github.com/crccheck/django-object-actions/pull/185), [`0228a99`](https://github.com/crccheck/django-object-actions/commit/0228a996dfaaa8bf56276f76e8cd8af5ce387272)) ### Chores - Add Ruff and use it for lint checks ([#175](https://github.com/crccheck/django-object-actions/pull/175), [`8478467`](https://github.com/crccheck/django-object-actions/commit/847846774442f20ac4ffc68f7f1f53707f53b209)) - Drop long-unsupported py3.7 py3.8 django versions ([#179](https://github.com/crccheck/django-object-actions/pull/179), [`988ce82`](https://github.com/crccheck/django-object-actions/commit/988ce82b39e92fa282ac0201cd105093b2d11289)) Python 3.7 hasn't been supported in ages, and it's causing errors on CI actions so let's just drop it. It appears that [dropping Python 3.7 is desired anyway](https://github.com/crccheck/django-object-actions/blob/master/CHANGELOG.md#v430-2024-09-10)! - Run tests for Django 5.1 on tested Python versions ([#178](https://github.com/crccheck/django-object-actions/pull/178), [`6e92eb7`](https://github.com/crccheck/django-object-actions/commit/6e92eb739969c63b0a4bcd1e7f134f14a8517d1f)) - Switch from Poetry to vanilla Pip ([#180](https://github.com/crccheck/django-object-actions/pull/180), [`080ee8c`](https://github.com/crccheck/django-object-actions/commit/080ee8c88e19282bbf147a8a4a4c6091374150a0)) Updating the project to match my current style and to reduce dependencies. - **ci**: Add Django 5.2 to test matrix ([#182](https://github.com/crccheck/django-object-actions/pull/182), [`a89ca4f`](https://github.com/crccheck/django-object-actions/commit/a89ca4f207cd668c21834f3370bb62e87fe65195)) No code changes needed for Django 5.2 support https://docs.djangoproject.com/en/5.2/releases/5.2/ ### Refactoring - Add more lint rules ([#181](https://github.com/crccheck/django-object-actions/pull/181), [`fa2b4d7`](https://github.com/crccheck/django-object-actions/commit/fa2b4d720662337fe9b7217661bf984f11640229)) Bringing in rules I've had success with elsewhere. More consistent style helps with readability and maintainability. Some of the rules help with code simplicity and with reducing bugs too. ## v4.3.0 (2024-09-10) ### Chores - Add Django v5 to CI matrix ([#166](https://github.com/crccheck/django-object-actions/pull/166), [`b63aac1`](https://github.com/crccheck/django-object-actions/commit/b63aac1e919c986df9687699c6494dab3093b295)) https://docs.djangoproject.com/en/5.0/releases/5.0/ - Update Django/Python test matrix and add classifier for py3.12 ([#171](https://github.com/crccheck/django-object-actions/pull/171), [`ad3b898`](https://github.com/crccheck/django-object-actions/commit/ad3b8987c5b20391c3e5b56147c799e9aa4804bd)) - **ci**: Upgrade python-semantic-release to v9.8.8 ([#176](https://github.com/crccheck/django-object-actions/pull/176), [`50a03af`](https://github.com/crccheck/django-object-actions/commit/50a03afdf1571a5cb17db282df089f81a3c20ad2)) There have been a lot of releases since v8.0.8 https://github.com/python-semantic-release/python-semantic-release/blob/master/CHANGELOG.md#v808-2023-08-26 The breaking change was dropping Python 3.7. While this project supports 3.7... that will change the next opportunity ### Code Style - Apply Black formatting ([#170](https://github.com/crccheck/django-object-actions/pull/170), [`fb3ce5b`](https://github.com/crccheck/django-object-actions/commit/fb3ce5b75bda44dfd3185c25e9cf7e943ca6fb61)) ### Documentation - Add Django Modal Actions as a similar package ([#173](https://github.com/crccheck/django-object-actions/pull/173), [`813687e`](https://github.com/crccheck/django-object-actions/commit/813687e76241f8a6786fa20ea707ae470f1463ab)) Adding new Django Modal Actions package Deleting Django Object Actions which hasn't had a commit in 3 years ### Features - Add a way to make a POST only action ([#174](https://github.com/crccheck/django-object-actions/pull/174), [`494d581`](https://github.com/crccheck/django-object-actions/commit/494d5817307343018ccc8398d64f95228e57f51b)) Followup to #168 to get CI to pass again, documents how to make a POST only action, and adds some test coverage. There are still a few cleanup issues but this should get things moving on POST only actions again. ## v4.2.0 (2023-09-08) ### Bug Fixes - **ci**: Maybe this will fix Semantic Release ([#161](https://github.com/crccheck/django-object-actions/pull/161), [`1595348`](https://github.com/crccheck/django-object-actions/commit/1595348d00235752857fef55f9fbbc8b854659d9)) - **ci**: Update [tool.semantic_release] names ([#160](https://github.com/crccheck/django-object-actions/pull/160), [`70d2c81`](https://github.com/crccheck/django-object-actions/commit/70d2c8110e3c087366a67c4499fa0895035fbdfd)) I missed some updated config changes - https://python-semantic-release.readthedocs.io/en/latest/migrating_from_v7.html#version-toml - https://python-semantic-release.readthedocs.io/en/latest/configuration.html#config-version-variables ### Chores - Fix formatting in example app ([#155](https://github.com/crccheck/django-object-actions/pull/155), [`9bd288f`](https://github.com/crccheck/django-object-actions/commit/9bd288ffc6768bcf39ec27abde024b7be0ee90c9)) Ran black on to comply with format from 23.x version - **ci**: Add Django 4.2 to the build matrix ([#154](https://github.com/crccheck/django-object-actions/pull/154), [`e73b4d0`](https://github.com/crccheck/django-object-actions/commit/e73b4d0d8921d566a880612f1622df87c99d062b)) - **ci**: Remove deprecated set-output syntax ([#146](https://github.com/crccheck/django-object-actions/pull/146), [`3e42b3b`](https://github.com/crccheck/django-object-actions/commit/3e42b3be4fdced017f23234b5da2c17373fbc50b)) fixes deprecation warnings in CI: > The `set-output` command is deprecated and will be disabled soon. Please upgrade to using Environment Files. For more information see: https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ - **ci**: Upgrade python-semantic-release ([#145](https://github.com/crccheck/django-object-actions/pull/145), [`53417a0`](https://github.com/crccheck/django-object-actions/commit/53417a01bc78a9a7bb59f4599154d180914f064b)) Hopefully this fixes the broken GitHub Action too https://github.com/crccheck/django-object-actions/actions/runs/3464471740 `error: No module named 'packaging'` https://github.com/python-semantic-release/python-semantic-release/issues/489 closes #144 - **deps**: Refresh dev dependencies ([#158](https://github.com/crccheck/django-object-actions/pull/158), [`7d439b6`](https://github.com/crccheck/django-object-actions/commit/7d439b6910c2c30cd692bdb8819fe714f1b584a7)) Also moves Coverage config to `pyproject.toml` to eliminate another top level project file ### Documentation - Update README style ([#157](https://github.com/crccheck/django-object-actions/pull/157), [`f92464e`](https://github.com/crccheck/django-object-actions/commit/f92464e43e195dc3dede7f1102cf02e08c2845df)) To reduce future diffs from autoformat ### Features - Test release for new python-semantic-release process ([#159](https://github.com/crccheck/django-object-actions/pull/159), [`6af5f36`](https://github.com/crccheck/django-object-actions/commit/6af5f367deb0f6787459058edbfdc92c1108be4e)) Just upgrading to stay current. Need to use "feat" to trigger a release. Docs: - https://python-semantic-release.readthedocs.io/en/latest/migrating_from_v7.html - https://github.com/pypa/gh-action-pypi-publish#usage ## v4.1.0 (2022-11-14) ### Bug Fixes - Fix link to ci.yml in README ([#139](https://github.com/crccheck/django-object-actions/pull/139), [`700dd9b`](https://github.com/crccheck/django-object-actions/commit/700dd9b848aea67c759dca61cd815a27b6b16fd1)) Fix README link to ci.yml ### Chores - **ci**: Add Python 3.11 and Django 4.1 to CI ([#143](https://github.com/crccheck/django-object-actions/pull/143), [`10e4743`](https://github.com/crccheck/django-object-actions/commit/10e4743ad3df72a85f7f11844d22ddbe091398cf)) Just some housekeeping and local dev tweaks. ### Features - Provide action decorator to pass label, description and atts to the admin method ([#141](https://github.com/crccheck/django-object-actions/pull/141), [`5638f99`](https://github.com/crccheck/django-object-actions/commit/5638f999d32ea7f6de60b895d23ce89624120769)) Add an `@action` decorator that behave's like Django's `admin.action` decorator[^1] to clean up customizing object actions. [closes #115](https://github.com/crccheck/django-object-actions/issues/115) Also relates to #107 [^1]: https://docs.djangoproject.com/en/stable/ref/contrib/admin/actions/#django.contrib.admin.action ## v4.0.0 (2022-03-12) ### Bug Fixes - Cleanup Django compatibility shims for <2.0 ([#126](https://github.com/crccheck/django-object-actions/pull/126), [`88cfb3b`](https://github.com/crccheck/django-object-actions/commit/88cfb3b2e06b17762639da7f48259eeae343942f)) ### Chores - **deps**: Refresh dev deps and refactor CI ([#132](https://github.com/crccheck/django-object-actions/pull/132), [`6283e62`](https://github.com/crccheck/django-object-actions/commit/6283e621eebe55e22c72323fc1509bf77d93932d)) This updates CI to use https://github.com/fabiocaccamo/create-matrix-action to simplify the config file. Poetry's lock file updated as I reinstalled on a new computer. ### Documentation - Add Django@4.0 to CI ([#133](https://github.com/crccheck/django-object-actions/pull/133), [`20e2418`](https://github.com/crccheck/django-object-actions/commit/20e2418e6ada4651b3e6d51b5d10c545d8a6c863)) * upgrade some more deps * add django 4.0 to ci * cleanup ci for Black * fix poetry's python version conflicts with CI version ### Features - Drop Python 3.6 support ([#135](https://github.com/crccheck/django-object-actions/pull/135), [`8deebed`](https://github.com/crccheck/django-object-actions/commit/8deebedda55d0e5d466969c7f27a9c60e680e5e8)) BREAKING CHANGE: Python 3.6 is past end-of-life and is no longer supported. Keeping it in `pyproject.toml` was causing pains trying to install packages. Let's drop it while we're dropping support for other old stuff. ### Breaking Changes - Python 3.6 is past end-of-life and is no longer supported. Keeping it in `pyproject.toml` was causing pains trying to install packages. Let's drop it while we're dropping support for other old stuff. ## v3.1.0 (2021-12-18) ### Bug Fixes - Fix typo in version_variable ([#130](https://github.com/crccheck/django-object-actions/pull/130), [`040a802`](https://github.com/crccheck/django-object-actions/commit/040a8029c298d8bb17ffab0b75b9b9ecc3d70de2)) Fix "error: [Errno 2] No such file or directory: 'django-object-actions/init.py'" error when creating a release ### Chores - **ci**: Run some tests using Poetry build instead of source ([#129](https://github.com/crccheck/django-object-actions/pull/129), [`c24c299`](https://github.com/crccheck/django-object-actions/commit/c24c299470d055bd1e0cb9256d65b6b0a56ce7f3)) To verify that it's getting packaged correctly. In particular, I need to make sure the `.html` files are in the package. ### Features - Add Python 3.9 & 3.10 support ([`28f0ef7`](https://github.com/crccheck/django-object-actions/commit/28f0ef7dd62eedbdac9d34ad115245ef8d935c4d)) * ci: add Python 3.9-310 to the build * fix: support Python 3.9-3.10 in trove classifiers - **ci**: Add manual semantic-release ([#128](https://github.com/crccheck/django-object-actions/pull/128), [`f43fd11`](https://github.com/crccheck/django-object-actions/commit/f43fd1199a72be013766d437fe54d875e2fdd53f)) I'll switch it to be automated on push to `master` at some point. ### Refactoring - Switch to Poetry for env+dep management ([#127](https://github.com/crccheck/django-object-actions/pull/127), [`f16cb00`](https://github.com/crccheck/django-object-actions/commit/f16cb0089d172cfa9a84c49121fc434d84e21abe)) I've been using Poetry because it takes the hassle out of virtualenv management and has sane defaults that just work for building artifacts and uploading to PyPI. Because I had to redo how tests were run, I went ahead and removed the Tox testing requirement too and so I had to redo the Github Actions for testing too. One thing I didn't anticipate is that Github Actions caching doesn't work for Poetry, only with `requirements.txt` and Pipenv https://github.blog/changelog/2021-11-23-github-actions-setup-python-now-supports-dependency-caching/ ## Verifying the change I compared `python setup.py build` vs `poetry build` and the only difference was some top level meta differences and Poetry added the `tests` directory which is fine. Both have the `.html` templates which is the important thing. ## v3.0.2 (2021-04-09) ### Chores - Add Django 3.2 to test grid ([#117](https://github.com/crccheck/django-object-actions/pull/117), [`e473bc3`](https://github.com/crccheck/django-object-actions/commit/e473bc32777496d6e98e045b51f9d901384f4193)) https://docs.djangoproject.com/en/3.2/releases/3.2/ I really need to drop support for old versions now. Lots of deps are starting to step on each other and drop things. Django itself doesn't support Django 3.0 anymore with the release of Django 3.2 - **release**: 3.0.2 ([`dbcecbf`](https://github.com/crccheck/django-object-actions/commit/dbcecbfe67254f6fff64e670c443a6c1d662a9ff)) ### Refactoring - Use django.urls.re_path instead of deprecated django.conf.urls.url ([#112](https://github.com/crccheck/django-object-actions/pull/112), [`9bb736a`](https://github.com/crccheck/django-object-actions/commit/9bb736a6ffb1e35ac3f441ff0d572ba6e13b447c)) Use `django.urls.re_path()` when available, instead of the deprecated `django.conf.urls.url()`. - `re_path()` is available since Django 2.0. \* `url()` will be removed in Django 4.0. ## v3.0.1 (2020-08-08) ### Bug Fixes - Objects with special symbols in primary key 404-ed ([#110](https://github.com/crccheck/django-object-actions/pull/110), [`0c90ce1`](https://github.com/crccheck/django-object-actions/commit/0c90ce12a066baf873037eed415052074430d9d2)) for case if object in database has any of special symbols https://github.com/django/django/blob/master/django/contrib/admin/utils.py#L17 clicking on action button causes 404 error, as in SingleObjectMixin there are already parsed kwargs from url, and they are unquoted made unquoting kwargs ### Chores - **release**: 3.0.1 ([`fa48985`](https://github.com/crccheck/django-object-actions/commit/fa48985e5da6acdc327eae72b97edcd387d8afba)) ## v3.0.0 (2020-08-08) ### Chores - **release**: 3.0.0 ([`6b61513`](https://github.com/crccheck/django-object-actions/commit/6b615133608caab492fe13ec3403dab281520186)) ### Features - Add Django 3 test support ([#106](https://github.com/crccheck/django-object-actions/pull/106), [`4eaf14c`](https://github.com/crccheck/django-object-actions/commit/4eaf14c3caff36d5ab274835d38baef7e66213dc)) Django 3.0 is out: https://docs.djangoproject.com/en/3.0/releases/3.0/ Let's see if we're compatible. It turns out no code changes are needed huzzah! - **deps**: Add Django 3.1 support ([#109](https://github.com/crccheck/django-object-actions/pull/109), [`2c7170e`](https://github.com/crccheck/django-object-actions/commit/2c7170e3a73317a9417733a7ddfe0fabab84fe85)) Pretty basic, looks like no code changes needed. https://docs.djangoproject.com/en/3.1/releases/3.1/ - **deps**: Drop Python 3.4 support ([#108](https://github.com/crccheck/django-object-actions/pull/108), [`68519d4`](https://github.com/crccheck/django-object-actions/commit/68519d48fa8dd4d3b203981a52157841e5152774)) BREAKING CHANGE: drop Python 3.4 support in preparation for adding type hints and Django 3.1 support Prereq for #107 ## v2.0.0 (2019-11-29) ### Chores - **release**: 2.0.0 ([`9169f0d`](https://github.com/crccheck/django-object-actions/commit/9169f0df7298169179407859368f50453ec064f0)) ### Features - Drop Python 2 support ([#105](https://github.com/crccheck/django-object-actions/pull/105), [`551d2bb`](https://github.com/crccheck/django-object-actions/commit/551d2bb2a66c5fd1c157b05c288032124affba41)) BREAKING CHANGE: This release drops Python 2 support Django has [dropped Python 2 support](https://docs.djangoproject.com/en/2.2/releases/2.0/#python-compatibility) ever since Django 2.0 (December 2, 2017). With Django 3.0 coming very soon and Python 2 reaching end of life, it doesn't make sense to continue supporting Python 2. ### Breaking Changes - This release drops Python 2 support ## v1.1.2 (2019-11-14) ### Chores - Use Black to format code ([#100](https://github.com/crccheck/django-object-actions/pull/100), [`42055a3`](https://github.com/crccheck/django-object-actions/commit/42055a391044a3d828531cb0ab7ff6abe4f5a659)) There's a lot of momentum to using [Black](https://github.com/psf/black). For example, [Django will use it](https://github.com/django/deps/blob/master/accepted/0008-black.rst) This pulls the bandaid off to avoid mixing lint changes w/ code changes in the future. I opted to not dictate how Black is run because I'm not 100% sure how that should happen. To make sure PRs contributors are following this, I added a lint check in CI. - **release**: 1.1.2 ([`0cd8a24`](https://github.com/crccheck/django-object-actions/commit/0cd8a24ac7ae90a169735e4035ce89701f7cff20)) ### Documentation - Add syntax highlighting ([#102](https://github.com/crccheck/django-object-actions/pull/102), [`399affa`](https://github.com/crccheck/django-object-actions/commit/399affa66664a65216c51346e96972eb5ae22499)) ## v1.1.1 (2019-10-06) ### Bug Fixes - Changelist action links had no 'href' ([#98](https://github.com/crccheck/django-object-actions/pull/98), [`8b8aed3`](https://github.com/crccheck/django-object-actions/commit/8b8aed3b131cf60bc8823c703299f50cf84d9dcc)) I probably copy pasted something wrong and brought an extra arg into the `reverse`, so `reverse` never found anything and the actions in the changelist never rendered with a `href`. This makes the args match the url definition so these buttons work again. Thanks to @mvbrn for the original fix. closes #96 ### Chores - Bump dev dependencies ([#95](https://github.com/crccheck/django-object-actions/pull/95), [`fceff29`](https://github.com/crccheck/django-object-actions/commit/fceff29fffb1fc962e19a3175cace3e9434cbd74)) * greenkeeper * django-extensions is safe to upgrade now * use consistent DJANGO comment to indicate backwards compatibility * exclude sqlite from docker too * TODO * don't email failures - Modernize some syntax, add Django 2.2 and Py37 ([#91](https://github.com/crccheck/django-object-actions/pull/91), [`ba9eb1b`](https://github.com/crccheck/django-object-actions/commit/ba9eb1b9fba5f6eebfbb605e9002c43bb0b6bfc8)) * greenkeeper * add py37 and django2.1 to testing matrix * update coveralls to use latest versions * make sure to use factoryboy's version of Faker * add versions to travisci * use Factoryboy's fakersyntax * ugh * selective coveralls * add Django 2.2 * disable coveralls in CI for nw * only build on PRs and master * don't commit .sqlite * add missing setting * fix broken test * haha need a script to run tests ## v1.1.0 (2019-05-04) ### Features - Make default labels prettier ([#93](https://github.com/crccheck/django-object-actions/pull/93), [`4191afd`](https://github.com/crccheck/django-object-actions/commit/4191afd691d9a70fd6b0de095477067cf3c35691)) With this change the default label changes from `some_action` to `Some action` ## v1.0.0 (2018-03-09) ## v0.10.0 (2017-05-10) ## v0.9.0 (2016-12-04) ## v0.8.2 (2016-04-23) ## v0.8.1 (2016-04-23) ## v0.8.0 (2016-02-25) ## v0.7.0 (2016-01-13) ### Documentation - More tweaks as I read code ([`936fe08`](https://github.com/crccheck/django-object-actions/commit/936fe084054daf90ab2011c337aac02d2701b5a5)) ## v0.6.0 (2015-12-06) ## v0.5.1 (2014-11-27) ## v0.5.0 (2014-07-01) ## v0.4.0 (2014-02-12) ## v0.3.0 (2014-01-09) ## v0.2.0 (2013-11-09) ## v0.1.1 (2013-02-26) ## v0.1.0 (2013-02-24) ### Bug Fixes - Actions showed up in /add/, they shouldn't ([`bd23a60`](https://github.com/crccheck/django-object-actions/commit/bd23a6023b3358d6ec0a59b50774d1f5d5d422ed)) - Make sure not to include pyc files ([`d52f802`](https://github.com/crccheck/django-object-actions/commit/d52f8020024499df7e0bec6f7606707e9045b90b)) crccheck-django-object-actions-5966877/CONTRIBUTING.md000066400000000000000000000000001520575344000220660ustar00rootroot00000000000000crccheck-django-object-actions-5966877/Dockerfile000066400000000000000000000007401520575344000216420ustar00rootroot00000000000000FROM python:3.14-alpine RUN apk add --no-cache make ADD . /app WORKDIR /app ENV PYTHONPATH=/app RUN pip --disable-pip-version-check install -e '.[dev]' RUN make resetdb RUN echo "from django.contrib.auth import get_user_model; \ User = get_user_model(); \ User.objects.create_superuser('admin', 'admin@example.com', 'admin')" | \ python example_project/manage.py shell ENV PORT=8000 EXPOSE 8000 CMD ["python", "example_project/manage.py", "runserver", "0.0.0.0:8000"] crccheck-django-object-actions-5966877/LICENSE000066400000000000000000000252311520575344000206570ustar00rootroot00000000000000Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 1. You must give any other recipients of the Work or Derivative Works a copy of this License; and 2. You must cause any modified files to carry prominent notices stating that You changed the files; and 3. You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 4. If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. crccheck-django-object-actions-5966877/Makefile000066400000000000000000000033641520575344000213150ustar00rootroot00000000000000PROJECT = ./example_project MANAGE = $(PROJECT)/manage.py IMAGE = crccheck/django-object-actions help: ## Shows this help @echo "$$(grep -h '#\{2\}' $(MAKEFILE_LIST) | sed 's/: #\{2\} / /' | column -t -s ' ')" quickstart: ## Set up a dev environment for the first time quickstart: resetdb python $(MANAGE) createsuperuser python $(MANAGE) runserver dev: ## Run the example project @echo Browse at http://localhost:8000/admin/ PYTHONPATH=. python $(MANAGE) runserver clean: ## Remove generated files rm -rf .coverage rm -rf build rm -rf dist rm -rf *.egg rm -rf *.egg-info find . -name ".DS_Store" -delete find . -type d -name "__pycache__" -exec rm -rf {} \; || true install: ## Install development dependencies uv pip install --upgrade -e '.[dev]' lint: ## Check the project for lint errors ruff check . ruff format --diff . tdd: ## Run tests with a file watcher PYTHONPATH=. nodemon --ext py -x sh -c "python -W ignore::RuntimeWarning $(MANAGE) test --failfast django_object_actions || true" test: ## Run test suite PYTHONPATH=. python -W ignore::RuntimeWarning $(MANAGE) test django_object_actions coverage: ## Run and then display coverage report coverage erase PYTHONPATH=. coverage run $(MANAGE) test django_object_actions coverage report --show-missing resetdb: ## Delete and then recreate the dev sqlite database python $(MANAGE) reset_db --router=default --noinput python $(MANAGE) migrate --noinput python $(MANAGE) loaddata sample_data docker/build: ## Build Docker image docker build -t $(IMAGE):latest . docker/run: ## Run Docker image @echo Login at http://localhost:8000/admin/ with admin/admin docker run --rm -p 8000:8000 -it $(IMAGE):latest docker/test: ## Run tests in Docker docker run --rm -t $(IMAGE):latest make test crccheck-django-object-actions-5966877/README.md000066400000000000000000000252761520575344000211420ustar00rootroot00000000000000# Django Object Actions [![CI](https://github.com/crccheck/django-object-actions/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/crccheck/django-object-actions/actions/workflows/ci.yml?query=branch%3Amain) If you've ever tried making admin object tools you may have thought, "why can't this be as easy as making Django Admin Actions?" Well now they can be. ## Quick-Start Guide Install Django Object Actions: ```shell $ pip install django-object-actions ``` Add `django_object_actions` to your `INSTALLED_APPS` so Django can find our templates. In your admin.py: ```python from django_object_actions import DjangoObjectActions, action class ArticleAdmin(DjangoObjectActions, admin.ModelAdmin): @action(label="Publish", description="Submit this article") # optional def publish_this(self, request, obj): publish_obj(obj) change_actions = ("publish_this", ) changelist_actions = ("action_name", ) ``` ## Usage Defining new _tool actions_ is just like defining regular [admin actions]. The major difference is the functions for `django-object-actions` will take an object instance instead of a queryset (see _Re-using Admin Actions_ below). _Tool actions_ are exposed by putting them in a `change_actions` attribute in your `admin.ModelAdmin`. You can also add _tool actions_ to the main changelist views too. There, you'll get a queryset like a regular [admin action][admin actions]: ```python from django_object_actions import DjangoObjectActions class MyModelAdmin(DjangoObjectActions, admin.ModelAdmin): @action( label="This will be the label of the button", # optional description="This will be the tooltip of the button" # optional ) def toolfunc(self, request, obj): pass def make_published(modeladmin, request, queryset): queryset.update(status='p') change_actions = ("toolfunc", ) changelist_actions = ("make_published", ) ``` Just like admin actions, you can send a message with `self.message_user`. Normally, you would do something to the object and return to the same url, but if you return a `HttpResponse`, it will follow it (hey, just like [admin actions]!). If your admin modifies `get_urls`, `change_view`, or `changelist_view`, you'll need to take extra care because `django-object-actions` uses them too. ### Re-using Admin Actions If you would like a preexisting admin action to also be an _object action_, add the `takes_instance_or_queryset` decorator to convert object instances into a queryset and pass querysets: ```python from django_object_actions import DjangoObjectActions, takes_instance_or_queryset class RobotAdmin(DjangoObjectActions, admin.ModelAdmin): # ... snip ... @takes_instance_or_queryset def tighten_lug_nuts(self, request, queryset): queryset.update(lugnuts=F('lugnuts') - 1) change_actions = ("tighten_lug_nuts", ) actions = ("tighten_lug_nuts", ) ``` [admin actions]: https://docs.djangoproject.com/en/stable/ref/contrib/admin/actions/ ### Customizing _Object Actions_ To give the action some a helpful title tooltip, you can use the `action` decorator and set the description argument. ```python @action(description="Increment the vote count by one") def increment_vote(self, request, obj): obj.votes = obj.votes + 1 obj.save() ``` Alternatively, you can also add a `short_description` attribute, similar to how admin actions work: ```python def increment_vote(self, request, obj): obj.votes = obj.votes + 1 obj.save() increment_vote.short_description = "Increment the vote count by one" ``` By default, Django Object Actions will guess what to label the button based on the name of the function. You can override this with a `label` attribute: ```python @action(label="Vote++") def increment_vote(self, request, obj): obj.votes = obj.votes + 1 obj.save() ``` or ```python def increment_vote(self, request, obj): obj.votes = obj.votes + 1 obj.save() increment_vote.label = "Vote++" ``` If you need even more control, you can add arbitrary attributes to the buttons by adding a Django widget style [attrs](https://docs.djangoproject.com/en/stable/ref/forms/widgets/#django.forms.Widget.attrs) attribute: ```python @action(attrs = {'class': 'addlink'}) def increment_vote(self, request, obj): obj.votes = obj.votes + 1 obj.save() ``` or ```python def increment_vote(self, request, obj): obj.votes = obj.votes + 1 obj.save() increment_vote.attrs = { 'class': 'addlink', } ``` ### Programmatically Disabling Actions You can programmatically disable registered actions by defining your own custom `get_change_actions()` method. In this example, certain actions only apply to certain object states (e.g. You should not be able to close an company account if the account is already closed): ```python def get_change_actions(self, request, object_id, form_url): actions = super(PollAdmin, self).get_change_actions(request, object_id, form_url) actions = list(actions) if not request.user.is_superuser: return [] obj = self.model.objects.get(pk=object_id) if obj.question.endswith('?'): actions.remove('question_mark') return actions ``` The same is true for changelist actions with `get_changelist_actions`. ### Using POST instead of GET for actions Since actions usually change data, for safety and semantics, it would be preferable that actions use a HTTP POST instead of a GET. #### Global default (recommended) To control default HTTP method for all actions globally, use `DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD` in your Django settings: ```python # settings.py DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD = "POST" # or "GET" ``` When set to `"POST"`, action buttons will render as forms that submit via POST. When set to `"GET"` (or not set), actions render as regular links. **Migration note:** The default will change from `"GET"` to `"POST"` in the next major version. #### Per-action configuration You can also configure individual actions to use POST: ```python @action(methods=("POST",), button_type="form") def my_action(self, request, obj): ... ``` The `button_type` parameter controls which kind of DOM element the button renders: - `"a"` (default for GET): renders as an anchor tag/link - `"form"`: renders as a form that submits via POST ### Alternate Installation You don't have to add this to `INSTALLED_APPS`, all you need to to do is copy the template `django_object_actions/change_form.html` some place Django's template loader [will find it](https://docs.djangoproject.com/en/stable/ref/settings/#template-dirs). If you don't intend to use the template customizations or bundled CSS at all, don't add `django_object_actions` to your `INSTALLED_APPS` at all and use `BaseDjangoObjectActions` instead of `DjangoObjectActions`. If you use `button_type='form'` actions, add this to your admin class: ```python class Media: css = {"all": ["django_object_actions/css/django-object-actions.css"]} ``` ## More Examples Making an action that links off-site: ```python def external_link(self, request, obj): from django.http import HttpResponseRedirect return HttpResponseRedirect(f'https://example.com/{obj.id}') ``` ### Intermediate confirmation and form pages Because an action is called with the current request, you can render a confirmation on GET and apply the change on POST. `@action` allows both methods by default. ```python from django.shortcuts import render @action(label="Delete all choices") def delete_all_choices(self, request, obj): if request.method == "POST": obj.choice_set.all().delete() return None return render(request, "admin/confirm.html", {"object": obj}) ``` For anything beyond a confirmation button, delegate to a `FormView` rather than handling the form inline. Unpacking `admin.site.each_context(request)` into the template context preserves the admin breadcrumbs, header, and user menu on the rendered page: ```python from django.contrib import admin from django.shortcuts import redirect from django.views.generic import FormView from .forms import VerifyForm class ArticleAdmin(DjangoObjectActions, admin.ModelAdmin): @action(label="Verify") def verify(self, request, obj): return VerifyView.as_view()(request, target=obj) change_actions = ("verify",) class VerifyView(FormView): form_class = VerifyForm template_name = "admin/generic_form.html" def dispatch(self, request, *args, target, **kwargs): self.target = target return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): return { **super().get_context_data(**kwargs), **admin.site.each_context(self.request), "title": "Verify article", } def form_valid(self, form): self.target.mark_as_verified(**form.cleaned_data) return redirect("admin:myapp_article_change", self.target.pk) ``` The template: ```html {% extends "admin/base_site.html" %} {% block content %}
{% csrf_token %} {{ form.as_p }}
{% endblock %} ``` ## Limitations 1. `django-object-actions` expects functions to be methods of the model admin. While Django gives you a lot more options for their admin actions. 2. If you provide your own custom `change_form.html`, you'll also need to manually copy in the relevant bits of [our change form ](./django_object_actions/templates/django_object_actions/change_form.html). 3. Security. This has been written with the assumption that everyone in the Django admin belongs there. Permissions should be enforced in your own actions irregardless of what this provides. Better default security is planned for the future. ## Python and Django compatibility See [`ci.yml`](./.github/workflows/ci.yml) for which Python and Django versions this supports. ## Demo Admin You can try the demo admin by running the example Django project in `./example_project`, which is based on the "polls" tutorial. `admin.py` demos what you can do with this app. ## Development Getting started: ```shell # get a copy of the code git clone git@github.com:crccheck/django-object-actions.git cd django-object-actions # Install requirements make install make test # run test suite make quickstart # runs 'make resetdb' and some extra steps ``` Various helpers are available as make commands. Type `make help` and view the `Makefile` to see what other things you can do. ## Similar Packages [Django Modal Actions](https://github.com/Mng-dev-ai/django-modal-actions) can open a simple form in a modal dialog. If you want an actions menu for each row of your changelist, check out [Django Admin Row Actions](https://github.com/DjangoAdminHackers/django-admin-row-actions). crccheck-django-object-actions-5966877/django_object_actions/000077500000000000000000000000001520575344000241575ustar00rootroot00000000000000crccheck-django-object-actions-5966877/django_object_actions/__init__.py000066400000000000000000000005171520575344000262730ustar00rootroot00000000000000"""A Django app for adding object tools for models in the admin.""" __version__ = "5.1.2" from .utils import ( BaseDjangoObjectActions, DjangoObjectActions, action, takes_instance_or_queryset, ) __all__ = [ "BaseDjangoObjectActions", "DjangoObjectActions", "action", "takes_instance_or_queryset", ] crccheck-django-object-actions-5966877/django_object_actions/apps.py000066400000000000000000000002361520575344000254750ustar00rootroot00000000000000from django.apps import AppConfig class DjangoObjectActionsConfig(AppConfig): name = "django_object_actions" verbose_name = "Django Object Actions" crccheck-django-object-actions-5966877/django_object_actions/static/000077500000000000000000000000001520575344000254465ustar00rootroot00000000000000crccheck-django-object-actions-5966877/django_object_actions/static/django_object_actions/000077500000000000000000000000001520575344000317565ustar00rootroot00000000000000crccheck-django-object-actions-5966877/django_object_actions/static/django_object_actions/css/000077500000000000000000000000001520575344000325465ustar00rootroot00000000000000django-object-actions.css000066400000000000000000000020541520575344000373460ustar00rootroot00000000000000crccheck-django-object-actions-5966877/django_object_actions/static/django_object_actions/css/* Mirrors Django admin's .object-tools a rules for {% endif %} change_form.html000066400000000000000000000005631520575344000355500ustar00rootroot00000000000000crccheck-django-object-actions-5966877/django_object_actions/templates/django_object_actions{% extends "admin/change_form.html" %} {% block object-tools-items %} {% for tool in objectactions %}
  • {% url tools_view_name pk=object_id tool=tool.name as action_url %} {% include 'django_object_actions/action_trigger.html' %}
  • {% endfor %} {{ block.super }} {% endblock %} change_list.html000066400000000000000000000005461520575344000355610ustar00rootroot00000000000000crccheck-django-object-actions-5966877/django_object_actions/templates/django_object_actions{% extends "admin/change_list.html" %} {% block object-tools-items %} {% for tool in objectactions %}
  • {% url tools_view_name tool=tool.name as action_url %} {% include 'django_object_actions/action_trigger.html' %}
  • {% endfor %} {{ block.super }} {% endblock %} crccheck-django-object-actions-5966877/django_object_actions/tests/000077500000000000000000000000001520575344000253215ustar00rootroot00000000000000crccheck-django-object-actions-5966877/django_object_actions/tests/__init__.py000066400000000000000000000001601520575344000274270ustar00rootroot00000000000000# HACK to get factoryboy logging to shut up import logging logging.getLogger("factory").setLevel(logging.WARN) crccheck-django-object-actions-5966877/django_object_actions/tests/test_admin.py000066400000000000000000000134011520575344000300210ustar00rootroot00000000000000""" Integration tests that actually try and use the tools setup in admin.py """ from unittest.mock import patch from django.contrib.admin.utils import quote from django.http import HttpResponse from django.urls import reverse from example_project.polls.admin import RelatedDataAdmin from example_project.polls.factories import ( CommentFactory, PollFactory, RelatedDataFactory, ) from .tests import LoggedInTestCase class RelatedDataTests(LoggedInTestCase): def test_get_change_actions_receives_unquoted_object_id(self): related_data = RelatedDataFactory(id="user_001_name") received = [] original = RelatedDataAdmin.get_change_actions def capturing(self, request, object_id, form_url): received.append(object_id) return original(self, request, object_id, form_url) url = "/admin/polls/relateddata/user_5F001_5Fname/change/" with patch.object(RelatedDataAdmin, "get_change_actions", capturing): response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(received, [related_data.pk]) class CommentTests(LoggedInTestCase): def test_action_on_a_model_with_uuid_pk_works(self): comment = CommentFactory() comment_url = reverse("admin:polls_comment_change", args=(comment.pk,)) action_url = f"/admin/polls/comment/{comment.pk}/actions/hodor/" # sanity check that url has a uuid self.assertIn("-", action_url) response = self.client.get(action_url) self.assertRedirects(response, comment_url) @patch("django_object_actions.utils.ChangeActionView.dispatch") def test_action_on_a_model_with_arbitrary_pk_works(self, mock_view): mock_view.return_value = HttpResponse() action_url = "/admin/polls/comment/{}/actions/hodor/".format(" i am a pk ") self.client.get(action_url) self.assertTrue(mock_view.called) self.assertEqual(mock_view.call_args[1]["pk"], " i am a pk ") @patch("django_object_actions.utils.ChangeActionView.dispatch") def test_action_on_a_model_with_slash_in_pk_works(self, mock_view): mock_view.return_value = HttpResponse() action_url = "/admin/polls/comment/{}/actions/hodor/".format("pk/slash") self.client.get(action_url) self.assertTrue(mock_view.called) self.assertEqual(mock_view.call_args[1]["pk"], "pk/slash") class ExtraTests(LoggedInTestCase): def test_action_on_a_model_with_complex_id(self): related_data = RelatedDataFactory() related_data_url = reverse( "admin:polls_relateddata_change", args=(related_data.pk,) ) action_url = ( f"/admin/polls/relateddata/{quote(related_data.pk)}/actions/fill_up/" ) response = self.client.get(action_url) self.assertNotEqual(response.status_code, 404) self.assertRedirects(response, related_data_url) class ChangeTests(LoggedInTestCase): def test_buttons_load(self): url = "/admin/polls/choice/" response = self.client.get(url) self.assertIn("objectactions", response.context_data) self.assertIn("Delete all", response.rendered_content) def test_changelist_template_context(self): url = reverse("admin:polls_poll_changelist") response = self.client.get(url) self.assertIn("objectactions", response.context_data) self.assertIn("tools_view_name", response.context_data) self.assertIn("foo", response.context_data) def test_changelist_action_view(self): url = reverse("admin:polls_choice_actions", args=("delete_all",)) response = self.client.get(url) self.assertRedirects(response, "/admin/polls/choice/") def test_changelist_action_post_only_tool_rejects_get(self): poll = PollFactory.create() url = reverse("admin:polls_choice_actions", args=(poll.pk, "reset_vote")) response = self.client.get(url) self.assertEqual(response.status_code, 405) def test_changelist_nonexistent_action(self): url = "/admin/polls/choice/actions/xyzzy/" response = self.client.get(url) self.assertEqual(response.status_code, 404) def test_get_changelist_can_remove_action(self): poll = PollFactory.create() self.assertFalse(poll.question.endswith("?")) admin_change_url = reverse("admin:polls_poll_change", args=(poll.pk,)) action_url = "/admin/polls/poll/1/actions/question_mark/" # button is in the admin response = self.client.get(admin_change_url) self.assertIn(action_url, response.rendered_content) response = self.client.get(action_url) # Click on the button self.assertRedirects(response, admin_change_url) # button is not in the admin anymore response = self.client.get(admin_change_url) self.assertNotIn(action_url, response.rendered_content) class ChangeListTests(LoggedInTestCase): def test_changelist_template_context(self): poll = PollFactory() url = reverse("admin:polls_poll_change", args=(poll.pk,)) response = self.client.get(url) self.assertIn("objectactions", response.context_data) self.assertIn("tools_view_name", response.context_data) self.assertIn("foo", response.context_data) class MultipleAdminsTests(LoggedInTestCase): def test_redirect_back_from_secondary_admin(self): poll = PollFactory() admin_change_url = reverse( "admin:polls_poll_change", args=(poll.pk,), current_app="support" ) action_url = "/support/polls/poll/1/actions/question_mark/" self.assertTrue(admin_change_url.startswith("/support/")) response = self.client.get(action_url) self.assertRedirects(response, admin_change_url) crccheck-django-object-actions-5966877/django_object_actions/tests/test_urls.py000066400000000000000000000006571520575344000277270ustar00rootroot00000000000000""" Integration tests """ from django.urls import reverse from .tests import LoggedInTestCase class ChangeListTests(LoggedInTestCase): def test_changelist_action_is_rendered(self): response = self.client.get(reverse("admin:polls_choice_changelist")) self.assertEqual(response.status_code, 200) self.assertIn( b'href="/admin/polls/choice/actions/delete_all/"', response.content ) crccheck-django-object-actions-5966877/django_object_actions/tests/test_utils.py000066400000000000000000000254041520575344000300770ustar00rootroot00000000000000import warnings from unittest import mock from django.conf import settings from django.test import TestCase, override_settings from example_project.polls.models import Poll from .. import utils from ..utils import ( BaseActionView, BaseDjangoObjectActions, action, get_default_button_type, get_default_http_method, takes_instance_or_queryset, ) class BaseDjangoObjectActionsTest(TestCase): def setUp(self): self.instance = BaseDjangoObjectActions() self.instance.model = mock.Mock( **{"_meta.app_label": "app", "_meta.model_name": "model"} ) @mock.patch( "django_object_actions.utils.BaseDjangoObjectActions.admin_site", create=True ) def test_get_action_urls_trivial_case(self, mock_site): urls = self.instance._get_action_urls() self.assertEqual(len(urls), 2) self.assertEqual(urls[0].name, "app_model_actions") def test_get_change_actions_gets_attribute(self): # Set up self.instance.change_actions = mock.Mock() # Test returned_value = self.instance.get_change_actions( request=mock.Mock(), object_id=mock.Mock(), form_url=mock.Mock() ) # Assert self.assertEqual(id(self.instance.change_actions), id(returned_value)) def test_get_button_attrs_returns_defaults(self): # TODO: use `mock` mock_tool = type("mock_tool", (object,), {}) attrs, __ = self.instance._get_button_attrs(mock_tool) self.assertEqual(attrs["class"], "") self.assertEqual(attrs["title"], "") def test_get_button_attrs_disallows_href(self): mock_tool = type("mock_tool", (object,), {"attrs": {"href": "hreeeeef"}}) attrs, __ = self.instance._get_button_attrs(mock_tool) self.assertNotIn("href", attrs) def test_get_button_attrs_disallows_title(self): mock_tool = type( "mock_tool", (object,), { "attrs": {"title": "i wanna be a title"}, "short_description": "real title", }, ) attrs, __ = self.instance._get_button_attrs(mock_tool) self.assertEqual(attrs["title"], "real title") def test_get_button_attrs_gets_set(self): mock_tool = type( "mock_tool", (object,), {"attrs": {"class": "class"}, "short_description": "description"}, ) attrs, __ = self.instance._get_button_attrs(mock_tool) self.assertEqual(attrs["class"], "class") self.assertEqual(attrs["title"], "description") def test_get_button_attrs_custom_attrs_get_partitioned(self): mock_tool = type("mock_tool", (object,), {"attrs": {"nonstandard": "wombat"}}) _attrs, custom = self.instance._get_button_attrs(mock_tool) self.assertEqual(custom["nonstandard"], "wombat") @override_settings(DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD="GET") class GetToolDictButtonTypeTest(TestCase): def setUp(self): self.instance = BaseDjangoObjectActions() self.instance.model = mock.Mock( **{"_meta.app_label": "app", "_meta.model_name": "model"} ) utils._SETTING_WARNING_EMITTED = False def tearDown(self): utils._SETTING_WARNING_EMITTED = False def test_get_tool_dict_bare_method_resolves_button_type_at_render_time(self): def my_action(modeladmin, request, obj): pass self.instance.my_action = my_action with override_settings(DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD="POST"): result = self.instance._get_tool_dict("my_action") self.assertEqual(result["button_type"], "form") def test_get_tool_dict_decorated_and_bare_methods_agree_under_setting_change(self): # @action decorated under GET, but setting changes to POST before render. # Both should reflect the current setting, not the decoration-time setting. @action(description="Decorated under GET") def decorated_action(modeladmin, request, obj): pass def bare_method(modeladmin, request, obj): pass self.instance.decorated_action = decorated_action self.instance.bare_method = bare_method with override_settings(DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD="POST"): decorated_result = self.instance._get_tool_dict("decorated_action") bare_result = self.instance._get_tool_dict("bare_method") self.assertEqual(decorated_result["button_type"], "form") self.assertEqual(bare_result["button_type"], "form") @override_settings(DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD="POST") def test_action_decorator_respects_post_setting(self): @action(description="Test action") def my_action(modeladmin, request, queryset): pass self.instance.my_action = my_action result = self.instance._get_tool_dict("my_action") self.assertEqual(result["button_type"], "form") def test_action_decorator_respects_get_setting(self): @action(description="Test action") def my_action(modeladmin, request, queryset): pass self.instance.my_action = my_action result = self.instance._get_tool_dict("my_action") self.assertEqual(result["button_type"], "a") @override_settings(DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD="POST") def test_explicit_button_type_overrides_default_http_method_setting(self): @action(button_type="a") def my_action(modeladmin, request, queryset): pass self.instance.my_action = my_action result = self.instance._get_tool_dict("my_action") self.assertEqual(result["button_type"], "a") class BaseActionViewTests(TestCase): def setUp(self): super().setUp() self.view = BaseActionView() @mock.patch("django_object_actions.utils.messages") def test_message_user_proxies_messages(self, mock_messages): self.view.message_user("request", "message") mock_messages.info.assert_called_once_with("request", "message") class DecoratorTest(TestCase): fixtures = ["sample_data"] def setUp(self): # WISHLIST don't depend on fixture self.obj = Poll.objects.get(pk=1) self.queryset = Poll.objects.all() def test_trivial(self): # setup def myfunc(foo, bar, queryset): return queryset # make sure my test function outputs the third arg self.assertEqual(myfunc(None, None, "foo"), "foo") # or the `queryset` kwarg self.assertEqual(myfunc(None, None, queryset="bar"), "bar") def test_decorated(self): # setup @takes_instance_or_queryset def myfunc(foo, bar, queryset): return queryset # passing in an instance yields a queryset (using positional args) queryset = myfunc(None, None, self.obj) # the resulting queryset only has one item and it's self.obj self.assertEqual(queryset.get(), self.obj) # passing in a queryset yields the same queryset queryset = myfunc(None, None, self.queryset) self.assertEqual(queryset, self.queryset) # passing in an instance yields a queryset (using keyword args) queryset = myfunc(None, None, queryset=self.obj) # the resulting queryset only has one item and it's self.obj self.assertEqual(queryset.get(), self.obj) class DecoratorActionTest(TestCase): def test_decorated(self): # setup @action(description="First action of this admin site.") def action_1(modeladmin, request, queryset): pass @action(permissions=["do_action2"]) def action_2(modeladmin, request, queryset): pass @action(label="Third action") def action_3(modeladmin, request, queryset): pass @action( attrs={ "class": "addlink", } ) def action_4(modeladmin, request, queryset): pass self.assertEqual(action_1.short_description, "First action of this admin site.") self.assertEqual(action_2.allowed_permissions, ["do_action2"]) self.assertEqual(action_3.label, "Third action") self.assertEqual( action_4.attrs, { "class": "addlink", }, ) class DefaultHttpMethodSettingTest(TestCase): def setUp(self): utils._SETTING_WARNING_EMITTED = False def tearDown(self): utils._SETTING_WARNING_EMITTED = False @override_settings(DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD="GET") def test_explicit_get_setting_returns_get(self): self.assertEqual(get_default_http_method(), "GET") @override_settings(DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD="POST") def test_explicit_post_setting_returns_post(self): self.assertEqual(get_default_http_method(), "POST") @override_settings(DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD="GET") def test_get_setting_returns_anchor_button_type(self): self.assertEqual(get_default_button_type(), "a") @override_settings(DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD="POST") def test_post_setting_returns_form_button_type(self): self.assertEqual(get_default_button_type(), "form") @override_settings(DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD="INVALID") def test_invalid_setting_raises_value_error(self): with self.assertRaises(ValueError) as ctx: get_default_http_method() self.assertIn("INVALID", str(ctx.exception)) self.assertIn('must be "GET" or "POST"', str(ctx.exception)) def test_no_setting_emits_deprecation_warning(self): with override_settings(): delattr(settings, "DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD") with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") get_default_http_method() self.assertEqual(len(w), 1) self.assertTrue(issubclass(w[0].category, DeprecationWarning)) self.assertIn("default HTTP method will change", str(w[0].message)) def test_deprecation_warning_emitted_only_once(self): with override_settings(): delattr(settings, "DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD") with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") get_default_http_method() get_default_http_method() get_default_http_method() self.assertEqual(len(w), 1) @override_settings(DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD="GET") def test_explicit_setting_does_not_emit_warning(self): with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") get_default_http_method() self.assertEqual(len(w), 0) crccheck-django-object-actions-5966877/django_object_actions/tests/tests.py000066400000000000000000000067371520575344000270520ustar00rootroot00000000000000from django.test import TestCase from django.urls import reverse from example_project.polls.factories import UserFactory from example_project.polls.models import Choice class LoggedInTestCase(TestCase): def setUp(self): super().setUp() UserFactory.create( is_staff=True, is_superuser=True, username="admin", password="admin" ) self.assertTrue(self.client.login(username="admin", password="admin")) # TODO move most of these to test_admin.py after I sit down and re-read these # and don't need the fixtures class AppTests(LoggedInTestCase): fixtures = ["sample_data"] def test_tool_func_gets_executed(self): c = Choice.objects.get(pk=1) votes = c.votes response = self.client.get( reverse("admin:polls_choice_actions", args=(1, "increment_vote")) ) self.assertEqual(response.status_code, 302) url = reverse("admin:polls_choice_change", args=(1,)) self.assertTrue(response["location"].endswith(url)) c = Choice.objects.get(pk=1) self.assertEqual(c.votes, votes + 1) def test_tool_can_return_httpresponse(self): # we know this url works because of fixtures url = reverse("admin:polls_choice_actions", args=(2, "edit_poll")) response = self.client.get(url) # we expect a redirect self.assertEqual(response.status_code, 302) self.assertTrue( response["location"].endswith(reverse("admin:polls_poll_change", args=(1,))) ) def test_can_return_template(self): # This is more of a test of render_to_response than the app, but I think # it's good to document that this is something we can do. url = reverse("admin:polls_poll_actions", args=(1, "delete_all_choices")) response = self.client.get(url) self.assertTemplateUsed(response, "clear_choices.html") def test_message_user_sends_message(self): url = reverse("admin:polls_poll_actions", args=(1, "delete_all_choices")) self.assertNotIn("messages", self.client.cookies) self.client.get(url) self.assertIn("messages", self.client.cookies) def test_intermediate_page_with_post_works(self): self.assertTrue(Choice.objects.filter(poll=1).count()) url = reverse("admin:polls_poll_actions", args=(1, "delete_all_choices")) response = self.client.post(url) self.assertEqual(response.status_code, 302) self.assertEqual(Choice.objects.filter(poll=1).count(), 0) def test_undefined_tool_404s(self): response = self.client.get( reverse("admin:polls_poll_actions", args=(1, "weeeewoooooo")) ) self.assertEqual(response.status_code, 404) def test_key_error_tool_500s(self): self.assertRaises( KeyError, self.client.get, reverse("admin:polls_choice_actions", args=(1, "raise_key_error")), ) def test_render_button(self): response = self.client.get(reverse("admin:polls_choice_change", args=(1,))) self.assertEqual(response.status_code, 200) def test_render_button_form_type_renders_button_element(self): response = self.client.get(reverse("admin:polls_choice_change", args=(1,))) self.assertContains(response, '