skip to content

Bulk Actions

Admin actions run one function over the rows a staff user ticks, from delete_selected to custom @admin.action ones. Interviewers ask how to gate them by permission and confirm first.

part ofDjangooverview, primer and where to startread it →
on this pageshow

explore

questions

6

How do you restrict a Django admin action so only staff holding a custom 'ship order' permission can see and run it?

level: middleimportance: must knowfreq 50%

answer

  1. decorator argument, not a check inside
  2. maps to a has_*_permission method
  3. any one check is enough
  4. objects are not checked for you

basics

~10 s

Pass permissions=["ship"] to @admin.action and define has_ship_permission(self, request) on the ModelAdmin. The admin hides the action from users failing every listed check and refuses a forged POST for it; per-object checks remain your job.

solid answer

~40 s

`@admin.action(permissions=[...])` stores the list as `allowed_permissions`. For each value the admin calls `has_<value>_permission(request)` on the `ModelAdmin`; the built-ins `add`, `change`, `delete` and `view` map to the existing hooks, and any other value needs its own method, or the `admin.E129` system check fails. The action stays available if **at least one** check passes. For shipping you declare the permission in the model's `Meta.permissions`, then write `has_ship_permission()` using `get_permission_codename("ship", self.opts)` and `request.user.has_perm()`. The filter runs inside `get_actions()`, which also builds the dropdown's valid choices, so a hand-crafted POST naming the hidden action fails validation instead of running. What the admin does not do is check object-level permission on each selected row.

code

python · 21 lines
python
from django.contrib import admin
from django.contrib.auth import get_permission_codename

from .models import Order


@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    actions = ["mark_shipped"]

    @admin.action(permissions=["ship"], description="Mark selected orders as shipped")
    def mark_shipped(self, request, queryset):
        allowed = [o.pk for o in queryset if o.warehouse_id == request.user.warehouse_id]
        updated = queryset.filter(pk__in=allowed, status=Order.Status.PAID).update(
            status=Order.Status.SHIPPED
        )
        self.message_user(request, f"{updated} order(s) marked as shipped.")

    def has_ship_permission(self, request):
        codename = get_permission_codename("ship", self.opts)  # "ship_order"
        return request.user.has_perm(f"{self.opts.app_label}.{codename}")

go deeper

for a junior

Recall that @admin.action takes a permissions list and that it hides the action from users without them.

for a middle

Explain the value-to-method mapping, the any-of rule, the admin.E129 check, and how a custom has_ship_permission is written.

for a senior

Point out that object-level rights are not enforced per selected row, and scope rows in get_queryset or inside the action accordingly.

for a principal

Judge whether admin permissions mirror the real authority model, or whether bulk operations need their own audited service with its own rules.

## Where the gate lives Django admin actions are filtered by **`ModelAdmin.get_actions(request, ...)`**. After collecting the site-wide actions and the model's own, it calls a private filter that looks at each function's **`allowed_permissions`** attribute, which `@admin.action(permissions=[...])` sets. An action without the attribute is always kept; an action with it is kept only when **at least one** of its permission checks returns `True`. Each value maps to a method name by convention: `"ship"` → `has_ship_permission`. The method receives only the `request` — no object — so it answers "may this user run the action on this model at all?" | `permissions` value | Method the admin calls | |---|---| | `"add"` | `has_add_permission(request)` | | `"change"` | `has_change_permission(request)` with `obj=None` | | `"delete"` | `has_delete_permission(request)` with `obj=None` | | `"view"` | `has_view_permission(request)` with `obj=None` | | anything else, e.g. `"ship"` | `has_ship_permission(request)` — you write it | If a listed value has no matching method, the admin's system checks report **`admin.E129`** at startup, so a typo fails fast instead of silently hiding the action. ## Wiring a custom permission 1. Declare the permission on the model, e.g. `Meta.permissions = [("ship_order", "Can mark orders as shipped")]`, and run `migrate` so the `Permission` row exists. 2. On the `ModelAdmin`, write `has_ship_permission(self, request)` that builds the full name — `f"{opts.app_label}.{get_permission_codename('ship', opts)}"` gives `shop.ship_order` — and returns `request.user.has_perm(...)`. 3. Decorate the action with `@admin.action(permissions=["ship"], description=...)`. How permissions are stored, named and granted to groups is the permissions model's subject; the admin part is the name mapping above. ## What the gate protects — and what it does not - **The dropdown.** Users who fail every check never see the action. - **Forged POSTs.** When the action form is submitted, its valid choices are rebuilt from the same filtered `get_actions()`. A POST naming a hidden action fails form validation, and the admin answers "No action selected." without calling it. - **Not individual objects.** The admin does not call `has_change_permission(request, obj)` per selected row. If some orders must not be shipped by this user — another warehouse's, say — check inside the action or narrow the queryset. - **Row visibility is upstream.** The queryset comes from `ModelAdmin.get_queryset()`, so scoping rows there limits what any action can reach. ## Per-request logic beyond permissions When the rule is not a permission — only during business hours, only for users in a region — override `get_actions()` and remove the entry for requests that should not have it. Since Django 6.1 the override must accept `action_location`, and the values are `Action` objects rather than tuples. ## Pitfalls 1. Checking permission **only inside** the action body: the action still shows in the dropdown and fails late. 2. Assuming a listed `"change"` means per-object change rights: it is the model-level check with `obj=None`. 3. Listing two permissions and expecting **both** to be required: it is any-of; require both inside one custom `has_*_permission` method. ## Testing the gate Permission gates on actions are cheap to test with Django's test client: 1. Create a staff user **without** the permission, log in, and GET the change list; the action's name should be absent from the dropdown choices in the rendered page. 2. POST to the change list with `action=mark_shipped` and a selected key as that user; the order's status must be unchanged and the response should carry the "No action selected." warning after the redirect. 3. Grant the permission, repeat the POST, and assert the status changed. The second step is the one that proves the gate is real, not just a hidden menu item. ## A worked rule set for shipping - Warehouse staff: `ship_order` permission, rows limited by `get_queryset()` to their warehouse. - Support staff: `change_order` but not `ship_order`; they can edit addresses but never see the action. - Superusers: pass every check, because `has_perm()` returns `True` for active superusers. The action itself still re-checks the warehouse on each row, because a queryset scoped in one place today can be widened by someone editing `get_queryset()` tomorrow.

  • In the Django admin, what happens if an action lists permissions=["change", "ship"]?
    The action is kept if either `has_change_permission(request)` or `has_ship_permission(request)` returns `True`; the list is any-of, not all-of. To require both, write one custom method, say `has_ship_permission`, that checks both, and list only that value.
  • A staff member POSTs the name of an admin action they cannot see. Does Django run it?
    No. `response_action()` rebuilds the action form's choices from `get_actions(request)`, which already dropped the action for this user, so the submitted value is not a valid choice. The form is invalid and the admin shows "No action selected." without calling the function.

saying these in an interview costs you the question

  • permissions=["change"] checks change rights on each selected object
  • Listing two permissions requires the user to hold both
  • Hiding the action is cosmetic; a forged POST still runs it
  • Custom permission values work without a has_*_permission method
  • The permissions argument takes full codenames like shop.ship_order
open as a page

How do you add a custom Django admin action that marks the selected orders as shipped, and what does the action function receive?

level: middleimportance: must knowfreq 60%

basics

~10 s

Write a function taking (modeladmin, request, queryset), decorate it with @admin.action(description=...), and list it in the ModelAdmin's actions. The queryset holds the ticked rows; returning None sends the user back to the change list.

open as a page

In the Django admin, what does the built-in 'Delete selected' action do, and how do you remove it from one model or the whole site?

level: juniorimportance: should knowfreq 42%

basics

~20 s

Django's admin registers delete_selected on every AdminSite: it lists the ticked rows plus everything that would cascade with them, asks for confirmation, then logs and deletes them. Remove it site-wide with admin.site.disable_action(), or per model by overriding get_actions().

open as a page

How do you make a custom Django admin action show an intermediate confirmation page before it changes any rows, as delete_selected does?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Return a TemplateResponse from the action on the first POST; its form re-posts the selected keys as _selected_action inputs, the action name and a marker such as apply. On that second POST the action sees the marker and does the work.

open as a page

Staff bulk-deleted orders via the Django admin's 'Delete selected' action and your Order.delete() cleanup never ran; how do you fix it in the admin?

level: seniorimportance: should knowfreq 38%

basics

~10 s

delete_selected calls ModelAdmin.delete_queryset(), whose default runs queryset.delete() and never calls Order.delete(). Override delete_queryset() to delete per object in a transaction, and keep delete_model() consistent for the single-object delete view.

open as a page

In Django 6.1, how do you offer an admin action on a single order's change form, and what do location and description_plural change?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

Django 6.1 added location= to @admin.action: ActionLocation.CHANGE_FORM puts the action on the change form, a list puts it on both. description labels it there; description_plural labels it on the change list and defaults to description.

open as a page