diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 307810a..8e02de7 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -15,14 +15,16 @@ on: jobs: backend-test: runs-on: [docker] - + env: + UNIQUE_DB: test_db_${{ github.run_id }} + services: postgres: image: postgres:15-alpine env: POSTGRES_USER: test POSTGRES_PASSWORD: test - POSTGRES_DB: test_db + POSTGRES_DB: test_db_${{ github.run_id }}_${{ github.run_attempt }} options: >- --health-cmd pg_isready --health-interval 10s @@ -39,13 +41,34 @@ jobs: - name: Set up Python run: | python --version - + - name: Install dependencies run: | cd backend python -m pip install --upgrade pip pip install --cache-dir /tmp/pip-cache -r requirements/dev.txt - + + - name: Create Unique Test Database + env: + DATABASE_URL: postgresql://test:test@postgres:5432/postgres + run: | + # Install postgresql-client if not present in the alpine image to run psql + # Or use python to create the db + cd backend + python -c " + import os + from sqlalchemy import create_engine, text + db_url = os.environ['DATABASE_URL'] + engine = create_engine(db_url) + new_db = os.environ['UNIQUE_DB'] + # Connect to default 'postgres' db to create the new one + with engine.connect() as conn: + conn.execute(text('COMMIT')) # Close any open transactions + conn.execute(text(f'DROP DATABASE IF EXISTS {new_db}')) + conn.execute(text(f'CREATE DATABASE {new_db}')) + print(f'Created database: {new_db}') + " + - name: Debug cache run: | echo "Listing PIP cache files:" @@ -59,8 +82,8 @@ jobs: - name: Run tests env: - TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test_db - DATABASE_URL: postgresql://test:test@postgres:5432/test_db + TEST_DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }} + DATABASE_URL: postgresql://test:test@postgres:5432/${{ env.UNIQUE_DB }} SECRET_KEY: test-secret-key JWT_SECRET_KEY: test-jwt-secret FLASK_ENV: test diff --git a/backend/app/config.py b/backend/app/config.py index 67710a6..246ec5d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -88,8 +88,8 @@ class TestingConfig(Config): # Conservative connection pool settings for testing SQLALCHEMY_ENGINE_OPTIONS = { - "pool_size": 1, # Only one connection in the pool - "max_overflow": 0, # No overflow connections allowed + "pool_size": 2, # Only one connection in the pool + "max_overflow": 2, # No overflow connections allowed "pool_timeout": 30, "pool_recycle": 3600, # Recycle after 1 hour "pool_pre_ping": True, # Verify connections before using diff --git a/backend/app/decorators/decorators.py b/backend/app/decorators/decorators.py index 75a4e6a..ea6a032 100644 --- a/backend/app/decorators/decorators.py +++ b/backend/app/decorators/decorators.py @@ -29,16 +29,17 @@ def load_file_accessible(f): user_id = get_current_user_id() file_id = kwargs.get("file_id") - # Try to find file uploaded by user - attachment = FileAttachment.query.filter_by( - id=file_id, uploaded_by=user_id - ).first() + # Try to find active file uploaded by user + attachment = ( + FileAttachment.active().filter_by(id=file_id, uploaded_by=user_id).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( + FileAttachment.active() + .join( Card, (FileAttachment.attachable_type == "Card") & (FileAttachment.attachable_id == Card.id), @@ -56,9 +57,10 @@ def load_file_accessible(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( + FileAttachment.active() + .join( Comment, (FileAttachment.attachable_type == "Comment") & (FileAttachment.attachable_id == Comment.id), @@ -98,16 +100,19 @@ 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 active file uploaded by user + attachment = ( + FileAttachment.active() + .filter_by(uuid=file_uuid, uploaded_by=user_id) + .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( + FileAttachment.active() + .join( Card, (FileAttachment.attachable_type == "Card") & (FileAttachment.attachable_id == Card.id), @@ -125,9 +130,10 @@ 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( + FileAttachment.active() + .join( Comment, (FileAttachment.attachable_type == "Comment") & (FileAttachment.attachable_id == Comment.id), diff --git a/backend/app/decorators/owned.py b/backend/app/decorators/owned.py index 90f17d8..d171a8b 100644 --- a/backend/app/decorators/owned.py +++ b/backend/app/decorators/owned.py @@ -20,7 +20,7 @@ def load_board_owned(f): board_id = kwargs.get("board_id") # SECURE QUERY: Filter by ID *and* User ID in the DB - board = Board.query.filter_by(id=board_id, user_id=user_id).first() + board = Board.active().filter_by(id=board_id, user_id=user_id).first() if not board: abort(404) @@ -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,9 +43,10 @@ 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) + Card.active() + .join(Board) .filter(Card.id == card_id, Board.user_id == user_id) .first() ) @@ -67,7 +69,8 @@ def load_list_owned(f): list_id = kwargs.get("list_id") lst = ( - List.query.join(Board) + List.active() + .join(Board) .filter(List.id == list_id, Board.user_id == user_id) .first() ) @@ -90,7 +93,8 @@ def load_checklist_owned(f): checklist_id = kwargs.get("checklist_id") checklist = ( - Checklist.query.join(Card) + Checklist.active() + .join(Card) .join(Board) .filter(Checklist.id == checklist_id, Board.user_id == user_id) .first() @@ -114,7 +118,8 @@ def load_check_item_owned(f): item_id = kwargs.get("item_id") check_item = ( - CheckItem.query.join(Checklist) + CheckItem.active() + .join(Checklist) .join(Card) .join(Board) .filter(CheckItem.id == item_id, Board.user_id == user_id) @@ -141,7 +146,7 @@ def load_comment_owned(f): user_id = get_current_user_id() comment_id = kwargs.get("comment_id") - comment = Comment.query.filter_by(id=comment_id, user_id=user_id).first() + comment = Comment.active().filter_by(id=comment_id, user_id=user_id).first() if not comment: abort(404) @@ -164,9 +169,9 @@ def load_file_owned(f): file_id = kwargs.get("file_id") # Filter by ID and user ID - attachment = FileAttachment.query.filter_by( - id=file_id, uploaded_by=user_id - ).first() + attachment = ( + FileAttachment.active().filter_by(id=file_id, uploaded_by=user_id).first() + ) if not attachment: abort(404) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d1fce73..43be910 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,9 @@ +# 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 +from app.models.card_link import CardLink from app.models.check_item import CheckItem from app.models.checklist import Checklist from app.models.comment import Comment @@ -12,12 +15,14 @@ from app.models.user import User from app.models.wiki import Wiki, wiki_entity_links __all__ = [ + "SoftDeleteMixin", "User", "Board", "List", "Card", "Label", "CardLabel", + "CardLink", "Checklist", "CheckItem", "Comment", @@ -26,3 +31,4 @@ __all__ = [ "Wiki", "wiki_entity_links", ] +# fmt: on diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..38bc946 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,137 @@ +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() + + For relationship filtering: + class Parent(db.Model, SoftDeleteMixin): + children = db.relationship( + "Child", + primaryjoin="and_(Parent.id == Child.parent_id, " + "Child.status == 'active')", + ... + ) + """ + + 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) + + @property + def is_active(self): + """Check if this record is active (not soft-deleted).""" + return self.status == self.STATUS_ACTIVE + + def soft_delete(self): + """Mark this record as deleted and cascade to child relationships. + + Every relationship defined with cascade="all, delete-orphan" or + cascade="all" is considered a *soft-deletable child* and will also + be soft-deleted recursively. + """ + self._do_soft_delete() + + def _do_soft_delete(self): + """Internal recursive soft-delete implementation.""" + now = datetime.now(UTC) + + # Soft-delete children from configured cascade relationships + for prop in self.__mapper__.relationships: + cascade = prop.cascade + if cascade is None: + continue + + # Only cascade to relationships that had delete/delete-orphan + if not (cascade.delete or cascade.delete_orphan): + continue + + # Get the related objects + children = getattr(self, prop.key, None) + if children is None: + continue + + # Handle dynamic/lazy relationships (query-like) + if hasattr(children, "all"): + children = children.all() + + # Handle single (many-to-one / scalar) relationships + if not isinstance(children, list): + children = [children] if children else [] + + for child in children: + if isinstance(child, SoftDeleteMixin): + child._do_soft_delete() + + self.status = self.STATUS_DELETED + self.deleted_at = now + + def restore(self): + """Restore a soft-deleted record and cascade to children.""" + self._do_restore() + + def _do_restore(self): + """Internal recursive restore implementation.""" + for prop in self.__mapper__.relationships: + cascade = prop.cascade + if cascade is None: + continue + + if not (cascade.delete or cascade.delete_orphan): + continue + + children = getattr(self, prop.key, None) + if children is None: + continue + + if hasattr(children, "all"): + children = children.all() + + if not isinstance(children, list): + children = [children] if children else [] + + for child in children: + if isinstance(child, SoftDeleteMixin): + child._do_restore() + + self.status = self.STATUS_ACTIVE + self.deleted_at = None diff --git a/backend/app/models/board.py b/backend/app/models/board.py index a640f2d..a654cf7 100644 --- a/backend/app/models/board.py +++ b/backend/app/models/board.py @@ -3,9 +3,10 @@ from datetime import UTC, datetime from sqlalchemy.dialects.postgresql import JSONB from app import db +from app.models.base import SoftDeleteMixin -class Board(db.Model): +class Board(db.Model, SoftDeleteMixin): """Board model for Kanban boards""" __tablename__ = "boards" @@ -41,15 +42,27 @@ class Board(db.Model): label_names = db.Column(JSONB) # label color mappings limits = db.Column(JSONB) # various limits - # Relationships + # Relationships - only active records lists = db.relationship( - "List", backref="board", cascade="all, delete-orphan", lazy="dynamic" + "List", + backref="board", + cascade="all, delete-orphan", + lazy="dynamic", + primaryjoin="and_(Board.id == List.board_id, List.status == 'active')", ) cards = db.relationship( - "Card", backref="board", cascade="all, delete-orphan", lazy="dynamic" + "Card", + backref="board", + cascade="all, delete-orphan", + lazy="dynamic", + primaryjoin="and_(Board.id == Card.board_id, Card.status == 'active')", ) labels = db.relationship( - "Label", backref="board", cascade="all, delete-orphan", lazy="dynamic" + "Label", + backref="board", + cascade="all, delete-orphan", + lazy="dynamic", + primaryjoin="and_(Board.id == Label.board_id, Label.status == 'active')", ) def to_dict(self): @@ -74,6 +87,8 @@ class Board(db.Model): "prefs": self.prefs, "label_names": self.label_names, "limits": self.limits, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/card.py b/backend/app/models/card.py index 547ea23..6297d3e 100644 --- a/backend/app/models/card.py +++ b/backend/app/models/card.py @@ -3,9 +3,10 @@ from datetime import UTC, datetime from sqlalchemy.dialects.postgresql import JSONB from app import db +from app.models.base import SoftDeleteMixin -class Card(db.Model): +class Card(db.Model, SoftDeleteMixin): """Card model for Kanban cards""" __tablename__ = "cards" @@ -50,28 +51,61 @@ class Card(db.Model): cover = db.Column(JSONB) # cover settings desc_data = db.Column(JSONB) - # Relationships + # Relationships - only active records checklists = db.relationship( - "Checklist", backref="card", cascade="all, delete-orphan", lazy="dynamic" + "Checklist", + backref="card", + cascade="all, delete-orphan", + lazy="dynamic", + primaryjoin="and_(Card.id == Checklist.card_id, Checklist.status == 'active')", ) labels = db.relationship( - "CardLabel", backref="card", cascade="all, delete-orphan", lazy="dynamic" + "CardLabel", + backref="card", + cascade="all, delete-orphan", + lazy="dynamic", + primaryjoin="and_(Card.id == CardLabel.card_id, CardLabel.status == 'active')", ) comments = db.relationship( - "Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic" + "Comment", + backref="card", + cascade="all, delete-orphan", + lazy="dynamic", + primaryjoin="and_(Card.id == Comment.card_id, Comment.status == 'active')", ) attachments = db.relationship( "FileAttachment", foreign_keys="FileAttachment.attachable_id", primaryjoin="""and_(FileAttachment.attachable_id == Card.id, - FileAttachment.attachable_type == 'Card')""", + FileAttachment.attachable_type == 'Card', + FileAttachment.status == 'active')""", cascade="all, delete-orphan", lazy="dynamic", ) - def to_dict(self): + # Card link relationships (self-referential many-to-many) - only active links + child_links = db.relationship( + "CardLink", + foreign_keys="CardLink.parent_card_id", + back_populates="parent_card", + cascade="all, delete-orphan", + lazy="dynamic", + primaryjoin="""and_(Card.id == CardLink.parent_card_id, + CardLink.status == 'active')""", + ) + parent_links = db.relationship( + "CardLink", + foreign_keys="CardLink.child_card_id", + back_populates="child_card", + cascade="all, delete-orphan", + lazy="dynamic", + primaryjoin="""and_(Card.id == CardLink.child_card_id, + CardLink.status == 'active')""", + ) + + def to_dict(self, include_linked=False): """Convert card to dictionary""" - return { + result = { "id": self.id, "name": self.name, "description": self.description, @@ -82,6 +116,7 @@ class Card(db.Model): "id_short": self.id_short, "board_id": self.board_id, "list_id": self.list_id, + "list_name": self.list.name if self.list else None, "epic_id": self.epic_id, "date_last_activity": self.date_last_activity.isoformat() if self.date_last_activity @@ -91,7 +126,26 @@ 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 + else None + ), } + if include_linked: + result["parent_cards"] = [ + link.child_card.to_dict() + for link in self.parent_links + if link.child_card + ] + result["child_cards"] = [ + link.child_card.to_dict() + for link in self.child_links + if link.child_card + ] + return result def __repr__(self): return f"" @@ -105,19 +159,16 @@ def update_epic_metrics_on_card_change(mapper, connection, target): from app.models import Epic - # Get total card count card_count_stmt = select(db.func.count(Card.id)).where( Card.epic_id == target.epic_id ) card_count = connection.execute(card_count_stmt).scalar() - # Get epic's completed_list_id completed_list_id_stmt = select(Epic.completed_list_id).where( Epic.id == target.epic_id ) completed_list_id = connection.execute(completed_list_id_stmt).scalar() - # Get completed card count (only if epic has completed_list_id) completed_cards_count = 0 if completed_list_id: completed_cards_stmt = select(db.func.count(Card.id)).where( @@ -125,7 +176,6 @@ def update_epic_metrics_on_card_change(mapper, connection, target): ) completed_cards_count = connection.execute(completed_cards_stmt).scalar() - # Update epic metrics connection.execute( update(Epic) .where(Epic.id == target.epic_id) @@ -145,19 +195,16 @@ def update_epic_metrics_on_card_insert(mapper, connection, target): from app.models import Epic - # Get total card count card_count_stmt = select(db.func.count(Card.id)).where( Card.epic_id == target.epic_id ) card_count = connection.execute(card_count_stmt).scalar() - # Get epic's completed_list_id completed_list_id_stmt = select(Epic.completed_list_id).where( Epic.id == target.epic_id ) completed_list_id = connection.execute(completed_list_id_stmt).scalar() - # Get completed card count (only if epic has completed_list_id) completed_cards_count = 0 if completed_list_id: completed_cards_stmt = select(db.func.count(Card.id)).where( @@ -165,7 +212,6 @@ def update_epic_metrics_on_card_insert(mapper, connection, target): ) completed_cards_count = connection.execute(completed_cards_stmt).scalar() - # Update epic metrics connection.execute( update(Epic) .where(Epic.id == target.epic_id) @@ -185,19 +231,16 @@ def update_epic_metrics_on_card_delete(mapper, connection, target): from app.models import Epic - # Get total card count card_count_stmt = select(db.func.count(Card.id)).where( Card.epic_id == target.epic_id ) card_count = connection.execute(card_count_stmt).scalar() - # Get epic's completed_list_id completed_list_id_stmt = select(Epic.completed_list_id).where( Epic.id == target.epic_id ) completed_list_id = connection.execute(completed_list_id_stmt).scalar() - # Get completed card count (only if epic has completed_list_id) completed_cards_count = 0 if completed_list_id: completed_cards_stmt = select(db.func.count(Card.id)).where( @@ -205,7 +248,6 @@ def update_epic_metrics_on_card_delete(mapper, connection, target): ) completed_cards_count = connection.execute(completed_cards_stmt).scalar() - # Update epic metrics connection.execute( update(Epic) .where(Epic.id == target.epic_id) diff --git a/backend/app/models/card_label.py b/backend/app/models/card_label.py index 17acde3..2eac06e 100644 --- a/backend/app/models/card_label.py +++ b/backend/app/models/card_label.py @@ -1,9 +1,10 @@ from datetime import UTC, datetime from app import db +from app.models.base import SoftDeleteMixin -class CardLabel(db.Model): +class CardLabel(db.Model, SoftDeleteMixin): """Many-to-many relationship between cards and labels""" __tablename__ = "card_labels" @@ -37,6 +38,8 @@ class CardLabel(db.Model): "card_id": self.card_id, "label_id": self.label_id, "created_at": self.created_at.isoformat() if self.created_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/card_link.py b/backend/app/models/card_link.py new file mode 100644 index 0000000..63de5b9 --- /dev/null +++ b/backend/app/models/card_link.py @@ -0,0 +1,69 @@ +from datetime import UTC, datetime + +from app import db +from app.models.base import SoftDeleteMixin + + +class CardLink(db.Model, SoftDeleteMixin): + """CardLink model for bidirectional card-to-card relationships""" + + __tablename__ = "card_links" + + id = db.Column(db.Integer, primary_key=True) + parent_card_id = db.Column( + db.Integer, + db.ForeignKey("cards.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + child_card_id = db.Column( + db.Integer, + db.ForeignKey("cards.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + created_by = db.Column( + db.Integer, db.ForeignKey("users.id"), nullable=True, index=True + ) + + # Timestamps + created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC)) + + # Relationships + parent_card = db.relationship( + "Card", + foreign_keys=[parent_card_id], + back_populates="child_links", + ) + child_card = db.relationship( + "Card", + foreign_keys=[child_card_id], + back_populates="parent_links", + ) + + def to_dict(self, include_cards=False): + """Convert card link to dictionary""" + result = { + "id": self.id, + "parent_card_id": self.parent_card_id, + "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"] = ( + self.parent_card.to_dict() if self.parent_card else None + ) + result["child_card"] = ( + self.child_card.to_dict() if self.child_card else None + ) + return result + + def __repr__(self): + return f" {self.child_card_id}>" + + __table_args__ = ( + db.UniqueConstraint("parent_card_id", "child_card_id", name="unique_card_link"), + ) diff --git a/backend/app/models/check_item.py b/backend/app/models/check_item.py index bc8c35d..1682072 100644 --- a/backend/app/models/check_item.py +++ b/backend/app/models/check_item.py @@ -1,9 +1,10 @@ from datetime import UTC, datetime from app import db +from app.models.base import SoftDeleteMixin -class CheckItem(db.Model): +class CheckItem(db.Model, SoftDeleteMixin): """CheckItem model for checklist items""" __tablename__ = "check_items" @@ -45,6 +46,8 @@ class CheckItem(db.Model): "user_id": self.user_id, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/checklist.py b/backend/app/models/checklist.py index 90808cf..826b87a 100644 --- a/backend/app/models/checklist.py +++ b/backend/app/models/checklist.py @@ -1,10 +1,11 @@ from datetime import UTC, datetime from app import db +from app.models.base import SoftDeleteMixin -class Checklist(db.Model): - """Checklist model for Kanban checklists""" +class Checklist(db.Model, SoftDeleteMixin): + """Checklist model for card checklists""" __tablename__ = "checklists" @@ -34,9 +35,14 @@ class Checklist(db.Model): onupdate=lambda: datetime.now(UTC), ) - # Relationships + # Relationships - only active check items check_items = db.relationship( - "CheckItem", backref="checklist", cascade="all, delete-orphan", lazy="dynamic" + "CheckItem", + backref="checklist", + cascade="all, delete-orphan", + lazy="dynamic", + primaryjoin="""and_(Checklist.id == CheckItem.checklist_id, + CheckItem.status == 'active')""", ) def to_dict(self): @@ -45,10 +51,12 @@ class Checklist(db.Model): "id": self.id, "name": self.name, "pos": self.pos, - "board_id": self.board_id, "card_id": self.card_id, + "board_id": self.card.board_id if self.card else None, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py index 26894f1..b76ccec 100644 --- a/backend/app/models/comment.py +++ b/backend/app/models/comment.py @@ -1,9 +1,10 @@ from datetime import UTC, datetime from app import db +from app.models.base import SoftDeleteMixin -class Comment(db.Model): +class Comment(db.Model, SoftDeleteMixin): """Comment model for card comments""" __tablename__ = "comments" @@ -50,6 +51,8 @@ class Comment(db.Model): "user_id": self.user_id, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/epic.py b/backend/app/models/epic.py index 5e4c137..e3d55d7 100644 --- a/backend/app/models/epic.py +++ b/backend/app/models/epic.py @@ -3,9 +3,10 @@ from datetime import UTC, datetime from sqlalchemy.dialects.postgresql import JSONB from app import db +from app.models.base import SoftDeleteMixin -class Epic(db.Model): +class Epic(db.Model, SoftDeleteMixin): """Epic model for tracking large features across multiple cards""" __tablename__ = "epics" @@ -81,6 +82,8 @@ class Epic(db.Model): "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, "metrics": self.metrics or {"card_count": 0, "completed_cards_count": 0}, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/file_attachment.py b/backend/app/models/file_attachment.py index 232a705..1d3d6a5 100644 --- a/backend/app/models/file_attachment.py +++ b/backend/app/models/file_attachment.py @@ -4,9 +4,10 @@ from datetime import UTC, datetime from sqlalchemy import Index from app import db +from app.models.base import SoftDeleteMixin -class FileAttachment(db.Model): +class FileAttachment(db.Model, SoftDeleteMixin): """Polymorphic file attachment model for Cards, Comments, and other entities""" __tablename__ = "file_attachments" @@ -69,6 +70,8 @@ class FileAttachment(db.Model): "attachable_id": self.attachable_id, "uploaded_by": self.uploaded_by, "created_at": self.created_at.isoformat() if self.created_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): diff --git a/backend/app/models/label.py b/backend/app/models/label.py index b8482ef..27554fe 100644 --- a/backend/app/models/label.py +++ b/backend/app/models/label.py @@ -1,10 +1,11 @@ from datetime import UTC, datetime from app import db +from app.models.base import SoftDeleteMixin -class Label(db.Model): - """Label model for Kanban labels""" +class Label(db.Model, SoftDeleteMixin): + """Label model for card labels""" __tablename__ = "labels" @@ -40,11 +41,12 @@ class Label(db.Model): "id": self.id, "name": self.name, "color": self.color, - "uses": self.uses, "board_id": self.board_id, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "status": self.status, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at else None, } def __repr__(self): - return f"