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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user