"""Add tree organization: tags, categories, folders, team admin Revision ID: 005 Revises: 004 Create Date: 2026-02-01 """ from typing import Sequence, Union from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = '005' down_revision: Union[str, None] = '004' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # =========================================== # 1. Add is_team_admin to users # =========================================== op.add_column('users', sa.Column('is_team_admin', sa.Boolean(), nullable=False, server_default='false')) # =========================================== # 2. Create tree_categories table # =========================================== op.create_table( 'tree_categories', sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), sa.Column('name', sa.String(100), nullable=False), sa.Column('slug', sa.String(100), nullable=False), sa.Column('description', sa.Text(), nullable=True), sa.Column('team_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('teams.id', ondelete='CASCADE'), nullable=True), sa.Column('display_order', sa.Integer(), nullable=False, server_default='0'), sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), sa.UniqueConstraint('slug', 'team_id', name='uq_tree_categories_slug_team') ) op.create_index('ix_tree_categories_team_id', 'tree_categories', ['team_id']) op.create_index('ix_tree_categories_slug', 'tree_categories', ['slug']) op.create_index('ix_tree_categories_display_order', 'tree_categories', ['display_order']) # =========================================== # 3. Create tree_tags table # =========================================== op.create_table( 'tree_tags', sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), sa.Column('name', sa.String(50), nullable=False), sa.Column('slug', sa.String(50), nullable=False), sa.Column('team_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('teams.id', ondelete='CASCADE'), nullable=True), sa.Column('usage_count', sa.Integer(), nullable=False, server_default='0'), sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), sa.UniqueConstraint('slug', 'team_id', name='uq_tree_tags_slug_team') ) op.create_index('ix_tree_tags_slug', 'tree_tags', ['slug']) op.create_index('ix_tree_tags_team_id', 'tree_tags', ['team_id']) op.create_index('ix_tree_tags_usage_count', 'tree_tags', ['usage_count']) # =========================================== # 4. Create tree_tag_assignments junction table # =========================================== op.create_table( 'tree_tag_assignments', sa.Column('tree_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('trees.id', ondelete='CASCADE'), primary_key=True), sa.Column('tag_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('tree_tags.id', ondelete='CASCADE'), primary_key=True), sa.Column('assigned_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), sa.Column('assigned_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')) ) op.create_index('ix_tree_tag_assignments_tree_id', 'tree_tag_assignments', ['tree_id']) op.create_index('ix_tree_tag_assignments_tag_id', 'tree_tag_assignments', ['tag_id']) # =========================================== # 5. Create user_folders table # =========================================== op.create_table( 'user_folders', sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')), sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), sa.Column('name', sa.String(100), nullable=False), sa.Column('color', sa.String(7), nullable=False, server_default='#6366f1'), sa.Column('icon', sa.String(50), nullable=False, server_default='folder'), sa.Column('display_order', sa.Integer(), nullable=False, server_default='0'), sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), sa.UniqueConstraint('user_id', 'name', name='uq_user_folders_user_name') ) op.create_index('ix_user_folders_user_id', 'user_folders', ['user_id']) # =========================================== # 6. Create user_folder_trees junction table # =========================================== op.create_table( 'user_folder_trees', sa.Column('folder_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('user_folders.id', ondelete='CASCADE'), primary_key=True), sa.Column('tree_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('trees.id', ondelete='CASCADE'), primary_key=True), sa.Column('added_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), sa.Column('display_order', sa.Integer(), nullable=False, server_default='0') ) op.create_index('ix_user_folder_trees_folder_id', 'user_folder_trees', ['folder_id']) op.create_index('ix_user_folder_trees_tree_id', 'user_folder_trees', ['tree_id']) # =========================================== # 7. Add category_id to trees table # =========================================== op.add_column('trees', sa.Column('category_id', postgresql.UUID(as_uuid=True), nullable=True)) op.create_foreign_key('fk_trees_category_id', 'trees', 'tree_categories', ['category_id'], ['id'], ondelete='SET NULL') op.create_index('ix_trees_category_id', 'trees', ['category_id']) # =========================================== # 8. Seed default global categories # =========================================== op.execute(""" INSERT INTO tree_categories (name, slug, description, display_order, team_id) VALUES ('Help Desk', 'help-desk', 'Tier 1 support - password resets, basic troubleshooting', 1, NULL), ('Desktop Support', 'desktop-support', 'Tier 2 support - workstation issues, software problems', 2, NULL), ('Systems Administration', 'systems-administration', 'Tier 3 support - servers, Active Directory, infrastructure', 3, NULL), ('Networking', 'networking', 'Network connectivity, VPN, firewall, DNS/DHCP', 4, NULL), ('Security', 'security', 'Security incidents, access issues, compliance', 5, NULL), ('Cloud & Microsoft 365', 'cloud-m365', 'Microsoft 365, Azure, cloud services', 6, NULL), ('Backup & Recovery', 'backup-recovery', 'Backup systems, disaster recovery, data restoration', 7, NULL), ('Printing', 'printing', 'Print servers, printers, drivers, spooler issues', 8, NULL) """) # =========================================== # 9. Migrate existing category strings to new system # =========================================== # First, create tags from existing categories op.execute(""" INSERT INTO tree_tags (name, slug, team_id, usage_count) SELECT DISTINCT category as name, LOWER(REGEXP_REPLACE(REGEXP_REPLACE(category, '[^a-zA-Z0-9 ]', '', 'g'), ' +', '-', 'g')) as slug, NULL::uuid as team_id, COUNT(*) OVER (PARTITION BY category) as usage_count FROM trees WHERE category IS NOT NULL AND category != '' ON CONFLICT (slug, team_id) DO NOTHING """) # Assign tags to trees based on their old category op.execute(""" INSERT INTO tree_tag_assignments (tree_id, tag_id) SELECT t.id as tree_id, tt.id as tag_id FROM trees t JOIN tree_tags tt ON tt.slug = LOWER(REGEXP_REPLACE(REGEXP_REPLACE(t.category, '[^a-zA-Z0-9 ]', '', 'g'), ' +', '-', 'g')) AND tt.team_id IS NULL WHERE t.category IS NOT NULL AND t.category != '' ON CONFLICT DO NOTHING """) def downgrade() -> None: # Drop foreign key and column from trees op.drop_constraint('fk_trees_category_id', 'trees', type_='foreignkey') op.drop_index('ix_trees_category_id', table_name='trees') op.drop_column('trees', 'category_id') # Drop user_folder_trees op.drop_index('ix_user_folder_trees_tree_id', table_name='user_folder_trees') op.drop_index('ix_user_folder_trees_folder_id', table_name='user_folder_trees') op.drop_table('user_folder_trees') # Drop user_folders op.drop_index('ix_user_folders_user_id', table_name='user_folders') op.drop_table('user_folders') # Drop tree_tag_assignments op.drop_index('ix_tree_tag_assignments_tag_id', table_name='tree_tag_assignments') op.drop_index('ix_tree_tag_assignments_tree_id', table_name='tree_tag_assignments') op.drop_table('tree_tag_assignments') # Drop tree_tags op.drop_index('ix_tree_tags_usage_count', table_name='tree_tags') op.drop_index('ix_tree_tags_team_id', table_name='tree_tags') op.drop_index('ix_tree_tags_slug', table_name='tree_tags') op.drop_table('tree_tags') # Drop tree_categories op.drop_index('ix_tree_categories_display_order', table_name='tree_categories') op.drop_index('ix_tree_categories_slug', table_name='tree_categories') op.drop_index('ix_tree_categories_team_id', table_name='tree_categories') op.drop_table('tree_categories') # Remove is_team_admin from users op.drop_column('users', 'is_team_admin')