add soft delete logic
This commit is contained in:
parent
543c28984f
commit
f407285e60
38 changed files with 1273 additions and 76 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
123
backend/app/models/base.py
Normal file
123
backend/app/models/base.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"] = (
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"<Label {self.name} ({self.color})>"
|
||||
return f"<Label {self.name}>"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
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)"""
|
||||
|
||||
__tablename__ = "lists"
|
||||
|
|
@ -44,6 +45,8 @@ class List(db.Model):
|
|||
"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):
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from sqlalchemy import and_
|
|||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from app import db
|
||||
from app.models.base import SoftDeleteMixin
|
||||
from app.models.card import Card
|
||||
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"""
|
||||
|
||||
__tablename__ = "wikis"
|
||||
|
|
@ -105,6 +106,8 @@ class Wiki(db.Model):
|
|||
"updated_by": self.updated_by,
|
||||
"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):
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ from flask_pydantic import validate
|
|||
from app import db
|
||||
from app.decorators import load_board_owned
|
||||
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,
|
||||
BoardWithDetailsResponse)
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ from . import kanban_bp
|
|||
def get_boards():
|
||||
"""Get all boards for current user"""
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -30,39 +31,54 @@ def get_board(board_id, board):
|
|||
"""Get a single board with all its details"""
|
||||
from app.models import User
|
||||
|
||||
# Get all lists for this board
|
||||
# Get all lists for this board (filter out soft-deleted lists)
|
||||
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 = []
|
||||
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()
|
||||
|
||||
# Add labels for this card
|
||||
# Add labels for this card (filter out soft-deleted card-label associations)
|
||||
card_dict["labels"] = [
|
||||
label.to_dict()
|
||||
for label in (
|
||||
db.session.query(Label)
|
||||
.join(CardLabel)
|
||||
.filter(CardLabel.card_id == card.id)
|
||||
.filter(
|
||||
CardLabel.card_id == card.id, CardLabel.deleted_at.is_(None)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
]
|
||||
|
||||
# Add comments for this card
|
||||
# Add comments for this card (filter out soft-deleted 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()
|
||||
user = db.session.get(User, comment.user_id)
|
||||
comment_dict["user"] = user.to_dict() if user else None
|
||||
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"] = [
|
||||
{
|
||||
**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
|
||||
|
|
@ -142,7 +158,7 @@ def update_board(board_id, board, body: BoardCreateRequest):
|
|||
@load_board_owned
|
||||
def delete_board(board_id, board):
|
||||
"""Delete a board"""
|
||||
db.session.delete(board)
|
||||
board.soft_delete()
|
||||
db.session.commit()
|
||||
|
||||
return {"message": "Board deleted"}, 200
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
return jsonify({"error": "Link does not belong to this card"}), 403
|
||||
|
||||
db.session.delete(link)
|
||||
link.soft_delete()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"message": "Cards unlinked successfully"}), 200
|
||||
|
|
@ -204,7 +204,7 @@ def delete_card_link(link_id):
|
|||
if not link:
|
||||
return jsonify({"error": "Link not found"}), 404
|
||||
|
||||
db.session.delete(link)
|
||||
link.soft_delete()
|
||||
db.session.commit()
|
||||
|
||||
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)
|
||||
|
||||
# Remove the checklist item
|
||||
db.session.delete(check_item)
|
||||
|
||||
check_item.soft_delete()
|
||||
db.session.commit()
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -146,9 +146,9 @@ def update_card(card_id, card, body: CardCreateRequest):
|
|||
@kanban_bp.route("/cards/<int:card_id>", methods=["DELETE"])
|
||||
@jwt_required()
|
||||
@load_card_owned
|
||||
def delete_card(card_id, card):
|
||||
def delete_card(card_id, card: Card):
|
||||
"""Delete a card"""
|
||||
db.session.delete(card)
|
||||
card.soft_delete()
|
||||
db.session.commit()
|
||||
|
||||
return {"message": "Card deleted"}, 200
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ def update_check_item(item_id, check_item, body: CheckItemCreateRequest):
|
|||
@load_checklist_owned
|
||||
def delete_checklist(checklist_id, checklist):
|
||||
"""Delete a checklist"""
|
||||
db.session.delete(checklist)
|
||||
checklist.soft_delete()
|
||||
db.session.commit()
|
||||
|
||||
return {"message": "Checklist deleted"}, 200
|
||||
|
|
@ -81,7 +81,7 @@ def delete_checklist(checklist_id, checklist):
|
|||
@load_check_item_owned
|
||||
def delete_check_item(item_id, check_item):
|
||||
"""Delete a check item"""
|
||||
db.session.delete(check_item)
|
||||
check_item.soft_delete()
|
||||
db.session.commit()
|
||||
|
||||
return {"message": "Check item deleted"}, 200
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ def update_comment(comment_id, comment, body: CommentCreateRequest):
|
|||
@load_comment_owned
|
||||
def delete_comment(comment_id, comment):
|
||||
"""Delete a comment"""
|
||||
db.session.delete(comment)
|
||||
comment.soft_delete()
|
||||
db.session.commit()
|
||||
|
||||
return {"message": "Comment deleted"}, 200
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ def get_board_epics(board_id):
|
|||
if not board:
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ def get_epic(epic_id):
|
|||
return {"error": "Epic not found"}, 404
|
||||
|
||||
# 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["cards"] = [card.to_dict() for card in cards]
|
||||
|
||||
|
|
@ -119,10 +119,10 @@ def delete_epic(epic_id):
|
|||
return {"error": "Epic not found"}, 404
|
||||
|
||||
# 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
|
||||
db.session.delete(epic)
|
||||
epic.soft_delete()
|
||||
db.session.commit()
|
||||
|
||||
return {"message": "Epic deleted successfully"}, 200
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ def remove_label_from_card(card_id, card, label_id):
|
|||
if not card_label:
|
||||
return {"error": "Label not found on card"}, 404
|
||||
|
||||
db.session.delete(card_label)
|
||||
card_label.soft_delete()
|
||||
db.session.commit()
|
||||
|
||||
return {"message": "Label removed from card"}, 200
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ def update_list(list_id, lst, body: ListCreateRequest):
|
|||
@load_list_owned
|
||||
def delete_list(list_id, lst):
|
||||
"""Delete a list"""
|
||||
db.session.delete(lst)
|
||||
lst.soft_delete()
|
||||
db.session.commit()
|
||||
|
||||
return {"message": "List deleted"}, 200
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ def get_board_wikis(board_id):
|
|||
if not board:
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -147,7 +147,7 @@ def delete_wiki(wiki_id):
|
|||
return {"error": "Wiki not found"}, 404
|
||||
|
||||
# Delete wiki (cascades to wiki_entity_links)
|
||||
db.session.delete(wiki)
|
||||
wiki.soft_delete()
|
||||
db.session.commit()
|
||||
|
||||
return {"message": "Wiki deleted successfully"}, 200
|
||||
|
|
|
|||
|
|
@ -171,8 +171,7 @@ class FileService:
|
|||
current_app.logger.error(f"Error deleting file from MinIO: {e}")
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(attachment)
|
||||
db.session.commit()
|
||||
attachment.soft_delete()
|
||||
|
||||
current_app.logger.info(f"File deleted: {attachment.original_name}")
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -14,7 +14,7 @@ log = logging.getLogger(__name__)
|
|||
fake = Faker()
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@pytest.fixture(scope="session")
|
||||
def app():
|
||||
"""Create application for testing with PostgreSQL database (session scope)"""
|
||||
app = create_app(config_name="test")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import pytest
|
||||
|
||||
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
|
||||
|
|
@ -147,7 +148,8 @@ class TestBoardRoutes:
|
|||
|
||||
# Verify board is deleted
|
||||
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):
|
||||
"""Test deleting a non-existent board"""
|
||||
|
|
@ -164,3 +166,328 @@ class TestBoardRoutes:
|
|||
response = client.delete(f"/api/boards/{board.id}")
|
||||
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -160,7 +160,8 @@ class TestCardRoutes:
|
|||
|
||||
# Verify card is deleted
|
||||
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):
|
||||
"""Test deleting a non-existent card"""
|
||||
|
|
@ -168,6 +169,38 @@ class TestCardRoutes:
|
|||
|
||||
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(
|
||||
self, client, db_session, regular_user, auth_headers
|
||||
):
|
||||
|
|
|
|||
|
|
@ -245,7 +245,8 @@ class TestChecklistRoutes:
|
|||
|
||||
# Verify checklist is deleted
|
||||
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):
|
||||
"""Test deleting a non-existent checklist"""
|
||||
|
|
@ -287,7 +288,8 @@ class TestChecklistRoutes:
|
|||
|
||||
# Verify check item is deleted
|
||||
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):
|
||||
"""Test deleting a non-existent check item"""
|
||||
|
|
|
|||
|
|
@ -196,7 +196,8 @@ class TestCommentRoutes:
|
|||
|
||||
# Verify comment is deleted
|
||||
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):
|
||||
"""Test deleting a non-existent comment"""
|
||||
|
|
|
|||
|
|
@ -416,7 +416,8 @@ class TestEpicRoutes:
|
|||
|
||||
# Verify epic is deleted
|
||||
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(
|
||||
self, client, db_session, auth_headers, test_board, test_card
|
||||
|
|
|
|||
|
|
@ -181,4 +181,5 @@ class TestLabelRoutes:
|
|||
.filter_by(card_id=card.id, label_id=label.id)
|
||||
.first()
|
||||
)
|
||||
assert deleted_card_label is None
|
||||
assert deleted_card_label is not None
|
||||
assert deleted_card_label.status == "deleted"
|
||||
|
|
|
|||
|
|
@ -116,7 +116,8 @@ class TestListRoutes:
|
|||
|
||||
# Verify list is deleted
|
||||
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):
|
||||
"""Test deleting a non-existent list"""
|
||||
|
|
@ -144,11 +145,13 @@ class TestListRoutes:
|
|||
|
||||
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_card = db.session.get(Card, card.id)
|
||||
assert deleted_list is None
|
||||
assert deleted_card is None
|
||||
assert deleted_list is not None
|
||||
assert deleted_list.status == "deleted"
|
||||
assert deleted_card is not None
|
||||
assert deleted_card.status == "deleted"
|
||||
|
||||
def test_update_list_position_reorders_others(
|
||||
self, client, db_session, regular_user, auth_headers
|
||||
|
|
|
|||
496
backend/tests/routes/test_soft_delete_integration.py
Normal file
496
backend/tests/routes/test_soft_delete_integration.py
Normal 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
|
||||
|
|
@ -518,12 +518,13 @@ class TestWikiRoutes:
|
|||
|
||||
# Verify wiki is deleted
|
||||
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(
|
||||
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(
|
||||
name="Wiki with Links",
|
||||
board_id=test_board.id,
|
||||
|
|
@ -545,13 +546,18 @@ class TestWikiRoutes:
|
|||
|
||||
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
|
||||
|
||||
links = db.session.scalars(
|
||||
select(wiki_entity_links).where(wiki_entity_links.c.wiki_id == wiki.id)
|
||||
).all()
|
||||
assert len(links) == 0
|
||||
assert len(links) == 1 # Links are preserved
|
||||
|
||||
def test_delete_wiki_not_found(self, client, db_session, auth_headers):
|
||||
"""Test deleting non-existent wiki"""
|
||||
|
|
|
|||
Loading…
Reference in a new issue