Files
resolutionflow/backend/app/api/endpoints/trees.py
Michael Chihlas db0b05eba7 Add is_default flag for system trees
- Add is_default column to trees table
- Default trees have no author and are visible to all users
- Only admins can create default trees
- Update seed script to mark seeded trees as default
- Update seed script to use CLI auth instead of creating seed user

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 01:32:10 -05:00

198 lines
6.4 KiB
Python

from typing import Annotated, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from app.core.database import get_db
from app.models.tree import Tree
from app.models.user import User
from app.schemas.tree import TreeCreate, TreeUpdate, TreeResponse, TreeListResponse
from app.api.deps import get_current_user, require_engineer_or_admin, require_admin
router = APIRouter(prefix="/trees", tags=["trees"])
@router.get("", response_model=list[TreeListResponse])
async def list_trees(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
category: Optional[str] = Query(None, description="Filter by category"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100)
):
"""List all trees with optional filters."""
query = select(Tree)
# Apply filters
if category:
query = query.where(Tree.category == category)
if is_active is not None:
query = query.where(Tree.is_active == is_active)
# Only show active trees or trees owned by user (for now)
# Later, add team-based filtering
query = query.where(
or_(
Tree.is_active == True,
Tree.author_id == current_user.id
)
)
query = query.order_by(Tree.usage_count.desc(), Tree.updated_at.desc())
query = query.offset(skip).limit(limit)
result = await db.execute(query)
trees = result.scalars().all()
return trees
@router.get("/categories", response_model=list[str])
async def list_categories(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""List all unique categories."""
query = select(Tree.category).where(
Tree.category.isnot(None),
Tree.is_active == True
).distinct()
result = await db.execute(query)
categories = [row[0] for row in result.all() if row[0]]
return sorted(categories)
@router.get("/search", response_model=list[TreeListResponse])
async def search_trees(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
q: str = Query(..., min_length=2, description="Search query"),
limit: int = Query(20, ge=1, le=50)
):
"""Full-text search trees by name and description."""
# Using PostgreSQL full-text search
search_vector = func.to_tsvector('english', func.coalesce(Tree.name, '') + ' ' + func.coalesce(Tree.description, ''))
search_query = func.plainto_tsquery('english', q)
query = select(Tree).where(
Tree.is_active == True,
search_vector.op('@@')(search_query)
).order_by(
func.ts_rank(search_vector, search_query).desc()
).limit(limit)
result = await db.execute(query)
trees = result.scalars().all()
return trees
@router.get("/{tree_id}", response_model=TreeResponse)
async def get_tree(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)]
):
"""Get a specific tree by ID."""
result = await db.execute(select(Tree).where(Tree.id == tree_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
# Check access: tree must be active OR user is the author
if not tree.is_active and tree.author_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this tree"
)
return tree
@router.post("", response_model=TreeResponse, status_code=status.HTTP_201_CREATED)
async def create_tree(
tree_data: TreeCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_engineer_or_admin)]
):
"""Create a new tree (engineers and admins only)."""
# Only admins can create default/system trees
is_default = tree_data.is_default and current_user.role == "admin"
new_tree = Tree(
name=tree_data.name,
description=tree_data.description,
category=tree_data.category,
tree_structure=tree_data.tree_structure,
author_id=None if is_default else current_user.id, # Default trees have no author
team_id=None if is_default else current_user.team_id,
is_default=is_default
)
db.add(new_tree)
await db.commit()
await db.refresh(new_tree)
return new_tree
@router.put("/{tree_id}", response_model=TreeResponse)
async def update_tree(
tree_id: UUID,
tree_data: TreeUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_engineer_or_admin)]
):
"""Update an existing tree (engineers and admins only)."""
result = await db.execute(select(Tree).where(Tree.id == tree_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
# Check if user can edit: must be author or admin
if tree.author_id != current_user.id and current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only edit your own trees"
)
# Update fields
update_data = tree_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tree, field, value)
# Increment version if tree structure changed
if "tree_structure" in update_data:
tree.version += 1
await db.commit()
await db.refresh(tree)
return tree
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tree(
tree_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)]
):
"""Soft delete a tree (admin only). Sets is_active to False."""
result = await db.execute(select(Tree).where(Tree.id == tree_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tree not found"
)
tree.is_active = False
await db.commit()
return None