Операции и расширения

Field, ModelSchema, Query, Relation описывают данные независимо от HTML.
Контролы настраиваются через FieldOptions; для новых экранов есть
страницы и шаблоны, для бизнес-правил — сервисный адаптер.

Lifecycle записи

  1. Перепроверить identity и право на операцию таблицы.
  2. Проверить CSRF, allowlist, типы полей и выбор связанных записей.
  3. Вызвать OperationHooks.before_write со снимком операции.
  4. Выполнить атомарную запись адаптера/сервиса с rollback при ошибке.
  5. После commit вызвать after_write, записать безопасные метаданные и перенаправить.
from quite_admin import OperationHooks, current_operation
from quite_admin.errors import ValidationError

async def validate(operation):
    if operation.values.get("stock", 0) < 0:
        raise ValidationError({"stock": "Остаток не может быть отрицательным."})

async def notify(operation, saved):
    await notifications.admin_change(operation.collection, operation.action)

admin = Admin(
    auth=auth_backend, secret_key=session_secret,
    hooks=OperationHooks(before_write=validate, after_write=notify),
)

Хуки бывают sync/async; sync-хуки не должны выполнять блокирующий I/O. Они работают
для create, edit со связями, delete и пользовательских действий. Operation содержит
request, актуальную identity, таблицу, модель, action, типизированный ключ, значения,
ключи связей и предыдущую запись. Mappings скопированы и неизменяемы на верхнем уровне;
изменение вложенного снимка не меняет данные предстоящей записи. Для преобразования
значений используйте parser поля или сервис.

current_operation() доступен в сервисах и ORM-хуках, включая worker синхронного
адаптера. Вне записи он возвращает None, контекст сбрасывается и при ошибке.
Не сохраняйте request/operation за пределами времени жизни запроса.

Before-хук выполняется вне транзакции адаптера и может видеть устаревшую запись.
Критичные инварианты и права на строку проверяются внутри транзакции сервиса.
After-хук — уведомление: при ошибке пишется quite_admin.after_write_failed без значений
и текста исключения; уже сохранённая запись не превращается в ошибку ответа. Для
надёжного аудита сохраняйте audit row/outbox внутри транзакции сервиса. Встроенного
durable audit store нет.

Сервисы и схемы форм отдельных моделей

Сохраните обычный адаптер и подключите бизнес-создание в настройках модели:

async def create_link(operation, payload):
    user = await users.get(operation.identity.subject)
    return await links.create(payload, user=user)

admin.register(Link, options=ModelOptions(
    create_fields=("long_url", "organization_id"),
    create_schema=LinkCreate,
    create_handler=create_link,
    update_schema=LinkUpdate,
))

create_handler(operation, payload) заменяет создание только этой модели. Остальные
модели и операции сохраняют поведение адаптера. Без create_schema payload — обычный
словарь; со схемой — проверенный объект Pydantic. Обработчик возвращает словарь скалярных
значений или ORM-объект с загруженными скалярными атрибутами. В редиректы, историю и
after-хуки попадают только поля схемы адаптера. Возвращайте полную сохранённую запись
с ключом, сгенерированными значениями и значениями по умолчанию.

Установите quite-admin[pydantic] для необязательных схем Pydantic 2 create_schema /
update_schema. FastAPI уже включает Pydantic. Схемы проверяются после CSRF, прав и
разрешённых скалярных полей, до lifecycle-хуков и сохранения. Ошибки полей отображаются
в форме, ошибки модели — сверху. Исходные значения и контекст ошибок не логируются.
Поля формы по-прежнему определяются адаптером и настройками create_fields, edit_fields
и input_fields. Схемы не добавляют колонки БД и не открывают поля
неявно.

Без собственного обработчика нормализованные отправленные значения сохраняются
штатной атомарной операцией с проверкой конкурентных изменений. Неотправленные значения
по умолчанию из схемы не записываются. Для частичных и inline-правок используйте схему
с необязательными полями: она получает изменения, без объединения с предыдущей записью.
Собственный обработчик получает полный проверенный payload с умолчаниями схемы и сам
определяет правила сохранения.

Если редактирование выполняет сервис, задайте update_handler:

async def update_link(operation, payload, *, guard):
    return await links.update(
        operation.key, payload, actor=operation.identity.subject,
        selections=operation.relations, guard=guard,
    )

Сервис отвечает за единую транзакцию, откат и преобразование ошибок. Ненулевой guard
нужно проверить по свежим заблокированным данным внутри этой транзакции, как описано
в контракте checked-адаптера. guard=None означает, что адаптер
не поддерживает проверяемую запись. Проверять старый снимок перед отдельной записью
недостаточно. Связи из operation.relations также проверяются и сохраняются атомарно:
обработчик заменяет всю операцию, включая связи. Обработчики работают внутри
current_operation(), между обычными before/after-хуками. Синхронные обработчики не
должны выполнять блокирующий I/O. Ожидаемые ошибки сервиса преобразуйте в публичные
ValidationError, Conflict или NotFound.

Действия записи

from quite_admin import ModelOptions, RecordAction

async def publish(operation):
    return await products.publish(operation.key, actor=operation.identity.subject)

admin.configure("products", options=ModelOptions(
    actions=(RecordAction("publish", "Опубликовать", publish),),
))

Кнопки появляются на единой странице сущности. Выполнение требует POST, сессию,
CSRF и по умолчанию право edit. Для разрушительного действия задайте
required_permission="delete"; дополнительный callback задаётся через permission.
Handler принимает Operation, управляет своей транзакцией и возвращает scalar mapping
или None; после него выполняется переход к текущей записи. Действия, удаляющие
запись, требуют собственного подтверждения и перехода в приложении. GET действия
не выполняет. Handler может выбрасывать публичные ошибки адаптера. Подпись действия
задаётся на языке настроенного интерфейса.

Совместимость определяют ADAPTER_API_VERSION и контракт адаптера.
Границы шаблонов и расширений описаны в гарантиях публичного API.

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

По умолчанию у каждой таблицы есть Удалить выбранные записи с учётом обычного
права delete и can_delete. Регистрировать его не нужно. Отключить только массовое
удаление можно через ModelOptions(bulk_delete=False); одиночное удаление остаётся доступным.

Когда на странице есть записи и разрешённая команда, рядом со строками появляются флажки,
а справа в панели таблицы, рядом с экспортом и созданием, — компактная иконка
Массовые действия, такого же размера, как иконка фильтров. Выберите отдельные строки
или всю текущую страницу флажком в заголовке и откройте меню иконки. Метка на ней показывает
число выбранных записей; список С выбранными позволяет выбрать команду.
Продолжить открывает отдельное подтверждение с подписями записей. Подтвердите выполнение
или отмените его, чтобы вернуться с прежними поиском, фильтрами и пагинацией. Escape закрывает
меню и возвращает фокус на иконку; нажатие снаружи также закрывает его. Выбор отдельных строк
и подтверждение работают без JavaScript. Можно выбрать до 100 разных записей; выбор
не распространяется на остальные страницы. Без разрешённых команд иконка и флажки скрыты.

Чтобы добавить команду только в список одной таблицы, передайте RecordAction в bulk_actions:

from quite_admin import ModelOptions, RecordAction

# Используйте Admin, модель Product и адаптер из настройки вашего приложения.
async def deactivate_product(operation):
    return await adapter.update(operation.model, operation.key, {"active": False})

admin.register(Product, adapter=adapter, options=ModelOptions(
    bulk_actions=(RecordAction("deactivate", "Деактивировать товары", deactivate_product),),
))

Команда появляется рядом со встроенным удалением только у Products. После подтверждения
каждый выбранный товар получает active=False; остальные товары и таблицы не меняются.
По умолчанию bulk_actions=(). Его элементы всегда доступны для массового выполнения:
bulk=True не требуется, кнопки в карточке и адреса одиночного выполнения не добавляются.
При автообнаружении используйте admin.configure("products", options=...).
Параметры заменяют всю конфигурацию таблицы: сохраните нужные настройки полей и прав.

Попробуйте действие в локальном FastAPI/Litestar-примере:
откройте Products, выберите два активных товара, команду Deactivate products,
продолжите и подтвердите. Оба значения Active станут ложными, результат покажет два успеха.
Обработчик находится в examples/common.py.
Автономное браузерное демо не выполняет Python-обработчики и массовые действия.

Чтобы использовать обработчик и в карточке записи, и в списке, по-прежнему задавайте
actions с bulk=True:

from quite_admin import ModelOptions, RecordAction

async def reset_counter(operation):
    return await counter_service.reset(operation.key, user=operation.identity)

admin.configure("counters", options=ModelOptions(actions=(
    RecordAction("reset", "Сбросить счётчики", reset_counter, bulk=True),
)))

Существующий API RecordAction(bulk=True) не изменился; у элементов actions по умолчанию
остаётся bulk=False. Идентификаторы должны быть уникальны среди actions и bulk_actions
одной таблицы; в других таблицах их можно повторять. Для общей кнопки зарегистрируйте
действие один раз в actions с bulk=True, не дублируя его идентификатор в обоих параметрах.

Оба способа по умолчанию требуют право edit. required_permission выбирает обычное право
таблицы, а permission добавляет синхронный или асинхронный callback. Для каждого ключа заново
проверяются права list и действия; в request.path_params["key"] передаётся его ключ.
Используются существующие Operation, current_operation(), before_write и
after_write. Правила, которым нужны данные записи, проверяйте в обработчике или
before_write. Новые методы адаптера и отдельный класс массового сервиса не нужны.

Подтверждение подписано, связано с таблицей, командой, пользователем и сессией
и действует 15 минут. Для обоих шагов нужен CSRF. Перед каждой командой проверяется
отпечаток записи на момент подтверждения. Это проверка перед вызовом, а не атомарный
compare-and-delete и не замена транзакции/блокировке обработчика. За транзакцию и
идемпотентность обработчик отвечает так же, как в одиночном действии. Команды
выполняются последовательно в запросе, максимум для 100 записей; фоновые задания
и выбор между страницами не создаются.

Результат показывает успех или ошибку каждой записи. Ошибка не останавливает остальные
записи; завершённые изменения сохраняются. Общей транзакции на весь набор нет.
Каждая операция получает собственное событие истории согласно history_level;
ошибки записываются на уровнях "audit" и "all". Неожиданная ошибка отображается
общим сообщением, а лог содержит только тип исключения и метаданные команды.