skip to content

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%

answer

  1. a function over ticked rows
  2. modeladmin, request, queryset
  3. @admin.action and actions list
  4. return None versus a response

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.

solid answer

~40 s

An 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 lines
python
from 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

for a junior

Recall the three arguments, the @admin.action decorator, and that the action must be listed in the ModelAdmin's actions.

for a middle

Explain how the queryset is built from get_queryset, filters and the ticked keys, what select_across changes, and what the return value controls.

for a senior

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.

for a principal

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