django-massactions
Mass (bulk) actions for Django list views: users select rows with checkboxes (or "select all" across pages), pick an action from a dropdown and confirm it in a Bootstrap modal (Bootstrap 4 and 5). Built-in actions: delete and update a field. Custom actions plug into the same selection mechanism.
How it works
- A list view mixes in
MassActionListViewMixinand includesmassactions/mass_action.html(the action bar) andmassactions/mass_action_checkbox.html(one per row). - The selection lives in
sessionStorageon the client (ids+selectAllflag + the current filter). - When the user picks an action, the page posts the selection to
massactions:encrypt, stores the returned value in a cookie and opens the action URL in a modal. - The action view resolves everything from a registered config (
?key=...): the queryset the user may see, the list filter (so "select all" respects the filtered list), the permission and the per-action restriction. The client never sends model names, queryset methods or form classes. - After the action, the view redirects to
back_urland sets the cookie toTrue, which makes the list page reset the stored selection.
Requirements
- Django >= 4.2, django-crispy-forms >= 1.13 (1.x and 2.x), django-bootstrap-modal-forms >= 2.2 (2.x and 3.x),
pycryptodome. The crispy template pack of your Bootstrap version:
crispy-bootstrap5(extra[bootstrap5]) or, for Bootstrap 4 with crispy-forms 2.x,crispy-bootstrap4(extra[bootstrap4]; crispy-forms 1.x ships thebootstrap4pack itself). CI runs the suite on Python 3.10 to 3.12 with Django 4.2, 5.0 and 5.1 (crispy 2.x stack) and on Django 4.2 with the crispy 1.13 stack. - Optional: django-filter (for
filter_class) - Frontend: jQuery, Bootstrap 4 or 5, Font Awesome (icons), js-cookie
and the bundled fork of the modal forms plugin:
{% static 'massactions/js/jquery.bootstrap.modal.forms.js' %}(addsshowModal()andinitOnClick, which the stock plugin does not have). Use it instead of the plugin's own JS file. django.contrib.messages(success/error messages) anddjango.contrib.sessions.
Installation
pip install django-massactions[bootstrap5] # or [bootstrap4] with crispy-forms 2.x
Until the package is on PyPI, or to pin a specific release from GitHub:
django-massactions @ git+https://github.com/soltys-m/django-massactions.git@v0.1.0
INSTALLED_APPS = [
...
'crispy_forms',
'crispy_bootstrap5', # 'crispy_bootstrap4' with crispy-forms 2.x; nothing for Bootstrap 4 with crispy-forms 1.x
'bootstrap_modal_forms',
'massactions',
]
CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5'
CRISPY_TEMPLATE_PACK = 'bootstrap5' # 'bootstrap4' on a Bootstrap 4 page
# urls.py
urlpatterns = [
...
path('massactions/', include('massactions.urls')), # namespace "massactions"
]
Quick start
1. Register a config in <your_app>/massactions.py (discovered automatically on startup,
like admin.py):
from massactions.registry import MassActionConfig, register
from .filters import OrderFilter
from .models import Order
@register
class OrderMassActions(MassActionConfig):
model = Order
key = 'Order' # used in URLs and the selection cookie
actions = ['update', 'delete']
update_fields = [
{'field_name': 'status', 'field_name_localized': 'Status',
'localized_action': 'Update status', 'choices': Order.STATUS},
]
filter_class = OrderFilter # optional, django-filter FilterSet
def get_queryset(self, request):
# everything the user may see in the list; scope by tenant / ownership here
return Order.objects.filter(workspace=request.user.workspace)
def restrict_queryset(self, request, queryset, action):
# subset of the selection the user may run `action` on; the rest is listed as "not allowed"
if action == 'delete':
return queryset.filter(is_locked=False)
return queryset
2. The list view:
from django.views.generic import ListView
from massactions.mixins import MassActionListViewMixin
class OrderListView(MassActionListViewMixin, ListView):
model = Order
mass_action_config = 'Order' # key, config class or instance
paginate_by = 50
def get_queryset(self):
self.filter = OrderFilter(self.request.GET, queryset=Order.objects.all())
return self.filter.qs
3. The template:
{% include 'massactions/mass_action.html' %}
{% for object in object_list %}
<tr>
<td>{% include 'massactions/mass_action_checkbox.html' %}</td>
<td>{{ object }}</td>
</tr>
{% endfor %}
The action bar renders only when object_list is not empty. It needs request and csrf_token
in the template context (django.template.context_processors.request).
Config reference
| Attribute / hook | Purpose |
|---|---|
model |
Required. |
key |
Identifier in URLs and cookie names. Defaults to model._meta.object_name. One model can have several configs (e.g. Customer and Carrier on a Company model). |
actions |
Names rendered in the menu. update and delete are built in; other names are for your own menu items (see Custom actions). |
update_fields |
List of dicts: field_name, field_name_localized, localized_action, optional choices (renders a submenu; the chosen value is validated against it). |
filter_class |
django-filter FilterSet applied with the query string of back_url. |
update_form_class |
BSModalMassUpdateForm subclass for the update action (extra inputs, custom layout). |
permission_map |
Maps action name to permission codename prefix; default {'update': 'change'}. |
get_queryset(request) |
Base queryset. Override this in multi-tenant projects. |
filter_queryset(request, queryset, data) |
Override when your FilterSet needs extra constructor arguments. |
restrict_queryset(request, queryset, action) |
Per-action object-level permission. |
has_permission(request, action) |
Extra check next to Django permissions (account flags, plan quotas). |
get_permission_required(action) |
Default '<app_label>.<codename>_<model_name>'. An explicit permission_required on the view wins. |
get_update_form_class(field_name) |
Per-field form class. |
update_object(request, obj, values) |
How the update action applies values to one object (default setattr + save()). |
get_related_list_url(request, model, ids) |
Link for protected related objects that block a delete (default: plain text). |
get_success_url(request) |
Fallback redirect when back_url is missing or external. |
Custom actions
Subclass one of the views, set action, and add a menu item in your template. The config's
restrict_queryset() and get_permission_required() receive your action name.
from massactions.views import BSModalMassActionViewMixin
from massactions.forms import BSModalMassActionFormMixin
class BSModalMassArchiveForm(BSModalMassActionFormMixin):
def get_modal_submit(self):
return _('Archive')
def get_modal_title(self, object):
return _('Archive objects of type: %s') % self.get_verbose_name(object)
def get_confirmation_message(self, count):
return ngettext('Archive %(count)d object?', 'Archive %(count)d objects?', count) % {'count': count}
class MassArchiveView(BSModalMassActionViewMixin):
action = 'archive' # permission: <app>.archive_<model>
form_class = BSModalMassArchiveForm
def post(self, request, *args, **kwargs):
count = self.restricted_object_list.update(is_archived=True)
messages.success(request, _('%d objects archived.') % count)
return self.finish() # redirect back + reset selection
{# templates/<app>/mass_action.html #}
{% extends 'massactions/mass_action.html' %}
{% block mass_action_menu_extra %}
{% if 'archive' in ctx.actions %}
<a href="#" class="dropdown-item mass-action-{{ ctx.key|slugify }}"
data-form-url="{% url 'myapp:mass_archive' %}?key={{ ctx.key|urlencode }}&back_url={{ request.get_full_path|urlencode }}">
{% trans 'Archive' %}
</a>
{% endif %}
{% endblock %}
Include your template instead of massactions/mass_action.html. Available blocks:
mass_action_bar (the whole bar), mass_action_selection, mass_action_selected_class,
mass_action_button_class, mass_action_menu (the dropdown-menu element), mass_action_menu_items,
mass_action_menu_start, mass_action_menu_update, mass_action_menu_extra, mass_action_menu_delete,
mass_action_menu_end, mass_action_js_extra. The selection dropdown template takes button_class
(default btn-outline-secondary).
The modal content template massactions/mass_action_modal_content.html has
mass_action_modal_heading, mass_action_modal_not_allowed_heading and mass_action_modal_extra.
Two flavours of menu links are supported by the page script:
class="mass-action-<key|slugify>"withdata-form-url: stores the selection and opens the URL in the modal.class="mass-action"withdata-form-url: stores the selection and redirects to the URL (full page action).class="mass-action-export"withdata-export-url: redirects to the URL with the current filter andids=/exclude_ids=appended (for exports).
Views that are not modals can use MassActionViewMixin directly; set action or permission_required.
window.massActions[key].resetSelection() is available to project scripts.
When a modal has been loaded and shown, the page triggers massactions:modal-shown on document with the
modal element, the config key and the form URL, so project scripts can initialise widgets in the form
(date pickers, select2, ...):
$(document).on('massactions:modal-shown', function (event, modal, key, formUrl) {
init_date_and_time_pickers($(modal));
});
Bootstrap 4 and 5
The templates carry both attribute sets side by side (data-toggle="dropdown" data-bs-toggle="dropdown",
text-end text-right, me-2 mr-2, ...); each Bootstrap version ignores the other's. The only markup that
differs, the close button of the modal header, follows CRISPY_TEMPLATE_PACK (bootstrap4 renders the
close button, anything else btn-close; with crispy-forms 2.x the bootstrap4 pack comes from the
crispy-bootstrap4 package). The modal container is appended to <body> by the helper script (an existing
#modal element on the page is reused and has to contain a .crispy-modal-content element), so
massactions/mass_action.html can be included anywhere, also inside an existing dropdown menu:
{% extends 'massactions/mass_action.html' %}
{% block mass_action_bar %}
{% block mass_action_menu_items %}{{ block.super }}{% endblock %}
{% endblock %}
renders only the menu items (<a class="dropdown-item">); include the selection dropdown with
is_table=True in the table header.
Views
MassDeleteView(massactions:mass_delete): shows the objects to delete, the ones the user may not delete and protected related objects (on_delete=PROTECT); overrideperform_delete().MassUpdateFieldView(massactions:mass_update):?field_name=must be declared inupdate_fields,?field_name_value=must be one of itschoices. Objects are saved one by one (signals,auto_now); overrideperform_update()forqueryset.update().EncryptSelectionView(massactions:encrypt): POST only, login required.
Views answer with a small modal (massactions/helpers/modal_content.html) when the user has no
permission, is not logged in or has nothing selected.
Settings
| Setting | Default | Purpose |
|---|---|---|
MASSACTIONS_AUTODISCOVER |
True |
Import massactions.py from every installed app on startup. |
MASSACTIONS_DEFAULT_SUCCESS_URL |
'/' |
Redirect target when back_url is missing or points to another host. |
Security notes
- Only registered keys are reachable; the model, queryset, filter and form are never taken from the request.
back_urlis only followed when it points to the current host.- The selection cookie is obfuscated (AES with the key appended), not authenticated. Do not treat it as
trusted input:
get_queryset()/restrict_queryset()are the authorization boundary. - Object names are inserted into the modal as escaped HTML, not re-parsed as templates.
Translations
The package ships message catalogs in massactions/locale/ (Slovak, Czech and Polish complete; Hungarian and
Romanian nearly complete; German only started; apart from Slovak they are not reviewed by native speakers).
Regenerate with
cd massactions && django-admin makemessages -a --ignore='tests/*' --no-location and compile with
django-admin compilemessages (or msgfmt). Projects that translated these strings before can drop them
from their own catalogs; Django finds the app catalog automatically.
Tests
pip install -r requirements-test.txt
pytest # or: python runtests.py
GitHub Actions (.github/workflows/tests.yml) runs the suite for every push to main and every pull
request across the supported Django/crispy combinations, plus pyflakes and a package build check.
Documentation
Full documentation lives in docs/ (mkdocs): installation, configuration reference, templates and
JavaScript, custom actions, migration from an inline implementation and security notes.
License
GPLv2, see LICENSE.
Release files for django-massactions 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_massactions-0.1.0.tar.gz | 39.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_massactions-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 92.4 kB
Release files / django_massactions-0.1.0.tar.gz
| Download URL | django_massactions-0.1.0.tar.gz |
|---|---|
| Size | 39.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cf9dc57131dd202b5d8b6fe0c0e5c9b70d62e64e4de931720f6fb951b9ca5ca3
|
|
BLAKE2b-256 checksum How to use checksums |
39a95a5fbeba13e521eff78195ab1257e1deb427366a5cc2e4e10a8e1f316506
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency logRelease files / django_massactions-0.1.0-py3-none-any.whl
| Download URL | django_massactions-0.1.0-py3-none-any.whl |
|---|---|
| Size | 53.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
17a0030fcf3d4bc55b5358fd39ee7c0d86119012ca39d6fc967bffad484726b9
|
|
BLAKE2b-256 checksum How to use checksums |
b5f7365eb8698594944f0ddae9494f80ec05d3e13467cad54490fe53141d0515
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.
Transparency log