Features: - Categories: Global and team-specific tree categorization (admin-managed) - Tags: Flexible tree tagging with autocomplete (author + admin) - User folders: Personal tree collections with subfolder support - Hierarchical structure (max 3 levels deep) - Right-click context menu for folder management - Cascade delete for subfolders - Filter trees by category, tags, and folder in library view Backend: - New models: Category, Tag, UserFolder with relationships - New API endpoints for categories, tags, and folders - Tree organization migrations (005, 006) Frontend: - FolderSidebar with hierarchical folder tree - FolderEditModal for create/edit with color picker - AddToFolderMenu for quick tree organization - TagInput with autocomplete and TagBadges display - Updated TreeMetadataForm and TreeLibraryPage Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
194 lines
10 KiB
Python
194 lines
10 KiB
Python
"""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')
|