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"]) 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): def _serialize_milestone(milestone):
"""Serialize milestone with JSON fields.""" """Serialize milestone with JSON fields and code."""
return { return {
"id": milestone.id, "id": milestone.id,
"title": milestone.title, "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_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 [], "depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [],
"project_id": milestone.project_id, "project_id": milestone.project_id,
"milestone_code": milestone.milestone_code,
"code": milestone.milestone_code,
"created_by_id": milestone.created_by_id, "created_by_id": milestone.created_by_id,
"started_at": milestone.started_at, "started_at": milestone.started_at,
"created_at": milestone.created_at, "created_at": milestone.created_at,
@@ -38,19 +70,24 @@ def _serialize_milestone(milestone):
@router.get("", response_model=List[schemas.MilestoneResponse]) @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)): def list_milestones(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="viewer") project = _find_project(db, project_id)
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all() 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] return [_serialize_milestone(m) for m in milestones]
@router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED) @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)): def create_milestone(project_id: str, 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") 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.project_code else f"P{project.id}"
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()
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 next_num = (max_ms.id + 1) if max_ms else 1
milestone_code = f"{project_code}:{next_num:05x}" 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"]) data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"])
if data.get("depend_on_tasks"): if data.get("depend_on_tasks"):
data["depend_on_tasks"] = json.dumps(data["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.add(db_milestone)
db.commit() db.commit()
db.refresh(db_milestone) 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) @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)): def get_milestone(project_id: str, milestone_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="viewer") project = _find_project(db, project_id)
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() 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: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
return _serialize_milestone(milestone) return _serialize_milestone(milestone)
@router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse) @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)): 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)):
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() 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: if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
ensure_can_edit_milestone(db, current_user.id, db_milestone) 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) @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)): def delete_milestone(project_id: str, milestone_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 = _find_project(db, project_id)
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() 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: if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found") 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 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"]) @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)): 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)):
check_project_role(db, current_user.id, project_id, min_role="dev") project = _find_project(db, project_id)
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() 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: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") 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"), task_subtype=data.get("task_subtype"),
status=TaskStatus.PENDING, status=TaskStatus.PENDING,
priority=TaskPriority.MEDIUM, priority=TaskPriority.MEDIUM,
project_id=project_id, project_id=project.id,
milestone_id=milestone_id, milestone_id=milestone.id,
reporter_id=current_user.id, reporter_id=current_user.id,
task_code=task_code, task_code=task_code,
estimated_effort=data.get("estimated_effort"), 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") @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)): 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)):
check_project_role(db, current_user.id, project_id, min_role="viewer") project = _find_project(db, project_id)
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() 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: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
tasks = db.query(Task).filter(Task.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() supports = db.query(Support).filter(Support.milestone_id == milestone.id).all()
meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone_id).all() meetings = db.query(Meeting).filter(Meeting.milestone_id == milestone.id).all()
return { return {
"tasks": [{ "tasks": [{
@@ -221,13 +273,16 @@ def get_milestone_items(project_id: int, milestone_id: int, db: Session = Depend
@router.get("/{milestone_id}/progress") @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)): 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)):
check_project_role(db, current_user.id, project_id, min_role="viewer") project = _find_project(db, project_id)
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() 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: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") 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) total = len(all_tasks)
completed = sum(1 for t in all_tasks if t.status == TaskStatus.CLOSED) completed = sum(1 for t in all_tasks if t.status == TaskStatus.CLOSED)
progress_pct = (completed / total * 100) if total > 0 else 0 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))) time_progress = min(100, max(0, (elapsed / total_duration * 100)))
return { return {
"milestone_id": milestone_id, "milestone_id": milestone.id,
"milestone_code": milestone.milestone_code,
"title": milestone.title, "title": milestone.title,
"total": total, "total": total,
"total_tasks": total, "total_tasks": total,

View File

@@ -145,17 +145,29 @@ def list_milestones(project_id: int = None, status_filter: str = None, db: Sessi
return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all() return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all()
def _find_milestone_by_id_or_code(db, identifier) -> MilestoneModel | None:
"""Look up milestone by numeric id or milestone_code."""
try:
mid = int(identifier)
ms = db.query(MilestoneModel).filter(MilestoneModel.id == mid).first()
if ms:
return ms
except (ValueError, TypeError):
pass
return db.query(MilestoneModel).filter(MilestoneModel.milestone_code == str(identifier)).first()
@router.get("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"]) @router.get("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"])
def get_milestone(milestone_id: int, db: Session = Depends(get_db)): def get_milestone(milestone_id: str, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() ms = _find_milestone_by_id_or_code(db, milestone_id)
if not ms: if not ms:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
return ms return ms
@router.patch("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"]) @router.patch("/milestones/{milestone_id}", response_model=schemas.MilestoneResponse, tags=["Milestones"])
def update_milestone(milestone_id: int, ms_update: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def update_milestone(milestone_id: str, ms_update: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() ms = _find_milestone_by_id_or_code(db, milestone_id)
if not ms: if not ms:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
ensure_can_edit_milestone(db, current_user.id, ms) ensure_can_edit_milestone(db, current_user.id, ms)
@@ -167,8 +179,8 @@ def update_milestone(milestone_id: int, ms_update: schemas.MilestoneUpdate, db:
@router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"]) @router.delete("/milestones/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Milestones"])
def delete_milestone(milestone_id: int, db: Session = Depends(get_db)): def delete_milestone(milestone_id: str, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() ms = _find_milestone_by_id_or_code(db, milestone_id)
if not ms: if not ms:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
db.delete(ms) db.delete(ms)
@@ -177,8 +189,8 @@ def delete_milestone(milestone_id: int, db: Session = Depends(get_db)):
@router.get("/milestones/{milestone_id}/progress", tags=["Milestones"]) @router.get("/milestones/{milestone_id}/progress", tags=["Milestones"])
def milestone_progress(milestone_id: int, db: Session = Depends(get_db)): def milestone_progress(milestone_id: str, db: Session = Depends(get_db)):
ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() ms = _find_milestone_by_id_or_code(db, milestone_id)
if not ms: if not ms:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all() tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all()

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() 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) @router.get("/{project_id}", response_model=schemas.ProjectResponse)
def get_project(project_id: int, db: Session = Depends(get_db)): def get_project(project_id: str, db: Session = Depends(get_db)):
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: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
return project 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) @router.patch("/{project_id}", response_model=schemas.ProjectResponse)
def update_project( def update_project(
project_id: int, project_id: str,
project_update: schemas.ProjectUpdate, project_update: schemas.ProjectUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), 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: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
ensure_can_edit_project(db, current_user.id, project) 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) @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_project( def delete_project(
project_id: int, project_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
check_project_role(db, current_user.id, project_id, min_role="admin") project = _find_project_by_id_or_code(db, project_id)
project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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 project_code = project.project_code
# Delete milestones and their tasks # Delete milestones and their tasks
from app.models.milestone import Milestone from app.models.milestone import Milestone
from app.models.task import Task 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: for ms in milestones:
tasks = db.query(Task).filter(Task.milestone_id == ms.id).all() tasks = db.query(Task).filter(Task.milestone_id == ms.id).all()
for task in tasks: for task in tasks:
@@ -242,7 +255,7 @@ def delete_project(
db.delete(ms) db.delete(ms)
# Delete project members # 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: for m in members:
db.delete(m) 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) @router.post("/{project_id}/members", response_model=schemas.ProjectMemberResponse, status_code=status.HTTP_201_CREATED)
def add_project_member( def add_project_member(
project_id: int, project_id: str,
member: schemas.ProjectMemberCreate, member: schemas.ProjectMemberCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
check_project_role(db, current_user.id, project_id, min_role="mgr") project = _find_project_by_id_or_code(db, project_id)
project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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() user = db.query(models.User).filter(models.User.id == member.user_id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
existing = db.query(models.ProjectMember).filter( 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() ).first()
if existing: if existing:
raise HTTPException(status_code=400, detail="User already a member") raise HTTPException(status_code=400, detail="User already a member")
# Convert role name to role_id # Convert role name to role_id
role = db.query(Role).filter(Role.name == member.role).first() role = db.query(Role).filter(Role.name == member.role).first()
role_id = role.id if role else None 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.add(db_member)
db.commit() db.commit()
db.refresh(db_member) db.refresh(db_member)
@@ -307,8 +320,11 @@ def add_project_member(
@router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse]) @router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse])
def list_project_members(project_id: int, db: Session = Depends(get_db)): def list_project_members(project_id: str, db: Session = Depends(get_db)):
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all() 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 = [] result = []
for m in members: for m in members:
role_name = "developer" 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) @router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_project_member( def remove_project_member(
project_id: int, project_id: str,
user_id: int, user_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), 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( 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() ).first()
# Prevent removing project owner (admin role) # Prevent removing project owner (admin role)
@@ -362,16 +381,20 @@ from sqlalchemy import func as sqlfunc
@router.get("/{project_id}/worklogs/summary") @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 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( results = db.query(
models.User.id, models.User.username, models.User.id, models.User.username,
sqlfunc.sum(WorkLog.hours).label("total_hours"), sqlfunc.sum(WorkLog.hours).label("total_hours"),
sqlfunc.count(WorkLog.id).label("log_count") sqlfunc.count(WorkLog.id).label("log_count")
).join(WorkLog, WorkLog.user_id == models.User.id)\ ).join(WorkLog, WorkLog.user_id == models.User.id)\
.join(TaskModel, WorkLog.task_id == TaskModel.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() .group_by(models.User.id, models.User.username).all()
total = sum(r.total_hours for r in results) 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] 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}

View File

@@ -17,6 +17,36 @@ from app.services.activity import log_activity
router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes"]) router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes"])
def _find_project(db, identifier):
"""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_propose(db, identifier, project_id: int = None) -> Propose | None:
"""Look up propose by numeric id or propose_code."""
try:
pid = int(identifier)
q = db.query(Propose).filter(Propose.id == pid)
if project_id:
q = q.filter(Propose.project_id == project_id)
p = q.first()
if p:
return p
except (ValueError, TypeError):
pass
q = db.query(Propose).filter(Propose.propose_code == str(identifier))
if project_id:
q = q.filter(Propose.project_id == project_id)
return q.first()
def _generate_propose_code(db: Session, project_id: int) -> str: def _generate_propose_code(db: Session, project_id: int) -> str:
"""Generate next propose code: {proj_code}:P{i:05x}""" """Generate next propose code: {proj_code}:P{i:05x}"""
project = db.query(models.Project).filter(models.Project.id == project_id).first() project = db.query(models.Project).filter(models.Project.id == project_id).first()
@@ -48,14 +78,17 @@ def _can_edit_propose(db: Session, user_id: int, propose: Propose) -> bool:
@router.get("", response_model=List[schemas.ProposeResponse]) @router.get("", response_model=List[schemas.ProposeResponse])
def list_proposes( def list_proposes(
project_id: int, project_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
check_project_role(db, current_user.id, project_id, min_role="viewer") 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")
proposes = ( proposes = (
db.query(Propose) db.query(Propose)
.filter(Propose.project_id == project_id) .filter(Propose.project_id == project.id)
.order_by(Propose.id.desc()) .order_by(Propose.id.desc())
.all() .all()
) )
@@ -64,20 +97,23 @@ def list_proposes(
@router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED)
def create_propose( def create_propose(
project_id: int, project_id: str,
propose_in: schemas.ProposeCreate, propose_in: schemas.ProposeCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
check_project_role(db, current_user.id, project_id, min_role="dev") 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")
propose_code = _generate_propose_code(db, project_id) propose_code = _generate_propose_code(db, project.id)
propose = Propose( propose = Propose(
title=propose_in.title, title=propose_in.title,
description=propose_in.description, description=propose_in.description,
status=ProposeStatus.OPEN, status=ProposeStatus.OPEN,
project_id=project_id, project_id=project.id,
created_by_id=current_user.id, created_by_id=current_user.id,
propose_code=propose_code, propose_code=propose_code,
) )
@@ -92,13 +128,16 @@ def create_propose(
@router.get("/{propose_id}", response_model=schemas.ProposeResponse) @router.get("/{propose_id}", response_model=schemas.ProposeResponse)
def get_propose( def get_propose(
project_id: int, project_id: str,
propose_id: int, propose_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
check_project_role(db, current_user.id, project_id, min_role="viewer") project = _find_project(db, project_id)
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first() if not project:
raise HTTPException(status_code=404, detail="Project not found")
check_project_role(db, current_user.id, project.id, min_role="viewer")
propose = _find_propose(db, propose_id, project.id)
if not propose: if not propose:
raise HTTPException(status_code=404, detail="Propose not found") raise HTTPException(status_code=404, detail="Propose not found")
return propose return propose
@@ -106,13 +145,16 @@ def get_propose(
@router.patch("/{propose_id}", response_model=schemas.ProposeResponse) @router.patch("/{propose_id}", response_model=schemas.ProposeResponse)
def update_propose( def update_propose(
project_id: int, project_id: str,
propose_id: int, propose_id: str,
propose_in: schemas.ProposeUpdate, propose_in: schemas.ProposeUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first() project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
propose = _find_propose(db, propose_id, project.id)
if not propose: if not propose:
raise HTTPException(status_code=404, detail="Propose not found") raise HTTPException(status_code=404, detail="Propose not found")
@@ -146,14 +188,17 @@ class AcceptRequest(schemas.BaseModel):
@router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse) @router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse)
def accept_propose( def accept_propose(
project_id: int, project_id: str,
propose_id: int, propose_id: str,
body: AcceptRequest, body: AcceptRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Accept a propose: create a feature story task in the chosen milestone.""" """Accept a propose: create a feature story task in the chosen milestone."""
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first() project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
propose = _find_propose(db, propose_id, project.id)
if not propose: if not propose:
raise HTTPException(status_code=404, detail="Propose not found") raise HTTPException(status_code=404, detail="Propose not found")
@@ -161,12 +206,12 @@ def accept_propose(
if propose_status != "open": if propose_status != "open":
raise HTTPException(status_code=400, detail="Only open proposes can be accepted") raise HTTPException(status_code=400, detail="Only open proposes can be accepted")
check_permission(db, current_user.id, project_id, "propose.accept") check_permission(db, current_user.id, project.id, "propose.accept")
# Validate milestone # Validate milestone
milestone = db.query(Milestone).filter( milestone = db.query(Milestone).filter(
Milestone.id == body.milestone_id, Milestone.id == body.milestone_id,
Milestone.project_id == project_id, Milestone.project_id == project.id,
).first() ).first()
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found in this project") raise HTTPException(status_code=404, detail="Milestone not found in this project")
@@ -189,7 +234,7 @@ def accept_propose(
task_subtype="feature", task_subtype="feature",
status=TaskStatus.PENDING, status=TaskStatus.PENDING,
priority=TaskPriority.MEDIUM, priority=TaskPriority.MEDIUM,
project_id=project_id, project_id=project.id,
milestone_id=milestone.id, milestone_id=milestone.id,
reporter_id=propose.created_by_id or current_user.id, reporter_id=propose.created_by_id or current_user.id,
created_by_id=propose.created_by_id or current_user.id, created_by_id=propose.created_by_id or current_user.id,
@@ -220,14 +265,17 @@ class RejectRequest(schemas.BaseModel):
@router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse) @router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse)
def reject_propose( def reject_propose(
project_id: int, project_id: str,
propose_id: int, propose_id: str,
body: RejectRequest | None = None, body: RejectRequest | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Reject a propose.""" """Reject a propose."""
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first() project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
propose = _find_propose(db, propose_id, project.id)
if not propose: if not propose:
raise HTTPException(status_code=404, detail="Propose not found") raise HTTPException(status_code=404, detail="Propose not found")
@@ -235,7 +283,7 @@ def reject_propose(
if propose_status != "open": if propose_status != "open":
raise HTTPException(status_code=400, detail="Only open proposes can be rejected") raise HTTPException(status_code=400, detail="Only open proposes can be rejected")
check_permission(db, current_user.id, project_id, "propose.reject") check_permission(db, current_user.id, project.id, "propose.reject")
propose.status = ProposeStatus.REJECTED propose.status = ProposeStatus.REJECTED
db.commit() db.commit()
@@ -250,13 +298,16 @@ def reject_propose(
@router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse) @router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse)
def reopen_propose( def reopen_propose(
project_id: int, project_id: str,
propose_id: int, propose_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
"""Reopen a rejected propose back to open.""" """Reopen a rejected propose back to open."""
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first() project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
propose = _find_propose(db, propose_id, project.id)
if not propose: if not propose:
raise HTTPException(status_code=404, detail="Propose not found") raise HTTPException(status_code=404, detail="Propose not found")
@@ -264,7 +315,7 @@ def reopen_propose(
if propose_status != "rejected": if propose_status != "rejected":
raise HTTPException(status_code=400, detail="Only rejected proposes can be reopened") raise HTTPException(status_code=400, detail="Only rejected proposes can be reopened")
check_permission(db, current_user.id, project_id, "propose.reopen") check_permission(db, current_user.id, project.id, "propose.reopen")
propose.status = ProposeStatus.OPEN propose.status = ProposeStatus.OPEN
db.commit() db.commit()

View File

@@ -2,7 +2,7 @@
import math import math
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel from pydantic import BaseModel
@@ -88,27 +88,100 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti
return n return n
def _resolve_project_id(db: Session, project_id: int | None, project_code: str | None) -> int | None:
if project_id:
return project_id
if not project_code:
return None
project = db.query(models.Project).filter(models.Project.project_code == project_code).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project.id
def _resolve_milestone(db: Session, milestone_id: int | None, milestone_code: str | None, project_id: int | None) -> Milestone | None:
if milestone_id:
query = db.query(Milestone).filter(Milestone.id == milestone_id)
if project_id:
query = query.filter(Milestone.project_id == project_id)
milestone = query.first()
elif milestone_code:
query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code)
if project_id:
query = query.filter(Milestone.project_id == project_id)
milestone = query.first()
else:
return None
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
return milestone
def _find_task_by_id_or_code(db: Session, identifier: str) -> Task | None:
try:
task_id = int(identifier)
task = db.query(Task).filter(Task.id == task_id).first()
if task:
return task
except ValueError:
pass
return db.query(Task).filter(Task.task_code == identifier).first()
def _serialize_task(db: Session, task: Task) -> dict:
payload = schemas.TaskResponse.model_validate(task).model_dump(mode="json")
project = db.query(models.Project).filter(models.Project.id == task.project_id).first()
milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first()
assignee = None
if task.assignee_id:
assignee = db.query(models.User).filter(models.User.id == task.assignee_id).first()
payload.update({
"code": task.task_code,
"type": task.task_type,
"project_code": project.project_code if project else None,
"milestone_code": milestone.milestone_code if milestone else None,
"taken_by": assignee.username if assignee else None,
"due_date": None,
})
return payload
# ---- CRUD ---- # ---- CRUD ----
@router.post("/tasks", response_model=schemas.TaskResponse, status_code=status.HTTP_201_CREATED) @router.post("/tasks", response_model=schemas.TaskResponse, status_code=status.HTTP_201_CREATED)
def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
_validate_task_type_subtype(task_in.task_type, task_in.task_subtype) requested_task_type = task_in.type or task_in.task_type
_validate_task_type_subtype(requested_task_type, task_in.task_subtype)
data = task_in.model_dump(exclude_unset=True) data = task_in.model_dump(exclude_unset=True)
if data.get("type") and not data.get("task_type"):
data["task_type"] = data.pop("type")
else:
data.pop("type", None)
data["project_id"] = _resolve_project_id(db, data.get("project_id"), data.pop("project_code", None))
milestone = _resolve_milestone(db, data.get("milestone_id"), data.pop("milestone_code", None), data.get("project_id"))
if milestone:
data["milestone_id"] = milestone.id
data["project_id"] = milestone.project_id
data["reporter_id"] = data.get("reporter_id") or current_user.id data["reporter_id"] = data.get("reporter_id") or current_user.id
data["created_by_id"] = current_user.id data["created_by_id"] = current_user.id
if not data.get("project_id"): if not data.get("project_id"):
raise HTTPException(status_code=400, detail="project_id is required") raise HTTPException(status_code=400, detail="project_id or project_code is required")
if not data.get("milestone_id"): if not data.get("milestone_id"):
raise HTTPException(status_code=400, detail="milestone_id is required") raise HTTPException(status_code=400, detail="milestone_id or milestone_code is required")
check_project_role(db, current_user.id, data["project_id"], min_role="dev") check_project_role(db, current_user.id, data["project_id"], min_role="dev")
milestone = db.query(Milestone).filter( if not milestone:
Milestone.id == data["milestone_id"], milestone = db.query(Milestone).filter(
Milestone.project_id == data["project_id"], Milestone.id == data["milestone_id"],
).first() Milestone.project_id == data["project_id"],
).first()
if not milestone: if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
@@ -139,7 +212,7 @@ def create_task(task_in: schemas.TaskCreate, bg: BackgroundTasks, db: Session =
db, db,
) )
log_activity(db, "task.created", "task", db_task.id, current_user.id, {"title": db_task.title}) log_activity(db, "task.created", "task", db_task.id, current_user.id, {"title": db_task.title})
return db_task return _serialize_task(db, db_task)
@router.get("/tasks") @router.get("/tasks")
@@ -148,27 +221,51 @@ def list_tasks(
assignee_id: int = None, tag: str = None, assignee_id: int = None, tag: str = None,
sort_by: str = "created_at", sort_order: str = "desc", sort_by: str = "created_at", sort_order: str = "desc",
page: int = 1, page_size: int = 50, page: int = 1, page_size: int = 50,
project: str = None, milestone: str = None, status_value: str = Query(None, alias="status"), taken_by: str = None,
order_by: str = None,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
query = db.query(Task) query = db.query(Task)
if project_id:
query = query.filter(Task.project_id == project_id) resolved_project_id = _resolve_project_id(db, project_id, project)
if task_status: if resolved_project_id:
query = query.filter(Task.status == task_status) query = query.filter(Task.project_id == resolved_project_id)
if milestone:
milestone_obj = _resolve_milestone(db, None, milestone, resolved_project_id)
query = query.filter(Task.milestone_id == milestone_obj.id)
effective_status = status_value or task_status
if effective_status:
query = query.filter(Task.status == effective_status)
if task_type: if task_type:
query = query.filter(Task.task_type == task_type) query = query.filter(Task.task_type == task_type)
if task_subtype: if task_subtype:
query = query.filter(Task.task_subtype == task_subtype) query = query.filter(Task.task_subtype == task_subtype)
if assignee_id:
query = query.filter(Task.assignee_id == assignee_id) effective_assignee_id = assignee_id
if taken_by == "null":
query = query.filter(Task.assignee_id.is_(None))
elif taken_by:
user = db.query(models.User).filter(models.User.username == taken_by).first()
if not user:
return {"items": [], "total": 0, "total_tasks": 0, "page": 1, "page_size": page_size, "total_pages": 1}
effective_assignee_id = user.id
if effective_assignee_id:
query = query.filter(Task.assignee_id == effective_assignee_id)
if tag: if tag:
query = query.filter(Task.tags.contains(tag)) query = query.filter(Task.tags.contains(tag))
effective_sort_by = order_by or sort_by
sort_fields = { sort_fields = {
"created_at": Task.created_at, "updated_at": Task.updated_at, "created": Task.created_at,
"priority": Task.priority, "title": Task.title, "created_at": Task.created_at,
"updated_at": Task.updated_at,
"priority": Task.priority,
"name": Task.title,
"title": Task.title,
} }
sort_col = sort_fields.get(sort_by, Task.created_at) sort_col = sort_fields.get(effective_sort_by, Task.created_at)
query = query.order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc()) query = query.order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc())
total = query.count() total = query.count()
@@ -177,7 +274,7 @@ def list_tasks(
total_pages = math.ceil(total / page_size) if total else 1 total_pages = math.ceil(total / page_size) if total else 1
items = query.offset((page - 1) * page_size).limit(page_size).all() items = query.offset((page - 1) * page_size).limit(page_size).all()
return { return {
"items": [schemas.TaskResponse.model_validate(i) for i in items], "items": [_serialize_task(db, i) for i in items],
"total": total, "total": total,
"total_tasks": total, "total_tasks": total,
"page": page, "page": page,
@@ -186,23 +283,56 @@ def list_tasks(
} }
@router.get("/tasks/search", response_model=List[schemas.TaskResponse])
def search_tasks_alias(
q: str,
project: str = None,
status: str = None,
db: Session = Depends(get_db),
):
query = db.query(Task).filter(
(Task.title.contains(q)) | (Task.description.contains(q))
)
resolved_project_id = _resolve_project_id(db, None, project)
if resolved_project_id:
query = query.filter(Task.project_id == resolved_project_id)
if status:
query = query.filter(Task.status == status)
items = query.order_by(Task.created_at.desc()).limit(100).all()
return [_serialize_task(db, i) for i in items]
@router.get("/tasks/{task_id}", response_model=schemas.TaskResponse) @router.get("/tasks/{task_id}", response_model=schemas.TaskResponse)
def get_task(task_id: int, db: Session = Depends(get_db)): def get_task(task_id: str, db: Session = Depends(get_db)):
task = db.query(Task).filter(Task.id == task_id).first() task = _find_task_by_id_or_code(db, task_id)
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
return task return _serialize_task(db, task)
@router.patch("/tasks/{task_id}", response_model=schemas.TaskResponse) @router.patch("/tasks/{task_id}", response_model=schemas.TaskResponse)
def update_task(task_id: int, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = db.query(Task).filter(Task.id == task_id).first() task = _find_task_by_id_or_code(db, task_id)
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
# P5.7: status-based edit restrictions # P5.7: status-based edit restrictions
current_status = task.status.value if hasattr(task.status, 'value') else task.status current_status = task.status.value if hasattr(task.status, 'value') else task.status
update_data = task_update.model_dump(exclude_unset=True) update_data = task_update.model_dump(exclude_unset=True)
if update_data.get("type") and not update_data.get("task_type"):
update_data["task_type"] = update_data.pop("type")
else:
update_data.pop("type", None)
if "taken_by" in update_data:
taken_by = update_data.pop("taken_by")
if taken_by in (None, "null", ""):
update_data["assignee_id"] = None
else:
assignee = db.query(models.User).filter(models.User.username == taken_by).first()
if not assignee:
raise HTTPException(status_code=404, detail="Assignee user not found")
update_data["assignee_id"] = assignee.id
# Fields that are always allowed regardless of status (non-body edits) # Fields that are always allowed regardless of status (non-body edits)
_always_allowed = {"status"} _always_allowed = {"status"}
@@ -268,12 +398,12 @@ def update_task(task_id: int, task_update: schemas.TaskUpdate, db: Session = Dep
from app.api.routers.milestone_actions import try_auto_complete_milestone from app.api.routers.milestone_actions import try_auto_complete_milestone
try_auto_complete_milestone(db, task, user_id=current_user.id) try_auto_complete_milestone(db, task, user_id=current_user.id)
return task return _serialize_task(db, task)
@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): def delete_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
task = db.query(Task).filter(Task.id == task_id).first() task = _find_task_by_id_or_code(db, task_id)
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="mgr") check_project_role(db, current_user.id, task.project_id, min_role="mgr")
@@ -286,22 +416,24 @@ def delete_task(task_id: int, db: Session = Depends(get_db), current_user: model
# ---- Transition ---- # ---- Transition ----
class TransitionBody(BaseModel): class TransitionBody(BaseModel):
status: Optional[str] = None
comment: Optional[str] = None comment: Optional[str] = None
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse) @router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse)
def transition_task( def transition_task(
task_id: int, task_id: str,
new_status: str,
bg: BackgroundTasks, bg: BackgroundTasks,
new_status: str | None = None,
body: TransitionBody = None, body: TransitionBody = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user_or_apikey),
): ):
new_status = new_status or (body.status if body else None)
valid_statuses = [s.value for s in TaskStatus] valid_statuses = [s.value for s in TaskStatus]
if new_status not in valid_statuses: if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}") raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
task = db.query(Task).filter(Task.id == task_id).first() task = _find_task_by_id_or_code(db, task_id)
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
old_status = task.status.value if hasattr(task.status, 'value') else task.status old_status = task.status.value if hasattr(task.status, 'value') else task.status
@@ -385,7 +517,40 @@ def transition_task(
bg.add_task(fire_webhooks_sync, event, bg.add_task(fire_webhooks_sync, event,
{"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status}, {"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status},
task.project_id, db) task.project_id, db)
return task return _serialize_task(db, task)
@router.post("/tasks/{task_id}/take", response_model=schemas.TaskResponse)
def take_task(
task_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
task = _find_task_by_id_or_code(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
check_project_role(db, current_user.id, task.project_id, min_role="dev")
if task.assignee_id and task.assignee_id != current_user.id:
assignee = db.query(models.User).filter(models.User.id == task.assignee_id).first()
assignee_name = assignee.username if assignee else str(task.assignee_id)
raise HTTPException(status_code=409, detail=f"Task is already taken by {assignee_name}")
task.assignee_id = current_user.id
db.commit()
db.refresh(task)
_notify_user(
db,
current_user.id,
"task.assigned",
f"Task {task.task_code or task.id} assigned to you",
f"'{task.title}' has been assigned to you.",
"task",
task.id,
)
return _serialize_task(db, task)
# ---- Assignment ---- # ---- Assignment ----
@@ -616,7 +781,7 @@ def search_tasks(q: str, project_id: int = None, page: int = 1, page_size: int =
total_pages = math.ceil(total / page_size) if total else 1 total_pages = math.ceil(total / page_size) if total else 1
items = query.offset((page - 1) * page_size).limit(page_size).all() items = query.offset((page - 1) * page_size).limit(page_size).all()
return { return {
"items": [schemas.TaskResponse.model_validate(i) for i in items], "items": [_serialize_task(db, i) for i in items],
"total": total, "total": total,
"total_tasks": total, "total_tasks": total,
"page": page, "page": page,

View File

@@ -44,9 +44,12 @@ class TaskBase(BaseModel):
class TaskCreate(TaskBase): class TaskCreate(TaskBase):
project_id: Optional[int] = None project_id: Optional[int] = None
project_code: Optional[str] = None
milestone_id: Optional[int] = None milestone_id: Optional[int] = None
milestone_code: Optional[str] = None
reporter_id: Optional[int] = None reporter_id: Optional[int] = None
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
type: Optional[TaskTypeEnum] = None
# Resolution specific # Resolution specific
resolution_summary: Optional[str] = None resolution_summary: Optional[str] = None
positions: Optional[str] = None positions: Optional[str] = None
@@ -57,10 +60,12 @@ class TaskUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
task_type: Optional[TaskTypeEnum] = None task_type: Optional[TaskTypeEnum] = None
type: Optional[TaskTypeEnum] = None
task_subtype: Optional[str] = None task_subtype: Optional[str] = None
status: Optional[TaskStatusEnum] = None status: Optional[TaskStatusEnum] = None
priority: Optional[TaskPriorityEnum] = None priority: Optional[TaskPriorityEnum] = None
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
taken_by: Optional[str] = None
tags: Optional[str] = None tags: Optional[str] = None
estimated_effort: Optional[int] = None estimated_effort: Optional[int] = None
# Resolution specific # Resolution specific
@@ -73,10 +78,16 @@ class TaskResponse(TaskBase):
id: int id: int
status: TaskStatusEnum status: TaskStatusEnum
task_code: Optional[str] = None task_code: Optional[str] = None
code: Optional[str] = None
type: Optional[str] = None
due_date: Optional[datetime] = None
project_id: int project_id: int
project_code: Optional[str] = None
milestone_id: int milestone_id: int
milestone_code: Optional[str] = None
reporter_id: int reporter_id: int
assignee_id: Optional[int] = None assignee_id: Optional[int] = None
taken_by: Optional[str] = None
created_by_id: Optional[int] = None created_by_id: Optional[int] = None
estimated_working_time: Optional[time] = None estimated_working_time: Optional[time] = None
resolution_summary: Optional[str] = None resolution_summary: Optional[str] = None