feat: add code-first API support for projects, milestones, proposes, tasks

- Projects: get/update/delete/members endpoints now accept project_code
- Milestones: all project-scoped and top-level endpoints accept milestone_code
- Proposes: all endpoints accept project_code and propose_code
- Tasks: code-first support for all CRUD + transition + take + search
- Schemas: add code/type/due_date/project_code/milestone_code/taken_by fields
- All endpoints use id-or-code lookup helpers for backward compatibility
- Milestone serializer now includes milestone_code and code fields
- Task serializer enriches responses with project_code, milestone_code, taken_by

Addresses TODO §2.1: code-first API support across CLI-targeted resources
This commit is contained in:
zhi
2026-03-21 18:12:04 +00:00
parent 32e79a41d8
commit 43af5b29f6
6 changed files with 442 additions and 124 deletions

View File

@@ -18,8 +18,38 @@ from app.schemas import schemas
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"])
def _find_project(db, identifier) -> models.Project | None:
"""Look up project by numeric id or project_code."""
try:
pid = int(identifier)
p = db.query(models.Project).filter(models.Project.id == pid).first()
if p:
return p
except (ValueError, TypeError):
pass
return db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
def _find_milestone(db, identifier, project_id: int = None) -> Milestone | None:
"""Look up milestone by numeric id or milestone_code."""
try:
mid = int(identifier)
q = db.query(Milestone).filter(Milestone.id == mid)
if project_id:
q = q.filter(Milestone.project_id == project_id)
ms = q.first()
if ms:
return ms
except (ValueError, TypeError):
pass
q = db.query(Milestone).filter(Milestone.milestone_code == str(identifier))
if project_id:
q = q.filter(Milestone.project_id == project_id)
return q.first()
def _serialize_milestone(milestone):
"""Serialize milestone with JSON fields."""
"""Serialize milestone with JSON fields and code."""
return {
"id": milestone.id,
"title": milestone.title,
@@ -30,6 +60,8 @@ def _serialize_milestone(milestone):
"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,
"milestone_code": milestone.milestone_code,
"code": milestone.milestone_code,
"created_by_id": milestone.created_by_id,
"started_at": milestone.started_at,
"created_at": milestone.created_at,
@@ -38,19 +70,24 @@ def _serialize_milestone(milestone):
@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)):
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
def list_milestones(project_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
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)):
check_project_role(db, current_user.id, project_id, min_role="mgr")
def create_milestone(project_id: str, milestone: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="mgr")
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()
project_code = project.project_code if project.project_code 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}"
@@ -60,7 +97,7 @@ def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Se
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, created_by_id=current_user.id, **data)
db_milestone = Milestone(project_id=project.id, milestone_code=milestone_code, created_by_id=current_user.id, **data)
db.add(db_milestone)
db.commit()
db.refresh(db_milestone)
@@ -68,17 +105,23 @@ def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Se
@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)):
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()
def get_milestone(project_id: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer")
milestone = _find_milestone(db, milestone_id, project.id)
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)):
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
def update_milestone(project_id: str, milestone_id: str, milestone: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
db_milestone = _find_milestone(db, milestone_id, project.id)
if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
ensure_can_edit_milestone(db, current_user.id, db_milestone)
@@ -124,9 +167,12 @@ def update_milestone(project_id: int, milestone_id: int, milestone: schemas.Mile
@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)):
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()
def delete_milestone(project_id: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="admin")
db_milestone = _find_milestone(db, milestone_id, project.id)
if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
ms_status = db_milestone.status.value if hasattr(db_milestone.status, 'value') else db_milestone.status
@@ -138,9 +184,12 @@ def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(g
@router.post("/{milestone_id}/tasks", status_code=status.HTTP_201_CREATED, tags=["Milestones"])
def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
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()
def create_milestone_task(project_id: str, milestone_id: str, task_data: schemas.TaskCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="dev")
milestone = _find_milestone(db, milestone_id, project.id)
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
@@ -177,8 +226,8 @@ def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas
task_subtype=data.get("task_subtype"),
status=TaskStatus.PENDING,
priority=TaskPriority.MEDIUM,
project_id=project_id,
milestone_id=milestone_id,
project_id=project.id,
milestone_id=milestone.id,
reporter_id=current_user.id,
task_code=task_code,
estimated_effort=data.get("estimated_effort"),
@@ -192,15 +241,18 @@ def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas
@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)):
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()
def get_milestone_items(project_id: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer")
milestone = _find_milestone(db, milestone_id, project.id)
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
supports = db.query(Support).filter(Support.milestone_id == milestone_id).all()
meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).all()
tasks = db.query(Task).filter(Task.milestone_id == milestone.id).all()
supports = db.query(Support).filter(Support.milestone_id == milestone.id).all()
meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone.id).all()
return {
"tasks": [{
@@ -221,13 +273,16 @@ def get_milestone_items(project_id: int, milestone_id: int, db: Session = Depend
@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)):
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()
def get_milestone_progress(project_id: str, milestone_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer")
milestone = _find_milestone(db, milestone_id, project.id)
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
all_tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()
all_tasks = db.query(Task).filter(Task.milestone_id == milestone.id).all()
total = len(all_tasks)
completed = sum(1 for t in all_tasks if t.status == TaskStatus.CLOSED)
progress_pct = (completed / total * 100) if total > 0 else 0
@@ -241,7 +296,8 @@ def get_milestone_progress(project_id: int, milestone_id: int, db: Session = Dep
time_progress = min(100, max(0, (elapsed / total_duration * 100)))
return {
"milestone_id": milestone_id,
"milestone_id": milestone.id,
"milestone_code": milestone.milestone_code,
"title": milestone.title,
"total": total,
"total_tasks": total,