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:
38
backend/alembic/versions/015_add_deleted_at_to_trees.py
Normal file
38
backend/alembic/versions/015_add_deleted_at_to_trees.py
Normal 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')
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user