- Add GET /api/backup/export and POST /api/backup/import endpoints for full data backup
- Add drag-and-drop reorder for goal steps with PUT /api/goals/{id}/steps/reorder
- Auto-assign sort_order on step creation (preserves creation order)
- Fix duplicate milestone rendering in goal detail page
- Add category management button in goal dialog
- Migrate database default from SQLite to PostgreSQL
- Fix router guard redirect loop for logged-in users on setup/login pages
- Fix ALTER TABLE ADD COLUMN crash on callable defaults (uuid.uuid4)
- Add auth status rate limiter and token version caching
- Update CLAUDE.md to reflect current architecture
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
433 lines
15 KiB
Python
433 lines
15 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
||
from sqlalchemy.orm import Session
|
||
from typing import List
|
||
|
||
from app.database import get_db
|
||
from app.models.goal import Goal, GoalStep, GoalReview
|
||
from app.models.task import Task
|
||
from app.schemas.goal import (
|
||
GoalCreate, GoalUpdate, GoalListResponse, GoalDetailResponse, GoalStatusUpdate,
|
||
GoalStepCreate, GoalStepUpdate, GoalStepResponse,
|
||
GoalReviewCreate, GoalReviewResponse,
|
||
ReorderRequest,
|
||
)
|
||
from app.schemas.common import DeleteResponse
|
||
from app.utils.crud import get_or_404
|
||
from app.utils.datetime import utcnow, today
|
||
from app.utils.logger import logger
|
||
|
||
router = APIRouter(prefix="/api/goals", tags=["目标"])
|
||
|
||
|
||
def recalc_progress(db: Session, goal_id: int):
|
||
"""根据里程碑完成比例重新计算目标进度"""
|
||
total = db.query(GoalStep).filter(
|
||
GoalStep.goal_id == goal_id,
|
||
GoalStep.step_type == "milestone",
|
||
).count()
|
||
if total == 0:
|
||
return 0
|
||
completed = db.query(GoalStep).filter(
|
||
GoalStep.goal_id == goal_id,
|
||
GoalStep.step_type == "milestone",
|
||
GoalStep.status == "completed",
|
||
).count()
|
||
return int(completed / total * 100)
|
||
|
||
|
||
def build_step_tree(steps: list[GoalStep]) -> list[dict]:
|
||
"""将扁平的 step 列表转为树形结构(phase 包含子 milestone),按 sort_order 排序"""
|
||
step_map = {}
|
||
roots = []
|
||
for s in sorted(steps, key=lambda x: (x.sort_order or 0)):
|
||
step_map[s.id] = {
|
||
"id": s.id,
|
||
"goal_id": s.goal_id,
|
||
"parent_id": s.parent_id,
|
||
"title": s.title,
|
||
"step_type": s.step_type,
|
||
"status": s.status,
|
||
"target_date": s.target_date,
|
||
"reached_at": s.reached_at,
|
||
"sort_order": s.sort_order,
|
||
"created_at": s.created_at,
|
||
"children": [],
|
||
}
|
||
for s in sorted(steps, key=lambda x: (x.sort_order or 0)):
|
||
node = step_map[s.id]
|
||
if s.parent_id and s.parent_id in step_map:
|
||
step_map[s.parent_id]["children"].append(node)
|
||
else:
|
||
roots.append(node)
|
||
return roots
|
||
|
||
|
||
# ============ Goals CRUD ============
|
||
|
||
@router.get("", response_model=List[GoalListResponse])
|
||
def get_goals(
|
||
status: str | None = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""获取所有目标"""
|
||
try:
|
||
query = db.query(Goal)
|
||
if status:
|
||
query = query.filter(Goal.status == status)
|
||
goals = query.order_by(Goal.sort_order, Goal.created_at.desc()).all()
|
||
|
||
result = []
|
||
for g in goals:
|
||
total = db.query(GoalStep).filter(
|
||
GoalStep.goal_id == g.id,
|
||
GoalStep.step_type == "milestone",
|
||
).count()
|
||
completed = db.query(GoalStep).filter(
|
||
GoalStep.goal_id == g.id,
|
||
GoalStep.step_type == "milestone",
|
||
GoalStep.status == "completed",
|
||
).count()
|
||
g.total_steps = total
|
||
g.completed_steps = completed
|
||
result.append(g)
|
||
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"获取目标列表失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="获取目标列表失败")
|
||
|
||
|
||
@router.post("", response_model=GoalDetailResponse, status_code=201)
|
||
def create_goal(data: GoalCreate, db: Session = Depends(get_db)):
|
||
"""创建目标"""
|
||
try:
|
||
goal = Goal(**data.model_dump())
|
||
db.add(goal)
|
||
db.commit()
|
||
db.refresh(goal)
|
||
logger.info(f"创建目标成功: id={goal.id}, title={goal.title}")
|
||
return goal
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"创建目标失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="创建目标失败")
|
||
|
||
|
||
@router.get("/{goal_id}", response_model=GoalDetailResponse)
|
||
def get_goal(goal_id: int, db: Session = Depends(get_db)):
|
||
"""获取目标详情(含 steps 树、reviews、关联 tasks)"""
|
||
try:
|
||
goal = get_or_404(db, Goal, goal_id, "目标")
|
||
goal.total_steps = db.query(GoalStep).filter(
|
||
GoalStep.goal_id == goal_id,
|
||
GoalStep.step_type == "milestone",
|
||
).count()
|
||
goal.completed_steps = db.query(GoalStep).filter(
|
||
GoalStep.goal_id == goal_id,
|
||
GoalStep.step_type == "milestone",
|
||
GoalStep.status == "completed",
|
||
).count()
|
||
return goal
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"获取目标详情失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="获取目标详情失败")
|
||
|
||
|
||
@router.put("/{goal_id}", response_model=GoalDetailResponse)
|
||
def update_goal(goal_id: int, data: GoalUpdate, db: Session = Depends(get_db)):
|
||
"""更新目标"""
|
||
try:
|
||
goal = get_or_404(db, Goal, goal_id, "目标")
|
||
update_data = data.model_dump(exclude_unset=True)
|
||
|
||
for field, value in update_data.items():
|
||
if value is not None or field in data.clearable_fields:
|
||
setattr(goal, field, value)
|
||
|
||
goal.updated_at = utcnow()
|
||
db.commit()
|
||
db.refresh(goal)
|
||
logger.info(f"更新目标成功: id={goal_id}")
|
||
return goal
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"更新目标失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="更新目标失败")
|
||
|
||
|
||
@router.delete("/{goal_id}")
|
||
def delete_goal(goal_id: int, db: Session = Depends(get_db)):
|
||
"""删除目标(级联删除 steps + reviews)"""
|
||
try:
|
||
goal = get_or_404(db, Goal, goal_id, "目标")
|
||
db.delete(goal)
|
||
db.commit()
|
||
logger.info(f"删除目标成功: id={goal_id}")
|
||
return DeleteResponse(message="目标删除成功")
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"删除目标失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="删除目标失败")
|
||
|
||
|
||
@router.patch("/{goal_id}/status", response_model=GoalDetailResponse)
|
||
def update_goal_status(goal_id: int, data: GoalStatusUpdate, db: Session = Depends(get_db)):
|
||
"""更新目标状态"""
|
||
try:
|
||
goal = get_or_404(db, Goal, goal_id, "目标")
|
||
goal.status = data.status
|
||
if data.status == "completed":
|
||
goal.completed_at = utcnow()
|
||
goal.progress = 100
|
||
else:
|
||
goal.completed_at = None
|
||
goal.updated_at = utcnow()
|
||
db.commit()
|
||
db.refresh(goal)
|
||
logger.info(f"更新目标状态成功: id={goal_id}, status={data.status}")
|
||
return goal
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"更新目标状态失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="更新目标状态失败")
|
||
|
||
|
||
# ============ Steps ============
|
||
|
||
@router.post("/{goal_id}/steps", response_model=GoalStepResponse, status_code=201)
|
||
def create_step(goal_id: int, data: GoalStepCreate, db: Session = Depends(get_db)):
|
||
"""添加阶段/里程碑"""
|
||
try:
|
||
get_or_404(db, Goal, goal_id, "目标")
|
||
# 自动分配 sort_order:同类步骤中取最大值 + 1
|
||
max_sort = db.query(GoalStep).filter(
|
||
GoalStep.goal_id == goal_id,
|
||
GoalStep.step_type == data.step_type,
|
||
).order_by(GoalStep.sort_order.desc()).first()
|
||
next_sort = (max_sort.sort_order + 1) if max_sort and max_sort.sort_order is not None else 0
|
||
step = GoalStep(goal_id=goal_id, sort_order=next_sort, **data.model_dump())
|
||
db.add(step)
|
||
db.commit()
|
||
db.refresh(step)
|
||
|
||
# 重算进度
|
||
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||
if goal:
|
||
goal.progress = recalc_progress(db, goal_id)
|
||
goal.updated_at = utcnow()
|
||
db.commit()
|
||
|
||
logger.info(f"添加{data.step_type}成功: id={step.id}, goal_id={goal_id}")
|
||
return step
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"添加步骤失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="添加步骤失败")
|
||
|
||
|
||
# ============ Reorder ============
|
||
|
||
@router.put("/{goal_id}/steps/reorder")
|
||
def reorder_steps(goal_id: int, data: ReorderRequest, db: Session = Depends(get_db)):
|
||
"""批量更新步骤排序"""
|
||
try:
|
||
get_or_404(db, Goal, goal_id, "目标")
|
||
for item in data.items:
|
||
step = db.query(GoalStep).filter(
|
||
GoalStep.id == item.id,
|
||
GoalStep.goal_id == goal_id,
|
||
).first()
|
||
if step:
|
||
step.sort_order = item.sort_order
|
||
db.commit()
|
||
logger.info(f"步骤排序更新成功: goal_id={goal_id}, count={len(data.items)}")
|
||
return {"message": "排序更新成功"}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"更新步骤排序失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="更新排序失败")
|
||
|
||
|
||
@router.put("/{goal_id}/steps/{step_id}", response_model=GoalStepResponse)
|
||
def update_step(goal_id: int, step_id: int, data: GoalStepUpdate, db: Session = Depends(get_db)):
|
||
"""更新阶段/里程碑"""
|
||
try:
|
||
get_or_404(db, Goal, goal_id, "目标")
|
||
step = get_or_404(db, GoalStep, step_id, "步骤")
|
||
update_data = data.model_dump(exclude_unset=True)
|
||
|
||
for field, value in update_data.items():
|
||
if value is not None or field in data.clearable_fields:
|
||
setattr(step, field, value)
|
||
|
||
db.commit()
|
||
db.refresh(step)
|
||
|
||
# 重算进度
|
||
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||
if goal:
|
||
goal.progress = recalc_progress(db, goal_id)
|
||
goal.updated_at = utcnow()
|
||
db.commit()
|
||
|
||
logger.info(f"更新步骤成功: id={step_id}")
|
||
return step
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"更新步骤失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="更新步骤失败")
|
||
|
||
|
||
@router.delete("/{goal_id}/steps/{step_id}")
|
||
def delete_step(goal_id: int, step_id: int, db: Session = Depends(get_db)):
|
||
"""删除阶段/里程碑(级联删除子 step)"""
|
||
try:
|
||
get_or_404(db, Goal, goal_id, "目标")
|
||
step = get_or_404(db, GoalStep, step_id, "步骤")
|
||
db.delete(step)
|
||
db.commit()
|
||
|
||
# 重算进度
|
||
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||
if goal:
|
||
goal.progress = recalc_progress(db, goal_id)
|
||
goal.updated_at = utcnow()
|
||
db.commit()
|
||
|
||
logger.info(f"删除步骤成功: id={step_id}")
|
||
return DeleteResponse(message="步骤删除成功")
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"删除步骤失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="删除步骤失败")
|
||
|
||
|
||
@router.patch("/{goal_id}/steps/{step_id}/toggle", response_model=GoalStepResponse)
|
||
def toggle_step(goal_id: int, step_id: int, db: Session = Depends(get_db)):
|
||
"""切换步骤状态 (pending → in_progress → completed → pending)"""
|
||
try:
|
||
get_or_404(db, Goal, goal_id, "目标")
|
||
step = get_or_404(db, GoalStep, step_id, "步骤")
|
||
|
||
cycle = {"pending": "in_progress", "in_progress": "completed", "completed": "pending"}
|
||
step.status = cycle.get(step.status, "pending")
|
||
|
||
if step.status == "completed":
|
||
step.reached_at = utcnow()
|
||
else:
|
||
step.reached_at = None
|
||
|
||
db.commit()
|
||
db.refresh(step)
|
||
|
||
# 重算进度
|
||
goal = db.query(Goal).filter(Goal.id == goal_id).first()
|
||
if goal:
|
||
goal.progress = recalc_progress(db, goal_id)
|
||
goal.updated_at = utcnow()
|
||
db.commit()
|
||
|
||
logger.info(f"切换步骤状态成功: id={step_id}, status={step.status}")
|
||
return step
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"切换步骤状态失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="切换步骤状态失败")
|
||
|
||
|
||
# ============ Reviews ============
|
||
|
||
@router.post("/{goal_id}/reviews", response_model=GoalReviewResponse, status_code=201)
|
||
def create_review(goal_id: int, data: GoalReviewCreate, db: Session = Depends(get_db)):
|
||
"""创建复盘记录"""
|
||
try:
|
||
get_or_404(db, Goal, goal_id, "目标")
|
||
review = GoalReview(goal_id=goal_id, **data.model_dump())
|
||
db.add(review)
|
||
db.commit()
|
||
db.refresh(review)
|
||
logger.info(f"创建复盘成功: goal_id={goal_id}")
|
||
return review
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"创建复盘失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="创建复盘失败")
|
||
|
||
|
||
@router.delete("/{goal_id}/reviews/{review_id}")
|
||
def delete_review(goal_id: int, review_id: int, db: Session = Depends(get_db)):
|
||
"""删除复盘记录"""
|
||
try:
|
||
get_or_404(db, Goal, goal_id, "目标")
|
||
review = get_or_404(db, GoalReview, review_id, "复盘记录")
|
||
db.delete(review)
|
||
db.commit()
|
||
logger.info(f"删除复盘成功: id={review_id}")
|
||
return DeleteResponse(message="复盘记录删除成功")
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"删除复盘失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="删除复盘失败")
|
||
|
||
|
||
# ============ Task Linking ============
|
||
|
||
@router.post("/{goal_id}/tasks/{task_id}")
|
||
def link_task(goal_id: int, task_id: int, db: Session = Depends(get_db)):
|
||
"""关联任务到目标"""
|
||
try:
|
||
goal = get_or_404(db, Goal, goal_id, "目标")
|
||
task = get_or_404(db, Task, task_id, "任务")
|
||
if task not in goal.tasks:
|
||
goal.tasks.append(task)
|
||
db.commit()
|
||
logger.info(f"关联任务成功: goal_id={goal_id}, task_id={task_id}")
|
||
return {"message": "关联成功"}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"关联任务失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="关联任务失败")
|
||
|
||
|
||
@router.delete("/{goal_id}/tasks/{task_id}")
|
||
def unlink_task(goal_id: int, task_id: int, db: Session = Depends(get_db)):
|
||
"""取消关联任务"""
|
||
try:
|
||
goal = get_or_404(db, Goal, goal_id, "目标")
|
||
task = get_or_404(db, Task, task_id, "任务")
|
||
if task in goal.tasks:
|
||
goal.tasks.remove(task)
|
||
db.commit()
|
||
logger.info(f"取消关联任务成功: goal_id={goal_id}, task_id={task_id}")
|
||
return DeleteResponse(message="取消关联成功")
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"取消关联任务失败: {str(e)}")
|
||
raise HTTPException(status_code=500, detail="取消关联任务失败")
|