Backend: Workspace model, migration (036), schemas, CRUD API endpoints. Adds workspace_id to trees and categories, seeds 4 default workspaces per account, auto-assigns existing trees by tree_type. Frontend: Complete AppLayout rewrite from top-nav to CSS Grid shell with persistent sidebar + topbar. New components: WorkspaceSwitcher, NavItem, CategoryList, TagCloud, TopBar, Sidebar. Dashboard components: QuickStats, FiltersBar, SectionGroup, TreeListItem, SessionsPanel. WorkspaceStore with localStorage persistence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
155 lines
5.0 KiB
Python
155 lines
5.0 KiB
Python
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()
|