Удаление
Подтверждение удаления подсчитывает, что произойдёт на самом деле — строки, которые будут удалены каскадно, строки, которые останутся с очищенной связью, и строки, которые полностью блокируют удаление.
Доступно с версии v0.1.0
Большинство админок спрашивают «вы уверены?», а последствия узнают уже постфактум. FastFort обходит граф связей до того, как что-либо записано, и сообщает по каждой связанной модели, что произойдёт на самом деле.
Три эффекта
| Эффект | Когда | Что говорит страница |
|---|---|---|
DELETE | каскад ORM или ON DELETE CASCADE | «будет удалено тоже» |
CLEAR | допускающий NULL внешний ключ, или ON DELETE SET NULL | «сохранено, без связи» |
PROTECT | NOT NULL внешний ключ без каскада | блокирует удаление |
Каждый эффект считывается из маппера и ограничения (constraint), а не угадывается.
class Invoice(Base):
# Declared, so the plan reports DELETE.
lines: Mapped[list[InvoiceLine]] = relationship(
back_populates="invoice", cascade="all, delete-orphan"
)
class InvoiceLine(Base):
# NOT NULL with no database cascade → PROTECT.
invoice_id: Mapped[int] = mapped_column(sa.ForeignKey("invoices.id"))
class Zone(Base):
# Nullable with SET NULL → CLEAR.
hub_id: Mapped[int | None] = mapped_column(
sa.ForeignKey("hubs.id", ondelete="SET NULL")
)
Он сообщает, что сделает ORM, а не то, что вы имели в виду
Это самая важная часть. Модель, которая никогда не объявляла
cascade="all, delete-orphan", получает поведение SQLAlchemy по
умолчанию: дочерние записи обнуляются, а не удаляются.
FastFort сообщает об этом честно, вместо того чтобы заявлять о каскаде, которого ORM не выполнит. Если в подтверждении написано «сохранено, без связи», а вы ожидали каскад, — подтверждение право, а в модели не хватает объявления.
PROTECT отказывает одним предложением
This customer cannot be deleted: 3 invoice lines would be left without an
invoice.
Не IntegrityError с именем ограничения внутри, возникающий уже после
того, как попытка записи была предпринята.
Обнаруживается со стороны потомка
Связи обнаруживаются путём сканирования мапперов реестра на предмет отношений «многие-к-одному», указывающих на эту таблицу, — со стороны потомка, а не через обратную ссылку у родителя. Поэтому внешний ключ, модель которого никогда не объявляла обратную ссылку, всё равно будет найден.
Связи «многие-ко-многим» намеренно исключены: удаление товара убирает его
строки из product_tags, но не сами теги.
Каскады отслеживаются и честно подсчитываются
На глубину до трёх уровней. Более глубокие уровни достигаются через подзапрос, а не через строки, которые случайно попали в выборку уровнем выше, — иначе второе число оказалось бы долей, напечатанной как итог.
Составной внешний ключ останавливает рекурсию.
Deleting 1 customer will affect:
Invoices 14 kept, without the link
Shipments 86 kept, without the link
Invoice lines 41 will be deleted too ← reached at depth 2, through
the invoice cascade
В качестве примера называются пять строк. Подсчёт останавливается на тысяче
и отображается как «1000+», вместо того чтобы выполнять COUNT(*) по
таблице, в которой могут быть миллионы строк.
Проверяется дважды
Один раз — на странице подтверждения, и ещё раз непосредственно перед записью — потому что страница подтверждения могла быть открыта час назад, а граф связей за это время мог измениться. Та же вторая проверка выполняется перед массовым удалением.
Встроенные кнопки
Кнопка удаления в каждой строке использует дешёвую подсказку на уровне спецификации — каскадирует ли хоть одна связь, — без единого запроса и без подсчётов. Полный план вычисляется только для страницы подтверждения, потому что вычислять его для каждой строки означало бы один обход графа на каждую строку таблицы.
Удаление через действие
Встроенное действие delete выполняет тот же план для каждой выбранной
строки. Если хотя бы одна из них защищена, ничего не удаляется, а
страница сообщает, какая именно, — наполовину выполненное массовое
удаление хуже, чем не выполненное вовсе, потому что оператору придётся
разбираться, какая половина была удалена.
Мягкое удаление
Всё, что описано выше, — это поведение delete по умолчанию: строка
удаляется, а граф обходится, чтобы сказать, что уйдёт вместе с ней.
ModelAdmin.soft_delete_field заменяет это чем-то более узким — записью
вместо удаления, и обходить вообще нечего.
from fastfort import admin
class OrderAdmin(admin.ModelAdmin):
soft_delete_field = "deleted_at"
Назовите nullable-столбец типа boolean или date/datetime, который уже есть
у проекта, — и delete перестаёт вызывать delete адаптера: вместо этого
он записывает значение в этот столбец. Никакой миграции, кроме самого
столбца, никакой второй таблицы: строка, отправленная в корзину, никогда не
покидает ту таблицу, в которой всегда была.
deletion_plan вообще не вызывается. Строка, отправленная в корзину, никуда
не делась, поэтому ничему, что на неё ссылается, не нужно каскадироваться,
очищаться или блокировать удаление — каждый эффект, описанный выше на этой
странице, — это вопрос, который данный процесс больше не задаёт. Страница
подтверждения отражает это: никаких предупреждений о связанных записях, а
«это действие необратимо» становится «это можно восстановить из корзины».
Список получает представление «Корзина» (?trashed=1, и ссылку на неё
рядом с «Добавить»), которое показывает только помеченные строки и
предлагает Восстановить вместо Удалить. Булев маркер принимает
True/False; маркер типа date или datetime — момент, когда строка была
отправлена в корзину, и None. Nullable требуется только от второго типа,
но не от первого, — потому что у булева значения уже есть оба состояния.
Сам столбец-маркер никогда не появляется в форме создания или редактирования. Это внутренняя учётная запись конвейера удаления, а не значение, которое кто-то должен задавать вручную.
Как это увидеть
В демо:
/admin/billing.invoice/1/delete DELETE — the lines cascade
/admin/billing.customer/1/delete CLEAR at two depths, and a DELETE at depth 2
/admin/logistics.hub/1/delete CLEAR — the zones are kept