Files
HarborForge.Backend/app/api/routers/milestones.py

249 lines
12 KiB
Python

"""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")
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 _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,
}