Adds SoftDeleteMixin (deleted_at + deleted_by_id) with automatic query
filtering via do_orm_execute event. Soft-deleted records are invisible
by default; bypass with execution_options={"include_deleted": True}.
Models: User, Merchant, Store, StoreUser, Customer, Order, Product,
LoyaltyProgram, LoyaltyCard.
Infrastructure:
- SoftDeleteMixin in models/database/base.py
- Auto query filter registered on SessionLocal and test sessions
- soft_delete(), restore(), soft_delete_cascade() in app/core/soft_delete.py
- Alembic migration adding columns to 9 tables
- Partial unique indexes on users.email/username, stores.store_code/subdomain
Service changes:
- admin_service: delete_user, delete_store → soft_delete/soft_delete_cascade
- merchant_service: delete_merchant → soft_delete_cascade (stores→children)
- store_team_service: remove_team_member → soft_delete (fixes is_active bug)
- product_service: delete_product → soft_delete
- program_service: delete_program → soft_delete_cascade
Admin API:
- include_deleted/only_deleted query params on admin list endpoints
- PUT restore endpoints for users, merchants, stores
Tests: 9 unit tests for soft-delete infrastructure.
Docs: docs/backend/soft-delete.md + follow-up proposals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
3.7 KiB
Soft Delete
Overview
Business-critical records use soft delete instead of hard delete. When a record is "deleted", it gets a deleted_at timestamp instead of being removed from the database. This preserves data for investigation, auditing, and potential restoration.
How It Works
SoftDeleteMixin
Models opt into soft delete by inheriting SoftDeleteMixin (from models/database/base.py):
from models.database.base import SoftDeleteMixin, TimestampMixin
class MyModel(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "my_table"
# ...
This adds two columns:
| Column | Type | Description |
|---|---|---|
deleted_at |
DateTime (nullable, indexed) | When the record was deleted. NULL = alive. |
deleted_by_id |
Integer (FK to users.id, nullable) | Who performed the deletion. |
Automatic Query Filtering
A do_orm_execute event on the session automatically appends WHERE deleted_at IS NULL to all SELECT queries for models with SoftDeleteMixin. This means:
- Normal queries never see deleted records — no code changes needed
- Relationship lazy loads are also filtered — e.g.,
store.productswon't include deleted products
Bypassing the Filter
To see deleted records (admin views, restore operations):
# Legacy query style
db.query(User).execution_options(include_deleted=True).all()
# Core select style
from sqlalchemy import select
db.execute(
select(User).filter(User.id == 42),
execution_options={"include_deleted": True}
).scalar_one_or_none()
Models Using Soft Delete
| Model | Table | Module |
|---|---|---|
| User | users | tenancy |
| Merchant | merchants | tenancy |
| Store | stores | tenancy |
| StoreUser | store_users | tenancy |
| Customer | customers | customers |
| Order | orders | orders |
| Product | products | catalog |
| LoyaltyProgram | loyalty_programs | loyalty |
| LoyaltyCard | loyalty_cards | loyalty |
Utility Functions
Import from app.core.soft_delete:
soft_delete(db, entity, deleted_by_id)
Marks a single record as deleted.
restore(db, model_class, entity_id, restored_by_id)
Restores a soft-deleted record. Queries with include_deleted=True internally.
soft_delete_cascade(db, entity, deleted_by_id, cascade_rels)
Soft-deletes a record and recursively soft-deletes its children:
soft_delete_cascade(db, merchant, deleted_by_id=admin.id, cascade_rels=[
("stores", [
("products", []),
("customers", []),
("orders", []),
("store_users", []),
]),
])
Partial Unique Indexes
Tables with unique constraints (e.g., users.email, stores.store_code) use partial unique indexes that only enforce uniqueness among non-deleted rows:
CREATE UNIQUE INDEX uq_users_email_active ON users (email) WHERE deleted_at IS NULL;
This allows a soft-deleted user's email to be reused by a new registration.
Adding Soft Delete to a New Model
- Add
SoftDeleteMixinto the model class - Create an alembic migration adding
deleted_atanddeleted_by_idcolumns - If the model has unique constraints, convert them to partial unique indexes
- If the model has relationships to users (ForeignKey to users.id), add
foreign_keys=to those relationships to resolve ambiguity withdeleted_by_id - Register the test session factory with
register_soft_delete_filter()if not already done
What Stays as Hard Delete
Operational and config data that doesn't need investigation trail:
- Roles, themes, email settings, invoice settings
- Cart items, application logs, notifications
- Password/email verification tokens
- Domains (store and merchant)
- Content pages, media files
- Import jobs, marketplace products