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:
40
backend/alembic/versions/037_add_user_pinned_trees.py
Normal file
40
backend/alembic/versions/037_add_user_pinned_trees.py
Normal 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')
|
||||
69
backend/alembic/versions/038_remove_workspace_system.py
Normal file
69
backend/alembic/versions/038_remove_workspace_system.py
Normal 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'])
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
37
backend/app/models/user_pinned_tree.py
Normal file
37
backend/app/models/user_pinned_tree.py
Normal 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)
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user