add soft delete logic

This commit is contained in:
david 2026-05-01 18:03:00 +03:00
parent 543c28984f
commit f407285e60
38 changed files with 1273 additions and 76 deletions

View file

@ -88,8 +88,8 @@ class TestingConfig(Config):
# Conservative connection pool settings for testing # Conservative connection pool settings for testing
SQLALCHEMY_ENGINE_OPTIONS = { SQLALCHEMY_ENGINE_OPTIONS = {
"pool_size": 1, # Only one connection in the pool "pool_size": 2, # Only one connection in the pool
"max_overflow": 0, # No overflow connections allowed "max_overflow": 2, # No overflow connections allowed
"pool_timeout": 30, "pool_timeout": 30,
"pool_recycle": 3600, # Recycle after 1 hour "pool_recycle": 3600, # Recycle after 1 hour
"pool_pre_ping": True, # Verify connections before using "pool_pre_ping": True, # Verify connections before using

View file

@ -98,14 +98,16 @@ def load_file_accessible_by_uuid(f):
user_id = get_current_user_id() user_id = get_current_user_id()
file_uuid = kwargs.get("file_uuid") file_uuid = kwargs.get("file_uuid")
# Try to find file uploaded by user # Try to find file uploaded by user (only active files)
attachment = FileAttachment.query.filter_by( attachment = (
uuid=file_uuid, uploaded_by=user_id FileAttachment.query.filter_by(uuid=file_uuid, uploaded_by=user_id)
).first() .filter(FileAttachment.status == "active")
.first()
)
# If not found, check if attached to a Card that belongs to user's board # If not found, check if attached to a Card that belongs to user's board
if not attachment: if not attachment:
# For Card attachments # For Card attachments (only active files)
card_attachment = ( card_attachment = (
FileAttachment.query.join( FileAttachment.query.join(
Card, Card,
@ -116,6 +118,7 @@ def load_file_accessible_by_uuid(f):
.filter( .filter(
FileAttachment.uuid == file_uuid, FileAttachment.uuid == file_uuid,
Board.user_id == user_id, Board.user_id == user_id,
FileAttachment.status == "active",
) )
.first() .first()
) )
@ -125,7 +128,7 @@ def load_file_accessible_by_uuid(f):
# If still not found, check if attached to a # If still not found, check if attached to a
# Comment that belongs to user's board # Comment that belongs to user's board
if not attachment: if not attachment:
# For Comment attachments # For Comment attachments (only active files)
comment_attachment = ( comment_attachment = (
FileAttachment.query.join( FileAttachment.query.join(
Comment, Comment,
@ -137,6 +140,7 @@ def load_file_accessible_by_uuid(f):
.filter( .filter(
FileAttachment.uuid == file_uuid, FileAttachment.uuid == file_uuid,
Board.user_id == user_id, Board.user_id == user_id,
FileAttachment.status == "active",
) )
.first() .first()
) )

View file

@ -35,6 +35,7 @@ def load_card_owned(f):
""" """
Loads a Card and ensures its Parent Board belongs to the current user. Loads a Card and ensures its Parent Board belongs to the current user.
Injects 'card' into the route kwargs. Injects 'card' into the route kwargs.
Aborts with 404 if not found, not owned, or soft-deleted.
""" """
@wraps(f) @wraps(f)
@ -42,10 +43,12 @@ def load_card_owned(f):
user_id = get_current_user_id() user_id = get_current_user_id()
card_id = kwargs.get("card_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 = (
Card.query.join(Board) 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() .first()
) )

View file

@ -1,4 +1,5 @@
# fmt: off # fmt: off
from app.models.base import SoftDeleteMixin
from app.models.board import Board from app.models.board import Board
from app.models.card import Card from app.models.card import Card
from app.models.card_label import CardLabel 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 from app.models.wiki import Wiki, wiki_entity_links
__all__ = [ __all__ = [
"SoftDeleteMixin",
"User", "User",
"Board", "Board",
"List", "List",

123
backend/app/models/base.py Normal file
View file

@ -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

View file

@ -3,9 +3,10 @@ from datetime import UTC, datetime
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from app import db from app import db
from app.models.base import SoftDeleteMixin
class Board(db.Model): class Board(db.Model, SoftDeleteMixin):
"""Board model for Kanban boards""" """Board model for Kanban boards"""
__tablename__ = "boards" __tablename__ = "boards"
@ -74,6 +75,8 @@ class Board(db.Model):
"prefs": self.prefs, "prefs": self.prefs,
"label_names": self.label_names, "label_names": self.label_names,
"limits": self.limits, "limits": self.limits,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
} }
def __repr__(self): def __repr__(self):

View file

@ -3,9 +3,10 @@ from datetime import UTC, datetime
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from app import db from app import db
from app.models.base import SoftDeleteMixin
class Card(db.Model): class Card(db.Model, SoftDeleteMixin):
"""Card model for Kanban cards""" """Card model for Kanban cards"""
__tablename__ = "cards" __tablename__ = "cards"
@ -108,6 +109,8 @@ class Card(db.Model):
"badges": self.badges, "badges": self.badges,
"cover": self.cover, "cover": self.cover,
"desc_data": self.desc_data, "desc_data": self.desc_data,
"status": self.status,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at else None,
"parent_card_name": ( "parent_card_name": (
pl.parent_card.name pl.parent_card.name
if (pl := self.parent_links.first()) and pl.parent_card if (pl := self.parent_links.first()) and pl.parent_card

View file

@ -1,9 +1,10 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db 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""" """Many-to-many relationship between cards and labels"""
__tablename__ = "card_labels" __tablename__ = "card_labels"
@ -37,6 +38,8 @@ class CardLabel(db.Model):
"card_id": self.card_id, "card_id": self.card_id,
"label_id": self.label_id, "label_id": self.label_id,
"created_at": self.created_at.isoformat() if self.created_at else None, "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): def __repr__(self):

View file

@ -1,9 +1,10 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db 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""" """CardLink model for bidirectional card-to-card relationships"""
__tablename__ = "card_links" __tablename__ = "card_links"
@ -48,6 +49,8 @@ class CardLink(db.Model):
"child_card_id": self.child_card_id, "child_card_id": self.child_card_id,
"created_by": self.created_by, "created_by": self.created_by,
"created_at": self.created_at.isoformat() if self.created_at else None, "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: if include_cards:
result["parent_card"] = ( result["parent_card"] = (

View file

@ -1,9 +1,10 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db from app import db
from app.models.base import SoftDeleteMixin
class CheckItem(db.Model): class CheckItem(db.Model, SoftDeleteMixin):
"""CheckItem model for checklist items""" """CheckItem model for checklist items"""
__tablename__ = "check_items" __tablename__ = "check_items"
@ -45,6 +46,8 @@ class CheckItem(db.Model):
"user_id": self.user_id, "user_id": self.user_id,
"created_at": self.created_at.isoformat() if self.created_at 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, "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): def __repr__(self):

View file

@ -1,10 +1,11 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db from app import db
from app.models.base import SoftDeleteMixin
class Checklist(db.Model): class Checklist(db.Model, SoftDeleteMixin):
"""Checklist model for Kanban checklists""" """Checklist model for card checklists"""
__tablename__ = "checklists" __tablename__ = "checklists"
@ -45,10 +46,12 @@ class Checklist(db.Model):
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"pos": self.pos, "pos": self.pos,
"board_id": self.board_id,
"card_id": self.card_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, "created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_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): def __repr__(self):

View file

@ -1,9 +1,10 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db from app import db
from app.models.base import SoftDeleteMixin
class Comment(db.Model): class Comment(db.Model, SoftDeleteMixin):
"""Comment model for card comments""" """Comment model for card comments"""
__tablename__ = "comments" __tablename__ = "comments"
@ -50,6 +51,8 @@ class Comment(db.Model):
"user_id": self.user_id, "user_id": self.user_id,
"created_at": self.created_at.isoformat() if self.created_at 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, "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): def __repr__(self):

View file

@ -3,9 +3,10 @@ from datetime import UTC, datetime
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from app import db 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""" """Epic model for tracking large features across multiple cards"""
__tablename__ = "epics" __tablename__ = "epics"
@ -81,6 +82,8 @@ class Epic(db.Model):
"created_at": self.created_at.isoformat() if self.created_at 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, "updated_at": self.updated_at.isoformat() if self.updated_at else None,
"metrics": self.metrics or {"card_count": 0, "completed_cards_count": 0}, "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): def __repr__(self):

View file

@ -4,9 +4,10 @@ from datetime import UTC, datetime
from sqlalchemy import Index from sqlalchemy import Index
from app import db 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""" """Polymorphic file attachment model for Cards, Comments, and other entities"""
__tablename__ = "file_attachments" __tablename__ = "file_attachments"
@ -69,6 +70,8 @@ class FileAttachment(db.Model):
"attachable_id": self.attachable_id, "attachable_id": self.attachable_id,
"uploaded_by": self.uploaded_by, "uploaded_by": self.uploaded_by,
"created_at": self.created_at.isoformat() if self.created_at else None, "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): def __repr__(self):

View file

@ -1,10 +1,11 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db from app import db
from app.models.base import SoftDeleteMixin
class Label(db.Model): class Label(db.Model, SoftDeleteMixin):
"""Label model for Kanban labels""" """Label model for card labels"""
__tablename__ = "labels" __tablename__ = "labels"
@ -40,11 +41,12 @@ class Label(db.Model):
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"color": self.color, "color": self.color,
"uses": self.uses,
"board_id": self.board_id, "board_id": self.board_id,
"created_at": self.created_at.isoformat() if self.created_at 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, "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): def __repr__(self):
return f"<Label {self.name} ({self.color})>" return f"<Label {self.name}>"

View file

@ -1,9 +1,10 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from app import db from app import db
from app.models.base import SoftDeleteMixin
class List(db.Model): class List(db.Model, SoftDeleteMixin):
"""List model for Kanban lists (columns)""" """List model for Kanban lists (columns)"""
__tablename__ = "lists" __tablename__ = "lists"
@ -44,6 +45,8 @@ class List(db.Model):
"board_id": self.board_id, "board_id": self.board_id,
"created_at": self.created_at.isoformat() if self.created_at 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, "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): def __repr__(self):

View file

@ -4,6 +4,7 @@ from sqlalchemy import and_
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from app import db from app import db
from app.models.base import SoftDeleteMixin
from app.models.card import Card from app.models.card import Card
from app.models.epic import Epic from app.models.epic import Epic
@ -22,7 +23,7 @@ wiki_entity_links = db.Table(
) )
class Wiki(db.Model): class Wiki(db.Model, SoftDeleteMixin):
"""Wiki model for reusable rich text content within a board""" """Wiki model for reusable rich text content within a board"""
__tablename__ = "wikis" __tablename__ = "wikis"
@ -105,6 +106,8 @@ class Wiki(db.Model):
"updated_by": self.updated_by, "updated_by": self.updated_by,
"created_at": self.created_at.isoformat() if self.created_at 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, "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): def __repr__(self):

View file

@ -7,7 +7,8 @@ from flask_pydantic import validate
from app import db from app import db
from app.decorators import load_board_owned from app.decorators import load_board_owned
from app.decorators.decorators import get_current_user_id from app.decorators.decorators import get_current_user_id
from app.models import Board, Card, CardLabel, Label, List from app.models import (Board, Card, CardLabel, CheckItem, Checklist, Comment,
Label, List)
from app.schemas import (BoardCreateRequest, BoardResponse, from app.schemas import (BoardCreateRequest, BoardResponse,
BoardWithDetailsResponse) BoardWithDetailsResponse)
@ -19,7 +20,7 @@ from . import kanban_bp
def get_boards(): def get_boards():
"""Get all boards for current user""" """Get all boards for current user"""
user_id = get_current_user_id() user_id = get_current_user_id()
boards = Board.query.filter_by(user_id=user_id).all() boards = Board.active().filter_by(user_id=user_id).all()
return [BoardResponse.model_validate(board).model_dump() for board in boards], 200 return [BoardResponse.model_validate(board).model_dump() for board in boards], 200
@ -30,39 +31,54 @@ def get_board(board_id, board):
"""Get a single board with all its details""" """Get a single board with all its details"""
from app.models import User from app.models import User
# Get all lists for this board # Get all lists for this board (filter out soft-deleted lists)
lists_data = [] lists_data = []
for lst in board.lists.filter_by(closed=False).order_by(List.pos).all(): for lst in (
board.lists.filter_by(closed=False, deleted_at=None).order_by(List.pos).all()
):
cards_data = [] cards_data = []
for card in lst.cards.filter_by(closed=False).order_by(Card.pos).all(): # Filter out soft-deleted cards
for card in (
lst.cards.filter_by(closed=False, deleted_at=None).order_by(Card.pos).all()
):
card_dict = card.to_dict() card_dict = card.to_dict()
# Add labels for this card # Add labels for this card (filter out soft-deleted card-label associations)
card_dict["labels"] = [ card_dict["labels"] = [
label.to_dict() label.to_dict()
for label in ( for label in (
db.session.query(Label) db.session.query(Label)
.join(CardLabel) .join(CardLabel)
.filter(CardLabel.card_id == card.id) .filter(
CardLabel.card_id == card.id, CardLabel.deleted_at.is_(None)
)
.all() .all()
) )
] ]
# Add comments for this card # Add comments for this card (filter out soft-deleted comments)
card_dict["comments"] = [] card_dict["comments"] = []
for comment in card.comments.all(): for comment in card.comments.filter(Comment.deleted_at.is_(None)).all():
comment_dict = comment.to_dict() comment_dict = comment.to_dict()
user = db.session.get(User, comment.user_id) user = db.session.get(User, comment.user_id)
comment_dict["user"] = user.to_dict() if user else None comment_dict["user"] = user.to_dict() if user else None
card_dict["comments"].append(comment_dict) card_dict["comments"].append(comment_dict)
# Add checklists with items for this card # Add checklists with items for this
# card (filter out soft-deleted checklists and items)
card_dict["checklists"] = [ card_dict["checklists"] = [
{ {
**checklist.to_dict(), **checklist.to_dict(),
"items": [item.to_dict() for item in checklist.check_items.all()], "items": [
item.to_dict()
for item in checklist.check_items.filter(
CheckItem.deleted_at.is_(None)
).all()
],
} }
for checklist in card.checklists.all() for checklist in card.checklists.filter(
Checklist.deleted_at.is_(None)
).all()
] ]
# Add epic for this card # Add epic for this card
@ -142,7 +158,7 @@ def update_board(board_id, board, body: BoardCreateRequest):
@load_board_owned @load_board_owned
def delete_board(board_id, board): def delete_board(board_id, board):
"""Delete a board""" """Delete a board"""
db.session.delete(board) board.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Board deleted"}, 200 return {"message": "Board deleted"}, 200

View file

@ -123,7 +123,7 @@ def unlink_card(card_id, link_id):
if link.parent_card_id != card.id and link.child_card_id != card.id: if link.parent_card_id != card.id and link.child_card_id != card.id:
return jsonify({"error": "Link does not belong to this card"}), 403 return jsonify({"error": "Link does not belong to this card"}), 403
db.session.delete(link) link.soft_delete()
db.session.commit() db.session.commit()
return jsonify({"message": "Cards unlinked successfully"}), 200 return jsonify({"message": "Cards unlinked successfully"}), 200
@ -204,7 +204,7 @@ def delete_card_link(link_id):
if not link: if not link:
return jsonify({"error": "Link not found"}), 404 return jsonify({"error": "Link not found"}), 404
db.session.delete(link) link.soft_delete()
db.session.commit() db.session.commit()
return jsonify({"message": "Cards unlinked successfully"}), 200 return jsonify({"message": "Cards unlinked successfully"}), 200
@ -264,8 +264,7 @@ def convert_check_item_to_card(card_id, check_item_id):
db.session.add(link) db.session.add(link)
# Remove the checklist item # Remove the checklist item
db.session.delete(check_item) check_item.soft_delete()
db.session.commit() db.session.commit()
return ( return (

View file

@ -146,9 +146,9 @@ def update_card(card_id, card, body: CardCreateRequest):
@kanban_bp.route("/cards/<int:card_id>", methods=["DELETE"]) @kanban_bp.route("/cards/<int:card_id>", methods=["DELETE"])
@jwt_required() @jwt_required()
@load_card_owned @load_card_owned
def delete_card(card_id, card): def delete_card(card_id, card: Card):
"""Delete a card""" """Delete a card"""
db.session.delete(card) card.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Card deleted"}, 200 return {"message": "Card deleted"}, 200

View file

@ -70,7 +70,7 @@ def update_check_item(item_id, check_item, body: CheckItemCreateRequest):
@load_checklist_owned @load_checklist_owned
def delete_checklist(checklist_id, checklist): def delete_checklist(checklist_id, checklist):
"""Delete a checklist""" """Delete a checklist"""
db.session.delete(checklist) checklist.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Checklist deleted"}, 200 return {"message": "Checklist deleted"}, 200
@ -81,7 +81,7 @@ def delete_checklist(checklist_id, checklist):
@load_check_item_owned @load_check_item_owned
def delete_check_item(item_id, check_item): def delete_check_item(item_id, check_item):
"""Delete a check item""" """Delete a check item"""
db.session.delete(check_item) check_item.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Check item deleted"}, 200 return {"message": "Check item deleted"}, 200

View file

@ -66,7 +66,7 @@ def update_comment(comment_id, comment, body: CommentCreateRequest):
@load_comment_owned @load_comment_owned
def delete_comment(comment_id, comment): def delete_comment(comment_id, comment):
"""Delete a comment""" """Delete a comment"""
db.session.delete(comment) comment.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Comment deleted"}, 200 return {"message": "Comment deleted"}, 200

View file

@ -22,7 +22,7 @@ def get_board_epics(board_id):
if not board: if not board:
return {"error": "Board not found"}, 404 return {"error": "Board not found"}, 404
epics = Epic.query.filter_by(board_id=board_id).all() epics = Epic.active().filter_by(board_id=board_id).all()
return epics, 200 return epics, 200
@ -65,7 +65,7 @@ def get_epic(epic_id):
return {"error": "Epic not found"}, 404 return {"error": "Epic not found"}, 404
# Get cards for this epic # Get cards for this epic
cards = Card.query.filter_by(epic_id=epic_id).all() cards = Card.active().filter_by(epic_id=epic_id).all()
epic_dict = EpicResponse.model_validate(epic).model_dump() epic_dict = EpicResponse.model_validate(epic).model_dump()
epic_dict["cards"] = [card.to_dict() for card in cards] epic_dict["cards"] = [card.to_dict() for card in cards]
@ -119,10 +119,10 @@ def delete_epic(epic_id):
return {"error": "Epic not found"}, 404 return {"error": "Epic not found"}, 404
# Unlink all cards from this epic # Unlink all cards from this epic
Card.query.filter_by(epic_id=epic_id).update({"epic_id": None}) Card.active().filter_by(epic_id=epic_id).update({"epic_id": None})
# Delete epic # Delete epic
db.session.delete(epic) epic.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Epic deleted successfully"}, 200 return {"message": "Epic deleted successfully"}, 200

View file

@ -73,7 +73,7 @@ def remove_label_from_card(card_id, card, label_id):
if not card_label: if not card_label:
return {"error": "Label not found on card"}, 404 return {"error": "Label not found on card"}, 404
db.session.delete(card_label) card_label.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Label removed from card"}, 200 return {"message": "Label removed from card"}, 200

View file

@ -64,7 +64,7 @@ def update_list(list_id, lst, body: ListCreateRequest):
@load_list_owned @load_list_owned
def delete_list(list_id, lst): def delete_list(list_id, lst):
"""Delete a list""" """Delete a list"""
db.session.delete(lst) lst.soft_delete()
db.session.commit() db.session.commit()
return {"message": "List deleted"}, 200 return {"message": "List deleted"}, 200

View file

@ -37,7 +37,7 @@ def get_board_wikis(board_id):
if not board: if not board:
return {"error": "Board not found"}, 404 return {"error": "Board not found"}, 404
wikis = Wiki.query.filter_by(board_id=board_id).all() wikis = Wiki.active().filter_by(board_id=board_id).all()
return wikis, 200 return wikis, 200
@ -147,7 +147,7 @@ def delete_wiki(wiki_id):
return {"error": "Wiki not found"}, 404 return {"error": "Wiki not found"}, 404
# Delete wiki (cascades to wiki_entity_links) # Delete wiki (cascades to wiki_entity_links)
db.session.delete(wiki) wiki.soft_delete()
db.session.commit() db.session.commit()
return {"message": "Wiki deleted successfully"}, 200 return {"message": "Wiki deleted successfully"}, 200

View file

@ -171,8 +171,7 @@ class FileService:
current_app.logger.error(f"Error deleting file from MinIO: {e}") current_app.logger.error(f"Error deleting file from MinIO: {e}")
# Delete from database # Delete from database
db.session.delete(attachment) attachment.soft_delete()
db.session.commit()
current_app.logger.info(f"File deleted: {attachment.original_name}") current_app.logger.info(f"File deleted: {attachment.original_name}")
return True return True

View file

@ -0,0 +1,146 @@
"""add soft delete columns
Revision ID: 7a0cfda486e1
Revises: bf430156bcf2
Create Date: 2026-05-01 13:40:24.316892
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7a0cfda486e1'
down_revision = 'bf430156bcf2'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('boards', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_boards_status'), ['status'], unique=False)
with op.batch_alter_table('card_labels', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_card_labels_status'), ['status'], unique=False)
with op.batch_alter_table('card_links', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_card_links_status'), ['status'], unique=False)
with op.batch_alter_table('cards', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_cards_status'), ['status'], unique=False)
with op.batch_alter_table('check_items', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_check_items_status'), ['status'], unique=False)
with op.batch_alter_table('checklists', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_checklists_status'), ['status'], unique=False)
with op.batch_alter_table('comments', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_comments_status'), ['status'], unique=False)
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_epics_status'), ['status'], unique=False)
with op.batch_alter_table('file_attachments', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_file_attachments_status'), ['status'], unique=False)
with op.batch_alter_table('labels', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_labels_status'), ['status'], unique=False)
with op.batch_alter_table('lists', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_lists_status'), ['status'], unique=False)
with op.batch_alter_table('wikis', schema=None) as batch_op:
batch_op.add_column(sa.Column('status', sa.String(length=20), nullable=False, server_default='active'))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_index(batch_op.f('ix_wikis_status'), ['status'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('wikis', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_wikis_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('lists', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_lists_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('labels', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_labels_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('file_attachments', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_file_attachments_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('epics', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_epics_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('comments', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_comments_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('checklists', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_checklists_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('check_items', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_check_items_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('cards', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_cards_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('card_links', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_card_links_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('card_labels', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_card_labels_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
with op.batch_alter_table('boards', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_boards_status'))
batch_op.drop_column('deleted_at')
batch_op.drop_column('status')
# ### end Alembic commands ###

View file

@ -14,7 +14,7 @@ log = logging.getLogger(__name__)
fake = Faker() fake = Faker()
@pytest.fixture(scope="function") @pytest.fixture(scope="session")
def app(): def app():
"""Create application for testing with PostgreSQL database (session scope)""" """Create application for testing with PostgreSQL database (session scope)"""
app = create_app(config_name="test") app = create_app(config_name="test")

View file

@ -1,7 +1,8 @@
import pytest import pytest
from app import db from app import db
from app.models import Board, Card, List from app.models import (Board, Card, CardLabel, CheckItem, Checklist, Comment,
Label, List)
@pytest.mark.integration @pytest.mark.integration
@ -147,7 +148,8 @@ class TestBoardRoutes:
# Verify board is deleted # Verify board is deleted
deleted_board = db.session.get(Board, board.id) deleted_board = db.session.get(Board, board.id)
assert deleted_board is None assert deleted_board is not None
assert deleted_board.status == "deleted"
def test_delete_board_not_found(self, client, db_session, auth_headers): def test_delete_board_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent board""" """Test deleting a non-existent board"""
@ -164,3 +166,328 @@ class TestBoardRoutes:
response = client.delete(f"/api/boards/{board.id}") response = client.delete(f"/api/boards/{board.id}")
assert response.status_code == 401 assert response.status_code == 401
def test_get_board_filters_deleted_cards(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted cards are filtered out from board response"""
# Create board with list and 2 cards
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card1 = Card(name="Active Card", board_id=board.id, list_id=lst.id, pos=0)
card2 = Card(name="Deleted Card", board_id=board.id, list_id=lst.id, pos=1)
db_session.add_all([card1, card2])
db_session.commit()
# Soft delete card2
card2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 card (the active one)
assert len(data["lists"][0]["cards"]) == 1
assert data["lists"][0]["cards"][0]["name"] == "Active Card"
def test_get_board_filters_deleted_lists(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted lists are filtered out from board response"""
# Create board with 2 lists
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst1 = List(name="To Do", board_id=board.id, pos=0)
lst2 = List(name="Deleted List", board_id=board.id, pos=1)
db_session.add_all([lst1, lst2])
db_session.commit()
# Soft delete lst2
lst2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 list (the active one)
assert len(data["lists"]) == 1
assert data["lists"][0]["name"] == "To Do"
def test_get_board_filters_deleted_comments(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted comments are filtered out from card response"""
# Create board, list, card and 2 comments
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
comment1 = Comment(
text="Active comment", card_id=card.id, user_id=regular_user.id
)
comment2 = Comment(
text="Deleted comment", card_id=card.id, user_id=regular_user.id
)
db_session.add_all([comment1, comment2])
db_session.commit()
# Soft delete comment2
comment2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 comment (the active one)
assert len(data["lists"][0]["cards"][0]["comments"]) == 1
assert data["lists"][0]["cards"][0]["comments"][0]["text"] == "Active comment"
def test_get_board_filters_deleted_checklists(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted checklists are filtered out from card response"""
# Create board, list, card and 2 checklists
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
checklist1 = Checklist(
name="Active Checklist", board_id=board.id, card_id=card.id, pos=0
)
checklist2 = Checklist(
name="Deleted Checklist", board_id=board.id, card_id=card.id, pos=1
)
db_session.add_all([checklist1, checklist2])
db_session.commit()
# Soft delete checklist2
checklist2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 checklist (the active one)
assert len(data["lists"][0]["cards"][0]["checklists"]) == 1
assert (
data["lists"][0]["cards"][0]["checklists"][0]["name"] == "Active Checklist"
)
def test_get_board_filters_deleted_check_items(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted check items are
filtered out from checklist response"""
# Create board, list, card, checklist and 2 check items
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
db_session.add(checklist)
db_session.flush()
item1 = CheckItem(name="Active Task", checklist_id=checklist.id, pos=0)
item2 = CheckItem(name="Deleted Task", checklist_id=checklist.id, pos=1)
db_session.add_all([item1, item2])
db_session.commit()
# Soft delete item2
item2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 check item (the active one)
assert len(data["lists"][0]["cards"][0]["checklists"][0]["items"]) == 1
assert (
data["lists"][0]["cards"][0]["checklists"][0]["items"][0]["name"]
== "Active Task"
)
def test_get_board_filters_deleted_card_labels(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted card-label associations are filtered out"""
# Create board, list, card, label and 2 card-label associations
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
label1 = Label(name="Urgent", color="red", board_id=board.id)
label2 = Label(name="Important", color="yellow", board_id=board.id)
db_session.add_all([label1, label2])
db_session.flush()
card_label1 = CardLabel(card_id=card.id, label_id=label1.id)
card_label2 = CardLabel(card_id=card.id, label_id=label2.id)
db_session.add_all([card_label1, card_label2])
db_session.commit()
# Soft delete card_label2
card_label2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Should only have 1 label (the active one)
assert len(data["lists"][0]["cards"][0]["labels"]) == 1
assert data["lists"][0]["cards"][0]["labels"][0]["name"] == "Urgent"
def test_get_board_comprehensive_soft_delete_filtering(
self, client, db_session, regular_user, auth_headers
):
"""Test comprehensive soft delete filtering across all nested resources"""
# Create board with all types of nested resources, some deleted
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
# Active list
lst1 = List(name="Active List", board_id=board.id, pos=0)
db_session.add(lst1)
db_session.flush()
card1 = Card(name="Active Card 1", board_id=board.id, list_id=lst1.id, pos=0)
card2 = Card(name="Deleted Card", board_id=board.id, list_id=lst1.id, pos=1)
db_session.add_all([card1, card2])
db_session.flush()
# Active comment
comment1 = Comment(
text="Active comment", card_id=card1.id, user_id=regular_user.id
)
# Deleted comment
comment2 = Comment(
text="Deleted comment", card_id=card1.id, user_id=regular_user.id
)
db_session.add_all([comment1, comment2])
db_session.flush()
# Active checklist
checklist1 = Checklist(
name="Active Checklist", board_id=board.id, card_id=card1.id, pos=0
)
# Deleted checklist
checklist2 = Checklist(
name="Deleted Checklist", board_id=board.id, card_id=card1.id, pos=1
)
db_session.add_all([checklist1, checklist2])
db_session.flush()
# Active check item
item1 = CheckItem(name="Active Task", checklist_id=checklist1.id, pos=0)
# Deleted check item
item2 = CheckItem(name="Deleted Task", checklist_id=checklist1.id, pos=1)
db_session.add_all([item1, item2])
db_session.flush()
# Labels
label1 = Label(name="Active Label", color="red", board_id=board.id)
label2 = Label(name="Deleted Label", color="yellow", board_id=board.id)
db_session.add_all([label1, label2])
db_session.flush()
# Active card-label
card_label1 = CardLabel(card_id=card1.id, label_id=label1.id)
# Deleted card-label
card_label2 = CardLabel(card_id=card1.id, label_id=label2.id)
db_session.add_all([card_label1, card_label2])
db_session.commit()
# Soft delete some resources
card2.soft_delete()
comment2.soft_delete()
checklist2.soft_delete()
item2.soft_delete()
card_label2.soft_delete()
db_session.commit()
# Get board
response = client.get(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
# Verify lists: should have 1 list (we didn't delete the list)
assert len(data["lists"]) == 1
assert data["lists"][0]["name"] == "Active List"
# Verify cards: should have 1 card (card2 is deleted)
assert len(data["lists"][0]["cards"]) == 1
assert data["lists"][0]["cards"][0]["name"] == "Active Card 1"
# Verify comments: should have 1 comment (comment2 is deleted)
assert len(data["lists"][0]["cards"][0]["comments"]) == 1
assert data["lists"][0]["cards"][0]["comments"][0]["text"] == "Active comment"
# Verify checklists: should have 1 checklist (checklist2 is deleted)
assert len(data["lists"][0]["cards"][0]["checklists"]) == 1
assert (
data["lists"][0]["cards"][0]["checklists"][0]["name"] == "Active Checklist"
)
# Verify check items: should have 1 item (item2 is deleted)
assert len(data["lists"][0]["cards"][0]["checklists"][0]["items"]) == 1
assert (
data["lists"][0]["cards"][0]["checklists"][0]["items"][0]["name"]
== "Active Task"
)
# Verify labels: should have 1 label (card_label2 is deleted)
assert len(data["lists"][0]["cards"][0]["labels"]) == 1
assert data["lists"][0]["cards"][0]["labels"][0]["name"] == "Active Label"

View file

@ -160,7 +160,8 @@ class TestCardRoutes:
# Verify card is deleted # Verify card is deleted
deleted_card = db.session.get(Card, card.id) deleted_card = db.session.get(Card, card.id)
assert deleted_card is None assert deleted_card is not None
assert deleted_card.status == "deleted"
def test_delete_card_not_found(self, client, db_session, auth_headers): def test_delete_card_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent card""" """Test deleting a non-existent card"""
@ -168,6 +169,38 @@ class TestCardRoutes:
assert response.status_code == 404 assert response.status_code == 404
def test_get_soft_deleted_card_returns_404(
self, client, db_session, regular_user, auth_headers
):
"""Test that getting a soft-deleted card returns 404"""
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="To Delete", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.commit()
# Soft delete the card
card.soft_delete()
db_session.commit()
# Verify card is deleted in database
deleted_card = db.session.get(Card, card.id)
assert deleted_card is not None
assert deleted_card.status == "deleted"
assert deleted_card.deleted_at is not None
# Try to get the deleted card via API
response = client.get(f"/api/cards/{card.id}", headers=auth_headers)
# Should return 404
assert response.status_code == 404
def test_update_card_position_within_same_list( def test_update_card_position_within_same_list(
self, client, db_session, regular_user, auth_headers self, client, db_session, regular_user, auth_headers
): ):

View file

@ -245,7 +245,8 @@ class TestChecklistRoutes:
# Verify checklist is deleted # Verify checklist is deleted
deleted_checklist = db.session.get(Checklist, checklist.id) deleted_checklist = db.session.get(Checklist, checklist.id)
assert deleted_checklist is None assert deleted_checklist is not None
assert deleted_checklist.status == "deleted"
def test_delete_checklist_not_found(self, client, db_session, auth_headers): def test_delete_checklist_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent checklist""" """Test deleting a non-existent checklist"""
@ -287,7 +288,8 @@ class TestChecklistRoutes:
# Verify check item is deleted # Verify check item is deleted
deleted_item = db.session.get(CheckItem, item.id) deleted_item = db.session.get(CheckItem, item.id)
assert deleted_item is None assert deleted_item is not None
assert deleted_item.status == "deleted"
def test_delete_check_item_not_found(self, client, db_session, auth_headers): def test_delete_check_item_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent check item""" """Test deleting a non-existent check item"""

View file

@ -196,7 +196,8 @@ class TestCommentRoutes:
# Verify comment is deleted # Verify comment is deleted
deleted_comment = db.session.get(Comment, comment.id) deleted_comment = db.session.get(Comment, comment.id)
assert deleted_comment is None assert deleted_comment is not None
assert deleted_comment.status == "deleted"
def test_delete_comment_not_found(self, client, db_session, auth_headers): def test_delete_comment_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent comment""" """Test deleting a non-existent comment"""

View file

@ -416,7 +416,8 @@ class TestEpicRoutes:
# Verify epic is deleted # Verify epic is deleted
deleted_epic = db.session.get(Epic, epic_id) deleted_epic = db.session.get(Epic, epic_id)
assert deleted_epic is None assert deleted_epic is not None
assert deleted_epic.status == "deleted"
def test_delete_epic_with_cards( def test_delete_epic_with_cards(
self, client, db_session, auth_headers, test_board, test_card self, client, db_session, auth_headers, test_board, test_card

View file

@ -181,4 +181,5 @@ class TestLabelRoutes:
.filter_by(card_id=card.id, label_id=label.id) .filter_by(card_id=card.id, label_id=label.id)
.first() .first()
) )
assert deleted_card_label is None assert deleted_card_label is not None
assert deleted_card_label.status == "deleted"

View file

@ -116,7 +116,8 @@ class TestListRoutes:
# Verify list is deleted # Verify list is deleted
deleted_list = db.session.get(List, lst.id) deleted_list = db.session.get(List, lst.id)
assert deleted_list is None assert deleted_list is not None
assert deleted_list.status == "deleted"
def test_delete_list_not_found(self, client, db_session, auth_headers): def test_delete_list_not_found(self, client, db_session, auth_headers):
"""Test deleting a non-existent list""" """Test deleting a non-existent list"""
@ -144,11 +145,13 @@ class TestListRoutes:
assert response.status_code == 200 assert response.status_code == 200
# Verify both list and card are deleted # Verify both list and card are soft-deleted
deleted_list = db.session.get(List, lst.id) deleted_list = db.session.get(List, lst.id)
deleted_card = db.session.get(Card, card.id) deleted_card = db.session.get(Card, card.id)
assert deleted_list is None assert deleted_list is not None
assert deleted_card is None assert deleted_list.status == "deleted"
assert deleted_card is not None
assert deleted_card.status == "deleted"
def test_update_list_position_reorders_others( def test_update_list_position_reorders_others(
self, client, db_session, regular_user, auth_headers self, client, db_session, regular_user, auth_headers

View file

@ -0,0 +1,496 @@
"""High-level integration tests for soft delete functionality
These tests verify that soft delete works correctly by checking:
1. The delete endpoint returns success
2. The record is marked as deleted in the database (deleted_at is set)
3. The record still exists (soft delete, not hard delete)
"""
import pytest
from app import db
from app.models import (Board, Card, CardLabel, CardLink, CheckItem, Checklist,
Comment, Epic, Label, List, Wiki)
@pytest.mark.integration
class TestSoftDeleteIntegration:
"""High-level integration tests for soft delete across all resources"""
def test_soft_delete_card_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a card marks it as deleted in the database"""
# Create board, list and card
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.commit()
# Delete the card
response = client.delete(f"/api/cards/{card.id}", headers=auth_headers)
assert response.status_code == 200
# Verify card is marked as deleted in database
deleted_card = db.session.get(Card, card.id)
assert deleted_card is not None
assert deleted_card.deleted_at is not None
def test_soft_delete_list_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a list marks it as deleted in the database"""
# Create board and list
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.commit()
# Delete the list
response = client.delete(f"/api/lists/{lst.id}", headers=auth_headers)
assert response.status_code == 200
# Verify list is marked as deleted in database
deleted_list = db.session.get(List, lst.id)
assert deleted_list is not None
assert deleted_list.deleted_at is not None
def test_soft_delete_comment_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a comment marks it as deleted in the database"""
# Create board, list, card and comment
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
comment = Comment(text="Test comment", card_id=card.id, user_id=regular_user.id)
db_session.add(comment)
db_session.commit()
# Delete the comment
response = client.delete(f"/api/comments/{comment.id}", headers=auth_headers)
assert response.status_code == 200
# Verify comment is marked as deleted in database
deleted_comment = db.session.get(Comment, comment.id)
assert deleted_comment is not None
assert deleted_comment.deleted_at is not None
def test_soft_delete_checklist_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a checklist marks it as deleted in the database"""
# Create board, list, card and checklist
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
db_session.add(checklist)
db_session.commit()
# Delete the checklist
response = client.delete(
f"/api/checklists/{checklist.id}", headers=auth_headers
)
assert response.status_code == 200
# Verify checklist is marked as deleted in database
deleted_checklist = db.session.get(Checklist, checklist.id)
assert deleted_checklist is not None
assert deleted_checklist.deleted_at is not None
def test_soft_delete_check_item_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a check item marks it as deleted in the database"""
# Create board, list, card, checklist and check item
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
db_session.add(checklist)
db_session.flush()
item = CheckItem(
name="Task", checklist_id=checklist.id, pos=0, state="incomplete"
)
db_session.add(item)
db_session.commit()
# Delete the check item
response = client.delete(f"/api/check-items/{item.id}", headers=auth_headers)
assert response.status_code == 200
# Verify check item is marked as deleted in database
deleted_item = db.session.get(CheckItem, item.id)
assert deleted_item is not None
assert deleted_item.deleted_at is not None
def test_soft_delete_card_label_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that removing a label from a card marks the association as deleted"""
# Create board, list, card and labels
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
label = Label(name="Urgent", color="red", board_id=board.id)
db_session.add(label)
db_session.flush()
card_label = CardLabel(card_id=card.id, label_id=label.id)
db_session.add(card_label)
db_session.commit()
# Remove label from card
response = client.delete(
f"/api/cards/{card.id}/labels/{label.id}", headers=auth_headers
)
assert response.status_code == 200
# Verify card-label association is marked as deleted in database
deleted_card_label = (
db_session.query(CardLabel)
.filter_by(card_id=card.id, label_id=label.id)
.first()
)
assert deleted_card_label is not None
assert deleted_card_label.deleted_at is not None
def test_soft_delete_epic_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting an epic marks it as deleted in the database"""
# Create board and epic
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
epic = Epic(name="Test Epic", board_id=board.id, color="#3b82f6")
db_session.add(epic)
db_session.commit()
# Delete the epic
response = client.delete(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
# Verify epic is marked as deleted in database
deleted_epic = db.session.get(Epic, epic.id)
assert deleted_epic is not None
assert deleted_epic.deleted_at is not None
def test_soft_delete_epic_unlinks_card(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleting an epic unlinks associated cards"""
# Create board, list, card and epic
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
epic = Epic(name="Test Epic", board_id=board.id, color="#3b82f6")
db_session.add(epic)
db_session.flush()
card.epic_id = epic.id
db_session.commit()
# Delete epic
response = client.delete(f"/api/epics/{epic.id}", headers=auth_headers)
assert response.status_code == 200
# Verify card no longer has epic
db.session.refresh(card)
assert card.epic_id is None
def test_soft_delete_wiki_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a wiki marks it as deleted in the database"""
# Create board and wiki
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
wiki = Wiki(
name="Test Wiki",
board_id=board.id,
slug="test-wiki",
content=[{"type": "paragraph", "children": [{"text": "Content"}]}],
created_by=regular_user.id,
)
db_session.add(wiki)
db_session.commit()
# Delete the wiki
response = client.delete(f"/api/wikis/{wiki.id}", headers=auth_headers)
assert response.status_code == 200
# Verify wiki is marked as deleted in database
deleted_wiki = db.session.get(Wiki, wiki.id)
assert deleted_wiki is not None
assert deleted_wiki.deleted_at is not None
def test_soft_delete_board_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a board marks it as deleted in the database"""
# Create board
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.commit()
# Delete the board
response = client.delete(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
# Verify board is marked as deleted in database
deleted_board = db.session.get(Board, board.id)
assert deleted_board is not None
assert deleted_board.status == "deleted"
def test_soft_delete_card_link_marks_deleted(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a card link marks it as deleted in the database"""
# Create board, list and cards
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
parent_card = Card(name="Parent Card", board_id=board.id, list_id=lst.id, pos=0)
child_card = Card(name="Child Card", board_id=board.id, list_id=lst.id, pos=1)
db_session.add_all([parent_card, child_card])
db_session.commit()
# Create card link
link = CardLink(
parent_card_id=parent_card.id,
child_card_id=child_card.id,
created_by=regular_user.id,
)
db_session.add(link)
db_session.commit()
# Delete the link
response = client.delete(f"/api/card-links/{link.id}", headers=auth_headers)
assert response.status_code == 200
# Verify link is marked as deleted in database
deleted_link = db.session.get(CardLink, link.id)
assert deleted_link is not None
assert deleted_link.deleted_at is not None
def test_soft_delete_cascade_from_list_to_cards(
self, client, db_session, regular_user, auth_headers
):
"""Test that deleting a list soft-deletes its cards"""
# Create board, list and cards
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card1 = Card(name="Card 1", board_id=board.id, list_id=lst.id, pos=0)
card2 = Card(name="Card 2", board_id=board.id, list_id=lst.id, pos=1)
db_session.add_all([card1, card2])
db_session.commit()
# Delete list
response = client.delete(f"/api/lists/{lst.id}", headers=auth_headers)
assert response.status_code == 200
# Verify list is soft-deleted
deleted_list = db.session.get(List, lst.id)
assert deleted_list is not None
assert deleted_list.deleted_at is not None
# Verify cards are also soft-deleted
deleted_card1 = db.session.get(Card, card1.id)
deleted_card2 = db.session.get(Card, card2.id)
assert deleted_card1 is not None
assert deleted_card1.deleted_at is not None
assert deleted_card2 is not None
assert deleted_card2.deleted_at is not None
def test_soft_delete_multiple_resources_in_sequence(
self, client, db_session, regular_user, auth_headers
):
"""Test deleting multiple resources in sequence and verifying soft delete"""
# Create board with lists, cards, comments, checklists, labels
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Test Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
comment = Comment(text="Test comment", card_id=card.id, user_id=regular_user.id)
db_session.add(comment)
db_session.flush()
label = Label(name="Urgent", color="red", board_id=board.id)
db_session.add(label)
db_session.flush()
card_label = CardLabel(card_id=card.id, label_id=label.id)
db_session.add(card_label)
db_session.flush()
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
db_session.add(checklist)
db_session.flush()
item = CheckItem(
name="Task", checklist_id=checklist.id, pos=0, state="incomplete"
)
db_session.add(item)
db_session.commit()
# Delete multiple resources in sequence
client.delete(f"/api/comments/{comment.id}", headers=auth_headers)
client.delete(f"/api/check-items/{item.id}", headers=auth_headers)
client.delete(f"/api/cards/{card.id}/labels/{label.id}", headers=auth_headers)
# Verify all are marked as deleted in database
deleted_comment = db.session.get(Comment, comment.id)
deleted_item = db.session.get(CheckItem, item.id)
deleted_card_label = (
db_session.query(CardLabel)
.filter_by(card_id=card.id, label_id=label.id)
.first()
)
assert deleted_comment is not None
assert deleted_comment.deleted_at is not None
assert deleted_item is not None
assert deleted_item.deleted_at is not None
assert deleted_card_label is not None
assert deleted_card_label.deleted_at is not None
def test_soft_delete_record_still_exists(
self, client, db_session, regular_user, auth_headers
):
"""Test that soft-deleted records still exist in the database"""
# Create board
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.commit()
# Delete the board
response = client.delete(f"/api/boards/{board.id}", headers=auth_headers)
assert response.status_code == 200
# Verify record still exists (not hard deleted)
deleted_board = db.session.get(Board, board.id)
assert deleted_board is not None
assert deleted_board.id == board.id
assert deleted_board.name == "Test Board"
def test_convert_check_item_to_card_deletes_check_item(
self, client, db_session, regular_user, auth_headers
):
"""Test that converting a check item to a card soft-deletes the check item"""
# Create board, list, card, checklist and check item
board = Board(name="Test Board", user_id=regular_user.id)
db_session.add(board)
db_session.flush()
lst = List(name="To Do", board_id=board.id, pos=0)
db_session.add(lst)
db_session.flush()
card = Card(name="Parent Card", board_id=board.id, list_id=lst.id, pos=0)
db_session.add(card)
db_session.flush()
checklist = Checklist(name="Tasks", board_id=board.id, card_id=card.id, pos=0)
db_session.add(checklist)
db_session.flush()
item = CheckItem(
name="Task to convert", checklist_id=checklist.id, pos=0, state="incomplete"
)
db_session.add(item)
db_session.commit()
# Convert check item to card
response = client.post(
f"/api/cards/{card.id}/checklist-items/{item.id}/convert-to-card",
headers=auth_headers,
json={"list_id": lst.id},
)
assert response.status_code == 201
# Verify check item is soft-deleted
deleted_item = db.session.get(CheckItem, item.id)
assert deleted_item is not None
assert deleted_item.deleted_at is not None

View file

@ -518,12 +518,13 @@ class TestWikiRoutes:
# Verify wiki is deleted # Verify wiki is deleted
deleted_wiki = db.session.get(Wiki, wiki_id) deleted_wiki = db.session.get(Wiki, wiki_id)
assert deleted_wiki is None assert deleted_wiki is not None
assert deleted_wiki.status == "deleted"
def test_delete_wiki_with_links( def test_delete_wiki_with_links(
self, client, db_session, auth_headers, test_board, regular_user, test_card self, client, db_session, auth_headers, test_board, regular_user, test_card
): ):
"""Test deleting wiki removes entity links""" """Test deleting wiki (soft delete) preserves entity links"""
wiki = Wiki( wiki = Wiki(
name="Wiki with Links", name="Wiki with Links",
board_id=test_board.id, board_id=test_board.id,
@ -545,13 +546,18 @@ class TestWikiRoutes:
assert response.status_code == 200 assert response.status_code == 200
# Verify links are deleted (cascade) # Verify wiki is soft-deleted
deleted_wiki = db.session.get(Wiki, wiki.id)
assert deleted_wiki is not None
assert deleted_wiki.status == "deleted"
# Verify links are preserved (not deleted with soft delete)
from sqlalchemy import select from sqlalchemy import select
links = db.session.scalars( links = db.session.scalars(
select(wiki_entity_links).where(wiki_entity_links.c.wiki_id == wiki.id) select(wiki_entity_links).where(wiki_entity_links.c.wiki_id == wiki.id)
).all() ).all()
assert len(links) == 0 assert len(links) == 1 # Links are preserved
def test_delete_wiki_not_found(self, client, db_session, auth_headers): def test_delete_wiki_not_found(self, client, db_session, auth_headers):
"""Test deleting non-existent wiki""" """Test deleting non-existent wiki"""