pax_global_header00006660000000000000000000000064147741472160014527gustar00rootroot0000000000000052 comment=b3a30cc093350ceffc9b73536c13741e176b15fc django-object-actions-5.0.0/000077500000000000000000000000001477414721600156755ustar00rootroot00000000000000django-object-actions-5.0.0/.dockerignore000066400000000000000000000000761477414721600203540ustar00rootroot00000000000000*.pyc .coverage .env *.db *.sqlite build/ dist/ *.egg-info django-object-actions-5.0.0/.github/000077500000000000000000000000001477414721600172355ustar00rootroot00000000000000django-object-actions-5.0.0/.github/workflows/000077500000000000000000000000001477414721600212725ustar00rootroot00000000000000django-object-actions-5.0.0/.github/workflows/ci.yml000066400000000000000000000025771477414721600224230ustar00rootroot00000000000000name: CI on: pull_request: push: branches: - main jobs: prepare: runs-on: ubuntu-latest steps: - name: Create matrix id: create_matrix uses: fabiocaccamo/create-matrix-action@v5 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} python-version {3.13}, django-version {5.1,5.2} 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@v4 - uses: actions/setup-python@v5 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@v4 - uses: actions/setup-python@v5 with: python-version: "3.13" - run: pip install -e '.[dev]' - run: make lint django-object-actions-5.0.0/.github/workflows/conventional-pr.yml000066400000000000000000000007161477414721600251370ustar00rootroot00000000000000name: conventional-pr on: pull_request: types: - opened - edited - synchronize branches: - main jobs: lint-pr: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 # check for the most recent release: https://github.com/CondeNast/conventional-pull-request-action/releases - uses: CondeNast/conventional-pull-request-action@v0.1.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} django-object-actions-5.0.0/.github/workflows/release.yml000066400000000000000000000031641477414721600234410ustar00rootroot00000000000000# 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@v4 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@v9.21.0 with: github_token: ${{ secrets.BOT_GITHUB_TOKEN }} - name: Publish | Upload package to PyPI 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 uses: python-semantic-release/publish-action@v9.21.0 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.BOT_GITHUB_TOKEN }} tag: ${{ steps.release.outputs.tag }} django-object-actions-5.0.0/.gitignore000066400000000000000000000002111477414721600176570ustar00rootroot00000000000000*.pyc __pycache__ .DS_Store .coverage .tox/ *.db *.sqlite .venv .idea # Private files .env # build MANIFEST build/* dist/* *.egg-info django-object-actions-5.0.0/CHANGELOG.md000066400000000000000000000516041477414721600175140ustar00rootroot00000000000000# CHANGELOG ## 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)) Unable to resolve action `python-semantic-release/upload-to-gh-release@9.21.0`, unable to find version `9.21.0` It is now named `python-semantic-release/publish-action` - **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)) django-object-actions-5.0.0/CONTRIBUTING.md000066400000000000000000000002751477414721600201320ustar00rootroot00000000000000This project uses [Black] to format code. To avoid manually running `black .`, you can install a Git hook or a format-on-save plugin for your editor. [Black]: https://github.com/psf/black django-object-actions-5.0.0/Dockerfile000066400000000000000000000012271477414721600176710ustar00rootroot00000000000000# TODO upgrade once old Django versions are dropped FROM python:3.9-alpine RUN apk add --no-cache make ADD requirements.txt /app/requirements.txt RUN pip --disable-pip-version-check install -r /app/requirements.txt ARG DJANGO_VERSION RUN pip --disable-pip-version-check install django==$DJANGO_VERSION ADD . /app WORKDIR /app ENV PYTHONPATH /app 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:$PORT django-object-actions-5.0.0/LICENSE000066400000000000000000000252311477414721600167050ustar00rootroot00000000000000Apache 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. django-object-actions-5.0.0/Makefile000066400000000000000000000045521477414721600173430ustar00rootroot00000000000000PROJECT = ./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 pip install -e '.[build,dev]' pip install Django 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 # DEPRECATED: Docker builds are currently broken and will likely get deleted rather than fixed docker/build: ## Build a full set of Docker images docker/build: docker/build/3.1 docker/build/3.0 docker/build/2.2.6 docker/build/2.1.13 docker/build/2.0.13 docker/build/1.11.25 docker/build/1.10.8 docker/build/1.9.13 docker/build/1.8.18 docker/build/%: docker build --build-arg DJANGO_VERSION=$* \ -t $(IMAGE):$$(echo "$*" | cut -f 1-2 -d.) . run: run/3.1 run/%: docker run --rm -p 8000:8000 -it $(IMAGE):$* docker/publish: ## Publish Docker images to the hub docker push $(IMAGE):3.1 docker push $(IMAGE):3.0 docker push $(IMAGE):2.2 docker push $(IMAGE):2.1 docker push $(IMAGE):2.0 docker push $(IMAGE):1.11 docker push $(IMAGE):1.10 docker push $(IMAGE):1.9 docker push $(IMAGE):1.8 test/%: docker run --rm -p 8000:8000 -t $(IMAGE):$* make test bash: docker run --rm -it $(IMAGE):3.1 /bin/sh django-object-actions-5.0.0/README.md000066400000000000000000000204441477414721600171600ustar00rootroot00000000000000# 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 = ('...', ) ``` ## 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 ⚠️ This is a beta feature and subject to change Since actions usually change data, for safety and semantics, it would be preferable that actions use a HTTP POST instead of a GET. You can configure an action to only use POST with: ```python @action(methods=("POST",), button_type="form") ``` One caveat is Django's styling is pinned to anchor tags[^1], so to maintain visual consistency, we have to use anchor tags and use JavaScript to make it act like the submit button of the form. [^1]: https://github.com/django/django/blob/826ef006681eae1e9b4bd0e4f18fa13713025cba/django/contrib/admin/static/admin/css/base.css#L786 ### 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 at all, don't add `django_object_actions` to your `INSTALLED_APPS` at all and use `BaseDjangoObjectActions` instead of `DjangoObjectActions`. ## 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}') ``` ## 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 & Docker images You can try the demo admin against several versions of Django with these Docker images: https://hub.docker.com/r/crccheck/django-object-actions/tags This runs the example Django project in `./example_project` 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). django-object-actions-5.0.0/django_object_actions/000077500000000000000000000000001477414721600222055ustar00rootroot00000000000000django-object-actions-5.0.0/django_object_actions/__init__.py000066400000000000000000000005171477414721600243210ustar00rootroot00000000000000"""A Django app for adding object tools for models in the admin.""" __version__ = "5.0.0" from .utils import ( BaseDjangoObjectActions, DjangoObjectActions, action, takes_instance_or_queryset, ) __all__ = [ "BaseDjangoObjectActions", "DjangoObjectActions", "action", "takes_instance_or_queryset", ] django-object-actions-5.0.0/django_object_actions/models.py000066400000000000000000000002461477414721600240440ustar00rootroot00000000000000# DJANGO1.7 https://docs.djangoproject.com/en/2.0/releases/1.7/#app-loading-refactor # DELETEME and use an app config # Empty models.py so django picks the templates django-object-actions-5.0.0/django_object_actions/templates/000077500000000000000000000000001477414721600242035ustar00rootroot00000000000000django-object-actions-5.0.0/django_object_actions/templates/django_object_actions/000077500000000000000000000000001477414721600305135ustar00rootroot00000000000000action_trigger.html000066400000000000000000000014141477414721600343220ustar00rootroot00000000000000django-object-actions-5.0.0/django_object_actions/templates/django_object_actions{% load add_preserved_filters from admin_urls %} {% if tool.button_type == 'a' %} {{ tool.label|capfirst }} {% elif tool.button_type == 'form' %}
{% csrf_token %} {{ tool.label|capfirst }}
{% endif %} django-object-actions-5.0.0/django_object_actions/templates/django_object_actions/change_form.html000066400000000000000000000005631477414721600336550ustar00rootroot00000000000000{% 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 %} django-object-actions-5.0.0/django_object_actions/templates/django_object_actions/change_list.html000066400000000000000000000005461477414721600336660ustar00rootroot00000000000000{% 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 %} django-object-actions-5.0.0/django_object_actions/tests/000077500000000000000000000000001477414721600233475ustar00rootroot00000000000000django-object-actions-5.0.0/django_object_actions/tests/__init__.py000066400000000000000000000001601477414721600254550ustar00rootroot00000000000000# HACK to get factoryboy logging to shut up import logging logging.getLogger("factory").setLevel(logging.WARN) django-object-actions-5.0.0/django_object_actions/tests/test_admin.py000066400000000000000000000120011477414721600260420ustar00rootroot00000000000000""" 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.factories import ( CommentFactory, PollFactory, RelatedDataFactory, ) from .tests import LoggedInTestCase 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) django-object-actions-5.0.0/django_object_actions/tests/test_urls.py000066400000000000000000000006571477414721600257550ustar00rootroot00000000000000""" 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 ) django-object-actions-5.0.0/django_object_actions/tests/test_utils.py000066400000000000000000000124571477414721600261310ustar00rootroot00000000000000from unittest import mock from django.test import TestCase from example_project.polls.models import Poll from ..utils import ( BaseActionView, BaseDjangoObjectActions, action, 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") 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", }, ) django-object-actions-5.0.0/django_object_actions/tests/tests.py000066400000000000000000000060761477414721600250740ustar00rootroot00000000000000from 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) django-object-actions-5.0.0/django_object_actions/utils.py000066400000000000000000000304461477414721600237260ustar00rootroot00000000000000from functools import wraps from itertools import chain from django.contrib import messages from django.contrib.admin.utils import unquote from django.db.models.query import QuerySet from django.http import Http404, HttpResponseRedirect from django.http.response import HttpResponseBase, HttpResponseNotAllowed from django.urls import re_path, reverse from django.views.generic import View from django.views.generic.detail import SingleObjectMixin from django.views.generic.list import MultipleObjectMixin DEFAULT_METHODS_ALLOWED = ("GET", "POST") DEFAULT_BUTTON_TYPE = "a" class BaseDjangoObjectActions: """ ModelAdmin mixin to add new actions just like adding admin actions. Attributes ---------- model : django.db.models.Model The Django Model these actions work on. This is populated by Django. change_actions : list of str Write the names of the methods of the model admin that can be used as tools in the change view. changelist_actions : list of str Write the names of the methods of the model admin that can be used as tools in the changelist view. tools_view_name : str The name of the Django Object Actions admin view, including the 'admin' namespace. Populated by `_get_action_urls`. """ change_actions = [] changelist_actions = [] tools_view_name = None # EXISTING ADMIN METHODS MODIFIED ################################# def get_urls(self): """Prepend `get_urls` with our own patterns.""" urls = super().get_urls() return self._get_action_urls() + urls def change_view(self, request, object_id, form_url="", extra_context=None): extra_context = extra_context or {} extra_context.update( { "objectactions": [ self._get_tool_dict(action) for action in self.get_change_actions(request, object_id, form_url) ], "tools_view_name": self.tools_view_name, } ) return super().change_view(request, object_id, form_url, extra_context) def changelist_view(self, request, extra_context=None): extra_context = extra_context or {} extra_context.update( { "objectactions": [ self._get_tool_dict(action) for action in self.get_changelist_actions(request) ], "tools_view_name": self.tools_view_name, } ) return super().changelist_view(request, extra_context) # USER OVERRIDABLE ################## def get_change_actions(self, request, object_id, form_url): """ Override this to customize what actions get to the change view. This takes the same parameters as `change_view`. For example, to restrict actions to superusers, you could do: class ChoiceAdmin(DjangoObjectActions, admin.ModelAdmin): def get_change_actions(self, request, **kwargs): if request.user.is_superuser: return super(ChoiceAdmin, self).get_change_actions( request, **kwargs ) return [] """ return self.change_actions def get_changelist_actions(self, request): """ Override this to customize what actions get to the changelist view. """ return self.changelist_actions # INTERNAL METHODS ################## def _get_action_urls(self): """Get the url patterns that route each action to a view.""" actions = {} model_name = self.model._meta.model_name # e.g.: polls_poll base_url_name = f"{self.model._meta.app_label}_{model_name}" # e.g.: polls_poll_actions model_actions_url_name = f"{base_url_name}_actions" self.tools_view_name = "admin:" + model_actions_url_name # WISHLIST use get_change_actions and get_changelist_actions # TODO separate change and changelist actions for action in chain(self.change_actions, self.changelist_actions): actions[action] = getattr(self, action) return [ # change, supports the same pks the admin does # https://github.com/django/django/blob/stable/1.10.x/django/contrib/admin/options.py#L555 re_path( r"^(?P.+)/actions/(?P\w+)/$", self.admin_site.admin_view( # checks permissions ChangeActionView.as_view( model=self.model, actions=actions, back=f"admin:{base_url_name}_change", current_app=self.admin_site.name, ) ), name=model_actions_url_name, ), # changelist re_path( r"^actions/(?P\w+)/$", self.admin_site.admin_view( # checks permissions ChangeListActionView.as_view( model=self.model, actions=actions, back=f"admin:{base_url_name}_changelist", current_app=self.admin_site.name, ) ), # Dupe name is fine. https://code.djangoproject.com/ticket/14259 name=model_actions_url_name, ), ] def _get_tool_dict(self, tool_name): """Represents the tool as a dict with extra meta.""" tool = getattr(self, tool_name) standard_attrs, custom_attrs = self._get_button_attrs(tool) return dict( name=tool_name, label=getattr(tool, "label", tool_name.replace("_", " ").capitalize()), standard_attrs=standard_attrs, custom_attrs=custom_attrs, button_type=getattr(tool, "button_type", DEFAULT_BUTTON_TYPE), ) def _get_button_attrs(self, tool): """ Get the HTML attributes associated with a tool. There are some standard attributes (class and title) that the template will always want. Any number of additional attributes can be specified and passed on. This is kinda awkward and due for a refactor for readability. """ attrs = getattr(tool, "attrs", {}) # href is not allowed to be set. should an exception be raised instead? if "href" in attrs: attrs.pop("href") # title is not allowed to be set. should an exception be raised instead? # `short_description` should be set instead to parallel django admin # actions if "title" in attrs: attrs.pop("title") default_attrs = { "class": attrs.get("class", ""), "title": getattr(tool, "short_description", ""), } standard_attrs = {} custom_attrs = {} for k, v in dict(default_attrs, **attrs).items(): if k in default_attrs: standard_attrs[k] = v else: custom_attrs[k] = v return standard_attrs, custom_attrs class DjangoObjectActions(BaseDjangoObjectActions): change_form_template = "django_object_actions/change_form.html" change_list_template = "django_object_actions/change_list.html" class BaseActionView(View): """ The view that runs a change/changelist action callable. Attributes ---------- back : str The urlpattern name to send users back to. This is set in `_get_action_urls` and turned into a url with the `back_url` property. model : django.db.model.Model The model this tool operates on. actions : dict A mapping of action names to callables. """ back = None model = None actions = None current_app = None @property def view_args(self): """ tuple: The argument(s) to send to the action (excluding `request`). Change actions are called with `(request, obj)` while changelist actions are called with `(request, queryset)`. """ raise NotImplementedError @property def back_url(self): """ str: The url path the action should send the user back to. If an action does not return a http response, we automagically send users back to either the change or the changelist page. """ raise NotImplementedError def dispatch(self, request, tool, **kwargs): # Fix for case if there are special symbols in object pk for k, v in self.kwargs.items(): self.kwargs[k] = unquote(v) try: view = self.actions[tool] except KeyError as exc: raise Http404("Action does not exist") from exc allowed_methods = getattr(view, "methods", DEFAULT_METHODS_ALLOWED) if request.method.upper() not in allowed_methods: return HttpResponseNotAllowed(allowed_methods) ret = view(request, *self.view_args) if isinstance(ret, HttpResponseBase): return ret return HttpResponseRedirect(self.back_url) def message_user(self, request, message): """ Mimic Django admin actions's `message_user`. Like the second example: https://docs.djangoproject.com/en/1.9/ref/contrib/admin/actions/#custom-admin-action """ messages.info(request, message) class ChangeActionView(SingleObjectMixin, BaseActionView): @property def view_args(self): return (self.get_object(),) @property def back_url(self): return reverse( self.back, args=(self.kwargs["pk"],), current_app=self.current_app ) class ChangeListActionView(MultipleObjectMixin, BaseActionView): @property def view_args(self): return (self.get_queryset(),) @property def back_url(self): return reverse(self.back, current_app=self.current_app) def takes_instance_or_queryset(func): """Decorator that makes standard Django admin actions compatible.""" @wraps(func) def decorated_function(self, request, queryset): # func follows the prototype documented at: # https://docs.djangoproject.com/en/dev/ref/contrib/admin/actions/#writing-action-functions if not isinstance(queryset, QuerySet): try: # Django >=1.8 queryset = self.get_queryset(request).filter(pk=queryset.pk) except AttributeError: try: # Django >=1.6,<1.8 model = queryset._meta.model except AttributeError: # pragma: no cover # Django <1.6 model = queryset._meta.concrete_model queryset = model.objects.filter(pk=queryset.pk) return func(self, request, queryset) return decorated_function def action( function=None, *, permissions=None, description=None, label=None, attrs=None, methods=DEFAULT_METHODS_ALLOWED, button_type=DEFAULT_BUTTON_TYPE, ): """ Conveniently add attributes to an action function: @action( permissions=['publish'], description='Mark selected stories as published', label='Publish' ) def make_published(self, request, queryset): queryset.update(status='p') This is equivalent to setting some attributes (with the original, longer names) on the function directly: def make_published(self, request, queryset): queryset.update(status='p') make_published.allowed_permissions = ['publish'] make_published.short_description = 'Mark selected stories as published' make_published.label = 'Publish' This is the django-object-actions equivalent of https://docs.djangoproject.com/en/stable/ref/contrib/admin/actions/#django.contrib.admin.action """ def decorator(func): if permissions is not None: func.allowed_permissions = permissions if description is not None: func.short_description = description if label is not None: func.label = label if attrs is not None: func.attrs = attrs func.methods = methods func.button_type = button_type return func if function is None: return decorator return decorator(function) django-object-actions-5.0.0/example_project/000077500000000000000000000000001477414721600210565ustar00rootroot00000000000000django-object-actions-5.0.0/example_project/__init__.py000066400000000000000000000000001477414721600231550ustar00rootroot00000000000000django-object-actions-5.0.0/example_project/manage.py000066400000000000000000000004021477414721600226540ustar00rootroot00000000000000#!/usr/bin/env python import os import sys if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings") from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) django-object-actions-5.0.0/example_project/polls/000077500000000000000000000000001477414721600222075ustar00rootroot00000000000000django-object-actions-5.0.0/example_project/polls/__init__.py000066400000000000000000000000001477414721600243060ustar00rootroot00000000000000django-object-actions-5.0.0/example_project/polls/admin.py000066400000000000000000000107271477414721600236600ustar00rootroot00000000000000from django.contrib import admin from django.contrib.admin import AdminSite from django.db.models import F from django.http import HttpResponseRedirect from django.urls import reverse from django_object_actions import ( DjangoObjectActions, action, takes_instance_or_queryset, ) from .models import Choice, Comment, Poll, RelatedData class ChoiceAdmin(DjangoObjectActions, admin.ModelAdmin): list_display = ("poll", "choice_text", "votes") # Actions ######### @action( description="+1", label="vote++", attrs={ "test": '"foo&bar"', "Robert": '"); DROP TABLE Students; ', # 327 "class": "addlink", }, ) @takes_instance_or_queryset def increment_vote(self, request, queryset): queryset.update(votes=F("votes") + 1) actions = ["increment_vote"] # Object actions ################ @action(description="-1") def decrement_vote(self, request, obj): obj.votes -= 1 obj.save() def delete_all(self, request, queryset): self.message_user(request, "just kidding!") @action(description="0", methods=("POST",), button_type="form") def reset_vote(self, request, obj): obj.votes = 0 obj.save() def edit_poll(self, request, obj): url = reverse("admin:polls_poll_change", args=(obj.poll.pk,)) return HttpResponseRedirect(url) def raise_key_error(self, request, obj): raise KeyError change_actions = ( "increment_vote", "decrement_vote", "reset_vote", "edit_poll", "raise_key_error", ) changelist_actions = ("delete_all",) admin.site.register(Choice, ChoiceAdmin) class ChoiceInline(admin.StackedInline): model = Choice extra = 3 class PollAdmin(DjangoObjectActions, admin.ModelAdmin): # List ###### list_display = ("question", "pub_date", "was_published_recently") list_filter = ["pub_date"] search_fields = ["question"] date_hierarchy = "pub_date" def changelist_view(self, request, extra_context=None): extra_context = {"foo": "changelist_view"} return super().changelist_view(request, extra_context) # Detail ######## fieldsets = [ (None, {"fields": ["question"]}), ("Date information", {"fields": ["pub_date"], "classes": ["collapse"]}), ] inlines = [ChoiceInline] def change_view(self, request, object_id, form_url="", extra_context=None): extra = {"foo": "change_view"} return super().change_view(request, object_id, form_url, extra) # Object actions ################ @action(label="Delete All Choices") def delete_all_choices(self, request, obj): from django.shortcuts import render if request.method == "POST": obj.choice_set.all().delete() return None self.message_user(request, "All choices deleted") return render(request, "clear_choices.html", {"object": obj}) def question_mark(self, request, obj): """Add a question mark.""" obj.question = obj.question + "?" obj.save() change_actions = ("delete_all_choices", "question_mark") def get_change_actions(self, request, object_id, form_url): actions = super().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 admin.site.register(Poll, PollAdmin) class CommentAdmin(DjangoObjectActions, admin.ModelAdmin): # Object actions ################ def hodor(self, request, obj): if not obj.comment: # bail because we need a comment return obj.comment = " ".join(["hodor" for x in obj.comment.split()]) obj.save() change_actions = ("hodor",) admin.site.register(Comment, CommentAdmin) class RelatedDataAdmin(DjangoObjectActions, admin.ModelAdmin): # Object actions ################ def fill_up(self, request, obj): if not obj.extra_data: # bail because we need a comment obj.extra_data = "hodor" else: obj.extra_data = "" obj.save() change_actions = ("fill_up",) admin.site.register(RelatedData, RelatedDataAdmin) support_admin = AdminSite(name="support") support_admin.register(Poll, PollAdmin) django-object-actions-5.0.0/example_project/polls/factories.py000066400000000000000000000026241477414721600245440ustar00rootroot00000000000000import random import string import factory from django.contrib.auth import get_user_model from django.utils import timezone from . import models class UserFactory(factory.django.DjangoModelFactory): class Meta: model = get_user_model() first_name = factory.Faker("first_name") last_name = factory.Faker("last_name") username = factory.Faker("slug") email = factory.Faker("email") password = factory.PostGenerationMethodCall("set_password", "password") class PollFactory(factory.django.DjangoModelFactory): class Meta: model = models.Poll question = factory.Faker("sentence") pub_date = factory.LazyAttribute(lambda __: timezone.now()) class ChoiceFactory(factory.django.DjangoModelFactory): class Meta: model = models.Choice poll = factory.SubFactory(PollFactory) choice_text = factory.Faker("word") votes = factory.Faker("pyint") class CommentFactory(factory.django.DjangoModelFactory): class Meta: model = models.Comment def get_random_string(length): letters = string.ascii_lowercase return "".join(random.choice(letters) for i in range(length)) class RelatedDataFactory(factory.django.DjangoModelFactory): id = factory.lazy_attribute( lambda __: f"{get_random_string(2)}:{get_random_string(2)}-{get_random_string(2)}!{get_random_string(2)}" ) class Meta: model = models.RelatedData django-object-actions-5.0.0/example_project/polls/fixtures/000077500000000000000000000000001477414721600240605ustar00rootroot00000000000000django-object-actions-5.0.0/example_project/polls/fixtures/sample_data.json000066400000000000000000000011111477414721600272170ustar00rootroot00000000000000[ { "pk": 1, "model": "polls.poll", "fields": { "pub_date": "2012-10-20T18:20:35", "question": "Do you like me?" } }, { "pk": 2, "model": "polls.poll", "fields": { "pub_date": "2012-10-20T18:20:38", "question": "Do you wanna build a snow man?" } }, { "pk": 1, "model": "polls.choice", "fields": { "choice_text": "Yes", "poll": 1, "votes": 0 } }, { "pk": 2, "model": "polls.choice", "fields": { "choice_text": "No", "poll": 1, "votes": 100 } } ] django-object-actions-5.0.0/example_project/polls/migrations/000077500000000000000000000000001477414721600243635ustar00rootroot00000000000000django-object-actions-5.0.0/example_project/polls/migrations/0001_initial.py000066400000000000000000000034731477414721600270350ustar00rootroot00000000000000# Generated by Django 1.9.2 on 2016-02-25 17:25 import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): initial = True dependencies = [] operations = [ migrations.CreateModel( name="Choice", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("choice_text", models.CharField(max_length=200)), ("votes", models.IntegerField()), ], ), migrations.CreateModel( name="Comment", fields=[ ( "uuid", models.UUIDField(editable=False, primary_key=True, serialize=False), ), ("comment", models.TextField(blank=True, null=True)), ], ), migrations.CreateModel( name="Poll", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ("question", models.CharField(max_length=200)), ("pub_date", models.DateTimeField(verbose_name=b"date published")), ], ), migrations.AddField( model_name="choice", name="poll", field=models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, to="polls.Poll" ), ), ] django-object-actions-5.0.0/example_project/polls/migrations/0002_auto_20200805_0239.py000066400000000000000000000017211477414721600300040ustar00rootroot00000000000000# Generated by Django 3.1 on 2020-08-05 02:39 import uuid from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ("polls", "0001_initial"), ] operations = [ migrations.CreateModel( name="RelatedData", fields=[ ( "id", models.CharField(max_length=32, primary_key=True, serialize=False), ), ("extra_data", models.TextField(blank=True, default="")), ], ), migrations.AlterField( model_name="comment", name="uuid", field=models.UUIDField( default=uuid.uuid4, editable=False, primary_key=True, serialize=False ), ), migrations.AlterField( model_name="poll", name="pub_date", field=models.DateTimeField(verbose_name="date published"), ), ] django-object-actions-5.0.0/example_project/polls/migrations/__init__.py000066400000000000000000000000001477414721600264620ustar00rootroot00000000000000django-object-actions-5.0.0/example_project/polls/models.py000066400000000000000000000023311477414721600240430ustar00rootroot00000000000000import datetime from uuid import uuid4 from django.db import models from django.utils import timezone class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") def __str__(self): return self.question def was_published_recently(self): return self.pub_date >= timezone.now() - datetime.timedelta(days=1) was_published_recently.admin_order_field = "pub_date" was_published_recently.boolean = True was_published_recently.short_description = "Published recently?" class Choice(models.Model): poll = models.ForeignKey(Poll, on_delete=models.CASCADE) choice_text = models.CharField(max_length=200) votes = models.IntegerField() def __str__(self): return self.choice_text class Comment(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False) comment = models.TextField(null=True, blank=True) def __str__(self): return self.comment or "" class RelatedData(models.Model): id = models.CharField(primary_key=True, max_length=32) extra_data = models.TextField(blank=True, default="") def __str__(self): return self.extra_data or self.id django-object-actions-5.0.0/example_project/polls/templates/000077500000000000000000000000001477414721600242055ustar00rootroot00000000000000django-object-actions-5.0.0/example_project/polls/templates/clear_choices.html000066400000000000000000000001641477414721600276570ustar00rootroot00000000000000
    {% csrf_token %} Delete All Choices?
    django-object-actions-5.0.0/example_project/settings.py000066400000000000000000000041551477414721600232750ustar00rootroot00000000000000# Django settings for example_project project. import os import dj_database_url def project_dir(*paths): base = os.path.realpath(os.path.dirname(__file__)) return os.path.join(base, *paths) DEBUG = True DATABASES = { "default": dj_database_url.config( default="sqlite:///" + project_dir("example_project.db") ) } DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # Local time zone for this installation. Choices can be found here: # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # although not all choices may be available on all operating systems. # In a Windows environment this must be set to your system time zone. TIME_ZONE = "America/Chicago" # Language code for this installation. All choices can be found here: # http://www.i18nguy.com/unicode/language-identifiers.html LANGUAGE_CODE = "en-us" SITE_ID = 1 USE_L10N = False USE_TZ = False STATIC_URL = "/static/" SECRET_KEY = "lolimasekrit" ROOT_URLCONF = "example_project.urls" # DJANGO1.9 switch to only MIDDLEWARE MIDDLEWARE_CLASSES = MIDDLEWARE = ( "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ) INSTALLED_APPS = ( "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.sites", "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.admin", "example_project.polls", # my app "django_object_actions", # dev helpers "django_extensions", ) TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ] }, } ] django-object-actions-5.0.0/example_project/urls.py000066400000000000000000000003271477414721600224170ustar00rootroot00000000000000from django.contrib import admin from django.urls import path from example_project.polls.admin import support_admin urlpatterns = [ path("admin/", admin.site.urls), path("support/", support_admin.urls), ] django-object-actions-5.0.0/pyproject.toml000066400000000000000000000040161477414721600206120ustar00rootroot00000000000000[build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [project] name = "django-object-actions" version = "5.0.0" description = "A Django app for adding object tools for models in the admin" authors = [{ name = "crccheck", email = "c@crccheck.com" }] license = { text = "Apache-2.0" } readme = "README.md" keywords = ["django", "admin"] requires-python = ">=3.9" classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] [project.urls] "Homepage" = "https://github.com/crccheck/django-object-actions" [project.optional-dependencies] dev = [ "coverage==7.*", "django-extensions==3.*", "factory-boy==3.*", "dj-database-url==2.*", "ruff", ] build = ["build", "twine"] [tool.setuptools] packages = [ "django_object_actions", "django_object_actions.templates.django_object_actions", ] [tool.setuptools.package-data] "django_object_actions.templates.django_object_actions" = ["*.html"] [tool.semantic_release] version_toml = ["pyproject.toml:project.version"] version_variables = ["django_object_actions/__init__.py:__version__"] build_command = "pip install -e '.[build]' && python -m build" [tool.coverage.run] source = ["django_object_actions"] omit = ["*/tests/*"] branch = true [tool.coverage.report] exclude_lines = [ "pragma: no cover", "__repr__", "__unicode__", "raise NotImplementedError", ] [tool.ruff] [tool.ruff.lint] extend-select = [ "A", # flake8-builtins "B", # flake8-bugbear "G", # flake8-logging-format "I", # isort "N", # pep8-naming "RET", # flakes8-return "RUF", # Ruff-specific rules "UP", # pyupgrade ] ignore = ["RUF012"]