Files
resolutionflow/backend/alembic/versions/005_add_tree_organization.py
chihlasm fafdaa50a5 Add tree organization system with categories, tags, and folders
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>
2026-02-02 01:31:13 -05:00

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