diff --git a/backend/alembic/versions/015_add_deleted_at_to_trees.py b/backend/alembic/versions/015_add_deleted_at_to_trees.py new file mode 100644 index 00000000..ac88aeeb --- /dev/null +++ b/backend/alembic/versions/015_add_deleted_at_to_trees.py @@ -0,0 +1,38 @@ +"""add deleted_at and deleted_by columns to trees + +Revision ID: 015 +Revises: 014 +Create Date: 2026-02-05 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = '015' +down_revision: Union[str, None] = '014' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('trees', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('trees', sa.Column('deleted_by', UUID(as_uuid=True), nullable=True)) + op.create_index('ix_trees_deleted_at', 'trees', ['deleted_at']) + op.create_foreign_key('fk_trees_deleted_by_users', 'trees', 'users', ['deleted_by'], ['id']) + + # Backfill: convert existing is_active=False to deleted_at=now() + op.execute("UPDATE trees SET deleted_at = NOW() WHERE is_active = FALSE") + + +def downgrade() -> None: + # Convert back + op.execute("UPDATE trees SET is_active = FALSE WHERE deleted_at IS NOT NULL") + op.drop_constraint('fk_trees_deleted_by_users', 'trees', type_='foreignkey') + op.drop_index('ix_trees_deleted_at', table_name='trees') + op.drop_column('trees', 'deleted_by') + op.drop_column('trees', 'deleted_at') diff --git a/backend/app/api/endpoints/trees.py b/backend/app/api/endpoints/trees.py index a246e8ab..53906793 100644 --- a/backend/app/api/endpoints/trees.py +++ b/backend/app/api/endpoints/trees.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import Annotated, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status, Query @@ -508,7 +509,7 @@ async def delete_tree( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(require_admin)] ): - """Soft delete a tree (admin only). Sets is_active to False.""" + """Soft delete a tree (admin only). Sets deleted_at timestamp and is_active=False.""" result = await db.execute(select(Tree).where(Tree.id == tree_id)) tree = result.scalar_one_or_none() @@ -519,6 +520,8 @@ async def delete_tree( ) tree.is_active = False + tree.deleted_at = datetime.now(timezone.utc) + tree.deleted_by = current_user.id await log_audit(db, current_user.id, "tree.delete", "tree", tree.id, {"tree_name": tree.name}) await db.commit() diff --git a/backend/app/models/tree.py b/backend/app/models/tree.py index 7551ccf7..56237d96 100644 --- a/backend/app/models/tree.py +++ b/backend/app/models/tree.py @@ -50,6 +50,16 @@ class Tree(Base): is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_public: Mapped[bool] = mapped_column(Boolean, default=False, index=True) is_default: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + index=True + ) + deleted_by: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + nullable=True + ) version: Mapped[int] = mapped_column(Integer, default=1) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), @@ -63,7 +73,7 @@ class Tree(Base): usage_count: Mapped[int] = mapped_column(Integer, default=0) # Relationships - author: Mapped[Optional["User"]] = relationship("User", back_populates="trees") + author: Mapped[Optional["User"]] = relationship("User", foreign_keys=[author_id], back_populates="trees") team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees") sessions: Mapped[list["Session"]] = relationship("Session", back_populates="tree") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 05aab42a..ac7648f7 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -52,7 +52,7 @@ class User(Base): # Relationships team: Mapped[Optional["Team"]] = relationship("Team", back_populates="users") - trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="author") + trees: Mapped[list["Tree"]] = relationship("Tree", foreign_keys="[Tree.author_id]", back_populates="author") sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user") folders: Mapped[list["UserFolder"]] = relationship("UserFolder", back_populates="user")