feat: add goal management module (long-term goals with phases, milestones, reviews)
Backend: - Goal model: title, description, status (active/paused/completed/abandoned), progress (auto-computed from milestones), target_date, category, color, icon - GoalStep model: unified phase/milestone with parent nesting - GoalReview model: periodic reflection with rating - goal_tasks M2M: link existing tasks to goals - /api/goals CRUD + steps CRUD + reviews + task linking + status toggle - Progress auto-calculated from milestone completion ratio Frontend: - GoalPage: card grid with progress bars, status filter - GoalDetailPage: step tree (phases > milestones), reviews, linked tasks - GoalDialog: create/edit form with color/icon picker - Goal navigation in AppHeader - useGoalStore: full Pinia store for all goal operations Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,7 +59,7 @@ def init_db():
|
||||
"""初始化数据库表,自动补充新增的列"""
|
||||
# 导入所有模型,确保 Base.metadata 包含全部表定义
|
||||
from app.models import ( # noqa: F401
|
||||
task, category, tag, user_settings, habit, anniversary,
|
||||
task, category, tag, user_settings, habit, anniversary, goal,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ from app.models.tag import Tag, task_tags
|
||||
from app.models.user_settings import UserSettings
|
||||
from app.models.habit import HabitGroup, Habit, HabitCheckin
|
||||
from app.models.anniversary import AnniversaryCategory, Anniversary
|
||||
from app.models.goal import Goal, GoalStep, GoalReview, goal_tasks
|
||||
|
||||
__all__ = [
|
||||
"Task", "Category", "Tag", "task_tags", "UserSettings",
|
||||
"HabitGroup", "Habit", "HabitCheckin",
|
||||
"AnniversaryCategory", "Anniversary",
|
||||
"Goal", "GoalStep", "GoalReview", "goal_tasks",
|
||||
]
|
||||
|
||||
80
api/app/models/goal.py
Normal file
80
api/app/models/goal.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Date, DateTime, ForeignKey, Table, desc
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
from app.utils.datetime import utcnow
|
||||
|
||||
|
||||
# 目标-任务关联表(多对多)
|
||||
goal_tasks = Table(
|
||||
"goal_tasks",
|
||||
Base.metadata,
|
||||
Column("goal_id", Integer, ForeignKey("goals.id"), primary_key=True),
|
||||
Column("task_id", Integer, ForeignKey("tasks.id"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Goal(Base):
|
||||
"""长期目标模型"""
|
||||
__tablename__ = "goals"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
status = Column(String(20), default="active") # active/paused/completed/abandoned
|
||||
progress = Column(Integer, default=0) # 0-100,从里程碑自动计算
|
||||
target_date = Column(Date, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
color = Column(String(20), default="#FFB7C5")
|
||||
icon = Column(String(50), default="flag")
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
# 关联关系
|
||||
category = relationship("Category")
|
||||
steps = relationship(
|
||||
"GoalStep", back_populates="goal",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="GoalStep.sort_order",
|
||||
)
|
||||
reviews = relationship(
|
||||
"GoalReview", back_populates="goal",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: desc(GoalReview.created_at),
|
||||
)
|
||||
tasks = relationship("Task", secondary=goal_tasks, back_populates="goals")
|
||||
|
||||
|
||||
class GoalStep(Base):
|
||||
"""目标阶段/里程碑模型(step_type 区分类型)"""
|
||||
__tablename__ = "goal_steps"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
|
||||
parent_id = Column(Integer, ForeignKey("goal_steps.id"), nullable=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
step_type = Column(String(20), nullable=False) # "phase" | "milestone"
|
||||
status = Column(String(20), default="pending") # pending/in_progress/completed
|
||||
target_date = Column(Date, nullable=True)
|
||||
reached_at = Column(DateTime, nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
|
||||
# 关联关系
|
||||
goal = relationship("Goal", back_populates="steps")
|
||||
parent = relationship("GoalStep", remote_side=[id], backref="children")
|
||||
|
||||
|
||||
class GoalReview(Base):
|
||||
"""目标复盘记录模型"""
|
||||
__tablename__ = "goal_reviews"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
rating = Column(Integer, nullable=True) # 1-5 自评
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
|
||||
# 关联关系
|
||||
goal = relationship("Goal", back_populates="reviews")
|
||||
@@ -21,3 +21,4 @@ class Task(Base):
|
||||
# 关联关系
|
||||
category = relationship("Category", back_populates="tasks")
|
||||
tags = relationship("Tag", secondary="task_tags", back_populates="tasks")
|
||||
goals = relationship("Goal", secondary="goal_tasks", back_populates="tasks")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth
|
||||
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -10,3 +10,4 @@ api_router.include_router(tags.router)
|
||||
api_router.include_router(user_settings.router)
|
||||
api_router.include_router(habits.router)
|
||||
api_router.include_router(anniversaries.router)
|
||||
api_router.include_router(goals.router)
|
||||
|
||||
400
api/app/routers/goals.py
Normal file
400
api/app/routers/goals.py
Normal file
@@ -0,0 +1,400 @@
|
||||
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="取消关联任务失败")
|
||||
119
api/app/schemas/goal.py
Normal file
119
api/app/schemas/goal.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List
|
||||
|
||||
from app.schemas.category import CategoryResponse
|
||||
from app.schemas.task import TaskResponse
|
||||
|
||||
|
||||
# ============ GoalStep Schemas ============
|
||||
|
||||
class GoalStepBase(BaseModel):
|
||||
title: str = Field(..., max_length=200)
|
||||
step_type: str = Field(..., pattern="^(phase|milestone)$")
|
||||
status: str = Field(default="pending", pattern="^(pending|in_progress|completed)$")
|
||||
target_date: Optional[date] = None
|
||||
parent_id: Optional[int] = None
|
||||
sort_order: int = Field(default=0)
|
||||
|
||||
|
||||
class GoalStepCreate(GoalStepBase):
|
||||
pass
|
||||
|
||||
|
||||
class GoalStepUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=200)
|
||||
step_type: Optional[str] = Field(None, pattern="^(phase|milestone)$")
|
||||
status: Optional[str] = Field(None, pattern="^(pending|in_progress|completed)$")
|
||||
target_date: Optional[date] = None
|
||||
parent_id: Optional[int] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
@property
|
||||
def clearable_fields(self) -> set:
|
||||
return {"target_date", "parent_id"}
|
||||
|
||||
|
||||
class GoalStepResponse(GoalStepBase):
|
||||
id: int
|
||||
goal_id: int
|
||||
reached_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
children: List["GoalStepResponse"] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ GoalReview Schemas ============
|
||||
|
||||
class GoalReviewCreate(BaseModel):
|
||||
content: str = Field(..., min_length=1)
|
||||
rating: Optional[int] = Field(None, ge=1, le=5)
|
||||
|
||||
|
||||
class GoalReviewResponse(BaseModel):
|
||||
id: int
|
||||
goal_id: int
|
||||
content: str
|
||||
rating: Optional[int] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Goal Schemas ============
|
||||
|
||||
class GoalBase(BaseModel):
|
||||
title: str = Field(..., max_length=200)
|
||||
description: Optional[str] = None
|
||||
status: str = Field(default="active", pattern="^(active|paused|completed|abandoned)$")
|
||||
target_date: Optional[date] = None
|
||||
category_id: Optional[int] = None
|
||||
color: str = Field(default="#FFB7C5", max_length=20)
|
||||
icon: str = Field(default="flag", max_length=50)
|
||||
sort_order: int = Field(default=0)
|
||||
|
||||
|
||||
class GoalCreate(GoalBase):
|
||||
pass
|
||||
|
||||
|
||||
class GoalUpdate(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=200)
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = Field(None, pattern="^(active|paused|completed|abandoned)$")
|
||||
target_date: Optional[date] = None
|
||||
category_id: Optional[int] = None
|
||||
color: Optional[str] = Field(None, max_length=20)
|
||||
icon: Optional[str] = Field(None, max_length=50)
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
@property
|
||||
def clearable_fields(self) -> set:
|
||||
return {"description", "target_date", "category_id"}
|
||||
|
||||
|
||||
class GoalListResponse(GoalBase):
|
||||
id: int
|
||||
progress: int
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
category: Optional[CategoryResponse] = None
|
||||
total_steps: int = 0
|
||||
completed_steps: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GoalDetailResponse(GoalListResponse):
|
||||
steps: List[GoalStepResponse] = []
|
||||
reviews: List[GoalReviewResponse] = []
|
||||
tasks: List[TaskResponse] = []
|
||||
|
||||
|
||||
class GoalStatusUpdate(BaseModel):
|
||||
status: str = Field(..., pattern="^(active|paused|completed|abandoned)$")
|
||||
Reference in New Issue
Block a user