UMBRA/backend/alembic/versions/036_add_cascade_to_transitive_fks.py
Kyle Pope e7cb6de7d5 Add admin delete-user with full cascade cleanup
Migration 036 adds ondelete rules to 5 transitive FKs that would
otherwise block user deletion (calendar_events via calendars,
project_tasks via projects, todos via projects, etc.).

DELETE /api/admin/users/{user_id} with self-action guard, last-admin
guard, session revocation, and audit logging. Frontend gets a red
two-click confirm button in the IAM actions menu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:20:47 +08:00

105 lines
3.2 KiB
Python

"""Add ondelete to transitive FK constraints.
Without these, deleting a user would fail because the DB-level CASCADE
only reaches first-level children (calendars, projects, people, locations).
Second-level children (calendar_events via calendar_id, project_tasks via
project_id, etc.) need their own ondelete rules to allow the full cascade.
FK changes:
calendar_events.calendar_id → CASCADE (events die with calendar)
calendar_events.location_id → SET NULL (optional ref, just unlink)
project_tasks.project_id → CASCADE (tasks die with project)
project_tasks.person_id → SET NULL (optional assignee, just unlink)
todos.project_id → SET NULL (optional ref, just unlink)
Revision ID: 036
Revises: 035
Create Date: 2026-02-27
"""
from alembic import op
revision = "036"
down_revision = "035"
branch_labels = None
depends_on = None
def upgrade() -> None:
# calendar_events.calendar_id → CASCADE
op.drop_constraint(
"fk_calendar_events_calendar_id", "calendar_events", type_="foreignkey"
)
op.create_foreign_key(
"fk_calendar_events_calendar_id",
"calendar_events",
"calendars",
["calendar_id"],
["id"],
ondelete="CASCADE",
)
# calendar_events.location_id → SET NULL
op.drop_constraint(
"calendar_events_location_id_fkey", "calendar_events", type_="foreignkey"
)
op.create_foreign_key(
"calendar_events_location_id_fkey",
"calendar_events",
"locations",
["location_id"],
["id"],
ondelete="SET NULL",
)
# project_tasks.project_id → CASCADE
op.drop_constraint(
"project_tasks_project_id_fkey", "project_tasks", type_="foreignkey"
)
op.create_foreign_key(
"project_tasks_project_id_fkey",
"project_tasks",
"projects",
["project_id"],
["id"],
ondelete="CASCADE",
)
# project_tasks.person_id → SET NULL
op.drop_constraint(
"project_tasks_person_id_fkey", "project_tasks", type_="foreignkey"
)
op.create_foreign_key(
"project_tasks_person_id_fkey",
"project_tasks",
"people",
["person_id"],
["id"],
ondelete="SET NULL",
)
# todos.project_id → SET NULL
op.drop_constraint(
"todos_project_id_fkey", "todos", type_="foreignkey"
)
op.create_foreign_key(
"todos_project_id_fkey",
"todos",
"projects",
["project_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
# Reverse: remove ondelete by re-creating without it
for table, col, ref_table, constraint in [
("todos", "project_id", "projects", "todos_project_id_fkey"),
("project_tasks", "person_id", "people", "project_tasks_person_id_fkey"),
("project_tasks", "project_id", "projects", "project_tasks_project_id_fkey"),
("calendar_events", "location_id", "locations", "calendar_events_location_id_fkey"),
("calendar_events", "calendar_id", "calendars", "fk_calendar_events_calendar_id"),
]:
op.drop_constraint(constraint, table, type_="foreignkey")
op.create_foreign_key(constraint, table, ref_table, [col], ["id"])