From d76328923c970e501b41627fc987a13d4ed629f2 Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 14:25:51 +0000 Subject: [PATCH] feat: milestone enhancements - new fields, task/support/meeting endpoints, progress --- app/api/routers/milestones.py | 194 ++++++++++++++++++++++++++++++++-- app/models/milestone.py | 6 ++ app/schemas/schemas.py | 8 +- 3 files changed, 200 insertions(+), 8 deletions(-) diff --git a/app/api/routers/milestones.py b/app/api/routers/milestones.py index 0092419..ea2af22 100644 --- a/app/api/routers/milestones.py +++ b/app/api/routers/milestones.py @@ -1,7 +1,9 @@ """Milestones API router.""" +import json +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from typing import List +from typing import List, Optional from app.core.config import get_db from app.api.deps import get_current_user_or_apikey @@ -13,23 +15,47 @@ from app.schemas import schemas router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"]) +def _serialize_milestone(milestone): + """Serialize milestone with JSON fields.""" + result = { + "id": milestone.id, + "title": milestone.title, + "description": milestone.description, + "status": milestone.status.value if hasattr(milestone.status, 'value') else milestone.status, + "due_date": milestone.due_date, + "planned_release_date": milestone.planned_release_date, + "depend_on_milestones": json.loads(milestone.depend_on_milestones) if milestone.depend_on_milestones else [], + "depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [], + "project_id": milestone.project_id, + "created_at": milestone.created_at, + "updated_at": milestone.updated_at, + } + return result + + @router.get("", response_model=List[schemas.MilestoneResponse]) def list_milestones(project_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): """List all milestones for a project.""" check_project_role(db, current_user.id, project_id, min_role="viewer") milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all() - return milestones + return [_serialize_milestone(m) for m in milestones] @router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED) def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): """Create a new milestone for a project.""" check_project_role(db, current_user.id, project_id, min_role="mgr") - db_milestone = Milestone(project_id=project_id, **milestone.model_dump()) + data = milestone.model_dump() + # Handle JSON fields + if data.get("depend_on_milestones"): + data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) + if data.get("depend_on_tasks"): + data["depend_on_tasks"] = json.dumps(data["depend_on_tasks"]) + db_milestone = Milestone(project_id=project_id, **data) db.add(db_milestone) db.commit() db.refresh(db_milestone) - return db_milestone + return _serialize_milestone(db_milestone) @router.get("/{milestone_id}", response_model=schemas.MilestoneResponse) @@ -39,7 +65,7 @@ def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_ milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() if not milestone: raise HTTPException(status_code=404, detail="Milestone not found") - return milestone + return _serialize_milestone(milestone) @router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse) @@ -49,11 +75,17 @@ def update_milestone(project_id: int, milestone_id: int, milestone: schemas.Mile db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() if not db_milestone: raise HTTPException(status_code=404, detail="Milestone not found") - for key, value in milestone.model_dump(exclude_unset=True).items(): + data = milestone.model_dump(exclude_unset=True) + # Handle JSON fields + if "depend_on_milestones" in data: + data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) if data["depend_on_milestones"] else None + if "depend_on_tasks" in data: + data["depend_on_tasks"] = json.dumps(data["depend_on_tasks"]) if data["depend_on_tasks"] else None + for key, value in data.items(): setattr(db_milestone, key, value) db.commit() db.refresh(db_milestone) - return db_milestone + return _serialize_milestone(db_milestone) @router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -66,3 +98,151 @@ def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(g db.delete(db_milestone) db.commit() return None + + +# Issue type helpers +ISSUE_TYPE_TASK = "task" +ISSUE_TYPE_SUPPORT = "support" +ISSUE_TYPE_MEETING = "meeting" + + +@router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED) +def create_milestone_task(project_id: int, milestone_id: int, issue: schemas.IssueCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + """Create a task under a milestone.""" + check_project_role(db, current_user.id, project_id, min_role="dev") + milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() + if not milestone: + raise HTTPException(status_code=404, detail="Milestone not found") + + # Check if milestone is in progressing status - cannot add new story + if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing": + raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress") + + issue_data = issue.model_dump() + issue_data["issue_type"] = ISSUE_TYPE_TASK + issue_data["milestone_id"] = milestone_id + issue_data["project_id"] = project_id + issue_data["reporter_id"] = current_user.id + db_issue = models.Issue(**issue_data) + db.add(db_issue) + db.commit() + db.refresh(db_issue) + return db_issue + + +@router.post("/{milestone_id}/supports", status_code=status.HTTP_201_CREATED) +def create_milestone_support(project_id: int, milestone_id: int, issue: schemas.IssueCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + """Create a support request under a milestone.""" + check_project_role(db, current_user.id, project_id, min_role="dev") + milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() + if not milestone: + raise HTTPException(status_code=404, detail="Milestone not found") + + if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing": + raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress") + + issue_data = issue.model_dump() + issue_data["issue_type"] = ISSUE_TYPE_SUPPORT + issue_data["milestone_id"] = milestone_id + issue_data["project_id"] = project_id + issue_data["reporter_id"] = current_user.id + db_issue = models.Issue(**issue_data) + db.add(db_issue) + db.commit() + db.refresh(db_issue) + return db_issue + + +@router.post("/{milestone_id}/meetings", status_code=status.HTTP_201_CREATED) +def create_milestone_meeting(project_id: int, milestone_id: int, issue: schemas.IssueCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + """Create a meeting under a milestone.""" + check_project_role(db, current_user.id, project_id, min_role="dev") + milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() + if not milestone: + raise HTTPException(status_code=404, detail="Milestone not found") + + if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "progressing": + raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is in_progress") + + issue_data = issue.model_dump() + issue_data["issue_type"] = ISSUE_TYPE_MEETING + issue_data["milestone_id"] = milestone_id + issue_data["project_id"] = project_id + issue_data["reporter_id"] = current_user.id + db_issue = models.Issue(**issue_data) + db.add(db_issue) + db.commit() + db.refresh(db_issue) + return db_issue + + +@router.get("/{milestone_id}/items") +def get_milestone_items(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + """Get all items (tasks, supports, meetings) for a milestone.""" + check_project_role(db, current_user.id, project_id, min_role="viewer") + milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() + if not milestone: + raise HTTPException(status_code=404, detail="Milestone not found") + + issues = db.query(models.Issue).filter(models.Issue.milestone_id == milestone_id).all() + + tasks = [] + supports = [] + meetings = [] + + for issue in issues: + issue_data = { + "id": issue.id, + "title": issue.title, + "description": issue.description, + "status": issue.status.value if hasattr(issue.status, 'value') else issue.status, + "priority": issue.priority.value if hasattr(issue.priority, 'value') else issue.priority, + "created_at": issue.created_at, + } + if issue.issue_type == ISSUE_TYPE_TASK: + tasks.append(issue_data) + elif issue.issue_type == ISSUE_TYPE_SUPPORT: + supports.append(issue_data) + elif issue.issue_type == ISSUE_TYPE_MEETING: + meetings.append(issue_data) + + return {"tasks": tasks, "supports": supports, "meetings": meetings} + + +@router.get("/{milestone_id}/progress") +def get_milestone_progress(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + """Get progress for a milestone - tasks only.""" + check_project_role(db, current_user.id, project_id, min_role="viewer") + milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() + if not milestone: + raise HTTPException(status_code=404, detail="Milestone not found") + + # Count tasks only (not meetings or supports) + all_issues = db.query(models.Issue).filter( + models.Issue.milestone_id == milestone_id, + models.Issue.issue_type == ISSUE_TYPE_TASK + ).all() + + total = len(all_issues) + completed = sum(1 for i in all_issues if i.status and hasattr(i.status, 'value') and i.status.value == "closed") + + progress_pct = (completed / total * 100) if total > 0 else 0 + + # Calculate time progress if planned_release_date is set + time_progress = None + if milestone.planned_release_date: + now = datetime.now() + if milestone.created_at and milestone.planned_release_date > milestone.created_at: + total_duration = (milestone.planned_release_date - milestone.created_at).total_seconds() + elapsed = (now - milestone.created_at).total_seconds() + time_progress = min(100, max(0, (elapsed / total_duration * 100))) + + return { + "milestone_id": milestone_id, + "title": milestone.title, + "total": total, + "completed": completed, + "progress_pct": round(progress_pct, 1), + "time_progress_pct": round(time_progress, 1) if time_progress else None, + "planned_release_date": milestone.planned_release_date, + } diff --git a/app/models/milestone.py b/app/models/milestone.py index 8758435..959082f 100644 --- a/app/models/milestone.py +++ b/app/models/milestone.py @@ -7,6 +7,9 @@ import enum class MilestoneStatus(str, enum.Enum): OPEN = "open" + PENDING = "pending" + DEFERRED = "deferred" + PROGRESSING = "progressing" CLOSED = "closed" @@ -18,6 +21,9 @@ class Milestone(Base): description = Column(Text, nullable=True) status = Column(Enum(MilestoneStatus), default=MilestoneStatus.OPEN) due_date = Column(DateTime(timezone=True), nullable=True) + planned_release_date = Column(DateTime(timezone=True), nullable=True) + depend_on_milestones = Column(Text, nullable=True) # JSON list of milestone codes + depend_on_tasks = Column(Text, nullable=True) # JSON list of task IDs project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 5d6dd10..349df31 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -203,7 +203,11 @@ class ProjectMemberResponse(BaseModel): class MilestoneBase(BaseModel): title: str description: Optional[str] = None + status: Optional[str] = "open" due_date: Optional[datetime] = None + planned_release_date: Optional[datetime] = None + depend_on_milestones: Optional[List[str]] = None + depend_on_tasks: Optional[List[int]] = None class MilestoneCreate(MilestoneBase): @@ -215,11 +219,13 @@ class MilestoneUpdate(BaseModel): description: Optional[str] = None status: Optional[str] = None due_date: Optional[datetime] = None + planned_release_date: Optional[datetime] = None + depend_on_milestones: Optional[List[str]] = None + depend_on_tasks: Optional[List[int]] = None class MilestoneResponse(MilestoneBase): id: int - status: str project_id: int created_at: datetime updated_at: Optional[datetime] = None