Files
ToDoList/api/app/routers/goals.py
祀梦 4ce7de48c4 feat: add cumulative checkin tracking mode for goals
Goals can now choose between milestone-based progress (existing) and
cumulative checkin-based progress (new). Cumulative mode supports
cross-unit conversion (e.g. kcal → g fat) via a configurable
conversion rate. New GoalCheckin model stores daily inputs; progress
auto-recalculates on every checkin C/U/D. Backup import/export covers
the new table. Frontend GoalDialog, GoalDetailPage and GoalPage cards
adapt to show cumulative progress or milestone progress based on
track_type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 23:00:24 +08:00

557 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List
from app.database import get_db
from app.models.goal import Goal, GoalStep, GoalReview, GoalCheckin
from app.models.task import Task
from app.schemas.goal import (
GoalCreate, GoalUpdate, GoalListResponse, GoalDetailResponse, GoalStatusUpdate,
GoalStepCreate, GoalStepUpdate, GoalStepResponse,
GoalReviewCreate, GoalReviewResponse,
GoalCheckinCreate, GoalCheckinUpdate, GoalCheckinResponse,
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):
"""根据追踪类型重新计算目标进度。里程碑模式按步骤完成比例;累计模式按打卡值/换算率/目标值。"""
goal = db.query(Goal).filter(Goal.id == goal_id).first()
if not goal:
return 0
if goal.track_type == "cumulative" and goal.target_value and goal.target_value > 0:
total = db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
GoalCheckin.goal_id == goal_id,
).scalar()
goal.current_value = float(total)
progress_in_target = goal.current_value / goal.conversion_rate if goal.conversion_rate else goal.current_value
progress = int(progress_in_target / goal.target_value * 100)
return min(progress, 100)
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
# 累计模式重新计算进度和当前值
if g.track_type == "cumulative":
g.current_value = float(db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
GoalCheckin.goal_id == g.id,
).scalar())
g.progress = recalc_progress(db, g.id)
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、checkins"""
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()
if goal.track_type == "cumulative":
goal.current_value = float(db.query(func.coalesce(func.sum(GoalCheckin.value), 0)).filter(
GoalCheckin.goal_id == goal_id,
).scalar())
goal.progress = recalc_progress(db, goal_id)
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="取消关联任务失败")
# ============ Checkins (累计打卡) ============
@router.get("/{goal_id}/checkins", response_model=List[GoalCheckinResponse])
def get_checkins(goal_id: int, db: Session = Depends(get_db)):
"""获取目标的打卡记录列表"""
try:
get_or_404(db, Goal, goal_id, "目标")
checkins = db.query(GoalCheckin).filter(
GoalCheckin.goal_id == goal_id,
).order_by(GoalCheckin.checkin_date.desc(), GoalCheckin.created_at.desc()).all()
return checkins
except HTTPException:
raise
except Exception as e:
logger.error(f"获取打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取打卡记录失败")
@router.post("/{goal_id}/checkins", response_model=GoalCheckinResponse, status_code=201)
def create_checkin(goal_id: int, data: GoalCheckinCreate, db: Session = Depends(get_db)):
"""创建打卡记录并重算进度"""
try:
get_or_404(db, Goal, goal_id, "目标")
checkin = GoalCheckin(goal_id=goal_id, **data.model_dump())
db.add(checkin)
db.commit()
db.refresh(checkin)
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={checkin.id}, goal_id={goal_id}, value={checkin.value}")
return checkin
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"创建打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建打卡记录失败")
@router.put("/{goal_id}/checkins/{checkin_id}", response_model=GoalCheckinResponse)
def update_checkin(goal_id: int, checkin_id: int, data: GoalCheckinUpdate, db: Session = Depends(get_db)):
"""修改打卡记录并重算进度"""
try:
get_or_404(db, Goal, goal_id, "目标")
checkin = get_or_404(db, GoalCheckin, checkin_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(checkin, field, value)
db.commit()
db.refresh(checkin)
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={checkin_id}")
return checkin
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新打卡记录失败")
@router.delete("/{goal_id}/checkins/{checkin_id}")
def delete_checkin(goal_id: int, checkin_id: int, db: Session = Depends(get_db)):
"""删除打卡记录并重算进度"""
try:
get_or_404(db, Goal, goal_id, "目标")
checkin = get_or_404(db, GoalCheckin, checkin_id, "打卡记录")
db.delete(checkin)
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={checkin_id}")
return DeleteResponse(message="打卡记录删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除打卡记录失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除打卡记录失败")