How do you restrict a Django admin action so only staff holding a custom 'ship order' permission can see and run it?
answer
- decorator argument, not a check inside
- maps to a has_*_permission method
- any one check is enough
- objects are not checked for you
basics
~10 sPass 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 linesfrom 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
Recall that @admin.action takes a permissions list and that it hides the action from users without them.
Explain the value-to-method mapping, the any-of rule, the admin.E129 check, and how a custom has_ship_permission is written.
Point out that object-level rights are not enforced per selected row, and scope rows in get_queryset or inside the action accordingly.
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