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>
This commit is contained in:
@@ -2,10 +2,23 @@ from .user import UserCreate, UserUpdate, UserResponse, UserLogin
|
||||
from .token import Token, TokenPayload
|
||||
from .tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse
|
||||
from .session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, DecisionRecord
|
||||
from .category import CategoryCreate, CategoryUpdate, CategoryResponse, CategoryListResponse
|
||||
from .tag import TagCreate, TagResponse, TagListResponse, TagAssignment
|
||||
from .folder import FolderCreate, FolderUpdate, FolderResponse, FolderListResponse, FolderReorderRequest, FolderTreeRequest
|
||||
|
||||
__all__ = [
|
||||
# User
|
||||
"UserCreate", "UserUpdate", "UserResponse", "UserLogin",
|
||||
# Token
|
||||
"Token", "TokenPayload",
|
||||
# Tree
|
||||
"TreeCreate", "TreeUpdate", "TreeResponse", "TreeListResponse",
|
||||
"SessionCreate", "SessionUpdate", "SessionResponse", "SessionExport", "DecisionRecord"
|
||||
# Session
|
||||
"SessionCreate", "SessionUpdate", "SessionResponse", "SessionExport", "DecisionRecord",
|
||||
# Category
|
||||
"CategoryCreate", "CategoryUpdate", "CategoryResponse", "CategoryListResponse",
|
||||
# Tag
|
||||
"TagCreate", "TagResponse", "TagListResponse", "TagAssignment",
|
||||
# Folder
|
||||
"FolderCreate", "FolderUpdate", "FolderResponse", "FolderListResponse", "FolderReorderRequest", "FolderTreeRequest",
|
||||
]
|
||||
|
||||
58
backend/app/schemas/category.py
Normal file
58
backend/app/schemas/category.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
import re
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Convert a name to a URL-safe slug."""
|
||||
# Remove non-alphanumeric chars except spaces, convert to lowercase
|
||||
slug = re.sub(r'[^a-zA-Z0-9 ]', '', name.lower())
|
||||
# Replace spaces with hyphens
|
||||
slug = re.sub(r' +', '-', slug.strip())
|
||||
return slug
|
||||
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
team_id: Optional[UUID] = Field(None, description="Team ID for team-specific category. NULL for global.")
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
display_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class CategoryResponse(CategoryBase):
|
||||
id: UUID
|
||||
slug: str
|
||||
team_id: Optional[UUID] = None
|
||||
display_order: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tree_count: int = 0 # Computed field
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CategoryListResponse(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
team_id: Optional[UUID] = None
|
||||
display_order: int
|
||||
is_active: bool
|
||||
tree_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
62
backend/app/schemas/folder.py
Normal file
62
backend/app/schemas/folder.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
import re
|
||||
|
||||
|
||||
# Valid hex color pattern
|
||||
HEX_COLOR_PATTERN = r'^#[0-9A-Fa-f]{6}$'
|
||||
|
||||
|
||||
class FolderBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
color: str = Field("#6366f1", pattern=HEX_COLOR_PATTERN)
|
||||
icon: str = Field("folder", max_length=50)
|
||||
|
||||
|
||||
class FolderCreate(FolderBase):
|
||||
parent_id: Optional[UUID] = Field(None, description="Parent folder ID for creating subfolders")
|
||||
|
||||
|
||||
class FolderUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
color: Optional[str] = Field(None, pattern=HEX_COLOR_PATTERN)
|
||||
icon: Optional[str] = Field(None, max_length=50)
|
||||
display_order: Optional[int] = None
|
||||
parent_id: Optional[UUID] = Field(None, description="Parent folder ID to move folder")
|
||||
|
||||
|
||||
class FolderResponse(FolderBase):
|
||||
id: UUID
|
||||
parent_id: Optional[UUID] = None
|
||||
display_order: int
|
||||
tree_count: int = 0 # Computed field
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FolderListResponse(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
color: str
|
||||
icon: str
|
||||
parent_id: Optional[UUID] = None
|
||||
display_order: int
|
||||
tree_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FolderReorderRequest(BaseModel):
|
||||
"""Request body for reordering folders."""
|
||||
folder_ids: list[UUID] = Field(..., min_length=1, description="Folder IDs in desired order")
|
||||
|
||||
|
||||
class FolderTreeRequest(BaseModel):
|
||||
"""Request body for adding a tree to a folder."""
|
||||
tree_id: UUID
|
||||
56
backend/app/schemas/tag.py
Normal file
56
backend/app/schemas/tag.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
import re
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Convert a tag name to a URL-safe slug."""
|
||||
# Remove non-alphanumeric chars except spaces, convert to lowercase
|
||||
slug = re.sub(r'[^a-zA-Z0-9 ]', '', name.lower())
|
||||
# Replace spaces with hyphens
|
||||
slug = re.sub(r' +', '-', slug.strip())
|
||||
return slug
|
||||
|
||||
|
||||
class TagBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=50)
|
||||
|
||||
|
||||
class TagCreate(TagBase):
|
||||
team_id: Optional[UUID] = Field(None, description="Team ID for team-specific tag. NULL for global.")
|
||||
|
||||
|
||||
class TagResponse(TagBase):
|
||||
id: UUID
|
||||
slug: str
|
||||
team_id: Optional[UUID] = None
|
||||
usage_count: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TagListResponse(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
slug: str
|
||||
team_id: Optional[UUID] = None
|
||||
usage_count: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TagAssignment(BaseModel):
|
||||
"""Request body for adding/removing tags from a tree."""
|
||||
tags: list[str] = Field(..., min_length=1, max_length=10, description="List of tag names to assign")
|
||||
|
||||
|
||||
class TagSearchParams(BaseModel):
|
||||
"""Query parameters for tag search/autocomplete."""
|
||||
q: str = Field(..., min_length=1, description="Search query")
|
||||
limit: int = Field(10, ge=1, le=50)
|
||||
include_team: bool = Field(True, description="Include team-specific tags")
|
||||
@@ -4,9 +4,20 @@ from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CategoryInfo(BaseModel):
|
||||
"""Embedded category info for tree responses."""
|
||||
id: UUID
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TreeBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
# Legacy category field - kept for backward compatibility
|
||||
category: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
@@ -14,15 +25,19 @@ class TreeCreate(TreeBase):
|
||||
tree_structure: dict[str, Any] = Field(..., description="The decision tree structure in JSON format")
|
||||
is_public: bool = Field(False, description="Make tree visible to all users")
|
||||
is_default: bool = Field(False, description="Mark as a default/system tree (admin only)")
|
||||
category_id: Optional[UUID] = Field(None, description="Category ID from tree_categories table")
|
||||
tags: Optional[list[str]] = Field(None, max_length=10, description="List of tag names to assign")
|
||||
|
||||
|
||||
class TreeUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = Field(None, max_length=100)
|
||||
category_id: Optional[UUID] = None
|
||||
tree_structure: Optional[dict[str, Any]] = None
|
||||
is_public: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
tags: Optional[list[str]] = Field(None, max_length=10, description="List of tag names to assign (replaces existing)")
|
||||
|
||||
|
||||
class TreeResponse(TreeBase):
|
||||
@@ -30,6 +45,9 @@ class TreeResponse(TreeBase):
|
||||
tree_structure: dict[str, Any]
|
||||
author_id: Optional[UUID] = None
|
||||
team_id: Optional[UUID] = None
|
||||
category_id: Optional[UUID] = None
|
||||
category_info: Optional[CategoryInfo] = None
|
||||
tags: list[str] = [] # List of tag names
|
||||
is_active: bool
|
||||
is_public: bool
|
||||
is_default: bool
|
||||
@@ -47,6 +65,10 @@ class TreeListResponse(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
category_id: Optional[UUID] = None
|
||||
category_info: Optional[CategoryInfo] = None
|
||||
tags: list[str] = [] # List of tag names
|
||||
author_id: Optional[UUID] = None
|
||||
is_active: bool
|
||||
is_public: bool
|
||||
is_default: bool
|
||||
|
||||
@@ -28,6 +28,7 @@ class UserLogin(BaseModel):
|
||||
class UserResponse(UserBase):
|
||||
id: UUID
|
||||
role: str
|
||||
is_team_admin: bool = False
|
||||
team_id: Optional[UUID] = None
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
Reference in New Issue
Block a user