- 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
194 lines
6.1 KiB
Python
194 lines
6.1 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)."""
|
|
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
|