"""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, Optional from app.core.config import get_db from app.api.deps import get_current_user_or_apikey from app.api.rbac import check_project_role from app.models import models from app.models.milestone import Milestone 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 [_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") # Generate milestone_code: projCode:{i:05x} project = db.query(models.Project).filter(models.Project.id == project_id).first() project_code = project.project_code if project else f"P{project_id}" max_ms = db.query(Milestone).filter(Milestone.project_id == project_id).order_by(Milestone.id.desc()).first() next_num = (max_ms.id + 1) if max_ms else 1 milestone_code = f"{project_code}:{next_num:05x}" 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, milestone_code=milestone_code, **data) db.add(db_milestone) db.commit() db.refresh(db_milestone) return _serialize_milestone(db_milestone) @router.get("/{milestone_id}", response_model=schemas.MilestoneResponse) def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): """Get a milestone by ID.""" 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") return _serialize_milestone(milestone) @router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse) def update_milestone(project_id: int, milestone_id: int, milestone: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): """Update a milestone.""" check_project_role(db, current_user.id, project_id, min_role="mgr") 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") 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 _serialize_milestone(db_milestone) @router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): """Delete a milestone.""" check_project_role(db, current_user.id, project_id, min_role="admin") 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") 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, }