2026-05-01 15:03:00 +00:00
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
|
|
|
|
from app import db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SoftDeleteMixin:
|
|
|
|
|
"""Mixin that provides soft-delete functionality.
|
|
|
|
|
|
|
|
|
|
Instead of removing rows from the database, records are marked with
|
|
|
|
|
status='deleted' and a deleted_at timestamp. This preserves data
|
|
|
|
|
integrity and supports audit/recovery use-cases.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
class MyModel(db.Model, SoftDeleteMixin):
|
|
|
|
|
...
|
|
|
|
|
|
|
|
|
|
# Queries
|
|
|
|
|
MyModel.active().filter_by(name="foo").all()
|
|
|
|
|
|
|
|
|
|
# Soft-delete
|
|
|
|
|
instance = db.session.get(MyModel, 1)
|
|
|
|
|
instance.soft_delete()
|
|
|
|
|
db.session.commit()
|
|
|
|
|
|
|
|
|
|
# Restore
|
|
|
|
|
instance.restore()
|
|
|
|
|
db.session.commit()
|
2026-05-01 16:21:28 +00:00
|
|
|
|
|
|
|
|
For relationship filtering:
|
|
|
|
|
class Parent(db.Model, SoftDeleteMixin):
|
|
|
|
|
children = db.relationship(
|
|
|
|
|
"Child",
|
|
|
|
|
primaryjoin="and_(Parent.id == Child.parent_id, "
|
|
|
|
|
"Child.status == 'active')",
|
|
|
|
|
...
|
|
|
|
|
)
|
2026-05-01 15:03:00 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
STATUS_ACTIVE = "active"
|
|
|
|
|
STATUS_DELETED = "deleted"
|
|
|
|
|
|
|
|
|
|
status = db.Column(
|
|
|
|
|
db.String(20),
|
|
|
|
|
default=STATUS_ACTIVE,
|
|
|
|
|
nullable=False,
|
|
|
|
|
index=True,
|
|
|
|
|
)
|
|
|
|
|
deleted_at = db.Column(db.DateTime, nullable=True)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def active(cls):
|
|
|
|
|
"""Return a query scoped to non-deleted records only.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
Board.active().filter_by(user_id=1).all()
|
|
|
|
|
Card.active().order_by(Card.pos).all()
|
|
|
|
|
"""
|
|
|
|
|
return cls.query.filter(cls.status == cls.STATUS_ACTIVE)
|
|
|
|
|
|
2026-05-01 16:21:28 +00:00
|
|
|
@property
|
|
|
|
|
def is_active(self):
|
|
|
|
|
"""Check if this record is active (not soft-deleted)."""
|
|
|
|
|
return self.status == self.STATUS_ACTIVE
|
|
|
|
|
|
2026-05-01 15:03:00 +00:00
|
|
|
def soft_delete(self):
|
|
|
|
|
"""Mark this record as deleted and cascade to child relationships.
|
|
|
|
|
|
|
|
|
|
Every relationship defined with cascade="all, delete-orphan" or
|
|
|
|
|
cascade="all" is considered a *soft-deletable child* and will also
|
|
|
|
|
be soft-deleted recursively.
|
|
|
|
|
"""
|
|
|
|
|
self._do_soft_delete()
|
|
|
|
|
|
|
|
|
|
def _do_soft_delete(self):
|
|
|
|
|
"""Internal recursive soft-delete implementation."""
|
|
|
|
|
now = datetime.now(UTC)
|
|
|
|
|
|
|
|
|
|
# Soft-delete children from configured cascade relationships
|
|
|
|
|
for prop in self.__mapper__.relationships:
|
|
|
|
|
cascade = prop.cascade
|
|
|
|
|
if cascade is None:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Only cascade to relationships that had delete/delete-orphan
|
|
|
|
|
if not (cascade.delete or cascade.delete_orphan):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Get the related objects
|
|
|
|
|
children = getattr(self, prop.key, None)
|
|
|
|
|
if children is None:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Handle dynamic/lazy relationships (query-like)
|
|
|
|
|
if hasattr(children, "all"):
|
|
|
|
|
children = children.all()
|
|
|
|
|
|
|
|
|
|
# Handle single (many-to-one / scalar) relationships
|
|
|
|
|
if not isinstance(children, list):
|
|
|
|
|
children = [children] if children else []
|
|
|
|
|
|
|
|
|
|
for child in children:
|
|
|
|
|
if isinstance(child, SoftDeleteMixin):
|
|
|
|
|
child._do_soft_delete()
|
|
|
|
|
|
|
|
|
|
self.status = self.STATUS_DELETED
|
|
|
|
|
self.deleted_at = now
|
|
|
|
|
|
|
|
|
|
def restore(self):
|
|
|
|
|
"""Restore a soft-deleted record and cascade to children."""
|
|
|
|
|
self._do_restore()
|
|
|
|
|
|
|
|
|
|
def _do_restore(self):
|
|
|
|
|
"""Internal recursive restore implementation."""
|
|
|
|
|
for prop in self.__mapper__.relationships:
|
|
|
|
|
cascade = prop.cascade
|
|
|
|
|
if cascade is None:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if not (cascade.delete or cascade.delete_orphan):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
children = getattr(self, prop.key, None)
|
|
|
|
|
if children is None:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if hasattr(children, "all"):
|
|
|
|
|
children = children.all()
|
|
|
|
|
|
|
|
|
|
if not isinstance(children, list):
|
|
|
|
|
children = [children] if children else []
|
|
|
|
|
|
|
|
|
|
for child in children:
|
|
|
|
|
if isinstance(child, SoftDeleteMixin):
|
|
|
|
|
child._do_restore()
|
|
|
|
|
|
|
|
|
|
self.status = self.STATUS_ACTIVE
|
|
|
|
|
self.deleted_at = None
|