feat: remove workspace system, add pinned flows and label renames

Replace workspace system with pinned flows API (pin/unpin/list/reorder).
Rename user-facing labels: Tree→Flow, Procedure→Project. Add sidebar
nav sub-items for flow type filtering. Remove 11 workspace files,
add migrations 037-038, clean all workspace references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-15 12:06:10 -05:00
parent 336e37d018
commit 58a55f4d88
39 changed files with 1612 additions and 2288 deletions

View File

@@ -0,0 +1,40 @@
"""Add user_pinned_trees table
Revision ID: 037
Revises: 036
Create Date: 2026-02-15
Adds pinned flows feature for sidebar favorites.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '037'
down_revision: Union[str, None] = '036'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'user_pinned_trees',
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('tree_id', UUID(as_uuid=True), sa.ForeignKey('trees.id', ondelete='CASCADE'), nullable=False),
sa.Column('display_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column('pinned_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
sa.UniqueConstraint('user_id', 'tree_id', name='uq_user_pinned_tree'),
)
op.create_index('idx_user_pinned_trees_user', 'user_pinned_trees', ['user_id'])
op.create_index('idx_user_pinned_trees_tree', 'user_pinned_trees', ['tree_id'])
def downgrade() -> None:
op.drop_index('idx_user_pinned_trees_tree', 'user_pinned_trees')
op.drop_index('idx_user_pinned_trees_user', 'user_pinned_trees')
op.drop_table('user_pinned_trees')

View File

@@ -0,0 +1,69 @@
"""Remove workspace system
Revision ID: 038
Revises: 037
Create Date: 2026-02-15
Drops workspace tables and columns. Keeps tree_categories.color.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '038'
down_revision: Union[str, None] = '037'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Drop workspace_id FK and column from trees
op.drop_index('ix_trees_workspace_id', 'trees')
op.drop_constraint('fk_trees_workspace_id', 'trees', type_='foreignkey')
op.drop_column('trees', 'workspace_id')
# 2. Drop workspace_id FK and column from tree_categories
op.drop_index('ix_tree_categories_workspace_id', 'tree_categories')
op.drop_constraint('fk_tree_categories_workspace_id', 'tree_categories', type_='foreignkey')
op.drop_column('tree_categories', 'workspace_id')
# 3. Drop workspaces table
op.drop_index('ix_workspaces_account_id', 'workspaces')
op.drop_index('ix_workspaces_slug', 'workspaces')
op.drop_table('workspaces')
# DO NOT drop tree_categories.color — we still use it
def downgrade() -> None:
# Recreate workspaces table
op.create_table(
'workspaces',
sa.Column('id', 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('icon', sa.String(10), nullable=True),
sa.Column('accent_color', sa.String(7), nullable=True),
sa.Column('account_id', UUID(as_uuid=True), sa.ForeignKey('accounts.id', ondelete='CASCADE'), nullable=False),
sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()')),
sa.UniqueConstraint('slug', 'account_id', name='uq_workspaces_slug_account'),
)
op.create_index('ix_workspaces_slug', 'workspaces', ['slug'])
op.create_index('ix_workspaces_account_id', 'workspaces', ['account_id'])
# Re-add workspace_id columns
op.add_column('trees', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True))
op.create_foreign_key('fk_trees_workspace_id', 'trees', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL')
op.create_index('ix_trees_workspace_id', 'trees', ['workspace_id'])
op.add_column('tree_categories', sa.Column('workspace_id', UUID(as_uuid=True), nullable=True))
op.create_foreign_key('fk_tree_categories_workspace_id', 'tree_categories', 'workspaces', ['workspace_id'], ['id'], ondelete='SET NULL')
op.create_index('ix_tree_categories_workspace_id', 'tree_categories', ['workspace_id'])

View File

@@ -17,8 +17,10 @@ from app.models.folder import UserFolder, user_folder_trees
from app.schemas.tree import (
TreeCreate, TreeUpdate, TreeResponse, TreeListResponse, CategoryInfo,
ForkCreate, ForkInfo, TreeShareCreate, TreeShareResponse,
TreeVisibilityUpdate, SharedTreeResponse, TreeValidationResponse, ValidationError
TreeVisibilityUpdate, SharedTreeResponse, TreeValidationResponse, ValidationError,
PinnedFlowResponse, PinnedFlowsListResponse, PinnedFlowReorderRequest
)
from app.models.user_pinned_tree import UserPinnedTree
from app.api.deps import get_current_active_user, require_engineer_or_admin, require_admin
from app.core.permissions import can_edit_tree, can_access_tree
from app.core.filters import build_tree_access_filter
@@ -1030,3 +1032,185 @@ async def check_tree_can_publish(
can_publish=can_publish,
errors=[ValidationError(**error) for error in validation_errors]
)
# --- Pinned Flows Endpoints ---
MAX_PINNED_FLOWS = 15
@router.get("/pinned", response_model=PinnedFlowsListResponse)
async def list_pinned_flows(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""List user's pinned flows, ordered by display_order."""
result = await db.execute(
select(UserPinnedTree, Tree)
.join(Tree, UserPinnedTree.tree_id == Tree.id)
.options(selectinload(Tree.category_rel))
.where(
UserPinnedTree.user_id == current_user.id,
Tree.is_active == True,
Tree.deleted_at.is_(None)
)
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
)
rows = result.all()
items = []
for pin, tree in rows:
items.append(PinnedFlowResponse(
id=pin.id,
tree_id=tree.id,
tree_name=tree.name,
tree_type=tree.tree_type,
category_emoji=None,
category_name=tree.category_rel.name if tree.category_rel else None,
pinned_at=pin.pinned_at,
display_order=pin.display_order,
))
return PinnedFlowsListResponse(items=items, count=len(items))
@router.post("/{tree_id}/pin", response_model=PinnedFlowResponse)
async def pin_flow(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Pin a flow to the user's sidebar."""
# Check tree exists and user can access it
tree_result = await db.execute(
select(Tree)
.options(selectinload(Tree.category_rel))
.where(Tree.id == tree_id, Tree.is_active == True)
)
tree = tree_result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
if not can_access_tree(current_user, tree):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this tree")
# Check if already pinned (idempotent)
existing = await db.execute(
select(UserPinnedTree).where(
UserPinnedTree.user_id == current_user.id,
UserPinnedTree.tree_id == tree_id
)
)
pin = existing.scalar_one_or_none()
if pin:
return PinnedFlowResponse(
id=pin.id,
tree_id=tree.id,
tree_name=tree.name,
tree_type=tree.tree_type,
category_emoji=None,
category_name=tree.category_rel.name if tree.category_rel else None,
pinned_at=pin.pinned_at,
display_order=pin.display_order,
)
# Check max pins
count_result = await db.execute(
select(func.count(UserPinnedTree.id)).where(
UserPinnedTree.user_id == current_user.id
)
)
count = count_result.scalar() or 0
if count >= MAX_PINNED_FLOWS:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Maximum of {MAX_PINNED_FLOWS} pinned flows reached"
)
# Create pin
pin = UserPinnedTree(
user_id=current_user.id,
tree_id=tree_id,
display_order=count, # Append at end
)
db.add(pin)
await db.commit()
await db.refresh(pin)
return PinnedFlowResponse(
id=pin.id,
tree_id=tree.id,
tree_name=tree.name,
tree_type=tree.tree_type,
category_emoji=None,
category_name=tree.category_rel.name if tree.category_rel else None,
pinned_at=pin.pinned_at,
display_order=pin.display_order,
)
@router.delete("/{tree_id}/pin")
async def unpin_flow(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Unpin a flow from the user's sidebar."""
result = await db.execute(
select(UserPinnedTree).where(
UserPinnedTree.user_id == current_user.id,
UserPinnedTree.tree_id == tree_id
)
)
pin = result.scalar_one_or_none()
if pin:
await db.delete(pin)
await db.commit()
return {"success": True}
@router.patch("/pinned/reorder", response_model=PinnedFlowsListResponse)
async def reorder_pinned_flows(
reorder_data: PinnedFlowReorderRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)]
):
"""Update display_order for all pinned flows."""
for item in reorder_data.order:
await db.execute(
update(UserPinnedTree)
.where(
UserPinnedTree.user_id == current_user.id,
UserPinnedTree.tree_id == item.tree_id
)
.values(display_order=item.display_order)
)
await db.commit()
# Return updated list
result = await db.execute(
select(UserPinnedTree, Tree)
.join(Tree, UserPinnedTree.tree_id == Tree.id)
.options(selectinload(Tree.category_rel))
.where(
UserPinnedTree.user_id == current_user.id,
Tree.is_active == True,
Tree.deleted_at.is_(None)
)
.order_by(UserPinnedTree.display_order, UserPinnedTree.pinned_at)
)
rows = result.all()
items = []
for pin, tree in rows:
items.append(PinnedFlowResponse(
id=pin.id,
tree_id=tree.id,
tree_name=tree.name,
tree_type=tree.tree_type,
category_emoji=None,
category_name=tree.category_rel.name if tree.category_rel else None,
pinned_at=pin.pinned_at,
display_order=pin.display_order,
))
return PinnedFlowsListResponse(items=items, count=len(items))

View File

@@ -1,154 +0,0 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.core.database import get_db
from app.models.workspace import Workspace
from app.models.tree import Tree
from app.models.user import User
from app.schemas.workspace import WorkspaceCreate, WorkspaceUpdate, WorkspaceResponse
from app.api.deps import get_current_active_user
router = APIRouter(prefix="/workspaces", tags=["workspaces"])
@router.get("", response_model=list[WorkspaceResponse])
async def list_workspaces(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""List all workspaces for the user's account."""
if not current_user.account_id:
return []
# Get workspaces with tree counts
query = (
select(
Workspace,
func.count(Tree.id).label("tree_count")
)
.outerjoin(Tree, (Tree.workspace_id == Workspace.id) & (Tree.deleted_at.is_(None)))
.where(Workspace.account_id == current_user.account_id)
.group_by(Workspace.id)
.order_by(Workspace.sort_order, Workspace.name)
)
result = await db.execute(query)
rows = result.all()
return [
WorkspaceResponse(
**{c.key: getattr(ws, c.key) for c in Workspace.__table__.columns},
tree_count=tree_count
)
for ws, tree_count in rows
]
@router.post("", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
async def create_workspace(
data: WorkspaceCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Create a new workspace."""
if not current_user.account_id:
raise HTTPException(status_code=400, detail="No account found")
# Check slug uniqueness within account
existing = await db.execute(
select(Workspace).where(
Workspace.account_id == current_user.account_id,
Workspace.slug == data.slug
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Workspace slug already exists")
workspace = Workspace(
name=data.name,
slug=data.slug,
description=data.description,
icon=data.icon,
accent_color=data.accent_color,
account_id=current_user.account_id,
sort_order=data.sort_order,
)
db.add(workspace)
await db.flush()
await db.commit()
return WorkspaceResponse(
**{c.key: getattr(workspace, c.key) for c in Workspace.__table__.columns},
tree_count=0
)
@router.patch("/{workspace_id}", response_model=WorkspaceResponse)
async def update_workspace(
workspace_id: UUID,
data: WorkspaceUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Update a workspace."""
workspace = await db.get(Workspace, workspace_id)
if not workspace or workspace.account_id != current_user.account_id:
raise HTTPException(status_code=404, detail="Workspace not found")
update_data = data.model_dump(exclude_unset=True)
if "slug" in update_data:
existing = await db.execute(
select(Workspace).where(
Workspace.account_id == current_user.account_id,
Workspace.slug == update_data["slug"],
Workspace.id != workspace_id
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Workspace slug already exists")
for key, value in update_data.items():
setattr(workspace, key, value)
await db.commit()
# Get tree count
count_result = await db.execute(
select(func.count(Tree.id)).where(
Tree.workspace_id == workspace_id,
Tree.deleted_at.is_(None)
)
)
tree_count = count_result.scalar() or 0
return WorkspaceResponse(
**{c.key: getattr(workspace, c.key) for c in Workspace.__table__.columns},
tree_count=tree_count
)
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_workspace(
workspace_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
):
"""Delete a workspace. Trees are unassigned, not deleted."""
workspace = await db.get(Workspace, workspace_id)
if not workspace or workspace.account_id != current_user.account_id:
raise HTTPException(status_code=404, detail="Workspace not found")
if workspace.is_default:
raise HTTPException(status_code=400, detail="Cannot delete the default workspace")
# Unassign trees (set workspace_id to NULL)
await db.execute(
Tree.__table__.update()
.where(Tree.workspace_id == workspace_id)
.values(workspace_id=None)
)
await db.delete(workspace)
await db.commit()

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown, workspaces
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
api_router = APIRouter()
@@ -25,4 +25,3 @@ api_router.include_router(webhooks.router)
api_router.include_router(shares.router)
api_router.include_router(shared.router) # Public endpoints (no auth)
api_router.include_router(tree_markdown.router)
api_router.include_router(workspaces.router)

View File

@@ -20,7 +20,7 @@ from .session_share import SessionShare, SessionShareView
from .account_limit_override import AccountLimitOverride
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
from .platform_setting import PlatformSetting
from .workspace import Workspace
from .user_pinned_tree import UserPinnedTree
__all__ = [
"User",
@@ -52,5 +52,5 @@ __all__ = [
"PlanFeatureDefault",
"AccountFeatureOverride",
"PlatformSetting",
"Workspace",
"UserPinnedTree",
]

View File

@@ -15,7 +15,6 @@ if TYPE_CHECKING:
from app.models.step_category import StepCategory
from app.models.step_library import StepLibrary
from app.models.account_limit_override import AccountLimitOverride
from app.models.workspace import Workspace
class Account(Base):
@@ -46,4 +45,3 @@ class Account(Base):
step_categories: Mapped[list["StepCategory"]] = relationship("StepCategory", foreign_keys="[StepCategory.account_id]", back_populates="account")
step_library: Mapped[list["StepLibrary"]] = relationship("StepLibrary", foreign_keys="[StepLibrary.account_id]", back_populates="account")
limit_override: Mapped[Optional["AccountLimitOverride"]] = relationship("AccountLimitOverride", back_populates="account", uselist=False)
workspaces: Mapped[list["Workspace"]] = relationship("Workspace", back_populates="account")

View File

@@ -11,7 +11,6 @@ if TYPE_CHECKING:
from app.models.team import Team
from app.models.account import Account
from app.models.user import User
from app.models.workspace import Workspace
class TreeCategory(Base):
@@ -52,13 +51,6 @@ class TreeCategory(Base):
String(7), nullable=True, default='#3b82f6',
comment="Hex color for category dot indicator"
)
workspace_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="Workspace this category belongs to"
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
@@ -79,7 +71,6 @@ class TreeCategory(Base):
account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys=[account_id], back_populates="categories")
creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by])
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="category_rel")
workspace: Mapped[Optional["Workspace"]] = relationship("Workspace", back_populates="categories")
@property
def is_global(self) -> bool:

View File

@@ -15,7 +15,6 @@ if TYPE_CHECKING:
from app.models.tag import TreeTag
from app.models.folder import UserFolder
from app.models.tree_share import TreeShare
from app.models.workspace import Workspace
class Tree(Base):
@@ -121,14 +120,6 @@ class Tree(Base):
onupdate=lambda: datetime.now(timezone.utc)
)
usage_count: Mapped[int] = mapped_column(Integer, default=0)
workspace_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
index=True,
comment="Workspace this tree belongs to (organizational context)"
)
# Fork tracking
parent_tree_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
@@ -192,9 +183,7 @@ class Tree(Base):
cascade="all, delete-orphan"
)
workspace: Mapped[Optional["Workspace"]] = relationship("Workspace", back_populates="trees")
# New organization relationships
# Organization relationships
category_rel: Mapped[Optional["TreeCategory"]] = relationship("TreeCategory", back_populates="trees")
tags: Mapped[list["TreeTag"]] = relationship(
"TreeTag",

View File

@@ -0,0 +1,37 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class UserPinnedTree(Base):
"""Tracks which trees a user has pinned to their sidebar."""
__tablename__ = "user_pinned_trees"
__table_args__ = (
UniqueConstraint('user_id', 'tree_id', name='uq_user_pinned_tree'),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True
)
tree_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("trees.id", ondelete="CASCADE"),
nullable=False,
index=True
)
display_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
pinned_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)

View File

@@ -1,57 +0,0 @@
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.account import Account
from app.models.tree import Tree
from app.models.category import TreeCategory
class Workspace(Base):
"""Workspaces are the top-level organizational context for trees/flows.
They sit above the folder system — a workspace scopes which trees/flows
are visible, while folders remain for personal organization within.
"""
__tablename__ = "workspaces"
__table_args__ = (
UniqueConstraint('slug', 'account_id', name='uq_workspaces_slug_account'),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
icon: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
accent_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("accounts.id", ondelete="CASCADE"),
nullable=False,
index=True
)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)
)
# Relationships
account: Mapped["Account"] = relationship("Account", back_populates="workspaces")
trees: Mapped[list["Tree"]] = relationship("Tree", back_populates="workspace")
categories: Mapped[list["TreeCategory"]] = relationship("TreeCategory", back_populates="workspace")

View File

@@ -37,7 +37,6 @@ class CategoryResponse(CategoryBase):
display_order: int
is_active: bool
color: Optional[str] = None
workspace_id: Optional[UUID] = None
created_at: datetime
updated_at: datetime
tree_count: int = 0 # Computed field
@@ -55,7 +54,6 @@ class CategoryListResponse(BaseModel):
display_order: int
is_active: bool
color: Optional[str] = None
workspace_id: Optional[UUID] = None
tree_count: int = 0
class Config:

View File

@@ -245,3 +245,37 @@ class TreeValidationResponse(BaseModel):
"""Response for tree validation endpoint."""
can_publish: bool
errors: list[ValidationError] = []
# --- Pinned Flows Schemas ---
class PinnedFlowResponse(BaseModel):
"""A pinned flow in the sidebar."""
id: UUID
tree_id: UUID
tree_name: str
tree_type: str
category_emoji: Optional[str] = None
category_name: Optional[str] = None
pinned_at: datetime
display_order: int
class Config:
from_attributes = True
class PinnedFlowsListResponse(BaseModel):
"""List of pinned flows."""
items: list[PinnedFlowResponse]
count: int
class PinnedFlowReorderItem(BaseModel):
"""Single item in a reorder request."""
tree_id: UUID
display_order: int
class PinnedFlowReorderRequest(BaseModel):
"""Request to reorder pinned flows."""
order: list[PinnedFlowReorderItem]

View File

@@ -1,39 +0,0 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class WorkspaceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
slug: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-z0-9-]+$')
description: Optional[str] = None
icon: Optional[str] = Field(None, max_length=10)
accent_color: Optional[str] = Field(None, pattern=r'^#[0-9a-fA-F]{6}$')
sort_order: int = 0
class WorkspaceUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
slug: Optional[str] = Field(None, min_length=1, max_length=100, pattern=r'^[a-z0-9-]+$')
description: Optional[str] = None
icon: Optional[str] = Field(None, max_length=10)
accent_color: Optional[str] = Field(None, pattern=r'^#[0-9a-fA-F]{6}$')
sort_order: Optional[int] = None
class WorkspaceResponse(BaseModel):
id: uuid.UUID
name: str
slug: str
description: Optional[str] = None
icon: Optional[str] = None
accent_color: Optional[str] = None
account_id: uuid.UUID
is_default: bool
sort_order: int
tree_count: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}