How do you add a custom Django admin action that marks the selected orders as shipped, and what does the action function receive?
answer
- a function over ticked rows
- modeladmin, request, queryset
- @admin.action and actions list
- return None versus a response
basics
~10 sWrite 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.
solid answer
~40 sAn admin action is a callable with the signature `(modeladmin, request, queryset)`; as a `ModelAdmin` method that is `(self, request, queryset)`. Decorate it with `@admin.action(description="Mark selected orders as shipped")` and add it to `actions`, by name for a method or by reference for a module-level function. The `queryset` is the change list's queryset — `get_queryset()` plus the current filters and search — narrowed to the ticked primary keys, or left whole when the user clicks "Select all". Inside, do the work, e.g. `queryset.filter(status="paid").update(status="shipped", shipped_at=timezone.now())`, and report with `self.message_user()`. Returning `None` makes the admin redirect back to the same change-list URL; returning an `HttpResponse` sends that response instead.
code
python · 25 linesfrom django.contrib import admin, messages
from django.db import transaction
from django.utils import timezone
from .models import Order
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ["number", "status", "shipped_at"]
list_filter = ["status"]
actions = ["mark_shipped"]
@admin.action(description="Mark selected orders as shipped")
def mark_shipped(self, request, queryset):
with transaction.atomic():
updated = queryset.filter(status=Order.Status.PAID).update(
status=Order.Status.SHIPPED,
shipped_at=timezone.now(),
)
skipped = queryset.count() - updated
self.message_user(request, f"{updated} order(s) marked as shipped.", messages.SUCCESS)
if skipped:
self.message_user(request, f"{skipped} order(s) were not paid and were skipped.", messages.WARNING)
# Returning None redirects back to the change list.go deeper
Recall the three arguments, the @admin.action decorator, and that the action must be listed in the ModelAdmin's actions.
Explain how the queryset is built from get_queryset, filters and the ticked keys, what select_across changes, and what the return value controls.
Make bulk actions idempotent and bounded: filter to valid states, avoid per-row work on huge selections, and wrap multi-step writes in a transaction.
Decide which bulk state changes belong in the admin at all, versus a service layer that the admin, the API and background jobs share.
## What an admin action is On a Django admin **change list** every row has a checkbox, and above the table sits an **action dropdown** with a **Go** button. An **action** is a plain Python callable that Django runs over the rows the staff user ticked. It is the admin's tool for bulk work — marking orders shipped, publishing articles, exporting rows — without opening each record's change form. ## Writing one 1. Write the callable. As a module-level function the signature is `(modeladmin, request, queryset)`; as a method on the `ModelAdmin` it is `(self, request, queryset)`. 2. Decorate it with `@admin.action(...)`. The decorator only sets attributes: `description` becomes `short_description`, `permissions` becomes `allowed_permissions`, and in Django 6.1 `description_plural` and `location` were added. 3. Register it in `ModelAdmin.actions` — a string name for a method, the function object for a module-level function — or site-wide with `admin.site.add_action()`. Without a `description`, the label is derived from the function name (`mark_shipped` → "Mark shipped"). Descriptions accept `%(verbose_name)s` and `%(verbose_name_plural)s` placeholders. ## What the arguments hold - **`modeladmin`** — the `ModelAdmin` instance, giving access to `message_user()`, `opts`, `get_queryset()` and the permission hooks. - **`request`** — the `HttpRequest` for the POST, including `request.user`. - **`queryset`** — built by the change list from `ModelAdmin.get_queryset()` plus the active `list_filter` choices and search, then narrowed with `pk__in` to the ticked checkboxes (they arrive as the `_selected_action` POST field). When the user clicks **"Select all N"**, the hidden `select_across` flag is set and the action receives **every** filtered row, not just the visible page. ## What the return value does | Return | Admin behaviour | |---|---| | `None` | Redirects back to the same change-list URL, keeping filters, and shows queued messages | | An `HttpResponse` (or subclass) | Sent as-is: a download, a confirmation page, a redirect elsewhere | If nothing is ticked, the action does not run; the admin shows "Items must be selected in order to perform actions on them." ## Things to get right - **Filter inside the action.** The ticked rows may include orders already shipped or not yet paid; narrow the queryset to the valid state before changing it, so the action is safe to re-run. - **Report the real count.** `QuerySet.update()` returns the number of rows changed, which is what `message_user()` should report. - **Know what `update()` skips.** It issues one `UPDATE` and bypasses `save()` and the save signals; if shipping must trigger per-order logic, loop and save instead. The detail of `update()` belongs to the QuerySet API. - **Transactions are yours.** Actions run from the change list are not wrapped in an atomic block by the admin (only the change form is), so wrap multi-step work in `transaction.atomic()`. - **Names must be unique** per `ModelAdmin`; duplicates fail the `admin.E130` system check. ## Walking through one run Suppose the change list is filtered to `?status__exact=paid` and a staff member ticks three orders, picks "Mark selected orders as shipped" and clicks **Go**: 1. The browser POSTs the action name, the three keys as `_selected_action`, and an `index` saying which dropdown was used. 2. `changelist_view()` builds its `ChangeList` from the URL's filters and passes the resulting queryset to `response_action()`. 3. `response_action()` validates the action name against the actions this user may run, narrows the queryset with `pk__in`, and calls `mark_shipped(self, request, queryset)`. 4. The action updates the rows and queues messages; it returns `None`. 5. The admin redirects to the same URL, so the list reloads with the filter still applied and the messages displayed. ## Site-wide actions An action useful on many models — export as CSV, mark as reviewed — can be registered once with `admin.site.add_action(func, name=None)`. It then appears on every `ModelAdmin` of that site, before the model's own actions. `admin.site.disable_action(name)` removes it again, and a `ModelAdmin` can opt back in by listing the name in its `actions`. Keep site-wide actions generic: they receive querysets of any model, so they should rely only on `queryset.model` and the `modeladmin` passed in.
- In the Django admin, what does an action receive when the user clicks 'Select all' across several pages?The action form's hidden `select_across` field is set, so the admin skips the `pk__in` narrowing and passes the change list's full queryset: `get_queryset()` plus the active filters and search, every page. An action written for "the 20 rows I can see" may then touch thousands, which is why it should filter to valid states and avoid per-row work it cannot afford.
- How do you make a Django admin action available on every model's change list?Register it on the site with `admin.site.add_action(func)`, optionally passing a name. It then appears on every `ModelAdmin` of that site, like `delete_selected`, and can be switched off globally with `disable_action(name)` or re-enabled per model by listing its name in `actions`.
- Why does a Django admin action that calls queryset.update() not trigger your post_save receivers?`update()` issues a single SQL `UPDATE` and never calls `save()` on instances, so `pre_save` and `post_save` are not sent. If shipping must notify customers or write per-order history, iterate and call `save(update_fields=[...])`, or call the service function that does it, accepting the extra queries.
saying these in an interview costs you the question
- The action receives only the primary keys of the ticked rows
- Returning None from an action leaves the user on a blank page
- Select all passes only the rows visible on the current page
- The admin wraps every change list action in a transaction
- An action must be a ModelAdmin method; functions are not accepted