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, ) 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)""" step_map = {} roots = [] for s in steps: 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 steps: 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, "目标") step = GoalStep(goal_id=goal_id, **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="添加步骤失败") @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="取消关联任务失败")