Skip to Content
ContributeBackendSoft Deletion

Soft Deletion

Soft-deleted rows stay in the database with deleted_at set but are hidden from normal queries. This preserves history and foreign-key integrity while supporting restore and admin visibility.

The behavior lives on Base (see Database Models), so every model inherits it: deleted_at, soft_delete(), and restore().

How filtering works

A before_compile event listener (app/models/soft_delete_events.py) adds deleted_at IS NULL to every query automatically, so callers never append the predicate manually.

The listener applies the filter before LIMIT/OFFSET, which matters for pagination: when a query already has a limit applied, adding .filter() raises InvalidRequestError, so the listener falls back to modifying the query’s _where_criteria directly. Both count queries and paginated result sets therefore exclude deleted rows and report accurate totals.

query-behavior.py
users = db.query(User).limit(10).all()
# SELECT * FROM user WHERE deleted_at IS NULL LIMIT 10

count = db.query(User).count()
# SELECT COUNT(*) FROM user WHERE deleted_at IS NULL

Bypassing the filter

Three escape hatches disable filtering when you need deleted rows:

soft-delete-bypass.py
from rhesis.backend.app.database import without_soft_delete_filter
from rhesis.backend.app.utils.model_utils import QueryBuilder

# QueryBuilder helpers
QueryBuilder(db, User).with_deleted().all()   # active + deleted
QueryBuilder(db, User).only_deleted().all()    # recycle-bin view

# Context manager (global, e.g. admin operations)
with without_soft_delete_filter():
    all_users = db.query(User).all()

CRUD operations

crud_utils wraps deletion and restore. delete_item soft-deletes by default (and cascades to configured children — see Cascade Operations).

crud-operations.py
from rhesis.backend.app.utils import crud_utils

crud_utils.delete_item(db, Model, item_id, organization_id=org_id)      # soft delete
crud_utils.restore_item(db, Model, item_id, organization_id=org_id)     # restore
crud_utils.get_deleted_items(db, Model, organization_id=org_id)         # only deleted
crud_utils.hard_delete_item(db, Model, item_id, organization_id=org_id) # permanent
crud_utils.get_item(db, Model, item_id, include_deleted=True)

Recycle Bin API (superuser only)

app/routers/recycle.py exposes REST endpoints for managing deleted records:

recycle-endpoints.txt
GET    /recycle/models                              # models with a recycle bin
GET    /recycle/{model_name}                         # list deleted records (paginated)
POST   /recycle/{model_name}/{item_id}/restore       # restore one
DELETE /recycle/{model_name}/{item_id}?confirm=true  # permanently delete one
POST   /recycle/bulk-restore/{model_name}            # body: { "item_ids": [...] }
DELETE /recycle/empty/{model_name}?confirm=true      # permanently delete all
GET    /recycle/stats/counts                         # per-model deleted counts

Key files

FilePurpose
app/models/base.pydeleted_at column, soft_delete() / restore()
app/models/soft_delete_events.pybefore_compile auto-filter listener
app/database.pywithout_soft_delete_filter() context manager
app/utils/crud_utils.pysoft/hard delete and restore helpers
app/utils/model_utils.pyQueryBuilder with with_deleted() / only_deleted()
app/routers/recycle.pyrecycle-bin REST API