Массовые действия

Панель, которая появляется при отметке строк, — как объявить действие, в какой транзакции оно выполняется и как отказаться от них вовсе.

Доступно с версии v0.1.0

Отметьте строку — и над таблицей появится панель. delete встроено и включено по умолчанию; всё остальное — методы на ModelAdmin.

@admin.register(Product, key="catalog.product")
class ProductAdmin(admin.ModelAdmin):
    actions = ("delete", "activate", "mark_discontinued")

    @admin.action("Activate", icon="check-circle")
    async def activate(self, adapter, objects):
        for product in objects:
            await adapter.update(product, {"is_active": True})
        return f"{len(objects)} products activated."

Декоратор несёт в себе подпись и иконку, поэтому они не могут разойтись с кодом, который их реализует, — а именно это допускало бы простое перечисление имён методов в списке.

@admin.action(label, *, icon=None, danger=False, confirm=None)

labelЧто написано на кнопке
iconИмя из fastfort.ui.icons, проверяется на этапе объявления
dangerОтрисовывается цветом опасности и спрашивает подтверждение перед запуском
confirmТекст вопроса. {count} подставляется автоматически
@admin.action(
    "Mark discontinued",
    icon="trash",
    danger=True,
    confirm="Mark {count} products discontinued? They will stop appearing in the shop.",
)
async def mark_discontinued(self, adapter, objects):
    for product in objects:
        await adapter.update(
            product, {"availability": Availability.DISCONTINUED, "is_active": False}
        )
    return f"{len(objects)} products discontinued."

Сигнатура

async def handler(self, adapter, objects) -> str: ...

adapter — это ModelAdapter для этой модели, привязанный к единице работы текущего запроса. objects — уже загруженные строки, которые были отмечены. Возвращаемое значение — сообщение, показанное после выполнения.

Действие никогда не коммитит

Это стоит усвоить как следует. Адаптер выполняет flush; UnitOfWork запроса коммитит при штатном завершении и откатывается при исключении. Поэтому действие, упавшее на сороковой строке, оставляет незаписанными и первые тридцать девять — а это почти всегда то, что нужно от массовой операции, и не то, что даёт цикл из отдельных сохранений.

@admin.action("Recalculate totals")
async def recalculate(self, adapter, objects):
    for invoice in objects:
        # If this raises on invoice 40, invoices 1–39 are rolled back with it.
        await adapter.update(invoice, {"total": compute_total(invoice)})
    return f"{len(objects)} invoices recalculated."

Не вызывайте commit() самостоятельно. Это разделит запрос на две транзакции и нарушит эту гарантию для всего, что идёт следом.

Ни одного действия

actions = ()

Панель никогда не появляется. Так модель, строки которой нельзя удалять массово, заявляет об этом явно — кнопка удаления отдельной строки при этом не затрагивается.

@admin.register(ApiToken, key="accounts.token")
class ApiTokenAdmin(admin.ModelAdmin):
    actions = ()

Доступность

Метод, помеченный @admin.action, но не включённый в actions, недоступен — отправка его имени не сработает. Проверка идёт по объявленному списку, а не по getattr — поэтому закомментированное в actions имя отключает действие, а не просто скрывает кнопку.

Встроенное удаление

"delete" выполняет тот же план удаления, что показан на странице подтверждения, и проверяет его повторно непосредственно перед записью — потому что страница могла быть открыта час назад, а граф связей за это время мог измениться.

Если хотя бы одна из выбранных строк защищена, не удаляется ничего, а страница сообщает, какая именно.

Ошибки

Бросьте исключение — и всё откатится, а оператору будет показано сообщение:

from fastfort import ValidationError

@admin.action("Ship", icon="truck")
async def ship(self, adapter, objects):
    unpaid = [s for s in objects if s.customer and not s.customer.is_active]
    if unpaid:
        raise ValidationError(
            f"{len(unpaid)} shipments belong to inactive customers.",
            hint="Reactivate the customer, or deselect those rows.",
        )
    ...

ValidationError отображается на странице как сообщение. Всё остальное в продакшене превращается в общую ошибку — с трассировкой в логах, а не на чьём-то экране.