skip to content

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%

answer

  1. return a response, not None
  2. hidden _selected_action inputs
  3. a marker field on the second POST
  4. post back to the change list

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.

solid answer

~40 s

An action that returns an `HttpResponse` replaces the admin's usual redirect, so the first call renders a confirmation template. That form POSTs back to the change-list URL with a CSRF token, one hidden `_selected_action` input per primary key (`helpers.ACTION_CHECKBOX_NAME`), `action=<name>` and a marker field such as `apply`. The change list routes a POST that carries `_selected_action` but no `index` field straight into `response_action()`, which rebuilds the queryset, re-applies the permission filter and calls the action again; this time `request.POST` has the marker, so it performs the change and returns `None`. That is exactly how `delete_selected` uses `post=yes`. For richer flows, redirect instead to your own admin view with the IDs. Either way, re-validate row state on the second POST, and remember `DATA_UPLOAD_MAX_NUMBER_FIELDS` (1000) caps how many hidden keys one form can carry.

code

python · 29 lines
python
from django.contrib import admin, messages
from django.contrib.admin import helpers
from django.db import transaction
from django.template.response import TemplateResponse

from .models import Order
from .services import ship_orders


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

    @admin.action(permissions=["change"], description="Mark selected orders as shipped")
    def mark_shipped(self, request, queryset):
        paid = queryset.filter(status=Order.Status.PAID)
        if request.POST.get("apply") == "yes":
            with transaction.atomic():
                count = ship_orders(paid.select_for_update(), by=request.user)
            self.message_user(request, f"{count} order(s) shipped.", messages.SUCCESS)
            return None  # back to the change list
        context = {
            **self.admin_site.each_context(request),
            "title": "Confirm shipping",
            "orders": paid,
            "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME,
            "opts": self.opts,
        }
        return TemplateResponse(request, "admin/shop/order/confirm_ship.html", context)

go deeper

for a junior

Recall that an action can return its own page instead of letting the admin redirect back to the list.

for a middle

Explain the hidden _selected_action, action and marker fields, and why the form posts back to the change-list URL.

for a senior

Re-validate state on the confirming POST, keep it idempotent and transactional, and handle select-all selections without hitting the form field limit.

for a principal

Decide when a confirm page is enough and when a bulk operation needs a reviewed, queued job with its own audit trail.

## Why a confirmation page A bulk action such as **"mark as shipped"** triggers irreversible side effects — carrier labels, customer emails, stock movements. Admins confirm first for the same reason Django's built-in `delete_selected` does: the user ticks rows on a paginated table, and a mis-click should not ship forty orders. ## The mechanism the admin gives you The admin calls an action from `ModelAdmin.response_action()`. Two facts make an intermediate page possible: - **A returned response wins.** If the action returns any `HttpResponse` subclass, the admin sends it; only `None` triggers the redirect back to the change list. - **The change list accepts a confirming POST.** `changelist_view()` has two routes into `response_action()`: the normal **Go** button, whose POST includes an `index` field saying which dropdown was used, and a **"confirmation"** route for a POST that carries `_selected_action` values but **no** `index`. The second route exists for pages like `delete_selected_confirmation.html`. ## The two-POST pattern 1. **First POST (from the change list).** The action sees no marker in `request.POST`, so it renders a template listing the orders and returns the `TemplateResponse`. Pass `**self.admin_site.each_context(request)` so the page gets the admin chrome. 2. **The confirmation form** POSTs to the same URL (so any change-list filters in the query string are preserved) and contains: - `{% csrf_token %}`; - one `<input type="hidden" name="_selected_action" value="<pk>">` per order — the name is `django.contrib.admin.helpers.ACTION_CHECKBOX_NAME`; - `<input type="hidden" name="action" value="mark_shipped">`; - a marker, e.g. `<input type="hidden" name="apply" value="yes">`. 3. **Second POST.** `response_action()` rebuilds the queryset from the change list, narrows it to the posted keys, **re-runs the permission filter** (so a user who lost the permission in between is refused), and calls the action. It sees `apply`, does the work, sends a message and returns `None`. ## Alternative: redirect to your own view When the intermediate step is a real form — choose a carrier, enter a tracking batch — return an `HttpResponseRedirect` to a custom view registered through `ModelAdmin.get_urls()` and wrapped with `admin_site.admin_view()`, passing the selected IDs in the query string. That view is ordinary Django code, so it must re-check permissions and re-fetch rows itself. | Approach | Good for | Watch out for | |---|---|---| | Return a `TemplateResponse`, post back | A yes/no confirmation | Marker naming; hidden-field count | | Redirect to a custom admin view | Multi-field forms, previews | URL length with many IDs; re-checking permissions | ## Production concerns - **Re-validate state on the second POST.** Minutes pass between the two requests; filter to `status=PAID` again so an order cancelled meanwhile is not shipped. - **Field limits.** `DATA_UPLOAD_MAX_NUMBER_FIELDS` defaults to **1000**; a confirmation form with thousands of hidden keys is rejected with a 400. For "Select all" selections, carry the `select_across` flag forward together with the visible page's keys instead of every key — the change list re-applies its filters — or process large batches in the background. - **Transactions.** Actions called from the change list are not wrapped in `transaction.atomic()` by the admin; add it for multi-table writes. - **Idempotency.** A double-submitted confirmation should be harmless because the second run finds no paid orders left. ## How `delete_selected` does it The built-in action is the reference implementation worth reading: - It checks `request.POST.get("post")` to tell the confirming request from the first one. - Its template, `delete_selected_confirmation.html`, contains `{% csrf_token %}`, a hidden `_selected_action` for every object, `action=delete_selected` and `post=yes`. - It builds its context from `modeladmin.admin_site.each_context(request)` plus `opts` and `action_checkbox_name`, so the page renders inside the admin's layout with the right breadcrumbs. - On the confirming POST it re-computes what would be deleted and refuses if anything became protected in between. ## Testing the flow A test should drive both requests: 1. POST the action with two keys and assert the response is the confirmation template and that no row changed. 2. POST again with the marker and the same keys, then assert the rows changed and the response redirects to the change list. 3. POST the marker for an order that was cancelled between the two steps and assert it was left alone.

  • Why must a Django admin confirmation form re-post every selected key instead of relying on the first request?
    The admin keeps no record of the earlier selection: each POST is handled on its own, and `response_action()` narrows the change list's queryset to the `_selected_action` values in that request. A confirming POST without them never reaches the action, so the page must carry the keys (or the `select_across` flag) forward.
  • A Django admin user selects all 5,000 filtered orders and the confirmation page fails with a 400. Why?
    The form carried 5,000 hidden `_selected_action` inputs, above the `DATA_UPLOAD_MAX_NUMBER_FIELDS` default of 1000, so Django rejects the request as too many fields. Carry `select_across` forward with just the visible page's keys and let the change list re-apply its filters, or hand large batches to a background job instead of raising the limit.

saying these in an interview costs you the question

  • An action cannot return anything but None
  • The confirmation page must be a separate view in urls.py
  • The second POST skips the admin's permission filtering
  • Rows confirmed on the first page cannot change before the second POST
  • Raising DATA_UPLOAD_MAX_NUMBER_FIELDS is the right fix for huge selections