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

@@ -182,9 +182,21 @@ def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)
return db.query(models.Project).offset(skip).limit(limit).all()
def _find_project_by_id_or_code(db, identifier) -> models.Project | None:
"""Look up project by numeric id or project_code."""
try:
pid = int(identifier)
project = db.query(models.Project).filter(models.Project.id == pid).first()
if project:
return project
except (ValueError, TypeError):
pass
return db.query(models.Project).filter(models.Project.project_code == str(identifier)).first()
@router.get("/{project_id}", response_model=schemas.ProjectResponse)
def get_project(project_id: int, db: Session = Depends(get_db)):
project = db.query(models.Project).filter(models.Project.id == project_id).first()
def get_project(project_id: str, db: Session = Depends(get_db)):
project = _find_project_by_id_or_code(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@@ -192,12 +204,12 @@ def get_project(project_id: int, db: Session = Depends(get_db)):
@router.patch("/{project_id}", response_model=schemas.ProjectResponse)
def update_project(
project_id: int,
project_id: str,
project_update: schemas.ProjectUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
project = db.query(models.Project).filter(models.Project.id == project_id).first()
project = _find_project_by_id_or_code(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
ensure_can_edit_project(db, current_user.id, project)
@@ -220,21 +232,22 @@ def update_project(
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_project(
project_id: int,
project_id: str,
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")
project = db.query(models.Project).filter(models.Project.id == project_id).first()
project = _find_project_by_id_or_code(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")
project_code = project.project_code
# Delete milestones and their tasks
from app.models.milestone import Milestone
from app.models.task import Task
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
milestones = db.query(Milestone).filter(Milestone.project_id == project.id).all()
for ms in milestones:
tasks = db.query(Task).filter(Task.milestone_id == ms.id).all()
for task in tasks:
@@ -242,7 +255,7 @@ def delete_project(
db.delete(ms)
# Delete project members
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all()
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project.id).all()
for m in members:
db.delete(m)
@@ -269,27 +282,27 @@ def delete_project(
@router.post("/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED)
def add_project_member(
project_id: int,
project_id: str,
member: schemas.ProjectMemberCreate,
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")
project = db.query(models.Project).filter(models.Project.id == project_id).first()
project = _find_project_by_id_or_code(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")
user = db.query(models.User).filter(models.User.id == member.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing = db.query(models.ProjectMember).filter(
models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == member.user_id
models.ProjectMember.project_id == project.id, models.ProjectMember.user_id == member.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already a member")
# Convert role name to role_id
role = db.query(Role).filter(Role.name == member.role).first()
role_id = role.id if role else None
db_member = models.ProjectMember(project_id=project_id, user_id=member.user_id, role_id=role_id)
db_member = models.ProjectMember(project_id=project.id, user_id=member.user_id, role_id=role_id)
db.add(db_member)
db.commit()
db.refresh(db_member)
@@ -307,8 +320,11 @@ def add_project_member(
@router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse])
def list_project_members(project_id: int, db: Session = Depends(get_db)):
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all()
def list_project_members(project_id: str, db: Session = Depends(get_db)):
project = _find_project_by_id_or_code(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project.id).all()
result = []
for m in members:
role_name = "developer"
@@ -327,14 +343,17 @@ def list_project_members(project_id: int, db: Session = Depends(get_db)):
@router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_project_member(
project_id: int,
project_id: str,
user_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
check_permission(db, current_user.id, project_id, "member.remove")
project = _find_project_by_id_or_code(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_permission(db, current_user.id, project.id, "member.remove")
member = db.query(models.ProjectMember).filter(
models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == user_id
models.ProjectMember.project_id == project.id, models.ProjectMember.user_id == user_id
).first()
# Prevent removing project owner (admin role)
@@ -362,16 +381,20 @@ from sqlalchemy import func as sqlfunc
@router.get("/{project_id}/worklogs/summary")
def project_worklog_summary(project_id: int, db: Session = Depends(get_db)):
def project_worklog_summary(project_id: str, db: Session = Depends(get_db)):
from app.models.task import Task as TaskModel
project = _find_project_by_id_or_code(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
resolved_project_id = project.id
results = db.query(
models.User.id, models.User.username,
sqlfunc.sum(WorkLog.hours).label("total_hours"),
sqlfunc.count(WorkLog.id).label("log_count")
).join(WorkLog, WorkLog.user_id == models.User.id)\
.join(TaskModel, WorkLog.task_id == TaskModel.id)\
.filter(TaskModel.project_id == project_id)\
.filter(TaskModel.project_id == resolved_project_id)\
.group_by(models.User.id, models.User.username).all()
total = sum(r.total_hours for r in results)
by_user = [{"user_id": r.id, "username": r.username, "hours": round(r.total_hours, 2), "logs": r.log_count} for r in results]
return {"project_id": project_id, "total_hours": round(total, 2), "by_user": by_user}
return {"project_id": resolved_project_id, "total_hours": round(total, 2), "by_user": by_user}