Model Mixins
All Tortoise models should inherit BaseModel and mix in the mixins they need. Source: app/core/base_model.py and app/core/soft_delete.py.
# pyright: reportIncompatibleVariableOverride=false
from tortoise import fields
from app.utils import AuditMixin, BaseModel, SoftDeleteMixin, StatusType, TreeMixin
class Department(BaseModel, AuditMixin, TreeMixin, SoftDeleteMixin):
"""Department"""
id = fields.IntField(primary_key=True)
name = fields.CharField(max_length=100, unique=True, description="department name")
code = fields.CharField(max_length=50, unique=True, description="department code")
status = fields.CharEnumField(enum_type=StatusType, default=StatusType.enable, description="status")
class Meta:
table = "biz_hr_department"
table_description = "Department"
# pyright: reportIncompatibleVariableOverride=falseis a known false-positive suppression for Tortoise + Pyright; add it to every model file.
BaseModel
BaseModel(models.Model) provides to_dict() — converts an instance to a camelCase dict, handling:
datetime→ millisecond timestamp; also outputs a formatted string fieldfmtCreatedAtetc. (toggleable)Decimal→floatEnum→valueUUID→str- PK + FK (
id/*_id) → sqid string;0(root / empty-reference semantics) is preserved as0
async def to_dict(
self,
include_fields: list[str] | None = None, # whitelist
exclude_fields: list[str] | None = None, # blacklist
m2m: bool = False, # serialize M2M relations
fmt_datetime: bool = True, # output formatted datetime fields
)data = await user.to_dict(exclude_fields=["password", "created_by", "updated_by"])
return Success(data=data)
CRUDRoutercallsobj.to_dict(exclude_fields=...)for you.
AuditMixin
created_by = CharField(max_length=64, null=True)
created_at = DatetimeField(auto_now_add=True)
updated_by = CharField(max_length=64, null=True)
updated_at = DatetimeField(auto_now=True)CRUDBase.create / update / soft_remove auto-write created_by / updated_by from CTX_USER_ID (stringified user id).
Use it on every persisted model
Even immutable seed data benefits massively for incident debugging.
TreeMixin
parent_id = IntField(default=0) # 0 = top
order = IntField(default=0)
level = IntField(default=1) # redundant; maintained by business codeConventions:
parent_id = 0is the rootlevelis not auto-maintained — set it on write asparent.level + 1(if you use it)- For tree serialization use
CRUDRouter(tree_endpoint=True)— calls_build_nested_tree(records, parent_id_key="parentId", root_value=0)
Don't add TreeMixin to Menu
Menu already declares its own parent_id / order — mixing in conflicts.
SoftDeleteMixin
Source: app/core/soft_delete.py.
deleted_at = DatetimeField(null=True, default=None)Behavior:
- Default Manager is replaced by
SoftDeleteManager;Model.filter()/.all()/.get()auto-adddeleted_at IS NULL - Soft delete via
controller.soft_remove(id=...)—UPDATE deleted_at = now()+ refreshupdated_by - Access deleted rows via
Model.all_objects.filter(deleted_at__isnull=False)
# soft delete
await dept_controller.soft_remove(id=1)
# default query excludes deleted
await Department.filter(name="Engineering") # deleted_at IS NULL
# include deleted
await Department.all_objects.all()PostgreSQL: partial unique index
SoftDeleteMixin paired with unique=True is tricky — soft-deleted rows still hold the constraint. In production on PostgreSQL replace plain UNIQUE with a partial index:
CREATE UNIQUE INDEX biz_department_code_active_uq
ON biz_department(code)
WHERE deleted_at IS NULL;SQLite doesn't support WHERE partial indexes — enforce in app layer (controller.exists).
Working with CRUDRouter
CRUDRouter(
...,
soft_delete=True, # delete / batch_delete use soft_remove
tree_endpoint=True, # register GET /resources/tree
)Custom mixins
Need a recurring field in your business (e.g. multi-tenant tenant_id)? Define your own:
class TenantMixin:
tenant_id = fields.IntField(db_index=True, description="tenant id")
class Meta:
abstract = Trueabstract = True is required, otherwise Tortoise tries to create a separate table for the mixin.
See also
- Data models (system) — full field listings
- CRUDBase —
soft_remove/get_treeetc. - Sqids — how PK / FK become sqid strings