django-class-based-admin-actions 0.1.0__py3-none-any.whl

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.
@@ -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
+ &rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
10
+ &rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
11
+ &rsaquo; {{ 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 %}
@@ -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,10 @@
1
+ class_based_admin_actions/__init__.py,sha256=6a03igBCDD06ohziZXrpH8sIf9Qx1QrXaDDl6sqDxVM,602
2
+ class_based_admin_actions/admin.py,sha256=oaI7TdpzMHHV1m5uDieV0m1ejfNNJsXQVvQEQI1nVtE,540
3
+ class_based_admin_actions/base.py,sha256=TwbVlr_MnlSaUmc9wwSdwrijb7wlS9-kZpx2N9d_5gU,5890
4
+ class_based_admin_actions/generic.py,sha256=HWio1ujiGf5inySa2qxJ88K2gG4qRhbq5Dr18x5L4Yw,5106
5
+ class_based_admin_actions/mixins.py,sha256=XRzdk8kzy2MfGst_MVBZRAkVB4mnD0r6BpyIdb7Mcu0,6960
6
+ class_based_admin_actions/templates/class_based_admin_actions/confirmation.html,sha256=6ZpGPKfD8QdHXVZdJ_ximh_wMaTmIU7DRqdtt9qvKGE,1347
7
+ django_class_based_admin_actions-0.1.0.dist-info/METADATA,sha256=XMkPWdhteAv5GHBP1BawLefoKH8apWclB3YDxCkrSw0,4289
8
+ django_class_based_admin_actions-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ django_class_based_admin_actions-0.1.0.dist-info/licenses/LICENSE,sha256=6upNXXuY-6XwLIabba5J0kNqRNpBZS3O_7qVHQYFypI,1075
10
+ django_class_based_admin_actions-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.