django-class-based-admin-actions 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_class_based_admin_actions-0.1.0/.github/workflows/publish.yml +31 -0
- django_class_based_admin_actions-0.1.0/.gitignore +7 -0
- django_class_based_admin_actions-0.1.0/LICENSE +21 -0
- django_class_based_admin_actions-0.1.0/PKG-INFO +99 -0
- django_class_based_admin_actions-0.1.0/README.md +73 -0
- django_class_based_admin_actions-0.1.0/pyproject.toml +43 -0
- django_class_based_admin_actions-0.1.0/src/class_based_admin_actions/__init__.py +17 -0
- django_class_based_admin_actions-0.1.0/src/class_based_admin_actions/admin.py +16 -0
- django_class_based_admin_actions-0.1.0/src/class_based_admin_actions/base.py +163 -0
- django_class_based_admin_actions-0.1.0/src/class_based_admin_actions/generic.py +137 -0
- django_class_based_admin_actions-0.1.0/src/class_based_admin_actions/mixins.py +200 -0
- django_class_based_admin_actions-0.1.0/src/class_based_admin_actions/templates/class_based_admin_actions/confirmation.html +30 -0
- django_class_based_admin_actions-0.1.0/tests/__init__.py +0 -0
- django_class_based_admin_actions-0.1.0/tests/conftest.py +25 -0
- django_class_based_admin_actions-0.1.0/tests/settings.py +30 -0
- django_class_based_admin_actions-0.1.0/tests/test_actions.py +182 -0
- django_class_based_admin_actions-0.1.0/tests/testapp/__init__.py +0 -0
- django_class_based_admin_actions-0.1.0/tests/testapp/admin.py +77 -0
- django_class_based_admin_actions-0.1.0/tests/testapp/models.py +10 -0
- django_class_based_admin_actions-0.1.0/tests/urls.py +4 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v5
|
|
14
|
+
- run: uv build
|
|
15
|
+
- uses: actions/upload-artifact@v4
|
|
16
|
+
with:
|
|
17
|
+
name: dist
|
|
18
|
+
path: dist/
|
|
19
|
+
|
|
20
|
+
publish:
|
|
21
|
+
needs: build
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
environment: pypi
|
|
24
|
+
permissions:
|
|
25
|
+
id-token: write
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/download-artifact@v4
|
|
28
|
+
with:
|
|
29
|
+
name: dist
|
|
30
|
+
path: dist/
|
|
31
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alejandro Rebecchi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-class-based-admin-actions
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Class-based Django admin actions: compose reusable actions from mixins, like class-based views.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cheverebe/class-based-admin-actions
|
|
6
|
+
Project-URL: Issues, https://github.com/cheverebe/class-based-admin-actions/issues
|
|
7
|
+
Author: Alejandro Rebecchi
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: actions,admin,class-based,django
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 4.2
|
|
13
|
+
Classifier: Framework :: Django :: 5.0
|
|
14
|
+
Classifier: Framework :: Django :: 5.1
|
|
15
|
+
Classifier: Framework :: Django :: 5.2
|
|
16
|
+
Classifier: Framework :: Django :: 6.0
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: django>=4.2
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# django-class-based-admin-actions
|
|
28
|
+
|
|
29
|
+
Django admin actions written as classes and composed from mixins, the way class-based views are.
|
|
30
|
+
`AdminAction.as_action()` is to admin actions what `View.as_view()` is to views.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
INSTALLED_APPS = [..., "class_based_admin_actions"] # needed for the confirmation template
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from class_based_admin_actions import (
|
|
38
|
+
ClassActionsMixin, ConfirmationMixin, CsvExportAction, FormUpdateAction, UpdateAction,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
class PublishAction(UpdateAction):
|
|
42
|
+
description = "Publish selected %(verbose_name_plural)s"
|
|
43
|
+
update_fields = {"status": "published"}
|
|
44
|
+
success_message = "Published %(count)d %(name)s."
|
|
45
|
+
allowed_permissions = ("change",)
|
|
46
|
+
|
|
47
|
+
class ArchiveAction(ConfirmationMixin, PublishAction): # reuse + ask first
|
|
48
|
+
update_fields = {"status": "archived"}
|
|
49
|
+
|
|
50
|
+
class SetStatusAction(FormUpdateAction): # ask for values in a form
|
|
51
|
+
form_class = StatusForm
|
|
52
|
+
|
|
53
|
+
@admin.register(Article)
|
|
54
|
+
class ArticleAdmin(ClassActionsMixin, admin.ModelAdmin):
|
|
55
|
+
actions = [PublishAction, ArchiveAction, SetStatusAction,
|
|
56
|
+
CsvExportAction.as_action(fields=["title", "status"])]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Classes can go straight in `actions` with `ClassActionsMixin`; without it use `X.as_action(**overrides)`
|
|
60
|
+
(also fine for `admin.site.add_action`).
|
|
61
|
+
|
|
62
|
+
## Hierarchy
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
AdminAction setup / dispatch / has_permission / handle / run / perform / finish
|
|
66
|
+
├─ BulkAction = QuerysetMixin + SuccessMessageMixin + AtomicMixin + AdminAction
|
|
67
|
+
│ ├─ ObjectAction process_object(obj), skip_errors, log_message
|
|
68
|
+
│ └─ UpdateAction update_fields -> queryset.update()
|
|
69
|
+
│ └─ FormUpdateAction FormMixin + UpdateAction
|
|
70
|
+
└─ CsvExportAction fields, filename, CSV-injection safe
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
| Mixin | Adds |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `QuerysetMixin` | `select_related`, `prefetch_related`, `max_objects` |
|
|
76
|
+
| `SuccessMessageMixin` | `success_message` with `%(count)d`, `%(name)s` |
|
|
77
|
+
| `AtomicMixin` | transaction around the run, `using` |
|
|
78
|
+
| `TemplateResponseMixin` | admin-context intermediate page |
|
|
79
|
+
| `ConfirmationMixin` | confirm page before `perform()` |
|
|
80
|
+
| `FormMixin` | form on the confirm page (`form_class`, `initial`, `form_valid/invalid`) |
|
|
81
|
+
|
|
82
|
+
All hooks call `super()`, so mixins combine in any order; put them before the base class.
|
|
83
|
+
Write your own by overriding a hook (`check_preconditions`, `get_queryset`, `run`, `on_success`, ...).
|
|
84
|
+
Raise `ActionError("msg")` anywhere to abort with an error message (and roll back under `AtomicMixin`).
|
|
85
|
+
|
|
86
|
+
## Notes
|
|
87
|
+
|
|
88
|
+
- Permissions: `allowed_permissions` is Django's mechanism (action hidden unless *any* listed
|
|
89
|
+
`has_<perm>_permission` passes) and is re-checked on dispatch. Override `has_permission()` for more.
|
|
90
|
+
- `description` is `%`-formatted by Django with `verbose_name`/`verbose_name_plural`; escape literal `%` as `%%`.
|
|
91
|
+
- The confirmation page posts back to the changelist; the form must not define fields named
|
|
92
|
+
`action`, `select_across` or `_selected_action`.
|
|
93
|
+
- `UpdateAction` uses `QuerySet.update()` (no `save()`, signals, or `auto_now`); use `ObjectAction` if you need them.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
uv pip install -e . pytest pytest-django && pytest
|
|
99
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# django-class-based-admin-actions
|
|
2
|
+
|
|
3
|
+
Django admin actions written as classes and composed from mixins, the way class-based views are.
|
|
4
|
+
`AdminAction.as_action()` is to admin actions what `View.as_view()` is to views.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
INSTALLED_APPS = [..., "class_based_admin_actions"] # needed for the confirmation template
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from class_based_admin_actions import (
|
|
12
|
+
ClassActionsMixin, ConfirmationMixin, CsvExportAction, FormUpdateAction, UpdateAction,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
class PublishAction(UpdateAction):
|
|
16
|
+
description = "Publish selected %(verbose_name_plural)s"
|
|
17
|
+
update_fields = {"status": "published"}
|
|
18
|
+
success_message = "Published %(count)d %(name)s."
|
|
19
|
+
allowed_permissions = ("change",)
|
|
20
|
+
|
|
21
|
+
class ArchiveAction(ConfirmationMixin, PublishAction): # reuse + ask first
|
|
22
|
+
update_fields = {"status": "archived"}
|
|
23
|
+
|
|
24
|
+
class SetStatusAction(FormUpdateAction): # ask for values in a form
|
|
25
|
+
form_class = StatusForm
|
|
26
|
+
|
|
27
|
+
@admin.register(Article)
|
|
28
|
+
class ArticleAdmin(ClassActionsMixin, admin.ModelAdmin):
|
|
29
|
+
actions = [PublishAction, ArchiveAction, SetStatusAction,
|
|
30
|
+
CsvExportAction.as_action(fields=["title", "status"])]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Classes can go straight in `actions` with `ClassActionsMixin`; without it use `X.as_action(**overrides)`
|
|
34
|
+
(also fine for `admin.site.add_action`).
|
|
35
|
+
|
|
36
|
+
## Hierarchy
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
AdminAction setup / dispatch / has_permission / handle / run / perform / finish
|
|
40
|
+
├─ BulkAction = QuerysetMixin + SuccessMessageMixin + AtomicMixin + AdminAction
|
|
41
|
+
│ ├─ ObjectAction process_object(obj), skip_errors, log_message
|
|
42
|
+
│ └─ UpdateAction update_fields -> queryset.update()
|
|
43
|
+
│ └─ FormUpdateAction FormMixin + UpdateAction
|
|
44
|
+
└─ CsvExportAction fields, filename, CSV-injection safe
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| Mixin | Adds |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `QuerysetMixin` | `select_related`, `prefetch_related`, `max_objects` |
|
|
50
|
+
| `SuccessMessageMixin` | `success_message` with `%(count)d`, `%(name)s` |
|
|
51
|
+
| `AtomicMixin` | transaction around the run, `using` |
|
|
52
|
+
| `TemplateResponseMixin` | admin-context intermediate page |
|
|
53
|
+
| `ConfirmationMixin` | confirm page before `perform()` |
|
|
54
|
+
| `FormMixin` | form on the confirm page (`form_class`, `initial`, `form_valid/invalid`) |
|
|
55
|
+
|
|
56
|
+
All hooks call `super()`, so mixins combine in any order; put them before the base class.
|
|
57
|
+
Write your own by overriding a hook (`check_preconditions`, `get_queryset`, `run`, `on_success`, ...).
|
|
58
|
+
Raise `ActionError("msg")` anywhere to abort with an error message (and roll back under `AtomicMixin`).
|
|
59
|
+
|
|
60
|
+
## Notes
|
|
61
|
+
|
|
62
|
+
- Permissions: `allowed_permissions` is Django's mechanism (action hidden unless *any* listed
|
|
63
|
+
`has_<perm>_permission` passes) and is re-checked on dispatch. Override `has_permission()` for more.
|
|
64
|
+
- `description` is `%`-formatted by Django with `verbose_name`/`verbose_name_plural`; escape literal `%` as `%%`.
|
|
65
|
+
- The confirmation page posts back to the changelist; the form must not define fields named
|
|
66
|
+
`action`, `select_across` or `_selected_action`.
|
|
67
|
+
- `UpdateAction` uses `QuerySet.update()` (no `save()`, signals, or `auto_now`); use `ObjectAction` if you need them.
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
uv pip install -e . pytest pytest-django && pytest
|
|
73
|
+
```
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-class-based-admin-actions"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Class-based Django admin actions: compose reusable actions from mixins, like class-based views."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Alejandro Rebecchi" }]
|
|
14
|
+
keywords = ["django", "admin", "actions", "class-based"]
|
|
15
|
+
dependencies = ["django>=4.2"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Framework :: Django",
|
|
24
|
+
"Framework :: Django :: 4.2",
|
|
25
|
+
"Framework :: Django :: 5.0",
|
|
26
|
+
"Framework :: Django :: 5.1",
|
|
27
|
+
"Framework :: Django :: 5.2",
|
|
28
|
+
"Framework :: Django :: 6.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/cheverebe/class-based-admin-actions"
|
|
33
|
+
Issues = "https://github.com/cheverebe/class-based-admin-actions/issues"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = ["pytest", "pytest-django"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/class_based_admin_actions"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
43
|
+
pythonpath = [".", "src"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .admin import ClassActionsMixin
|
|
2
|
+
from .base import ActionError, AdminAction
|
|
3
|
+
from .generic import BulkAction, CsvExportAction, FormUpdateAction, ObjectAction, UpdateAction
|
|
4
|
+
from .mixins import (
|
|
5
|
+
AtomicMixin,
|
|
6
|
+
ConfirmationMixin,
|
|
7
|
+
FormMixin,
|
|
8
|
+
QuerysetMixin,
|
|
9
|
+
SuccessMessageMixin,
|
|
10
|
+
TemplateResponseMixin,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ActionError", "AdminAction", "AtomicMixin", "BulkAction", "ClassActionsMixin",
|
|
15
|
+
"ConfirmationMixin", "CsvExportAction", "FormMixin", "FormUpdateAction", "ObjectAction",
|
|
16
|
+
"QuerysetMixin", "SuccessMessageMixin", "TemplateResponseMixin", "UpdateAction",
|
|
17
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .base import AdminAction
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ClassActionsMixin:
|
|
5
|
+
"""``ModelAdmin`` mixin allowing action *classes* directly in ``actions``::
|
|
6
|
+
|
|
7
|
+
class ArticleAdmin(ClassActionsMixin, admin.ModelAdmin):
|
|
8
|
+
actions = [PublishAction, ArchiveAction.as_action(name="archive_now")]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, *args, **kwargs):
|
|
12
|
+
super().__init__(*args, **kwargs)
|
|
13
|
+
self.actions = [
|
|
14
|
+
a.as_action() if isinstance(a, type) and issubclass(a, AdminAction) else a
|
|
15
|
+
for a in self.actions or ()
|
|
16
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The core of the plugin: ``AdminAction``, the analogue of ``django.views.generic.View``.
|
|
2
|
+
|
|
3
|
+
A Django admin action is a plain callable ``(modeladmin, request, queryset)`` with a few
|
|
4
|
+
attributes (``__name__``, ``short_description``, ``allowed_permissions``).
|
|
5
|
+
``AdminAction.as_action()`` builds such a callable from a class, exactly like
|
|
6
|
+
``View.as_view()`` builds a view function. A new instance is created per call, so
|
|
7
|
+
instance state is never shared between requests.
|
|
8
|
+
|
|
9
|
+
Behaviour is layered with cooperative mixins: every hook calls ``super()``.
|
|
10
|
+
"""
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from django.contrib import messages
|
|
14
|
+
from django.core.exceptions import PermissionDenied
|
|
15
|
+
from django.http import HttpResponseRedirect
|
|
16
|
+
from django.http.response import HttpResponseBase
|
|
17
|
+
from django.utils.text import capfirst
|
|
18
|
+
|
|
19
|
+
_SNAKE_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def camel_to_snake(name):
|
|
23
|
+
return _SNAKE_RE.sub("_", name).lower()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ActionError(Exception):
|
|
27
|
+
"""Raise anywhere inside an action to abort it.
|
|
28
|
+
|
|
29
|
+
The message is shown to the user as an error and the changelist is re-displayed.
|
|
30
|
+
Inside ``AtomicMixin`` the transaction is rolled back.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AdminAction:
|
|
35
|
+
"""Minimal action. Subclasses implement ``perform()``.
|
|
36
|
+
|
|
37
|
+
Request lifecycle::
|
|
38
|
+
|
|
39
|
+
as_action()() -> setup() -> dispatch()
|
|
40
|
+
dispatch: has_permission() -> check_preconditions() -> handle()
|
|
41
|
+
handle: run() -> perform() -> finish() -> on_success() -> get_success_response()
|
|
42
|
+
|
|
43
|
+
``ActionError`` raised in any of these is reported through ``handle_error()``.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
name = None
|
|
47
|
+
description = None
|
|
48
|
+
#: Passed to Django, which hides the action unless ``modeladmin.has_<perm>_permission``
|
|
49
|
+
#: passes for at least one entry (Django's rule). Enforced again in ``has_permission()``.
|
|
50
|
+
allowed_permissions = ()
|
|
51
|
+
|
|
52
|
+
def __init__(self, **kwargs):
|
|
53
|
+
for key, value in kwargs.items():
|
|
54
|
+
setattr(self, key, value)
|
|
55
|
+
|
|
56
|
+
# -- construction ---------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def as_action(cls, **initkwargs):
|
|
60
|
+
"""Return a callable usable in ``ModelAdmin.actions``.
|
|
61
|
+
|
|
62
|
+
``initkwargs`` override class attributes, e.g.
|
|
63
|
+
``PublishAction.as_action(update_fields={"status": "live"}, name="go_live")``.
|
|
64
|
+
"""
|
|
65
|
+
for key in initkwargs:
|
|
66
|
+
if key in ("modeladmin", "request", "queryset"):
|
|
67
|
+
raise TypeError(f"{cls.__name__}.as_action() received reserved argument '{key}'.")
|
|
68
|
+
if not hasattr(cls, key):
|
|
69
|
+
raise TypeError(
|
|
70
|
+
f"{cls.__name__}.as_action() received an invalid keyword '{key}': "
|
|
71
|
+
"it must be an attribute of the class."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def action(modeladmin, request, queryset):
|
|
75
|
+
self = cls(**initkwargs)
|
|
76
|
+
self.setup(modeladmin, request, queryset)
|
|
77
|
+
return self.dispatch()
|
|
78
|
+
|
|
79
|
+
meta = cls(**initkwargs)
|
|
80
|
+
action.__name__ = meta.get_name()
|
|
81
|
+
action.__qualname__ = f"{cls.__qualname__}.as_action.<locals>.action"
|
|
82
|
+
action.__doc__ = cls.__doc__
|
|
83
|
+
action.__module__ = cls.__module__
|
|
84
|
+
action.short_description = meta.get_description()
|
|
85
|
+
if meta.allowed_permissions: # Django hides actions whose (even empty) tuple has no match
|
|
86
|
+
action.allowed_permissions = tuple(meta.allowed_permissions)
|
|
87
|
+
action.action_class = cls
|
|
88
|
+
action.initkwargs = initkwargs
|
|
89
|
+
return action
|
|
90
|
+
|
|
91
|
+
def get_name(self):
|
|
92
|
+
return self.name or camel_to_snake(type(self).__name__)
|
|
93
|
+
|
|
94
|
+
def get_description(self):
|
|
95
|
+
if self.description is not None:
|
|
96
|
+
return self.description
|
|
97
|
+
return capfirst(self.get_name().replace("_", " "))
|
|
98
|
+
|
|
99
|
+
def setup(self, modeladmin, request, queryset):
|
|
100
|
+
self.modeladmin = modeladmin
|
|
101
|
+
self.request = request
|
|
102
|
+
self.queryset = queryset
|
|
103
|
+
self.model = modeladmin.model
|
|
104
|
+
self.opts = modeladmin.opts
|
|
105
|
+
|
|
106
|
+
# -- request lifecycle ----------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def dispatch(self):
|
|
109
|
+
try:
|
|
110
|
+
if not self.has_permission():
|
|
111
|
+
return self.handle_no_permission()
|
|
112
|
+
self.check_preconditions()
|
|
113
|
+
return self.handle()
|
|
114
|
+
except ActionError as exc:
|
|
115
|
+
return self.handle_error(exc)
|
|
116
|
+
|
|
117
|
+
def has_permission(self):
|
|
118
|
+
return not self.allowed_permissions or any(
|
|
119
|
+
getattr(self.modeladmin, f"has_{perm}_permission")(self.request)
|
|
120
|
+
for perm in self.allowed_permissions
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def handle_no_permission(self):
|
|
124
|
+
raise PermissionDenied
|
|
125
|
+
|
|
126
|
+
def check_preconditions(self):
|
|
127
|
+
"""Hook for validations that must run before anything else (raise ``ActionError``)."""
|
|
128
|
+
|
|
129
|
+
def get_queryset(self):
|
|
130
|
+
return self.queryset
|
|
131
|
+
|
|
132
|
+
def handle(self):
|
|
133
|
+
return self.finish(self.run())
|
|
134
|
+
|
|
135
|
+
def run(self):
|
|
136
|
+
"""Executes ``perform()``; wrappers such as ``AtomicMixin`` hook here."""
|
|
137
|
+
return self.perform()
|
|
138
|
+
|
|
139
|
+
def perform(self):
|
|
140
|
+
raise NotImplementedError(f"{type(self).__name__} must implement perform().")
|
|
141
|
+
|
|
142
|
+
def finish(self, result):
|
|
143
|
+
if isinstance(result, HttpResponseBase):
|
|
144
|
+
return result
|
|
145
|
+
self.on_success(result)
|
|
146
|
+
return self.get_success_response()
|
|
147
|
+
|
|
148
|
+
def on_success(self, result):
|
|
149
|
+
"""Hook run after ``perform()`` returned a non-response result."""
|
|
150
|
+
|
|
151
|
+
def get_success_url(self):
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def get_success_response(self):
|
|
155
|
+
url = self.get_success_url()
|
|
156
|
+
return HttpResponseRedirect(url) if url else None # None: stay on the changelist
|
|
157
|
+
|
|
158
|
+
def handle_error(self, exc):
|
|
159
|
+
self.message_user(str(exc) or "The action could not be completed.", messages.ERROR)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def message_user(self, message, level=messages.SUCCESS):
|
|
163
|
+
self.modeladmin.message_user(self.request, message, level)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Ready-to-subclass actions, the analogue of ``django.views.generic``.
|
|
2
|
+
|
|
3
|
+
AdminAction
|
|
4
|
+
└─ BulkAction (QuerysetMixin, SuccessMessageMixin, AtomicMixin)
|
|
5
|
+
├─ ObjectAction process_object(obj) for each selected object
|
|
6
|
+
├─ UpdateAction queryset.update(**update_fields)
|
|
7
|
+
│ └─ FormUpdateAction (+ FormMixin) update with values from a form
|
|
8
|
+
└─ ...
|
|
9
|
+
└─ CsvExportAction (QuerysetMixin)
|
|
10
|
+
|
|
11
|
+
Add ``ConfirmationMixin`` / ``FormMixin`` in front of any of them to ask the user first.
|
|
12
|
+
"""
|
|
13
|
+
import csv
|
|
14
|
+
|
|
15
|
+
from django.contrib import messages
|
|
16
|
+
from django.db import transaction
|
|
17
|
+
|
|
18
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
19
|
+
from django.http import HttpResponse
|
|
20
|
+
from django.utils.http import content_disposition_header
|
|
21
|
+
|
|
22
|
+
from .base import ActionError, AdminAction
|
|
23
|
+
from .mixins import AtomicMixin, FormMixin, QuerysetMixin, SuccessMessageMixin
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BulkAction(QuerysetMixin, SuccessMessageMixin, AtomicMixin, AdminAction):
|
|
27
|
+
"""Batteries-included base for actions that modify the selected rows."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ObjectAction(BulkAction):
|
|
31
|
+
"""Calls ``process_object(obj)`` for every selected object.
|
|
32
|
+
|
|
33
|
+
With ``skip_errors`` an ``ActionError`` only skips that object (its own writes are rolled
|
|
34
|
+
back to a savepoint) and is reported as a warning; otherwise the whole run is aborted.
|
|
35
|
+
``log_message`` adds a ``LogEntry`` (admin history) per processed object.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
skip_errors = False
|
|
39
|
+
log_message = None
|
|
40
|
+
|
|
41
|
+
def process_object(self, obj):
|
|
42
|
+
raise NotImplementedError(f"{type(self).__name__} must implement process_object().")
|
|
43
|
+
|
|
44
|
+
def perform(self):
|
|
45
|
+
count, self.failures = 0, []
|
|
46
|
+
for obj in self.get_queryset().iterator(chunk_size=self.iterator_chunk_size):
|
|
47
|
+
label = str(obj) # before process_object() mutates it
|
|
48
|
+
try:
|
|
49
|
+
if self.skip_errors:
|
|
50
|
+
with transaction.atomic(using=self.get_using()):
|
|
51
|
+
self.process_object(obj)
|
|
52
|
+
else:
|
|
53
|
+
self.process_object(obj)
|
|
54
|
+
except ActionError as exc:
|
|
55
|
+
if not self.skip_errors:
|
|
56
|
+
raise
|
|
57
|
+
self.failures.append((label, exc))
|
|
58
|
+
continue
|
|
59
|
+
if self.log_message:
|
|
60
|
+
self.modeladmin.log_change(self.request, obj, self.log_message)
|
|
61
|
+
count += 1
|
|
62
|
+
return count
|
|
63
|
+
|
|
64
|
+
def on_success(self, result):
|
|
65
|
+
super().on_success(result)
|
|
66
|
+
for label, exc in self.failures:
|
|
67
|
+
self.message_user(f"{label}: {exc}", level=messages.WARNING)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class UpdateAction(BulkAction):
|
|
71
|
+
"""Sets ``update_fields`` on the selected rows with a single ``UPDATE`` query.
|
|
72
|
+
|
|
73
|
+
Note ``QuerySet.update()`` skips ``save()``, signals and ``auto_now`` fields. Use
|
|
74
|
+
``ObjectAction`` when those matter.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
update_fields = None
|
|
78
|
+
|
|
79
|
+
def get_update_fields(self):
|
|
80
|
+
if not self.update_fields:
|
|
81
|
+
raise ImproperlyConfigured(f"{type(self).__name__} requires update_fields.")
|
|
82
|
+
return dict(self.update_fields)
|
|
83
|
+
|
|
84
|
+
def perform(self):
|
|
85
|
+
return self.get_queryset().update(**self.get_update_fields())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FormUpdateAction(FormMixin, UpdateAction):
|
|
89
|
+
"""Asks for values in a form, then updates the selection with them.
|
|
90
|
+
|
|
91
|
+
By default every cleaned value is written; narrow it with ``update_fields`` (a list of
|
|
92
|
+
form field names) or override ``get_update_fields()``.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
update_fields = None
|
|
96
|
+
|
|
97
|
+
def get_update_fields(self):
|
|
98
|
+
data = self.form.cleaned_data
|
|
99
|
+
return {k: data[k] for k in (self.update_fields or data)}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CsvExportAction(QuerysetMixin, AdminAction):
|
|
103
|
+
"""Downloads the selection as CSV. ``fields`` may name model fields, attributes or
|
|
104
|
+
methods (callables are called). String cells that start with a formula character are
|
|
105
|
+
prefixed with ``'`` (``escape_formulas``) to prevent CSV injection in spreadsheets."""
|
|
106
|
+
|
|
107
|
+
allowed_permissions = ("view",)
|
|
108
|
+
fields = None
|
|
109
|
+
filename = None
|
|
110
|
+
header = True
|
|
111
|
+
escape_formulas = True
|
|
112
|
+
|
|
113
|
+
def get_fields(self):
|
|
114
|
+
return self.fields or [f.name for f in self.opts.concrete_fields]
|
|
115
|
+
|
|
116
|
+
def get_filename(self):
|
|
117
|
+
return self.filename or f"{self.opts.model_name}.csv"
|
|
118
|
+
|
|
119
|
+
def get_value(self, obj, field):
|
|
120
|
+
value = getattr(obj, field)
|
|
121
|
+
if callable(value):
|
|
122
|
+
value = value()
|
|
123
|
+
value = "" if value is None else value
|
|
124
|
+
if self.escape_formulas and isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r"):
|
|
125
|
+
value = "'" + value
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
def perform(self):
|
|
129
|
+
response = HttpResponse(content_type="text/csv; charset=utf-8")
|
|
130
|
+
response.headers["Content-Disposition"] = content_disposition_header(True, self.get_filename())
|
|
131
|
+
fields = self.get_fields()
|
|
132
|
+
writer = csv.writer(response)
|
|
133
|
+
if self.header:
|
|
134
|
+
writer.writerow(fields)
|
|
135
|
+
for obj in self.get_queryset().iterator(chunk_size=self.iterator_chunk_size):
|
|
136
|
+
writer.writerow([self.get_value(obj, f) for f in fields])
|
|
137
|
+
return response
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Reusable behaviours. Combine them with ``AdminAction`` (mixins first), like view mixins."""
|
|
2
|
+
from django.contrib import messages
|
|
3
|
+
from django.contrib.admin import helpers
|
|
4
|
+
from django.contrib.admin.utils import model_format_dict, model_ngettext
|
|
5
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
6
|
+
from django.db import router, transaction
|
|
7
|
+
from django.template.response import TemplateResponse
|
|
8
|
+
from django.utils.translation import gettext_lazy as _
|
|
9
|
+
|
|
10
|
+
from .base import ActionError
|
|
11
|
+
|
|
12
|
+
CONFIRM_FIELD = "_cba_confirm"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class QuerysetMixin:
|
|
16
|
+
"""Refines the selected queryset and guards against huge selections."""
|
|
17
|
+
|
|
18
|
+
select_related = None # True, or a list of field names
|
|
19
|
+
prefetch_related = ()
|
|
20
|
+
max_objects = None
|
|
21
|
+
iterator_chunk_size = 2000
|
|
22
|
+
|
|
23
|
+
def get_queryset(self):
|
|
24
|
+
queryset = super().get_queryset()
|
|
25
|
+
if self.select_related is True:
|
|
26
|
+
queryset = queryset.select_related()
|
|
27
|
+
elif self.select_related:
|
|
28
|
+
queryset = queryset.select_related(*self.select_related)
|
|
29
|
+
if self.prefetch_related:
|
|
30
|
+
queryset = queryset.prefetch_related(*self.prefetch_related)
|
|
31
|
+
return queryset
|
|
32
|
+
|
|
33
|
+
def check_preconditions(self):
|
|
34
|
+
super().check_preconditions()
|
|
35
|
+
if self.max_objects is not None:
|
|
36
|
+
count = self.queryset.count()
|
|
37
|
+
if count > self.max_objects:
|
|
38
|
+
raise ActionError(
|
|
39
|
+
_("This action can process at most %(max)d items at once; %(count)d were selected.")
|
|
40
|
+
% {"max": self.max_objects, "count": count}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SuccessMessageMixin:
|
|
45
|
+
"""Flashes ``success_message`` after a successful run.
|
|
46
|
+
|
|
47
|
+
The message may use ``%(count)d`` and ``%(name)s`` (the singular or plural verbose name
|
|
48
|
+
matching the count). ``count`` is the integer returned by ``perform()``, if any.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
success_message = None
|
|
52
|
+
|
|
53
|
+
def on_success(self, result):
|
|
54
|
+
super().on_success(result)
|
|
55
|
+
message = self.get_success_message(result)
|
|
56
|
+
if message:
|
|
57
|
+
self.message_user(message, messages.SUCCESS)
|
|
58
|
+
|
|
59
|
+
def get_success_message(self, result):
|
|
60
|
+
if not self.success_message:
|
|
61
|
+
return None
|
|
62
|
+
count = result if isinstance(result, int) and not isinstance(result, bool) else None
|
|
63
|
+
if count is None:
|
|
64
|
+
count = self.get_queryset().count()
|
|
65
|
+
return self.success_message % {"count": count, "name": model_ngettext(self.opts, count)}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AtomicMixin:
|
|
69
|
+
"""Runs the action in a transaction; any exception rolls everything back."""
|
|
70
|
+
|
|
71
|
+
using = None
|
|
72
|
+
|
|
73
|
+
def get_using(self):
|
|
74
|
+
return self.using or router.db_for_write(self.model)
|
|
75
|
+
|
|
76
|
+
def run(self):
|
|
77
|
+
with transaction.atomic(using=self.get_using()):
|
|
78
|
+
return super().run()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TemplateResponseMixin:
|
|
82
|
+
"""Renders an intermediate admin page with the usual admin context."""
|
|
83
|
+
|
|
84
|
+
template_name = None
|
|
85
|
+
title = None
|
|
86
|
+
extra_context = None
|
|
87
|
+
preview_limit = 50
|
|
88
|
+
|
|
89
|
+
def get_template_names(self):
|
|
90
|
+
if self.template_name is None:
|
|
91
|
+
raise ImproperlyConfigured(f"{type(self).__name__} requires template_name.")
|
|
92
|
+
return [self.template_name]
|
|
93
|
+
|
|
94
|
+
def get_title(self):
|
|
95
|
+
return self.title or self.get_description() % model_format_dict(self.opts)
|
|
96
|
+
|
|
97
|
+
def get_context_data(self, **kwargs):
|
|
98
|
+
queryset = self.get_queryset()
|
|
99
|
+
count = queryset.count()
|
|
100
|
+
shown = list(queryset[: self.preview_limit])
|
|
101
|
+
post = self.request.POST
|
|
102
|
+
context = {
|
|
103
|
+
**self.modeladmin.admin_site.each_context(self.request),
|
|
104
|
+
"title": self.get_title(),
|
|
105
|
+
"subtitle": None,
|
|
106
|
+
"opts": self.opts,
|
|
107
|
+
"media": self.modeladmin.media,
|
|
108
|
+
"action_name": self.get_name(),
|
|
109
|
+
"objects": shown,
|
|
110
|
+
"object_count": count,
|
|
111
|
+
"hidden_count": max(count - len(shown), 0),
|
|
112
|
+
"selected_values": post.getlist(helpers.ACTION_CHECKBOX_NAME),
|
|
113
|
+
"select_across": post.get("select_across", "0"),
|
|
114
|
+
"action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
|
|
115
|
+
"confirm_field": CONFIRM_FIELD,
|
|
116
|
+
**(self.extra_context or {}),
|
|
117
|
+
**kwargs,
|
|
118
|
+
}
|
|
119
|
+
return context
|
|
120
|
+
|
|
121
|
+
def render_to_response(self, context):
|
|
122
|
+
self.request.current_app = self.modeladmin.admin_site.name
|
|
123
|
+
return TemplateResponse(self.request, self.get_template_names(), context)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ConfirmationMixin(TemplateResponseMixin):
|
|
127
|
+
"""Asks for confirmation on an intermediate page before running the action.
|
|
128
|
+
|
|
129
|
+
The page re-submits the same selection to the changelist with ``CONFIRM_FIELD`` set; the
|
|
130
|
+
second request is the one that reaches ``perform()``. Subclasses hook the two phases
|
|
131
|
+
through ``unconfirmed()`` and ``confirmed()``.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
template_name = "class_based_admin_actions/confirmation.html"
|
|
135
|
+
confirm_message = _("Are you sure you want to apply this action to the selected items?")
|
|
136
|
+
confirm_button = _("Confirm")
|
|
137
|
+
|
|
138
|
+
def is_confirmed(self):
|
|
139
|
+
return CONFIRM_FIELD in self.request.POST
|
|
140
|
+
|
|
141
|
+
def handle(self):
|
|
142
|
+
if self.is_confirmed():
|
|
143
|
+
return self.confirmed()
|
|
144
|
+
return self.unconfirmed()
|
|
145
|
+
|
|
146
|
+
def unconfirmed(self):
|
|
147
|
+
return self.render_to_response(self.get_context_data())
|
|
148
|
+
|
|
149
|
+
def confirmed(self):
|
|
150
|
+
return super().handle()
|
|
151
|
+
|
|
152
|
+
def get_context_data(self, **kwargs):
|
|
153
|
+
return super().get_context_data(
|
|
154
|
+
confirm_message=self.confirm_message, confirm_button=self.confirm_button, **kwargs
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class FormMixin(ConfirmationMixin):
|
|
159
|
+
"""Like ``django.views.generic.edit.FormMixin``: collects input on the confirmation page.
|
|
160
|
+
|
|
161
|
+
The validated form is available as ``self.form`` once ``perform()`` runs.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
form_class = None
|
|
165
|
+
initial = None
|
|
166
|
+
prefix = None
|
|
167
|
+
|
|
168
|
+
def get_form_class(self):
|
|
169
|
+
if self.form_class is None:
|
|
170
|
+
raise ImproperlyConfigured(f"{type(self).__name__} requires form_class.")
|
|
171
|
+
return self.form_class
|
|
172
|
+
|
|
173
|
+
def get_form_kwargs(self):
|
|
174
|
+
kwargs = {"initial": dict(self.initial or {}), "prefix": self.prefix}
|
|
175
|
+
if self.is_confirmed():
|
|
176
|
+
kwargs.update(data=self.request.POST, files=self.request.FILES)
|
|
177
|
+
return kwargs
|
|
178
|
+
|
|
179
|
+
def get_form(self):
|
|
180
|
+
return self.get_form_class()(**self.get_form_kwargs())
|
|
181
|
+
|
|
182
|
+
def unconfirmed(self):
|
|
183
|
+
self.form = self.get_form()
|
|
184
|
+
return self.render_to_response(self.get_context_data())
|
|
185
|
+
|
|
186
|
+
def confirmed(self):
|
|
187
|
+
self.form = self.get_form()
|
|
188
|
+
if self.form.is_valid():
|
|
189
|
+
return self.form_valid(self.form)
|
|
190
|
+
return self.form_invalid(self.form)
|
|
191
|
+
|
|
192
|
+
def form_valid(self, form):
|
|
193
|
+
return super().confirmed()
|
|
194
|
+
|
|
195
|
+
def form_invalid(self, form):
|
|
196
|
+
return self.render_to_response(self.get_context_data())
|
|
197
|
+
|
|
198
|
+
def get_context_data(self, **kwargs):
|
|
199
|
+
kwargs.setdefault("media", self.modeladmin.media + self.form.media)
|
|
200
|
+
return super().get_context_data(form=self.form, **kwargs)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{% extends "admin/base_site.html" %}
|
|
2
|
+
{% load i18n admin_urls %}
|
|
3
|
+
|
|
4
|
+
{% block extrahead %}{{ block.super }}{{ media }}{% endblock %}
|
|
5
|
+
|
|
6
|
+
{% block breadcrumbs %}
|
|
7
|
+
<div class="breadcrumbs">
|
|
8
|
+
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
|
9
|
+
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
|
10
|
+
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
|
11
|
+
› {{ title }}
|
|
12
|
+
</div>
|
|
13
|
+
{% endblock %}
|
|
14
|
+
|
|
15
|
+
{% block content %}
|
|
16
|
+
<form method="post">{% csrf_token %}
|
|
17
|
+
<p>{{ confirm_message }}</p>
|
|
18
|
+
<ul>
|
|
19
|
+
{% for obj in objects %}<li>{{ obj }}</li>{% endfor %}
|
|
20
|
+
</ul>
|
|
21
|
+
{% if hidden_count %}<p>{% blocktranslate with n=hidden_count %}…and {{ n }} more.{% endblocktranslate %}</p>{% endif %}
|
|
22
|
+
{% if form %}{{ form.as_p }}{% endif %}
|
|
23
|
+
{% for value in selected_values %}<input type="hidden" name="{{ action_checkbox_name }}" value="{{ value }}">{% endfor %}
|
|
24
|
+
<input type="hidden" name="action" value="{{ action_name }}">
|
|
25
|
+
<input type="hidden" name="select_across" value="{{ select_across }}">
|
|
26
|
+
<input type="hidden" name="{{ confirm_field }}" value="1">
|
|
27
|
+
<input type="submit" value="{{ confirm_button }}">
|
|
28
|
+
<a href="{% url opts|admin_urlname:'changelist' %}" class="button cancel-link">{% translate "No, take me back" %}</a>
|
|
29
|
+
</form>
|
|
30
|
+
{% endblock %}
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.contrib.auth import get_user_model
|
|
3
|
+
from django.urls import reverse
|
|
4
|
+
|
|
5
|
+
from tests.testapp.models import Article
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def articles(db):
|
|
10
|
+
return [Article.objects.create(title=t, views=v) for t, v in [("a", 1), ("b", 500), ("=cmd", 3)]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def admin_client(client, db):
|
|
15
|
+
client.force_login(get_user_model().objects.create_superuser("root", "r@x.io", "pw"))
|
|
16
|
+
return client
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def run(admin_client):
|
|
21
|
+
"""POST an action to the changelist like the admin dropdown does."""
|
|
22
|
+
def _run(action, objs, **extra):
|
|
23
|
+
data = {"action": action, "_selected_action": [o.pk for o in objs], **extra}
|
|
24
|
+
return admin_client.post(reverse("admin:testapp_article_changelist"), data, follow=False)
|
|
25
|
+
return _run
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
SECRET_KEY = "test"
|
|
2
|
+
DEBUG = True
|
|
3
|
+
USE_TZ = True
|
|
4
|
+
ROOT_URLCONF = "tests.urls"
|
|
5
|
+
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
6
|
+
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
|
|
7
|
+
INSTALLED_APPS = [
|
|
8
|
+
"django.contrib.admin",
|
|
9
|
+
"django.contrib.auth",
|
|
10
|
+
"django.contrib.contenttypes",
|
|
11
|
+
"django.contrib.sessions",
|
|
12
|
+
"django.contrib.messages",
|
|
13
|
+
"class_based_admin_actions",
|
|
14
|
+
"tests.testapp",
|
|
15
|
+
]
|
|
16
|
+
MIDDLEWARE = [
|
|
17
|
+
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
18
|
+
"django.middleware.csrf.CsrfViewMiddleware",
|
|
19
|
+
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
20
|
+
"django.contrib.messages.middleware.MessageMiddleware",
|
|
21
|
+
]
|
|
22
|
+
TEMPLATES = [{
|
|
23
|
+
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
24
|
+
"APP_DIRS": True,
|
|
25
|
+
"OPTIONS": {"context_processors": [
|
|
26
|
+
"django.template.context_processors.request",
|
|
27
|
+
"django.contrib.auth.context_processors.auth",
|
|
28
|
+
"django.contrib.messages.context_processors.messages",
|
|
29
|
+
]},
|
|
30
|
+
}]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import io
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from django.contrib.admin.models import LogEntry
|
|
6
|
+
from django.contrib.auth import get_user_model
|
|
7
|
+
from django.contrib.messages import get_messages
|
|
8
|
+
from django.urls import reverse
|
|
9
|
+
|
|
10
|
+
from class_based_admin_actions import AdminAction, UpdateAction
|
|
11
|
+
from class_based_admin_actions.mixins import CONFIRM_FIELD
|
|
12
|
+
from tests.testapp.admin import ArticleAdmin, PublishAction
|
|
13
|
+
from tests.testapp.models import Article
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def messages_of(response):
|
|
17
|
+
return [str(m) for m in get_messages(response.wsgi_request)]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def statuses():
|
|
21
|
+
return {a.title: a.status for a in Article.objects.all()}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# -- construction -------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_as_action_metadata():
|
|
28
|
+
action = PublishAction.as_action(name="go_live")
|
|
29
|
+
assert action.__name__ == "go_live"
|
|
30
|
+
assert action.short_description == "Publish selected %(verbose_name_plural)s"
|
|
31
|
+
assert action.allowed_permissions == ("change",)
|
|
32
|
+
assert not hasattr(AdminAction.as_action(), "allowed_permissions")
|
|
33
|
+
assert action.action_class is PublishAction
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_default_name_and_description():
|
|
37
|
+
class CSVExportAllAction(AdminAction):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
action = CSVExportAllAction.as_action()
|
|
41
|
+
assert action.__name__ == "csv_export_all_action"
|
|
42
|
+
assert action.short_description == "Csv export all action"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_as_action_rejects_unknown_and_reserved_kwargs():
|
|
46
|
+
with pytest.raises(TypeError, match="invalid keyword"):
|
|
47
|
+
PublishAction.as_action(nope=1)
|
|
48
|
+
with pytest.raises(TypeError, match="reserved"):
|
|
49
|
+
PublishAction.as_action(request=1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_initkwargs_override_class_attributes(articles, run):
|
|
53
|
+
class Custom(PublishAction):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
assert Custom.as_action(update_fields={"status": "x"}).initkwargs == {"update_fields": {"status": "x"}}
|
|
57
|
+
assert Custom.update_fields == {"status": "published"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_class_in_actions_is_converted():
|
|
61
|
+
from django.contrib import admin
|
|
62
|
+
names = [getattr(a, "__name__", a) for a in ArticleAdmin(Article, admin.site).actions]
|
|
63
|
+
assert "publish_action" in names and "archive_action" in names
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_lazy_description_formatted_by_django(admin_client, articles):
|
|
67
|
+
html = admin_client.get(reverse("admin:testapp_article_changelist")).content.decode()
|
|
68
|
+
assert "Publish selected articles" in html
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# -- update / messages --------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_update_action_updates_only_selection_and_flashes(articles, run):
|
|
75
|
+
response = run("publish_action", articles[:2])
|
|
76
|
+
assert response.status_code == 302
|
|
77
|
+
assert statuses() == {"a": "published", "b": "published", "=cmd": "draft"}
|
|
78
|
+
assert messages_of(response) == ["Published 2 articles."]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_singular_name(articles, run):
|
|
82
|
+
assert messages_of(run("publish_action", articles[:1])) == ["Published 1 article."]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# -- object action ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_object_action_skip_errors_rolls_back_failed_object_only(articles, run):
|
|
89
|
+
response = run("reset_views_action", articles)
|
|
90
|
+
titles = {a.title: a.views for a in Article.objects.all()}
|
|
91
|
+
assert titles == {"a (reset)": 0, "b": 500, "=cmd (reset)": 0} # 'b' fully rolled back
|
|
92
|
+
msgs = messages_of(response)
|
|
93
|
+
assert "Reset 2 articles." in msgs and "b: too popular" in msgs
|
|
94
|
+
assert LogEntry.objects.count() == 2
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_object_action_without_skip_errors_aborts_everything(articles, run):
|
|
98
|
+
response = run("strict_reset_action", articles)
|
|
99
|
+
assert all(a.title != "touched" for a in Article.objects.all()) # whole run rolled back
|
|
100
|
+
assert messages_of(response) == ["too popular"]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# -- confirmation & forms -----------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_confirmation_page_then_perform(articles, run):
|
|
107
|
+
response = run("archive_action", articles[:1], select_across="0")
|
|
108
|
+
assert response.status_code == 200
|
|
109
|
+
body = response.content.decode()
|
|
110
|
+
assert CONFIRM_FIELD in body and 'name="action" value="archive_action"' in body
|
|
111
|
+
assert statuses()["a"] == "draft" # nothing happened yet
|
|
112
|
+
|
|
113
|
+
response = run("archive_action", articles[:1], **{CONFIRM_FIELD: "1"})
|
|
114
|
+
assert response.status_code == 302
|
|
115
|
+
assert statuses()["a"] == "archived"
|
|
116
|
+
assert messages_of(response) == ["Archived 1 article."]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_confirmation_preserves_select_across(articles, run):
|
|
120
|
+
body = run("archive_action", articles[:1], select_across="1").content.decode()
|
|
121
|
+
assert 'name="select_across" value="1"' in body
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_form_flow_invalid_then_valid(articles, run):
|
|
125
|
+
first = run("set_status_action", articles[:2])
|
|
126
|
+
assert first.status_code == 200 and "<select" in first.content.decode()
|
|
127
|
+
|
|
128
|
+
invalid = run("set_status_action", articles[:2], **{CONFIRM_FIELD: "1", "status": "bogus"})
|
|
129
|
+
assert invalid.status_code == 200
|
|
130
|
+
assert "Select a valid choice" in invalid.content.decode()
|
|
131
|
+
assert set(statuses().values()) == {"draft"}
|
|
132
|
+
|
|
133
|
+
valid = run("set_status_action", articles[:2], **{CONFIRM_FIELD: "1", "status": "published"})
|
|
134
|
+
assert valid.status_code == 302
|
|
135
|
+
assert statuses() == {"a": "published", "b": "published", "=cmd": "draft"}
|
|
136
|
+
assert messages_of(valid) == ["Status set on 2 articles."]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# -- preconditions, permissions -----------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_max_objects(articles, run):
|
|
143
|
+
response = run("two_at_most_action", articles)
|
|
144
|
+
assert "at most 2 items" in messages_of(response)[0]
|
|
145
|
+
assert set(statuses().values()) == {"draft"}
|
|
146
|
+
run("two_at_most_action", articles[:2])
|
|
147
|
+
assert list(statuses().values()).count("published") == 2
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_action_hidden_and_denied_without_permission(articles, client):
|
|
151
|
+
user = get_user_model().objects.create_user("staff", password="pw", is_staff=True)
|
|
152
|
+
from django.contrib.auth.models import Permission
|
|
153
|
+
user.user_permissions.add(*Permission.objects.filter(codename__in=["view_article", "change_article"]))
|
|
154
|
+
client.force_login(user)
|
|
155
|
+
url = reverse("admin:testapp_article_changelist")
|
|
156
|
+
html = client.get(url).content.decode()
|
|
157
|
+
assert "Admin only action" not in html and "Publish selected" in html
|
|
158
|
+
client.post(url, {"action": "admin_only_action", "_selected_action": [articles[0].pk]})
|
|
159
|
+
assert set(statuses().values()) == {"draft"}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_has_permission_enforced_even_if_action_is_called_directly(articles, rf):
|
|
163
|
+
from django.core.exceptions import PermissionDenied
|
|
164
|
+
from django.contrib import admin
|
|
165
|
+
|
|
166
|
+
request = rf.post("/")
|
|
167
|
+
request.user = get_user_model().objects.create_user("nobody", password="pw")
|
|
168
|
+
action = UpdateAction.as_action(update_fields={"status": "x"}, allowed_permissions=("change",))
|
|
169
|
+
with pytest.raises(PermissionDenied):
|
|
170
|
+
action(ArticleAdmin(Article, admin.site), request, Article.objects.all())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# -- export -------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_csv_export_escapes_formulas(articles, run):
|
|
177
|
+
response = run("export_action", articles)
|
|
178
|
+
assert response["Content-Type"].startswith("text/csv")
|
|
179
|
+
assert 'filename="article.csv"' in response["Content-Disposition"]
|
|
180
|
+
rows = list(csv.reader(io.StringIO(response.content.decode())))
|
|
181
|
+
assert rows[0] == ["title", "status", "views"]
|
|
182
|
+
assert ["'=cmd", "draft", "3"] in rows and ["a", "draft", "1"] in rows
|
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
from django.contrib import admin
|
|
3
|
+
|
|
4
|
+
from class_based_admin_actions import (
|
|
5
|
+
ActionError, ClassActionsMixin, ConfirmationMixin, CsvExportAction, FormUpdateAction,
|
|
6
|
+
ObjectAction, UpdateAction,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
from .models import Article
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StatusForm(forms.Form):
|
|
13
|
+
status = forms.ChoiceField(choices=[("draft", "Draft"), ("published", "Published")])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PublishAction(UpdateAction):
|
|
17
|
+
description = "Publish selected %(verbose_name_plural)s"
|
|
18
|
+
update_fields = {"status": "published"}
|
|
19
|
+
success_message = "Published %(count)d %(name)s."
|
|
20
|
+
allowed_permissions = ("change",)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ArchiveAction(ConfirmationMixin, PublishAction):
|
|
24
|
+
update_fields = {"status": "archived"}
|
|
25
|
+
success_message = "Archived %(count)d %(name)s."
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SetStatusAction(FormUpdateAction):
|
|
29
|
+
form_class = StatusForm
|
|
30
|
+
success_message = "Status set on %(count)d %(name)s."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResetViewsAction(ObjectAction):
|
|
34
|
+
skip_errors = True
|
|
35
|
+
log_message = "Views reset."
|
|
36
|
+
success_message = "Reset %(count)d %(name)s."
|
|
37
|
+
|
|
38
|
+
def process_object(self, obj):
|
|
39
|
+
obj.title = obj.title + " (reset)"
|
|
40
|
+
obj.save()
|
|
41
|
+
if obj.views > 100:
|
|
42
|
+
raise ActionError("too popular")
|
|
43
|
+
obj.views = 0
|
|
44
|
+
obj.save()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StrictResetAction(ObjectAction):
|
|
48
|
+
def process_object(self, obj):
|
|
49
|
+
obj.title = "touched"
|
|
50
|
+
obj.save()
|
|
51
|
+
if obj.views > 100:
|
|
52
|
+
raise ActionError("too popular")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TwoAtMostAction(UpdateAction):
|
|
56
|
+
update_fields = {"status": "published"}
|
|
57
|
+
max_objects = 2
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ExportAction(CsvExportAction):
|
|
61
|
+
fields = ["title", "status", "views"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AdminOnlyAction(UpdateAction):
|
|
65
|
+
update_fields = {"status": "published"}
|
|
66
|
+
allowed_permissions = ("publish",)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@admin.register(Article)
|
|
70
|
+
class ArticleAdmin(ClassActionsMixin, admin.ModelAdmin):
|
|
71
|
+
actions = [
|
|
72
|
+
PublishAction, ArchiveAction, SetStatusAction, ResetViewsAction, StrictResetAction,
|
|
73
|
+
TwoAtMostAction, ExportAction, AdminOnlyAction,
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
def has_publish_permission(self, request):
|
|
77
|
+
return request.user.is_superuser
|