skip to content

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%

answer

  1. new in 6.1
  2. an enum of two places
  3. singular label versus plural label
  4. get_actions gains a parameter

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.

solid answer

~40 s

Before 6.1, admin actions lived only on the change list. Django 6.1 adds `location=` to `@admin.action`, taking `ActionLocation.CHANGE_LIST` (the default), `ActionLocation.CHANGE_FORM`, or a list of both. On the change form the action gets a one-row queryset, `select_across` is forced off, it is not offered on the add form, and any unsaved edits on the form are lost; returning `None` redirects back to the same form. Because one function now serves two places, `description` labels it on the change form ("Ship order") and `description_plural` labels it on the change list ("Ship selected orders"), defaulting to `description`. Overrides of `get_actions()` and `get_action_choices()` must accept the new `action_location` argument — the old signature is deprecated until Django 7.0 — and `get_actions()` now returns `Action` objects instead of tuples.

code

python · 19 lines
python
from django.contrib import admin
from django.contrib.admin import ActionLocation

from .models import Order


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

    @admin.action(
        permissions=["change"],
        location=[ActionLocation.CHANGE_LIST, ActionLocation.CHANGE_FORM],
        description="Mark as shipped",
        description_plural="Mark selected orders as shipped",
    )
    def mark_shipped(self, request, queryset):
        updated = queryset.filter(status=Order.Status.PAID).update(status=Order.Status.SHIPPED)
        self.message_user(request, f"{updated} order(s) marked as shipped.")

go deeper

for a junior

Recall that Django 6.1 lets an admin action appear on a single object's edit page as well as the list.

for a middle

Explain ActionLocation, which label shows where, and how the change form's one-row queryset differs from the list's selection.

for a senior

Audit get_actions overrides for the new action_location parameter and Action objects before upgrading, and warn staff that unsaved edits are discarded.

for a principal

Weigh adopting 6.1-only admin features against staying on the 5.2 LTS for the rest of the platform.

## The gap it closes For most of the admin's history an **action** — a function run over selected rows — appeared only on the **change list**. Offering the same operation from one record's **change form** meant overriding `change_form.html`, adding a button, and wiring a URL via `get_urls()` or intercepting the save hooks. **Django 6.1** makes it a decorator argument. ## The new arguments `@admin.action` in Django 6.1 accepts `permissions`, `description`, `description_plural` and `location`. - **`location`** takes a member of `django.contrib.admin.ActionLocation` or a list of them: - `ActionLocation.CHANGE_LIST` — the default, the classic dropdown above the table; - `ActionLocation.CHANGE_FORM` — a dropdown on an existing object's edit page. - **`description`** — the label on the change form, a singular phrase such as "Mark as shipped". - **`description_plural`** — the label on the change list, e.g. "Mark selected orders as shipped". It **defaults to `description`**, so older actions keep their label. | | Change list | Change form | |---|---|---| | Label used | `description_plural` | `description` | | Queryset | ticked rows, or all filtered rows with "Select all" | exactly the one object | | `select_across` | honoured | forced to `False` | | After returning `None` | redirect to the list | redirect to the same form | | Template | `admin/actions.html` | `admin/change_form_actions.html` | ## How it behaves on the change form 1. The dropdown is shown only when editing an existing object — not on the **add** form. 2. Submitting it posts the object's key as the selection; the admin rejects the request with a 400 if the posted key does not match the object being edited. 3. The action receives `get_queryset(request)` narrowed to that one row, so any row scoping on the `ModelAdmin` still applies. 4. Running an action **does not save the form**: unsaved edits are discarded, which the docs call out explicitly. 5. Permission filtering is identical: `permissions=` is checked the same way in both places. ## API changes that come with it - `ModelAdmin.get_actions(request, action_location=ActionLocation.CHANGE_LIST)` and `get_action_choices(..., action_location=...)` gained the parameter. Overrides without it still work on the change list but trigger a `RemovedInDjango70Warning`, and actions are then **not** offered on the change form. - The values of the dict `get_actions()` returns are now **`Action`** dataclass instances with `func`, `name`, `description`, `plural_description` and `locations`. Unpacking or indexing them as the old `(function, name, description)` tuples is deprecated. - Custom templates for the change-form dropdown extend the change-list actions template. ## When to use it - One-record operations that staff otherwise do by editing a status field: ship, refund, resend an invoice email. - Operations that also make sense in bulk, so one function with both locations replaces two code paths. Keep in mind that it is new in 6.1: a project on the 5.2 LTS does not have it, and its `@admin.action` rejects the extra keyword arguments. ## Upgrading an existing admin to 6.1 1. **Find `get_actions()` overrides** and add `action_location=ActionLocation.CHANGE_LIST` to the signature, passing it to `super()`; otherwise the deprecation warning fires and change-form actions stay hidden. 2. **Find code that unpacks the returned values** as `(func, name, description)` tuples and switch to the `Action` attributes `func`, `name` and `description`. 3. **Do the same for `get_action_choices()` overrides.** 4. **Decide per action** whether it belongs on the change form. Bulk-only operations such as exports usually do not; per-record state changes usually do. 5. **Add `description_plural`** where the singular label reads badly on the list. ## A note on the single-row queryset Because the change-form action still receives a queryset, the same function body serves both places: `queryset.filter(status=PAID).update(...)` works for one row or three hundred. Code that assumes "many" — progress messages, confirmation pages listing rows — should read naturally for one row too.

  • A Django 6.1 project overrides get_actions(self, request) without action_location. What happens?
    The admin still calls it for the change list, emitting a `RemovedInDjango70Warning`. For the change form it returns no actions at all, so change-form actions silently disappear until the override accepts `action_location` and passes it to `super()`.
  • In Django 6.1, what happens to a staff user's unsaved edits when they run an admin action from the change form?
    They are lost. Choosing an action submits the action form, not the model form, so the admin runs the action on the stored row and redirects back to the form, which reloads from the database. Save first, or make the action's label say it acts on the saved record.

saying these in an interview costs you the question

  • Actions have always been available on the change form
  • description_plural is required whenever location is set
  • A change form action saves the form before running
  • location defaults to both the change list and the change form