From f407285e60b87ae102d6b0d9bfd341e9e6b18351 Mon Sep 17 00:00:00 2001 From: david Date: Fri, 1 May 2026 18:03:00 +0300 Subject: [PATCH] add soft delete logic --- backend/app/config.py | 4 +- backend/app/decorators/decorators.py | 16 +- backend/app/decorators/owned.py | 7 +- backend/app/models/__init__.py | 2 + backend/app/models/base.py | 123 +++++ backend/app/models/board.py | 5 +- backend/app/models/card.py | 5 +- backend/app/models/card_label.py | 5 +- backend/app/models/card_link.py | 5 +- backend/app/models/check_item.py | 5 +- backend/app/models/checklist.py | 9 +- backend/app/models/comment.py | 5 +- backend/app/models/epic.py | 5 +- backend/app/models/file_attachment.py | 5 +- backend/app/models/label.py | 10 +- backend/app/models/list_model.py | 5 +- backend/app/models/wiki.py | 5 +- backend/app/routes/kanban/boards.py | 42 +- backend/app/routes/kanban/card_links.py | 7 +- backend/app/routes/kanban/cards.py | 4 +- backend/app/routes/kanban/checklists.py | 4 +- backend/app/routes/kanban/comments.py | 2 +- backend/app/routes/kanban/epics.py | 8 +- backend/app/routes/kanban/labels.py | 2 +- backend/app/routes/kanban/lists.py | 2 +- backend/app/routes/kanban/wikis.py | 4 +- backend/app/services/storage/file_service.py | 3 +- .../7a0cfda486e1_add_soft_delete_columns.py | 146 ++++++ backend/tests/conftest.py | 2 +- backend/tests/routes/test_boards.py | 331 +++++++++++- backend/tests/routes/test_cards.py | 35 +- backend/tests/routes/test_checklists.py | 6 +- backend/tests/routes/test_comments.py | 3 +- backend/tests/routes/test_epics.py | 3 +- backend/tests/routes/test_labels.py | 3 +- backend/tests/routes/test_lists.py | 11 +- .../routes/test_soft_delete_integration.py | 496 ++++++++++++++++++ backend/tests/routes/test_wikis.py | 14 +- 38 files changed, 1273 insertions(+), 76 deletions(-) create mode 100644 backend/app/models/base.py create mode 100644 backend/migrations/versions/7a0cfda486e1_add_soft_delete_columns.py create mode 100644 backend/tests/routes/test_soft_delete_integration.py diff --git a/backend/app/config.py b/backend/app/config.py index 67710a6..246ec5d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -88,8 +88,8 @@ class TestingConfig(Config): # Conservative connection pool settings for testing SQLALCHEMY_ENGINE_OPTIONS = { - "pool_size": 1, # Only one connection in the pool - "max_overflow": 0, # No overflow connections allowed + "pool_size": 2, # Only one connection in the pool + "max_overflow": 2, # No overflow connections allowed "pool_timeout": 30, "pool_recycle": 3600, # Recycle after 1 hour "pool_pre_ping": True, # Verify connections before using diff --git a/backend/app/decorators/decorators.py b/backend/app/decorators/decorators.py index 75a4e6a..8c7eec2 100644 --- a/backend/app/decorators/decorators.py +++ b/backend/app/decorators/decorators.py @@ -98,14 +98,16 @@ def load_file_accessible_by_uuid(f): user_id = get_current_user_id() file_uuid = kwargs.get("file_uuid") - # Try to find file uploaded by user - attachment = FileAttachment.query.filter_by( - uuid=file_uuid, uploaded_by=user_id - ).first() + # Try to find file uploaded by user (only active files) + attachment = ( + FileAttachment.query.filter_by(uuid=file_uuid, uploaded_by=user_id) + .filter(FileAttachment.status == "active") + .first() + ) # If not found, check if attached to a Card that belongs to user's board if not attachment: - # For Card attachments + # For Card attachments (only active files) card_attachment = ( FileAttachment.query.join( Card, @@ -116,6 +118,7 @@ def load_file_accessible_by_uuid(f): .filter( FileAttachment.uuid == file_uuid, Board.user_id == user_id, + FileAttachment.status == "active", ) .first() ) @@ -125,7 +128,7 @@ def load_file_accessible_by_uuid(f): # If still not found, check if attached to a # Comment that belongs to user's board if not attachment: - # For Comment attachments + # For Comment attachments (only active files) comment_attachment = ( FileAttachment.query.join( Comment, @@ -137,6 +140,7 @@ def load_file_accessible_by_uuid(f): .filter( FileAttachment.uuid == file_uuid, Board.user_id == user_id, + FileAttachment.status == "active", ) .first() ) diff --git a/backend/app/decorators/owned.py b/backend/app/decorators/owned.py index 90f17d8..d9ca106 100644 --- a/backend/app/decorators/owned.py +++ b/backend/app/decorators/owned.py @@ -35,6 +35,7 @@ def load_card_owned(f): """ Loads a Card and ensures its Parent Board belongs to the current user. Injects 'card' into the route kwargs. + Aborts with 404 if not found, not owned, or soft-deleted. """ @wraps(f) @@ -42,10 +43,12 @@ def load_card_owned(f): user_id = get_current_user_id() card_id = kwargs.get("card_id") - # Join Board to check ownership securely in one query + # Join Board to check ownership and filter soft-deleted cards card = ( Card.query.join(Board) - .filter(Card.id == card_id, Board.user_id == user_id) + .filter( + Card.id == card_id, Board.user_id == user_id, Card.deleted_at.is_(None) + ) .first() ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5fc864c..43be910 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,5 @@ # fmt: off +from app.models.base import SoftDeleteMixin from app.models.board import Board from app.models.card import Card from app.models.card_label import CardLabel @@ -14,6 +15,7 @@ from app.models.user import User from app.models.wiki import Wiki, wiki_entity_links __all__ = [ + "SoftDeleteMixin", "User", "Board", "List", diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..6b03c1b --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,123 @@ +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() + """ + + 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) + + 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 diff --git a/backend/app/models/board.py b/backend/app/models/board.py index a640f2d..d2faa8a 100644 --- a/backend/app/models/board.py +++ b/backend/app/models/board.py @@ -3,9 +3,10 @@ from datetime import UTC, datetime from sqlalchemy.dialects.postgresql import JSONB from app import db +from app.models.base import SoftDeleteMixin -class Board(db.Model): +class Board(db.Model, SoftDeleteMixin): """Board model for Kanban boards""" __tablename__ = "boards" @@ -74,6 +75,8 @@ class Board(db.Model): "prefs": self.prefs, "label_names": self.label_names, "limits": self.limits, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/card.py b/backend/app/models/card.py index 0949ae1..f7d5477 100644 --- a/backend/app/models/card.py +++ b/backend/app/models/card.py @@ -3,9 +3,10 @@ from datetime import UTC, datetime from sqlalchemy.dialects.postgresql import JSONB from app import db +from app.models.base import SoftDeleteMixin -class Card(db.Model): +class Card(db.Model, SoftDeleteMixin): """Card model for Kanban cards""" __tablename__ = "cards" @@ -108,6 +109,8 @@ class Card(db.Model): "badges": self.badges, "cover": self.cover, "desc_data": self.desc_data, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, "parent_card_name": ( pl.parent_card.name if (pl := self.parent_links.first()) and pl.parent_card diff --git a/backend/app/models/card_label.py b/backend/app/models/card_label.py index 17acde3..2eac06e 100644 --- a/backend/app/models/card_label.py +++ b/backend/app/models/card_label.py @@ -1,9 +1,10 @@ from datetime import UTC, datetime from app import db +from app.models.base import SoftDeleteMixin -class CardLabel(db.Model): +class CardLabel(db.Model, SoftDeleteMixin): """Many-to-many relationship between cards and labels""" __tablename__ = "card_labels" @@ -37,6 +38,8 @@ class CardLabel(db.Model): "card_id": self.card_id, "label_id": self.label_id, "created_at": self.created_at.isoformat() if self.created_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/card_link.py b/backend/app/models/card_link.py index 49850cc..63de5b9 100644 --- a/backend/app/models/card_link.py +++ b/backend/app/models/card_link.py @@ -1,9 +1,10 @@ from datetime import UTC, datetime from app import db +from app.models.base import SoftDeleteMixin -class CardLink(db.Model): +class CardLink(db.Model, SoftDeleteMixin): """CardLink model for bidirectional card-to-card relationships""" __tablename__ = "card_links" @@ -48,6 +49,8 @@ class CardLink(db.Model): "child_card_id": self.child_card_id, "created_by": self.created_by, "created_at": self.created_at.isoformat() if self.created_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } if include_cards: result["parent_card"] = ( diff --git a/backend/app/models/check_item.py b/backend/app/models/check_item.py index bc8c35d..1682072 100644 --- a/backend/app/models/check_item.py +++ b/backend/app/models/check_item.py @@ -1,9 +1,10 @@ from datetime import UTC, datetime from app import db +from app.models.base import SoftDeleteMixin -class CheckItem(db.Model): +class CheckItem(db.Model, SoftDeleteMixin): """CheckItem model for checklist items""" __tablename__ = "check_items" @@ -45,6 +46,8 @@ class CheckItem(db.Model): "user_id": self.user_id, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/checklist.py b/backend/app/models/checklist.py index 90808cf..e61c03d 100644 --- a/backend/app/models/checklist.py +++ b/backend/app/models/checklist.py @@ -1,10 +1,11 @@ from datetime import UTC, datetime from app import db +from app.models.base import SoftDeleteMixin -class Checklist(db.Model): - """Checklist model for Kanban checklists""" +class Checklist(db.Model, SoftDeleteMixin): + """Checklist model for card checklists""" __tablename__ = "checklists" @@ -45,10 +46,12 @@ class Checklist(db.Model): "id": self.id, "name": self.name, "pos": self.pos, - "board_id": self.board_id, "card_id": self.card_id, + "board_id": self.card.board_id if self.card else None, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py index 26894f1..b76ccec 100644 --- a/backend/app/models/comment.py +++ b/backend/app/models/comment.py @@ -1,9 +1,10 @@ from datetime import UTC, datetime from app import db +from app.models.base import SoftDeleteMixin -class Comment(db.Model): +class Comment(db.Model, SoftDeleteMixin): """Comment model for card comments""" __tablename__ = "comments" @@ -50,6 +51,8 @@ class Comment(db.Model): "user_id": self.user_id, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/epic.py b/backend/app/models/epic.py index 5e4c137..e3d55d7 100644 --- a/backend/app/models/epic.py +++ b/backend/app/models/epic.py @@ -3,9 +3,10 @@ from datetime import UTC, datetime from sqlalchemy.dialects.postgresql import JSONB from app import db +from app.models.base import SoftDeleteMixin -class Epic(db.Model): +class Epic(db.Model, SoftDeleteMixin): """Epic model for tracking large features across multiple cards""" __tablename__ = "epics" @@ -81,6 +82,8 @@ class Epic(db.Model): "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, "metrics": self.metrics or {"card_count": 0, "completed_cards_count": 0}, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/file_attachment.py b/backend/app/models/file_attachment.py index 232a705..1d3d6a5 100644 --- a/backend/app/models/file_attachment.py +++ b/backend/app/models/file_attachment.py @@ -4,9 +4,10 @@ from datetime import UTC, datetime from sqlalchemy import Index from app import db +from app.models.base import SoftDeleteMixin -class FileAttachment(db.Model): +class FileAttachment(db.Model, SoftDeleteMixin): """Polymorphic file attachment model for Cards, Comments, and other entities""" __tablename__ = "file_attachments" @@ -69,6 +70,8 @@ class FileAttachment(db.Model): "attachable_id": self.attachable_id, "uploaded_by": self.uploaded_by, "created_at": self.created_at.isoformat() if self.created_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/label.py b/backend/app/models/label.py index b8482ef..27554fe 100644 --- a/backend/app/models/label.py +++ b/backend/app/models/label.py @@ -1,10 +1,11 @@ from datetime import UTC, datetime from app import db +from app.models.base import SoftDeleteMixin -class Label(db.Model): - """Label model for Kanban labels""" +class Label(db.Model, SoftDeleteMixin): + """Label model for card labels""" __tablename__ = "labels" @@ -40,11 +41,12 @@ class Label(db.Model): "id": self.id, "name": self.name, "color": self.color, - "uses": self.uses, "board_id": self.board_id, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): - return f"