feat: upgrade tree deletion to soft delete with deleted_at timestamp

Adds deleted_at and deleted_by columns to trees table for proper soft
delete tracking. Supports future 30-day restore window functionality.
The delete endpoint now sets both is_active=False (backward compat) and
deleted_at/deleted_by. Migration backfills existing is_active=False rows.

Fixed ambiguous FK relationship between User/Tree models by adding
explicit foreign_keys to both sides of the author relationship.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-05 23:33:05 -05:00
parent 3a5ac0f201
commit 33368688b2
4 changed files with 54 additions and 3 deletions

View File

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

View File

@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from typing import Annotated, Optional from typing import Annotated, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
@@ -508,7 +509,7 @@ async def delete_tree(
db: Annotated[AsyncSession, Depends(get_db)], db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)] 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)) result = await db.execute(select(Tree).where(Tree.id == tree_id))
tree = result.scalar_one_or_none() tree = result.scalar_one_or_none()
@@ -519,6 +520,8 @@ async def delete_tree(
) )
tree.is_active = False 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, await log_audit(db, current_user.id, "tree.delete", "tree", tree.id,
{"tree_name": tree.name}) {"tree_name": tree.name})
await db.commit() await db.commit()

View File

@@ -50,6 +50,16 @@ class Tree(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_public: Mapped[bool] = mapped_column(Boolean, default=False, index=True) is_public: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
is_default: 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) version: Mapped[int] = mapped_column(Integer, default=1)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
@@ -63,7 +73,7 @@ class Tree(Base):
usage_count: Mapped[int] = mapped_column(Integer, default=0) usage_count: Mapped[int] = mapped_column(Integer, default=0)
# Relationships # 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") team: Mapped[Optional["Team"]] = relationship("Team", back_populates="trees")
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="tree") sessions: Mapped[list["Session"]] = relationship("Session", back_populates="tree")

View File

@@ -52,7 +52,7 @@ class User(Base):
# Relationships # Relationships
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="users") 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") sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user")
folders: Mapped[list["UserFolder"]] = relationship("UserFolder", back_populates="user") folders: Mapped[list["UserFolder"]] = relationship("UserFolder", back_populates="user")