Bulk actions on list pages¶
Every Danbyte list page (Prefixes, IPs, Devices, Tags, Users, …) shares one piece of plumbing for "tick rows, then act on them": a master checkbox in the table header, per-row checkboxes, and a sticky bottom bar that surfaces when at least one row is selected.
The plumbing is intentionally minimal so new list pages — including plugin pages — opt in with three small changes, never reinventing the table chrome.
How it slots together¶
┌────────────────────────────────────────┐
│ _list_shell.html (extends _shell) │
│ • renders the toolbar + table │
│ • when `bulk_action_url` is in │
│ context, also renders: │
│ - {% bulk_th %} → 1st <th> │
│ - <form id="bulk-form"> │
│ - {% include "_bulk_bar.html" %} │
│ - <script src="bulk-select.js"> │
│ │
│ each list template │
│ • adds {% bulk_td obj.pk %} as the │
│ 1st <td> of every row │
│ │
│ urls.py │
│ • binds /…/_bulk/ → bulk_action_view │
│ │
│ bulk-select.js │
│ • tracks selection, shift-click, │
│ master checkbox, sticky bar reveal │
└────────────────────────────────────────┘
Wiring a new list page (5 minutes)¶
1. Build the bulk endpoint¶
In your URL config, call the factory at api/bulk.py:
from api import bulk
from myapp.models import Widget
widget_bulk = bulk.bulk_action_view(
model=Widget,
delete_perm="widgets.delete", # the slug @require_perm uses
redirect_url="/widgets/",
tenant_field="tenant", # "" for tenant-less models
label_plural="widgets", # "Deleted 4 widgets."
)
urlpatterns = [
path("widgets/", views.widget_list, name="widgets"),
path("widgets/_bulk/", widget_bulk, name="widget-bulk"),
...
]
The factory enforces @require_POST + @require_perm + tenant scoping and
deletes the chosen rows in a single transaction. It also gates the
optional action=edit path behind edit_perm + edit_redirect.
2. Pass the URL into the list view's context¶
def widget_list(request):
...
return render(request, "myapp/widget_list.html", {
...,
"bulk_action_url": reverse("myapp:widget-bulk"),
"bulk_label_plural": "widgets", # shown in the sticky bar
})
Leave bulk_action_url out of the context to opt the page out of bulk
actions — the shell renders normally without the checkbox column.
3. Add the per-row checkbox cell¶
In your list template, drop one tag into the tbody loop:
{% block tbody %}
{% for w in widgets %}
<tr>
{% bulk_td w.pk %}
<td>{{ w.name }}</td>
...
</tr>
{% empty %}
{# Don't forget to bump colspan by +1 #}
<tr><td colspan="6">No widgets yet.</td></tr>
{% endfor %}
{% endblock %}
The bulk_th master-checkbox header is added automatically by
_list_shell.html when bulk_action_url is in context — you don't need
to repeat it in {% block thead %}.
4. (Optional) Wire bulk-edit¶
Subclass BulkEditFormBase (in api/bulk.py) declaring only the fields
you want bulk-editable. The base auto-adds a _set_<name> toggle per
field; only fields whose toggle is ticked get applied.
# api/bulk_forms.py
class WidgetBulkEditForm(BulkEditFormBase):
# For M2M fields where you want add/remove semantics (not replace),
# name the manager in M2M_ADD_REMOVE_FIELDS and declare TWO fields
# named "<name>_add" + "<name>_remove". The base routes each pick
# through `manager.add(*qs)` / `manager.remove(*qs)`.
M2M_ADD_REMOVE_FIELDS = ("tags",)
# Use M2M_FIELDS instead when you want REPLACE semantics (overwrites
# the row's entire M2M set).
status = forms.ChoiceField(choices=Widget.STATUS_CHOICES, required=False, ...)
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, ...)
tags_add = forms.ModelMultipleChoiceField(queryset=Tag.objects.all(), required=False, ...)
tags_remove = forms.ModelMultipleChoiceField(queryset=Tag.objects.all(), required=False, ...)
Then plug it into the factory:
widget_bulk = bulk.bulk_action_view(
model=Widget,
delete_perm="widgets.delete",
edit_perm="widgets.edit",
edit_form=WidgetBulkEditForm,
edit_title="Bulk-edit widgets",
redirect_url="/widgets/",
label_plural="widgets",
)
In the list view, also pass bulk_can_edit=True to show the button as
enabled. Without edit_form, the button still renders, but disabled with
a tooltip — that keeps the bar's layout consistent across every page.
Flow when the user clicks Edit:
1. Bar posts action=edit + bulk_ids=... to bulk_action_url.
2. The factory renders _bulk_edit_page.html with the form scaffold and
the chosen IDs preserved in hidden inputs.
3. User ticks the "Set …" toggle next to each field they want to change,
fills the value, clicks Apply.
4. Form posts action=edit_apply back to the same URL; the factory
validates, runs form.apply(queryset) in a transaction, and
redirects with a success flash.
Per-tenant catalogs: if the form takes a tenant= kwarg (via the
_TenantScopedMixin pattern used in bulk_forms.py), the factory
auto-passes the current tenant so dropdowns only show that tenant's
sites/VLANs/statuses/roles.
What you get for free¶
- Shift-click range selection — spreadsheet-style; works on every page.
- Master checkbox tri-state —
checked/indeterminate/unchecked, driven by the row checkboxes' current state. - Selection survives htmx swaps —
bulk-select.jsre-binds onhtmx:afterSwap, so the filter rail can refresh the table without losing selection. - Confirm dialog before delete — the JS prompts with a count
(
"Delete 4 selected rows? This cannot be undone.") so users can't destroy data with a stray click. - Per-tenant scoping — the factory filters the POSTed IDs through the active tenant so a user can't smuggle in IDs from a tenant they don't have access to.
- CSRF — provided by the hidden
<form id="bulk-form">that_list_shell.htmlrenders next to the bar.
Where to look in the code¶
| Concern | Path |
|---|---|
| The factory | api/bulk.py |
| The header / row tags | api/templatetags/api_extras.py (bulk_th, bulk_td) |
| The sticky bar partial | api/templates/api/_bulk_bar.html |
| Client-side wiring | design/bulk-select.js (served as static/bulk-select.js) |
| Shell integration | api/templates/api/_list_shell.html |
| Reference wiring | api/urls.py (Tags is the simplest end-to-end example) |