diff --git a/app/api/routers/milestones.py b/app/api/routers/milestones.py index a269131..1b4c973 100644 --- a/app/api/routers/milestones.py +++ b/app/api/routers/milestones.py @@ -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, diff --git a/app/api/routers/misc.py b/app/api/routers/misc.py index 4cb975d..83b9467 100644 --- a/app/api/routers/misc.py +++ b/app/api/routers/misc.py @@ -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() +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"]) -def get_milestone(milestone_id: int, db: Session = Depends(get_db)): - ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() +def get_milestone(milestone_id: str, db: Session = Depends(get_db)): + ms = _find_milestone_by_id_or_code(db, milestone_id) if not ms: raise HTTPException(status_code=404, detail="Milestone not found") return ms @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)): - ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() +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 = _find_milestone_by_id_or_code(db, milestone_id) if not ms: raise HTTPException(status_code=404, detail="Milestone not found") 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"]) -def delete_milestone(milestone_id: int, db: Session = Depends(get_db)): - ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() +def delete_milestone(milestone_id: str, db: Session = Depends(get_db)): + ms = _find_milestone_by_id_or_code(db, milestone_id) if not ms: raise HTTPException(status_code=404, detail="Milestone not found") 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"]) -def milestone_progress(milestone_id: int, db: Session = Depends(get_db)): - ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() +def milestone_progress(milestone_id: str, db: Session = Depends(get_db)): + ms = _find_milestone_by_id_or_code(db, milestone_id) if not ms: raise HTTPException(status_code=404, detail="Milestone not found") tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all() diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 9c97843..faf3b5f 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -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} diff --git a/app/api/routers/proposes.py b/app/api/routers/proposes.py index cddcba8..f8877cf 100644 --- a/app/api/routers/proposes.py +++ b/app/api/routers/proposes.py @@ -17,6 +17,36 @@ from app.services.activity import log_activity 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: """Generate next propose code: {proj_code}:P{i:05x}""" 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]) def list_proposes( - 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="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 = ( db.query(Propose) - .filter(Propose.project_id == project_id) + .filter(Propose.project_id == project.id) .order_by(Propose.id.desc()) .all() ) @@ -64,20 +97,23 @@ def list_proposes( @router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED) def create_propose( - project_id: int, + project_id: str, propose_in: schemas.ProposeCreate, 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) + 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( title=propose_in.title, description=propose_in.description, status=ProposeStatus.OPEN, - project_id=project_id, + project_id=project.id, created_by_id=current_user.id, propose_code=propose_code, ) @@ -92,13 +128,16 @@ def create_propose( @router.get("/{propose_id}", response_model=schemas.ProposeResponse) def get_propose( - project_id: int, - propose_id: int, + project_id: str, + propose_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") - 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") + check_project_role(db, current_user.id, project.id, min_role="viewer") + propose = _find_propose(db, propose_id, project.id) if not propose: raise HTTPException(status_code=404, detail="Propose not found") return propose @@ -106,13 +145,16 @@ def get_propose( @router.patch("/{propose_id}", response_model=schemas.ProposeResponse) def update_propose( - project_id: int, - propose_id: int, + project_id: str, + propose_id: str, propose_in: schemas.ProposeUpdate, db: Session = Depends(get_db), 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: 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) def accept_propose( - project_id: int, - propose_id: int, + project_id: str, + propose_id: str, body: AcceptRequest, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """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: raise HTTPException(status_code=404, detail="Propose not found") @@ -161,12 +206,12 @@ def accept_propose( if propose_status != "open": 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 milestone = db.query(Milestone).filter( Milestone.id == body.milestone_id, - Milestone.project_id == project_id, + Milestone.project_id == project.id, ).first() if not milestone: raise HTTPException(status_code=404, detail="Milestone not found in this project") @@ -189,7 +234,7 @@ def accept_propose( task_subtype="feature", status=TaskStatus.PENDING, priority=TaskPriority.MEDIUM, - project_id=project_id, + project_id=project.id, milestone_id=milestone.id, reporter_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) def reject_propose( - project_id: int, - propose_id: int, + project_id: str, + propose_id: str, body: RejectRequest | None = None, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """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: raise HTTPException(status_code=404, detail="Propose not found") @@ -235,7 +283,7 @@ def reject_propose( if propose_status != "open": 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 db.commit() @@ -250,13 +298,16 @@ def reject_propose( @router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse) def reopen_propose( - project_id: int, - propose_id: int, + project_id: str, + propose_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """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: raise HTTPException(status_code=404, detail="Propose not found") @@ -264,7 +315,7 @@ def reopen_propose( if propose_status != "rejected": 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 db.commit() diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py index f29c75a..78926ff 100644 --- a/app/api/routers/tasks.py +++ b/app/api/routers/tasks.py @@ -2,7 +2,7 @@ import math from typing import List, Optional 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 pydantic import BaseModel @@ -88,27 +88,100 @@ def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, enti 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 ---- @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)): - _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) + 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["created_by_id"] = current_user.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"): - 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") - milestone = db.query(Milestone).filter( - Milestone.id == data["milestone_id"], - Milestone.project_id == data["project_id"], - ).first() + if not milestone: + milestone = db.query(Milestone).filter( + Milestone.id == data["milestone_id"], + Milestone.project_id == data["project_id"], + ).first() if not milestone: 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, ) 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") @@ -148,27 +221,51 @@ def list_tasks( assignee_id: int = None, tag: str = None, sort_by: str = "created_at", sort_order: str = "desc", 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) ): query = db.query(Task) - if project_id: - query = query.filter(Task.project_id == project_id) - if task_status: - query = query.filter(Task.status == task_status) + + resolved_project_id = _resolve_project_id(db, project_id, project) + if resolved_project_id: + 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: query = query.filter(Task.task_type == task_type) if 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: query = query.filter(Task.tags.contains(tag)) + effective_sort_by = order_by or sort_by sort_fields = { - "created_at": Task.created_at, "updated_at": Task.updated_at, - "priority": Task.priority, "title": Task.title, + "created": Task.created_at, + "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()) total = query.count() @@ -177,7 +274,7 @@ def list_tasks( total_pages = math.ceil(total / page_size) if total else 1 items = query.offset((page - 1) * page_size).limit(page_size).all() return { - "items": [schemas.TaskResponse.model_validate(i) for i in items], + "items": [_serialize_task(db, i) for i in items], "total": total, "total_tasks": total, "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) -def get_task(task_id: int, db: Session = Depends(get_db)): - task = db.query(Task).filter(Task.id == task_id).first() +def get_task(task_id: str, db: Session = Depends(get_db)): + task = _find_task_by_id_or_code(db, task_id) if not task: 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) -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)): - task = db.query(Task).filter(Task.id == task_id).first() +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 = _find_task_by_id_or_code(db, task_id) if not task: raise HTTPException(status_code=404, detail="Task not found") # P5.7: status-based edit restrictions current_status = task.status.value if hasattr(task.status, 'value') else task.status 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) _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 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) -def delete_task(task_id: int, 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() +def delete_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="mgr") @@ -286,22 +416,24 @@ def delete_task(task_id: int, db: Session = Depends(get_db), current_user: model # ---- Transition ---- class TransitionBody(BaseModel): + status: Optional[str] = None comment: Optional[str] = None @router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse) def transition_task( - task_id: int, - new_status: str, + task_id: str, bg: BackgroundTasks, + new_status: str | None = None, body: TransitionBody = None, db: Session = Depends(get_db), 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] if new_status not in 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: raise HTTPException(status_code=404, detail="Task not found") 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, {"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status}, 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 ---- @@ -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 items = query.offset((page - 1) * page_size).limit(page_size).all() return { - "items": [schemas.TaskResponse.model_validate(i) for i in items], + "items": [_serialize_task(db, i) for i in items], "total": total, "total_tasks": total, "page": page, diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 57bfec9..fa7c248 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -44,9 +44,12 @@ class TaskBase(BaseModel): class TaskCreate(TaskBase): project_id: Optional[int] = None + project_code: Optional[str] = None milestone_id: Optional[int] = None + milestone_code: Optional[str] = None reporter_id: Optional[int] = None assignee_id: Optional[int] = None + type: Optional[TaskTypeEnum] = None # Resolution specific resolution_summary: Optional[str] = None positions: Optional[str] = None @@ -57,10 +60,12 @@ class TaskUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None task_type: Optional[TaskTypeEnum] = None + type: Optional[TaskTypeEnum] = None task_subtype: Optional[str] = None status: Optional[TaskStatusEnum] = None priority: Optional[TaskPriorityEnum] = None assignee_id: Optional[int] = None + taken_by: Optional[str] = None tags: Optional[str] = None estimated_effort: Optional[int] = None # Resolution specific @@ -73,10 +78,16 @@ class TaskResponse(TaskBase): id: int status: TaskStatusEnum task_code: Optional[str] = None + code: Optional[str] = None + type: Optional[str] = None + due_date: Optional[datetime] = None project_id: int + project_code: Optional[str] = None milestone_id: int + milestone_code: Optional[str] = None reporter_id: int assignee_id: Optional[int] = None + taken_by: Optional[str] = None created_by_id: Optional[int] = None estimated_working_time: Optional[time] = None resolution_summary: Optional[str] = None