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
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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 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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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"] = (
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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}>"
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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
|
# 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"""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue