Initial commit: Backend API Phase 1a complete
- FastAPI backend with JWT auth - PostgreSQL database schema - Trees and Sessions CRUD APIs - Export functionality (Markdown, Text, HTML) - Docker setup for local development - Alembic migrations
This commit is contained in:
193
backend/app/api/endpoints/trees.py
Normal file
193
backend/app/api/endpoints/trees.py
Normal file
@@ -0,0 +1,193 @@
|
||||
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)."""
|
||||
new_tree = Tree(
|
||||
name=tree_data.name,
|
||||
description=tree_data.description,
|
||||
category=tree_data.category,
|
||||
tree_structure=tree_data.tree_structure,
|
||||
author_id=current_user.id,
|
||||
team_id=current_user.team_id
|
||||
)
|
||||
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
|
||||
Reference in New Issue
Block a user