From e5fd89f9723758d618afd86e463c98dc1d6fd4f0 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 14:21:54 +0000 Subject: [PATCH 01/13] feat: add username-based user lookup and permission introspection endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - users router: accept username or id in get/update/delete/worklogs via _find_user_by_id_or_username() - auth router: add GET /auth/me/permissions for CLI help introspection (token → user → role → permissions) --- app/api/routers/auth.py | 45 ++++++++++++++++++++++++++++++++++++++++ app/api/routers/users.py | 40 ++++++++++++++++++++++------------- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index bb3702c..efd6196 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -1,11 +1,15 @@ """Auth router.""" from datetime import timedelta +from typing import List + from fastapi import APIRouter, Depends, HTTPException from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel from sqlalchemy.orm import Session from app.core.config import get_db, settings from app.models import models +from app.models.role_permission import Permission, Role, RolePermission from app.schemas import schemas from app.api.deps import Token, verify_password, create_access_token, get_current_user @@ -30,3 +34,44 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = @router.get("/me", response_model=schemas.UserResponse) async def get_me(current_user: models.User = Depends(get_current_user)): return current_user + + +class PermissionIntrospectionResponse(BaseModel): + username: str + role_name: str | None + is_admin: bool + permissions: List[str] + + +@router.get("/me/permissions", response_model=PermissionIntrospectionResponse) +async def get_my_permissions( + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Return the current user's effective permissions for CLI help introspection.""" + perms: List[str] = [] + role_name: str | None = None + + if current_user.is_admin: + # Admin gets all permissions + all_perms = db.query(Permission).order_by(Permission.name).all() + perms = [p.name for p in all_perms] + role_name = "admin" + elif current_user.role_id: + role = db.query(Role).filter(Role.id == current_user.role_id).first() + if role: + role_name = role.name + perm_ids = db.query(RolePermission.permission_id).filter( + RolePermission.role_id == role.id + ).all() + if perm_ids: + pid_list = [p[0] for p in perm_ids] + matched = db.query(Permission).filter(Permission.id.in_(pid_list)).order_by(Permission.name).all() + perms = [p.name for p in matched] + + return PermissionIntrospectionResponse( + username=current_user.username, + role_name=role_name, + is_admin=current_user.is_admin, + permissions=perms, + ) diff --git a/app/api/routers/users.py b/app/api/routers/users.py index 42845e5..fb764f3 100644 --- a/app/api/routers/users.py +++ b/app/api/routers/users.py @@ -102,31 +102,40 @@ def list_users( return db.query(models.User).order_by(models.User.created_at.desc()).offset(skip).limit(limit).all() -@router.get("/{user_id}", response_model=schemas.UserResponse) +def _find_user_by_id_or_username(db: Session, identifier: str) -> models.User | None: + """Resolve a user by numeric id or username string.""" + try: + uid = int(identifier) + return db.query(models.User).filter(models.User.id == uid).first() + except ValueError: + return db.query(models.User).filter(models.User.username == identifier).first() + + +@router.get("/{identifier}", response_model=schemas.UserResponse) def get_user( - user_id: int, + identifier: str, db: Session = Depends(get_db), _: models.User = Depends(require_admin), ): - user = db.query(models.User).filter(models.User.id == user_id).first() + user = _find_user_by_id_or_username(db, identifier) if not user: raise HTTPException(status_code=404, detail="User not found") return user -@router.patch("/{user_id}", response_model=schemas.UserResponse) +@router.patch("/{identifier}", response_model=schemas.UserResponse) def update_user( - user_id: int, + identifier: str, payload: schemas.UserUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(require_admin), ): - user = db.query(models.User).filter(models.User.id == user_id).first() + user = _find_user_by_id_or_username(db, identifier) if not user: raise HTTPException(status_code=404, detail="User not found") if payload.email is not None and payload.email != user.email: - existing = db.query(models.User).filter(models.User.email == payload.email, models.User.id != user_id).first() + existing = db.query(models.User).filter(models.User.email == payload.email, models.User.id != user.id).first() if existing: raise HTTPException(status_code=400, detail="Email already exists") user.email = payload.email @@ -153,13 +162,13 @@ def update_user( return user -@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT) def delete_user( - user_id: int, + identifier: str, db: Session = Depends(get_db), current_user: models.User = Depends(require_admin), ): - user = db.query(models.User).filter(models.User.id == user_id).first() + user = _find_user_by_id_or_username(db, identifier) if not user: raise HTTPException(status_code=404, detail="User not found") if current_user.id == user.id: @@ -186,13 +195,16 @@ class WorkLogResponse(BaseModel): from_attributes = True -@router.get("/{user_id}/worklogs", response_model=List[WorkLogResponse]) +@router.get("/{identifier}/worklogs", response_model=List[WorkLogResponse]) def list_user_worklogs( - user_id: int, + identifier: str, limit: int = 50, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): - if current_user.id != user_id and not current_user.is_admin: + user = _find_user_by_id_or_username(db, identifier) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if current_user.id != user.id and not current_user.is_admin: raise HTTPException(status_code=403, detail="Forbidden") - return db.query(WorkLog).filter(WorkLog.user_id == user_id).order_by(WorkLog.logged_date.desc()).limit(limit).all() + return db.query(WorkLog).filter(WorkLog.user_id == user.id).order_by(WorkLog.logged_date.desc()).limit(limit).all() From 32e79a41d866923e975d0ca86c7dcee17920b275 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 16:06:40 +0000 Subject: [PATCH 02/13] Expose milestone codes in response schema --- app/schemas/schemas.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 08d4388..57bfec9 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -240,6 +240,7 @@ class MilestoneUpdate(BaseModel): class MilestoneResponse(MilestoneBase): id: int + milestone_code: Optional[str] = None project_id: int created_by_id: Optional[int] = None started_at: Optional[datetime] = None From 43af5b29f6ce5e9bbd99732efa959c761885f765 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 18:12:04 +0000 Subject: [PATCH 03/13] feat: add code-first API support for projects, milestones, proposes, tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/routers/milestones.py | 124 +++++++++++++----- app/api/routers/misc.py | 28 +++-- app/api/routers/projects.py | 67 ++++++---- app/api/routers/proposes.py | 107 +++++++++++----- app/api/routers/tasks.py | 229 +++++++++++++++++++++++++++++----- app/schemas/schemas.py | 11 ++ 6 files changed, 442 insertions(+), 124 deletions(-) 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 From 96cbe109ece349a275bde12612c293fcfac8969e Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 18:17:11 +0000 Subject: [PATCH 04/13] Add support code-based action routes --- app/api/routers/misc.py | 148 +++++++++++++++++++++++++++++++++++----- 1 file changed, 131 insertions(+), 17 deletions(-) diff --git a/app/api/routers/misc.py b/app/api/routers/misc.py index 83b9467..fefa4a5 100644 --- a/app/api/routers/misc.py +++ b/app/api/routers/misc.py @@ -13,7 +13,7 @@ from pydantic import BaseModel from app.core.config import get_db from app.api.deps import get_current_user_or_apikey -from app.api.rbac import ensure_can_edit_milestone +from app.api.rbac import check_project_role, ensure_can_edit_milestone from app.models import models from app.models.apikey import APIKey from app.models.activity import ActivityLog @@ -484,26 +484,58 @@ def create_milestone_task(project_code: str, milestone_id: int, task_data: dict, # ============ Supports ============ + +def _find_support_by_id_or_code(db: Session, identifier: str) -> Support | None: + try: + support_id = int(identifier) + support = db.query(Support).filter(Support.id == support_id).first() + if support: + return support + except (TypeError, ValueError): + pass + return db.query(Support).filter(Support.support_code == str(identifier)).first() + + + +def _serialize_support(db: Session, support: Support) -> dict: + project = db.query(models.Project).filter(models.Project.id == support.project_id).first() + milestone = db.query(MilestoneModel).filter(MilestoneModel.id == support.milestone_id).first() + assignee = None + if support.assignee_id: + assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first() + + return { + "id": support.id, + "code": support.support_code, + "support_code": support.support_code, + "title": support.title, + "description": support.description, + "status": support.status.value if hasattr(support.status, "value") else support.status, + "priority": support.priority.value if hasattr(support.priority, "value") else support.priority, + "project_id": support.project_id, + "project_code": project.project_code if project else None, + "milestone_id": support.milestone_id, + "milestone_code": milestone.milestone_code if milestone else None, + "reporter_id": support.reporter_id, + "assignee_id": support.assignee_id, + "taken_by": assignee.username if assignee else None, + "created_at": support.created_at, + "updated_at": support.updated_at, + } + + @router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"]) def list_supports(project_code: str, milestone_id: int, db: Session = Depends(get_db)): 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") - + supports = db.query(Support).filter( Support.project_id == project.id, Support.milestone_id == milestone_id ).all() - - return [{ - "id": s.id, - "title": s.title, - "description": s.description, - "status": s.status.value, - "priority": s.priority.value, - "assignee_id": s.assignee_id, - "created_at": s.created_at, - } for s in supports] + + return [_serialize_support(db, s) for s in supports] @router.post("/supports/{project_code}/{milestone_id}", status_code=status.HTTP_201_CREATED, tags=["Supports"]) @@ -511,19 +543,19 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db: 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") - + ms = db.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() if not ms: raise HTTPException(status_code=404, detail="Milestone not found") - + if ms.status and hasattr(ms.status, "value") and ms.status.value == "undergoing": raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing") - + milestone_code = ms.milestone_code or f"m{ms.id}" max_support = db.query(Support).filter(Support.milestone_id == milestone_id).order_by(Support.id.desc()).first() next_num = (max_support.id + 1) if max_support else 1 support_code = f"{milestone_code}:S{next_num:05x}" - + support = Support( title=support_data.get("title"), description=support_data.get("description"), @@ -537,7 +569,89 @@ def create_support(project_code: str, milestone_id: int, support_data: dict, db: db.add(support) db.commit() db.refresh(support) - return support + return _serialize_support(db, support) + + +@router.get("/supports/{support_id}", tags=["Supports"]) +def get_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + support = _find_support_by_id_or_code(db, support_id) + if not support: + raise HTTPException(status_code=404, detail="Support not found") + check_project_role(db, current_user.id, support.project_id, min_role="viewer") + return _serialize_support(db, support) + + +@router.patch("/supports/{support_id}", tags=["Supports"]) +def update_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + support = _find_support_by_id_or_code(db, support_id) + if not support: + raise HTTPException(status_code=404, detail="Support not found") + check_project_role(db, current_user.id, support.project_id, min_role="dev") + + allowed_fields = {"title", "description", "status", "priority"} + updated = False + for field, value in support_data.items(): + if field not in allowed_fields: + continue + if field == "status" and value is not None: + value = SupportStatus(value) + if field == "priority" and value is not None: + value = SupportPriority(value) + setattr(support, field, value) + updated = True + + if not updated: + raise HTTPException(status_code=400, detail="No supported fields to update") + + db.commit() + db.refresh(support) + return _serialize_support(db, support) + + +@router.delete("/supports/{support_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Supports"]) +def delete_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + support = _find_support_by_id_or_code(db, support_id) + if not support: + raise HTTPException(status_code=404, detail="Support not found") + check_project_role(db, current_user.id, support.project_id, min_role="dev") + db.delete(support) + db.commit() + return None + + +@router.post("/supports/{support_id}/take", tags=["Supports"]) +def take_support(support_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + support = _find_support_by_id_or_code(db, support_id) + if not support: + raise HTTPException(status_code=404, detail="Support not found") + check_project_role(db, current_user.id, support.project_id, min_role="dev") + + if support.assignee_id and support.assignee_id != current_user.id: + assignee = db.query(models.User).filter(models.User.id == support.assignee_id).first() + assignee_name = assignee.username if assignee else str(support.assignee_id) + raise HTTPException(status_code=409, detail=f"Support is already taken by {assignee_name}") + + support.assignee_id = current_user.id + db.commit() + db.refresh(support) + return _serialize_support(db, support) + + +@router.post("/supports/{support_id}/transition", tags=["Supports"]) +def transition_support(support_id: str, support_data: dict, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + support = _find_support_by_id_or_code(db, support_id) + if not support: + raise HTTPException(status_code=404, detail="Support not found") + check_project_role(db, current_user.id, support.project_id, min_role="dev") + + status_value = support_data.get("status") + if not status_value: + raise HTTPException(status_code=400, detail="status is required") + + support.status = SupportStatus(status_value) + db.commit() + db.refresh(support) + return _serialize_support(db, support) # ============ Meetings ============ From 86911286c08cc053ee5e52fa5520f16cf7fac481 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 19:18:20 +0000 Subject: [PATCH 05/13] feat: add code-based meetings router with participant/attend support - New dedicated meetings.py router with full CRUD (list/get/create/update/delete) - All endpoints accept meeting_code or numeric id - MeetingParticipant model for tracking meeting attendance - POST /meetings/{id}/attend adds current user to participant list - Serialization includes participants list, project_code, milestone_code - Creator auto-added as participant on meeting creation - Registered in main.py alongside existing routers --- app/api/routers/meetings.py | 289 ++++++++++++++++++++++++++++++++++++ app/main.py | 2 + app/models/meeting.py | 18 ++- 3 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 app/api/routers/meetings.py diff --git a/app/api/routers/meetings.py b/app/api/routers/meetings.py new file mode 100644 index 0000000..27b13bd --- /dev/null +++ b/app/api/routers/meetings.py @@ -0,0 +1,289 @@ +"""Meetings router — code-first CRUD with participant/attend support.""" +import math +from datetime import datetime +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from pydantic import BaseModel + +from app.core.config import get_db +from app.models import models +from app.models.meeting import Meeting, MeetingStatus, MeetingPriority, MeetingParticipant +from app.models.milestone import Milestone +from app.api.deps import get_current_user_or_apikey +from app.api.rbac import check_project_role + +router = APIRouter(tags=["Meetings"]) + + +# ---- helpers ---- + +def _find_meeting_by_id_or_code(db: Session, identifier: str) -> Meeting | None: + try: + mid = int(identifier) + meeting = db.query(Meeting).filter(Meeting.id == mid).first() + if meeting: + return meeting + except (ValueError, TypeError): + pass + return db.query(Meeting).filter(Meeting.meeting_code == str(identifier)).first() + + +def _resolve_project_id(db: Session, project_code: str | None) -> int | None: + 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_code: str | None, project_id: int | None) -> Milestone | None: + if not milestone_code: + return None + query = db.query(Milestone).filter(Milestone.milestone_code == milestone_code) + if project_id: + query = query.filter(Milestone.project_id == project_id) + ms = query.first() + if not ms: + raise HTTPException(status_code=404, detail="Milestone not found") + return ms + + +def _get_participant_usernames(db: Session, meeting: Meeting) -> list[str]: + parts = db.query(MeetingParticipant).filter(MeetingParticipant.meeting_id == meeting.id).all() + usernames = [] + for p in parts: + user = db.query(models.User).filter(models.User.id == p.user_id).first() + if user: + usernames.append(user.username) + return usernames + + +def _serialize_meeting(db: Session, meeting: Meeting) -> dict: + project = db.query(models.Project).filter(models.Project.id == meeting.project_id).first() + milestone = db.query(Milestone).filter(Milestone.id == meeting.milestone_id).first() + return { + "id": meeting.id, + "code": meeting.meeting_code, + "meeting_code": meeting.meeting_code, + "title": meeting.title, + "description": meeting.description, + "status": meeting.status.value if hasattr(meeting.status, "value") else meeting.status, + "priority": meeting.priority.value if hasattr(meeting.priority, "value") else meeting.priority, + "project_id": meeting.project_id, + "project_code": project.project_code if project else None, + "milestone_id": meeting.milestone_id, + "milestone_code": milestone.milestone_code if milestone else None, + "reporter_id": meeting.reporter_id, + "meeting_time": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None, + "scheduled_at": meeting.scheduled_at.isoformat() if meeting.scheduled_at else None, + "duration_minutes": meeting.duration_minutes, + "participants": _get_participant_usernames(db, meeting), + "created_at": meeting.created_at.isoformat() if meeting.created_at else None, + "updated_at": meeting.updated_at.isoformat() if meeting.updated_at else None, + } + + +# ---- CRUD ---- + +class MeetingCreateBody(BaseModel): + project_code: str + title: str + milestone_code: Optional[str] = None + description: Optional[str] = None + meeting_time: Optional[str] = None + duration_minutes: Optional[int] = None + + +@router.post("/meetings", status_code=status.HTTP_201_CREATED) +def create_meeting( + body: MeetingCreateBody, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + project_id = _resolve_project_id(db, body.project_code) + if not project_id: + raise HTTPException(status_code=400, detail="project_code is required") + check_project_role(db, current_user.id, project_id, min_role="dev") + + milestone = _resolve_milestone(db, body.milestone_code, project_id) + if not milestone: + # If no milestone_code, try to use the first active milestone + milestone = db.query(Milestone).filter( + Milestone.project_id == project_id, + ).order_by(Milestone.id.desc()).first() + if not milestone: + raise HTTPException(status_code=400, detail="No milestone available for project") + + milestone_code = milestone.milestone_code or f"m{milestone.id}" + max_meeting = db.query(Meeting).filter(Meeting.milestone_id == milestone.id).order_by(Meeting.id.desc()).first() + next_num = (max_meeting.id + 1) if max_meeting else 1 + meeting_code = f"{milestone_code}:M{next_num:05x}" + + scheduled_at = None + if body.meeting_time: + try: + scheduled_at = datetime.fromisoformat(body.meeting_time.replace("Z", "+00:00")) + except Exception: + raise HTTPException(status_code=400, detail="Invalid meeting_time format") + + meeting = Meeting( + title=body.title, + description=body.description, + status=MeetingStatus.SCHEDULED, + priority=MeetingPriority.MEDIUM, + project_id=project_id, + milestone_id=milestone.id, + reporter_id=current_user.id, + meeting_code=meeting_code, + scheduled_at=scheduled_at, + duration_minutes=body.duration_minutes, + ) + db.add(meeting) + db.commit() + db.refresh(meeting) + + # Auto-add creator as participant + participant = MeetingParticipant(meeting_id=meeting.id, user_id=current_user.id) + db.add(participant) + db.commit() + + return _serialize_meeting(db, meeting) + + +@router.get("/meetings") +def list_meetings( + project: str = None, + status_value: str = Query(None, alias="status"), + order_by: str = None, + page: int = 1, + page_size: int = 50, + db: Session = Depends(get_db), +): + query = db.query(Meeting) + + if project: + project_id = _resolve_project_id(db, project) + if project_id: + query = query.filter(Meeting.project_id == project_id) + + if status_value: + query = query.filter(Meeting.status == status_value) + + sort_fields = { + "created": Meeting.created_at, + "created_at": Meeting.created_at, + "due-date": Meeting.scheduled_at, + "scheduled_at": Meeting.scheduled_at, + "name": Meeting.title, + "title": Meeting.title, + } + sort_col = sort_fields.get(order_by, Meeting.created_at) + query = query.order_by(sort_col.desc()) + + total = query.count() + page = max(1, page) + page_size = min(max(1, page_size), 200) + total_pages = math.ceil(total / page_size) if total else 1 + items = query.offset((page - 1) * page_size).limit(page_size).all() + + return { + "items": [_serialize_meeting(db, m) for m in items], + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + } + + +@router.get("/meetings/{meeting_id}") +def get_meeting(meeting_id: str, db: Session = Depends(get_db)): + meeting = _find_meeting_by_id_or_code(db, meeting_id) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + return _serialize_meeting(db, meeting) + + +class MeetingUpdateBody(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + meeting_time: Optional[str] = None + duration_minutes: Optional[int] = None + + +@router.patch("/meetings/{meeting_id}") +def update_meeting( + meeting_id: str, + body: MeetingUpdateBody, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + meeting = _find_meeting_by_id_or_code(db, meeting_id) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + check_project_role(db, current_user.id, meeting.project_id, min_role="dev") + + update_data = body.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="No supported fields to update") + + if "title" in update_data and update_data["title"] is not None: + meeting.title = update_data["title"] + if "description" in update_data: + meeting.description = update_data["description"] + if "status" in update_data and update_data["status"] is not None: + meeting.status = MeetingStatus(update_data["status"]) + if "meeting_time" in update_data and update_data["meeting_time"] is not None: + try: + meeting.scheduled_at = datetime.fromisoformat(update_data["meeting_time"].replace("Z", "+00:00")) + except Exception: + raise HTTPException(status_code=400, detail="Invalid meeting_time format") + if "duration_minutes" in update_data: + meeting.duration_minutes = update_data["duration_minutes"] + + db.commit() + db.refresh(meeting) + return _serialize_meeting(db, meeting) + + +@router.delete("/meetings/{meeting_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_meeting( + meeting_id: str, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + meeting = _find_meeting_by_id_or_code(db, meeting_id) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + check_project_role(db, current_user.id, meeting.project_id, min_role="dev") + db.delete(meeting) + db.commit() + return None + + +# ---- Attend ---- + +@router.post("/meetings/{meeting_id}/attend") +def attend_meeting( + meeting_id: str, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + meeting = _find_meeting_by_id_or_code(db, meeting_id) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + check_project_role(db, current_user.id, meeting.project_id, min_role="viewer") + + existing = db.query(MeetingParticipant).filter( + MeetingParticipant.meeting_id == meeting.id, + MeetingParticipant.user_id == current_user.id, + ).first() + if existing: + return _serialize_meeting(db, meeting) + + participant = MeetingParticipant(meeting_id=meeting.id, user_id=current_user.id) + db.add(participant) + db.commit() + return _serialize_meeting(db, meeting) diff --git a/app/main.py b/app/main.py index 524f46c..2d6b19a 100644 --- a/app/main.py +++ b/app/main.py @@ -39,6 +39,7 @@ from app.api.routers.milestones import router as milestones_router from app.api.routers.roles import router as roles_router from app.api.routers.proposes import router as proposes_router from app.api.routers.milestone_actions import router as milestone_actions_router +from app.api.routers.meetings import router as meetings_router app.include_router(auth_router) app.include_router(tasks_router) @@ -52,6 +53,7 @@ app.include_router(milestones_router) app.include_router(roles_router) app.include_router(proposes_router) app.include_router(milestone_actions_router) +app.include_router(meetings_router) # Auto schema migration for lightweight deployments diff --git a/app/models/meeting.py b/app/models/meeting.py index 739476d..e94f49d 100644 --- a/app/models/meeting.py +++ b/app/models/meeting.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, UniqueConstraint from sqlalchemy.orm import relationship from sqlalchemy.sql import func from app.core.config import Base @@ -35,3 +35,19 @@ class Meeting(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + participants = relationship("MeetingParticipant", back_populates="meeting", cascade="all, delete-orphan") + + +class MeetingParticipant(Base): + __tablename__ = "meeting_participants" + __table_args__ = ( + UniqueConstraint("meeting_id", "user_id", name="uq_meeting_participant"), + ) + + id = Column(Integer, primary_key=True, index=True) + meeting_id = Column(Integer, ForeignKey("meetings.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + joined_at = Column(DateTime(timezone=True), server_default=func.now()) + + meeting = relationship("Meeting", back_populates="participants") From f45f5957f4291cb6ed2a6f015064a78e61157086 Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 19:52:09 +0000 Subject: [PATCH 06/13] docs: refresh openclaw plugin architecture docs --- docs/OPENCLAW_PLUGIN_DEV_PLAN.md | 17 +++++++++-------- docs/openclaw-monitor-plugin-plan.md | 14 ++++++++------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/OPENCLAW_PLUGIN_DEV_PLAN.md b/docs/OPENCLAW_PLUGIN_DEV_PLAN.md index 0ef7222..3b684a1 100644 --- a/docs/OPENCLAW_PLUGIN_DEV_PLAN.md +++ b/docs/OPENCLAW_PLUGIN_DEV_PLAN.md @@ -5,9 +5,10 @@ ## 当前架构 - HarborForge Monitor Backend 提供服务器注册与遥测接收接口 -- OpenClaw Gateway 加载 `harborforge-monitor` 插件 -- 插件在 `gateway_start` 时启动 sidecar (`server/telemetry.mjs`) -- sidecar 通过 **HTTP + X-API-Key** 向 Backend 上报遥测 +- OpenClaw Gateway 加载 `harbor-forge` 插件 +- 旧 sidecar (`server/telemetry.mjs`) 已移除 +- 插件通过 Gateway/runtime 路径直接提供 OpenClaw 元数据 +- Monitor 可选通过本地 `monitor_port` 桥接读取补充信息 ## 当前后端接口 @@ -39,7 +40,7 @@ ## 数据语义 - `openclaw_version`: 远程服务器上的 OpenClaw 版本 -- `plugin_version`: 远程服务器上的 harborforge-monitor 插件版本 +- `plugin_version`: 远程服务器上的 `harbor-forge` 插件版本 ## 已废弃内容 @@ -67,10 +68,10 @@ Monitor 管理页应提供: 1. 管理员在 Monitor 中注册服务器 2. 管理员为服务器生成 API Key 3. 将 API Key 写入 `~/.openclaw/openclaw.json` -4. 重启 OpenClaw Gateway -5. 插件启动 sidecar -6. sidecar 定时向 `/monitor/server/heartbeat-v2` 上报 +4. 如需本地桥接补充信息,配置 `monitor_port` +5. 重启 OpenClaw Gateway +6. 插件直接参与遥测链路;若本地桥接可达,则额外提供 OpenClaw 补充元数据 ## 备注 -当前保留了对旧 challenge 数据表的**删除兼容清理**(仅为兼容老数据库中的遗留数据),但不再保留 challenge 功能入口与运行时逻辑。 +当前保留了对旧 challenge 数据表的**删除兼容清理**(仅为兼容老数据库中的遗留数据),但不再保留 challenge 功能入口、WebSocket 方案或 sidecar 运行时逻辑。 diff --git a/docs/openclaw-monitor-plugin-plan.md b/docs/openclaw-monitor-plugin-plan.md index 4dd1c4c..30d196c 100644 --- a/docs/openclaw-monitor-plugin-plan.md +++ b/docs/openclaw-monitor-plugin-plan.md @@ -38,13 +38,14 @@ ## 语义 - `openclaw_version`: 远程主机上的 OpenClaw 版本 -- `plugin_version`: harborforge-monitor 插件版本 +- `plugin_version`: `harbor-forge` 插件版本 ## 插件生命周期 -- 插件注册到 Gateway -- 在 `gateway_start` 启动 `server/telemetry.mjs` -- 在 `gateway_stop` 停止 sidecar +- 插件注册名为 `harbor-forge` +- 不再启动独立 `server/telemetry.mjs` sidecar +- 插件直接通过 Gateway/runtime 路径暴露 OpenClaw 元数据 +- 如配置了 `monitor_port`,插件还可通过本地桥接与 HarborForge.Monitor 交互 ## 配置位置 @@ -54,13 +55,14 @@ { "plugins": { "entries": { - "harborforge-monitor": { + "harbor-forge": { "enabled": true, "config": { "enabled": true, "backendUrl": "http://127.0.0.1:8000", "identifier": "vps.t1", - "apiKey": "your-api-key" + "apiKey": "your-api-key", + "monitor_port": 9100 } } } From 3ff9132596858402f9845c7eea7da1009606515d Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 20:28:28 +0000 Subject: [PATCH 07/13] feat: enrich member/comment/propose APIs with usernames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProjectMemberResponse now includes username and full_name - Comment list endpoint returns author_username - ProposeResponse now includes created_by_username - All serializers resolve User objects to surface human-readable names - Supports frontend code-first migration (TODO §3.1/3.2) --- app/api/routers/comments.py | 17 +++++++++++++++-- app/api/routers/projects.py | 3 +++ app/api/routers/proposes.py | 32 +++++++++++++++++++++++++------- app/schemas/schemas.py | 3 +++ 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/app/api/routers/comments.py b/app/api/routers/comments.py index 39e7b05..d104691 100644 --- a/app/api/routers/comments.py +++ b/app/api/routers/comments.py @@ -50,9 +50,22 @@ def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db) return db_comment -@router.get("/tasks/{task_id}/comments", response_model=List[schemas.CommentResponse]) +@router.get("/tasks/{task_id}/comments") def list_comments(task_id: int, db: Session = Depends(get_db)): - return db.query(models.Comment).filter(models.Comment.task_id == task_id).all() + comments = db.query(models.Comment).filter(models.Comment.task_id == task_id).all() + result = [] + for c in comments: + author = db.query(models.User).filter(models.User.id == c.author_id).first() + result.append({ + "id": c.id, + "content": c.content, + "task_id": c.task_id, + "author_id": c.author_id, + "author_username": author.username if author else None, + "created_at": c.created_at, + "updated_at": c.updated_at, + }) + return result @router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse) diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index faf3b5f..fa33080 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -332,9 +332,12 @@ def list_project_members(project_id: str, db: Session = Depends(get_db)): role = db.query(Role).filter(Role.id == m.role_id).first() if role: role_name = role.name + user = db.query(models.User).filter(models.User.id == m.user_id).first() result.append({ "id": m.id, "user_id": m.user_id, + "username": user.username if user else None, + "full_name": user.full_name if user else None, "project_id": m.project_id, "role": role_name }) diff --git a/app/api/routers/proposes.py b/app/api/routers/proposes.py index f8877cf..2111b12 100644 --- a/app/api/routers/proposes.py +++ b/app/api/routers/proposes.py @@ -17,6 +17,24 @@ from app.services.activity import log_activity router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes"]) +def _serialize_propose(db: Session, propose: Propose) -> dict: + """Serialize propose with created_by_username.""" + creator = db.query(models.User).filter(models.User.id == propose.created_by_id).first() if propose.created_by_id else None + return { + "id": propose.id, + "title": propose.title, + "description": propose.description, + "propose_code": propose.propose_code, + "status": propose.status.value if hasattr(propose.status, "value") else propose.status, + "project_id": propose.project_id, + "created_by_id": propose.created_by_id, + "created_by_username": creator.username if creator else None, + "feat_task_id": propose.feat_task_id, + "created_at": propose.created_at, + "updated_at": propose.updated_at, + } + + def _find_project(db, identifier): """Look up project by numeric id or project_code.""" try: @@ -92,7 +110,7 @@ def list_proposes( .order_by(Propose.id.desc()) .all() ) - return proposes + return [_serialize_propose(db, p) for p in proposes] @router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED) @@ -123,7 +141,7 @@ def create_propose( log_activity(db, "create", "propose", propose.id, user_id=current_user.id, details={"title": propose.title}) - return propose + return _serialize_propose(db, propose) @router.get("/{propose_id}", response_model=schemas.ProposeResponse) @@ -140,7 +158,7 @@ def get_propose( propose = _find_propose(db, propose_id, project.id) if not propose: raise HTTPException(status_code=404, detail="Propose not found") - return propose + return _serialize_propose(db, propose) @router.patch("/{propose_id}", response_model=schemas.ProposeResponse) @@ -177,7 +195,7 @@ def update_propose( log_activity(db, "update", "propose", propose.id, user_id=current_user.id, details=data) - return propose + return _serialize_propose(db, propose) # ---- Actions ---- @@ -256,7 +274,7 @@ def accept_propose( "task_code": task_code, }) - return propose + return _serialize_propose(db, propose) class RejectRequest(schemas.BaseModel): @@ -293,7 +311,7 @@ def reject_propose( "reason": body.reason if body else None, }) - return propose + return _serialize_propose(db, propose) @router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse) @@ -323,4 +341,4 @@ def reopen_propose( log_activity(db, "reopen", "propose", propose.id, user_id=current_user.id) - return propose + return _serialize_propose(db, propose) diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index fa7c248..90b2d09 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -208,6 +208,8 @@ class ProjectMemberCreate(ProjectMemberBase): class ProjectMemberResponse(BaseModel): id: int user_id: int + username: Optional[str] = None + full_name: Optional[str] = None project_id: int role: str = "dev" @@ -290,6 +292,7 @@ class ProposeResponse(ProposeBase): status: ProposeStatusEnum project_id: int created_by_id: Optional[int] = None + created_by_username: Optional[str] = None feat_task_id: Optional[str] = None created_at: datetime updated_at: Optional[datetime] = None From b351075561fdff999f38bea6925e6daf600405df Mon Sep 17 00:00:00 2001 From: zhi Date: Sat, 21 Mar 2026 21:38:08 +0000 Subject: [PATCH 08/13] chore: remove legacy Python CLI and update README - Remove cli.py (superseded by Go-based hf CLI) - Update README to point to HarborForge.Cli for CLI usage --- README.md | 24 +--- cli.py | 408 ------------------------------------------------------ 2 files changed, 2 insertions(+), 430 deletions(-) delete mode 100755 cli.py diff --git a/README.md b/README.md index e1ea154..175a709 100644 --- a/README.md +++ b/README.md @@ -98,29 +98,9 @@ Agent/人类协同任务管理平台 - FastAPI 后端 ## CLI -```bash -# 环境变量 -export HARBORFORGE_URL=http://localhost:8000 -export HARBORFORGE_TOKEN= +The legacy Python CLI (`cli.py`) has been retired. Use the Go-based `hf` CLI instead. -# 命令 -python3 cli.py login -python3 cli.py issues [-p project_id] [-t type] [-s status] -python3 cli.py create-issue "title" -p 1 -r 1 [-t resolution --summary "..." --positions "..." --pending "..."] -python3 cli.py search "keyword" -python3 cli.py transition -python3 cli.py stats [-p project_id] -python3 cli.py projects -python3 cli.py users -python3 cli.py milestones [-p project_id] -python3 cli.py milestone-progress -python3 cli.py notifications -u [--unread] -python3 cli.py overdue [-p project_id] -python3 cli.py log-time [-d "description"] -python3 cli.py worklogs -python3 cli.py health -python3 cli.py version -``` +See [HarborForge.Cli](../HarborForge.Cli/README.md) for installation and usage. ## 技术栈 diff --git a/cli.py b/cli.py deleted file mode 100755 index 8346916..0000000 --- a/cli.py +++ /dev/null @@ -1,408 +0,0 @@ -#!/usr/bin/env python3 -"""HarborForge CLI - 简易命令行工具""" - -import argparse -import json -import os -import sys -import urllib.error -import urllib.parse -import urllib.request - -BASE_URL = os.environ.get("HARBORFORGE_URL", "http://localhost:8000") -TOKEN = os.environ.get("HARBORFORGE_TOKEN", "") - - -STATUS_ICON = { - "open": "🟢", - "pending": "🟡", - "freeze": "🧊", - "undergoing": "🔵", - "completed": "✅", - "closed": "⚫", -} -TYPE_ICON = { - "resolution": "⚖️", - "story": "📖", - "test": "🧪", - "issue": "📌", - "maintenance": "🛠️", - "research": "🔬", - "review": "🧐", -} - - -def _request(method, path, data=None): - url = f"{BASE_URL}{path}" - headers = {"Content-Type": "application/json"} - if TOKEN: - headers["Authorization"] = f"Bearer {TOKEN}" - - body = json.dumps(data).encode() if data is not None else None - req = urllib.request.Request(url, data=body, headers=headers, method=method) - - try: - with urllib.request.urlopen(req) as resp: - if resp.status == 204: - return None - raw = resp.read() - return json.loads(raw) if raw else None - except urllib.error.HTTPError as e: - print(f"Error {e.code}: {e.read().decode()}", file=sys.stderr) - sys.exit(1) - - -def cmd_login(args): - data = urllib.parse.urlencode({"username": args.username, "password": args.password}).encode() - req = urllib.request.Request(f"{BASE_URL}/auth/token", data=data, method="POST") - req.add_header("Content-Type", "application/x-www-form-urlencoded") - try: - with urllib.request.urlopen(req) as resp: - result = json.loads(resp.read()) - print(f"Token: {result['access_token']}") - print(f"\nExport it:\nexport HARBORFORGE_TOKEN={result['access_token']}") - except urllib.error.HTTPError as e: - print(f"Login failed: {e.read().decode()}", file=sys.stderr) - sys.exit(1) - - -def cmd_tasks(args): - params = [] - if args.project: - params.append(f"project_id={args.project}") - if args.type: - params.append(f"task_type={args.type}") - if args.status: - params.append(f"task_status={args.status}") - qs = f"?{'&'.join(params)}" if params else "" - result = _request("GET", f"/tasks{qs}") - items = result.get("items", result if isinstance(result, list) else []) - for task in items: - status_icon = STATUS_ICON.get(task["status"], "❓") - type_icon = TYPE_ICON.get(task.get("task_type"), "📌") - print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}") - - -def cmd_task_create(args): - data = { - "title": args.title, - "project_id": args.project, - "milestone_id": args.milestone, - "reporter_id": args.reporter, - "task_type": args.type, - "priority": args.priority or "medium", - } - if args.description: - data["description"] = args.description - if args.assignee: - data["assignee_id"] = args.assignee - if args.subtype: - data["task_subtype"] = args.subtype - - if args.type == "resolution": - if args.summary: - data["resolution_summary"] = args.summary - if args.positions: - data["positions"] = args.positions - if args.pending: - data["pending_matters"] = args.pending - - result = _request("POST", "/tasks", data) - print(f"Created task #{result['id']}: {result['title']}") - - -def cmd_projects(args): - projects = _request("GET", "/projects") - for project in projects: - print(f" #{project['id']} {project['name']} - {project.get('description', '')}") - - -def cmd_users(args): - users = _request("GET", "/users") - for user in users: - role = "👑" if user["is_admin"] else "👤" - print(f" {role} #{user['id']} {user['username']} ({user.get('full_name', '')})") - - -def cmd_version(args): - result = _request("GET", "/version") - print(f"{result['name']} v{result['version']}") - - -def cmd_health(args): - result = _request("GET", "/health") - print(f"Status: {result['status']}") - - -def cmd_search(args): - params = [f"q={urllib.parse.quote(args.query)}"] - if args.project: - params.append(f"project_id={args.project}") - result = _request("GET", f"/search/tasks?{'&'.join(params)}") - items = result.get("items", result if isinstance(result, list) else []) - if not items: - print(" No results found.") - return - for task in items: - status_icon = STATUS_ICON.get(task["status"], "❓") - type_icon = TYPE_ICON.get(task.get("task_type"), "📌") - print(f" {status_icon} {type_icon} #{task['id']} [{task['priority']}] {task['title']}") - - -def cmd_transition(args): - body = {} - if args.comment: - body["comment"] = args.comment - result = _request("POST", f"/tasks/{args.task_id}/transition?new_status={args.status}", body or None) - print(f"Task #{result['id']} transitioned to: {result['status']}") - - -# ── Propose commands ────────────────────────────────────────────── - -def cmd_proposes(args): - if not args.project: - print("Error: --project is required for proposes", file=sys.stderr) - sys.exit(1) - result = _request("GET", f"/projects/{args.project}/proposes") - items = result if isinstance(result, list) else result.get("items", []) - if not items: - print(" No proposes found.") - return - for p in items: - status_icon = STATUS_ICON.get(p["status"], "❓") - feat = f" → task {p['feat_task_id']}" if p.get("feat_task_id") else "" - print(f" {status_icon} 💡 {p['propose_code']} {p['title']}{feat}") - - -def cmd_propose_create(args): - data = {"title": args.title} - if args.description: - data["description"] = args.description - result = _request("POST", f"/projects/{args.project}/proposes", data) - print(f"Created propose {result['propose_code']}: {result['title']}") - - -def cmd_propose_accept(args): - result = _request("POST", f"/projects/{args.project}/proposes/{args.propose_id}/accept?milestone_id={args.milestone}") - print(f"Propose #{args.propose_id} accepted → task {result.get('feat_task_id', '?')}") - - -def cmd_propose_reject(args): - data = {} - if args.reason: - data["reason"] = args.reason - result = _request("POST", f"/projects/{args.project}/proposes/{args.propose_id}/reject", data or None) - print(f"Propose #{args.propose_id} rejected") - - -def cmd_propose_reopen(args): - result = _request("POST", f"/projects/{args.project}/proposes/{args.propose_id}/reopen") - print(f"Propose #{args.propose_id} reopened") - - -def cmd_stats(args): - params = f"?project_id={args.project}" if args.project else "" - stats = _request("GET", f"/dashboard/stats{params}") - print(f"Total: {stats['total_tasks']}") - print("By status:") - for status_name, count in stats["by_status"].items(): - if count > 0: - print(f" {status_name}: {count}") - print("By type:") - for task_type, count in stats["by_type"].items(): - if count > 0: - print(f" {task_type}: {count}") - - -def cmd_milestones(args): - params = [] - if args.project: - params.append(f"project_id={args.project}") - if args.status: - params.append(f"status={args.status}") - qs = f"?{'&'.join(params)}" if params else "" - milestones = _request("GET", f"/milestones{qs}") - if not milestones: - print(" No milestones found.") - return - for milestone in milestones: - status_icon = STATUS_ICON.get(milestone["status"], "⚪") - due = f" (due: {milestone['due_date'][:10]})" if milestone.get("due_date") else "" - print(f" {status_icon} #{milestone['id']} {milestone['title']}{due}") - - -def cmd_milestone_progress(args): - result = _request("GET", f"/milestones/{args.milestone_id}/progress") - bar_len = 20 - filled = int(bar_len * result["progress_pct"] / 100) - bar = "█" * filled + "░" * (bar_len - filled) - print(f" {result['title']}") - print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_tasks']})") - - -def cmd_notifications(args): - params = [] - if args.unread: - params.append("unread_only=true") - qs = f"?{'&'.join(params)}" if params else "" - notifications = _request("GET", f"/notifications{qs}") - if not notifications: - print(" No notifications.") - return - for notification in notifications: - icon = "🔴" if not notification["is_read"] else "⚪" - print(f" {icon} [{notification['type']}] {notification.get('message') or notification['title']}") - - -def cmd_overdue(args): - print("Overdue tasks are not supported by the current milestone-based task schema.") - - -def cmd_log_time(args): - from datetime import datetime - - data = { - "task_id": args.task_id, - "user_id": args.user_id, - "hours": args.hours, - "logged_date": datetime.utcnow().isoformat(), - } - if args.desc: - data["description"] = args.desc - result = _request("POST", "/worklogs", data) - print(f"Logged {result['hours']}h on task #{result['task_id']} (log #{result['id']})") - - -def cmd_worklogs(args): - logs = _request("GET", f"/tasks/{args.task_id}/worklogs") - for log in logs: - desc = f" - {log['description']}" if log.get("description") else "" - print(f" [{log['id']}] {log['hours']}h by user#{log['user_id']} on {log['logged_date']}{desc}") - summary = _request("GET", f"/tasks/{args.task_id}/worklogs/summary") - print(f" Total: {summary['total_hours']}h ({summary['log_count']} logs)") - - -def main(): - parser = argparse.ArgumentParser(description="HarborForge CLI") - sub = parser.add_subparsers(dest="command") - - p_login = sub.add_parser("login", help="Login and get token") - p_login.add_argument("username") - p_login.add_argument("password") - - p_tasks = sub.add_parser("tasks", aliases=["issues"], help="List tasks") - p_tasks.add_argument("--project", "-p", type=int) - p_tasks.add_argument("--type", "-t", choices=["story", "test", "resolution", "issue", "maintenance", "research", "review"]) - p_tasks.add_argument("--status", "-s", choices=["open", "pending", "undergoing", "completed", "closed"]) - - p_create = sub.add_parser("create-task", aliases=["create-issue"], help="Create a task") - p_create.add_argument("title") - p_create.add_argument("--project", "-p", type=int, required=True) - p_create.add_argument("--milestone", "-m", type=int, required=True) - p_create.add_argument("--reporter", "-r", type=int, required=True) - p_create.add_argument("--type", "-t", default="issue", choices=["story", "test", "resolution", "issue", "maintenance", "research", "review"]) - p_create.add_argument("--subtype") - p_create.add_argument("--priority", choices=["low", "medium", "high", "critical"]) - p_create.add_argument("--description", "-d") - p_create.add_argument("--assignee", "-a", type=int) - p_create.add_argument("--summary") - p_create.add_argument("--positions") - p_create.add_argument("--pending") - - sub.add_parser("projects", help="List projects") - sub.add_parser("users", help="List users") - sub.add_parser("version", help="Show version") - sub.add_parser("health", help="Health check") - - p_search = sub.add_parser("search", help="Search tasks") - p_search.add_argument("query") - p_search.add_argument("--project", "-p", type=int) - - p_trans = sub.add_parser("transition", help="Transition task status") - p_trans.add_argument("task_id", type=int) - p_trans.add_argument("status", choices=["open", "pending", "undergoing", "completed", "closed"]) - p_trans.add_argument("--comment", "-c", help="Comment (required for undergoing→completed)") - - p_stats = sub.add_parser("stats", help="Dashboard stats") - p_stats.add_argument("--project", "-p", type=int) - - p_ms = sub.add_parser("milestones", help="List milestones") - p_ms.add_argument("--project", "-p", type=int) - p_ms.add_argument("--status", "-s", choices=["open", "freeze", "undergoing", "completed", "closed"]) - - p_msp = sub.add_parser("milestone-progress", help="Show milestone progress") - p_msp.add_argument("milestone_id", type=int) - - p_notif = sub.add_parser("notifications", help="List notifications for current token user") - p_notif.add_argument("--unread", action="store_true") - - p_overdue = sub.add_parser("overdue", help="Explain overdue-task support status") - p_overdue.add_argument("--project", "-p", type=int) - - p_logtime = sub.add_parser("log-time", help="Log time on a task") - p_logtime.add_argument("task_id", type=int) - p_logtime.add_argument("user_id", type=int) - p_logtime.add_argument("hours", type=float) - p_logtime.add_argument("--desc", "-d", type=str) - - p_worklogs = sub.add_parser("worklogs", help="List work logs for a task") - p_worklogs.add_argument("task_id", type=int) - - # ── Propose subcommands ── - p_proposes = sub.add_parser("proposes", help="List proposes for a project") - p_proposes.add_argument("--project", "-p", type=int, required=True) - - p_pc = sub.add_parser("propose-create", help="Create a propose") - p_pc.add_argument("title") - p_pc.add_argument("--project", "-p", type=int, required=True) - p_pc.add_argument("--description", "-d") - - p_pa = sub.add_parser("propose-accept", help="Accept a propose into a milestone") - p_pa.add_argument("propose_id", type=int) - p_pa.add_argument("--project", "-p", type=int, required=True) - p_pa.add_argument("--milestone", "-m", type=int, required=True) - - p_pr = sub.add_parser("propose-reject", help="Reject a propose") - p_pr.add_argument("propose_id", type=int) - p_pr.add_argument("--project", "-p", type=int, required=True) - p_pr.add_argument("--reason", "-r") - - p_pro = sub.add_parser("propose-reopen", help="Reopen a rejected propose") - p_pro.add_argument("propose_id", type=int) - p_pro.add_argument("--project", "-p", type=int, required=True) - - args = parser.parse_args() - if not args.command: - parser.print_help() - sys.exit(1) - - cmds = { - "login": cmd_login, - "tasks": cmd_tasks, - "issues": cmd_tasks, - "create-task": cmd_task_create, - "create-issue": cmd_task_create, - "projects": cmd_projects, - "users": cmd_users, - "version": cmd_version, - "health": cmd_health, - "search": cmd_search, - "transition": cmd_transition, - "stats": cmd_stats, - "milestones": cmd_milestones, - "milestone-progress": cmd_milestone_progress, - "notifications": cmd_notifications, - "overdue": cmd_overdue, - "log-time": cmd_log_time, - "worklogs": cmd_worklogs, - "proposes": cmd_proposes, - "propose-create": cmd_propose_create, - "propose-accept": cmd_propose_accept, - "propose-reject": cmd_propose_reject, - "propose-reopen": cmd_propose_reopen, - } - cmds[args.command](args) - - -if __name__ == "__main__": - main() From d17072881b0f8abf9775e40861666175976afb1a Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 22 Mar 2026 00:17:44 +0000 Subject: [PATCH 09/13] feat: add general /supports list endpoint with status/taken_by filters - New GET /supports endpoint for listing all support tickets across projects - Supports optional ?status= and ?taken_by= (me|null|username) query params - Ordered by created_at descending - Complements the existing scoped /supports/{project_code}/{milestone_id} endpoint --- app/api/routers/misc.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/api/routers/misc.py b/app/api/routers/misc.py index fefa4a5..aa757d1 100644 --- a/app/api/routers/misc.py +++ b/app/api/routers/misc.py @@ -524,6 +524,35 @@ def _serialize_support(db: Session, support: Support) -> dict: } +@router.get("/supports", tags=["Supports"]) +def list_all_supports( + status: str | None = None, + taken_by: str | None = None, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + """List support tickets across all projects. Optional status/taken_by filters.""" + query = db.query(Support) + + if status: + query = query.filter(Support.status == SupportStatus(status)) + + if taken_by == "me": + query = query.filter(Support.assignee_id == current_user.id) + elif taken_by == "null": + query = query.filter(Support.assignee_id.is_(None)) + elif taken_by: + assignee = db.query(models.User).filter(models.User.username == taken_by).first() + if assignee: + query = query.filter(Support.assignee_id == assignee.id) + else: + return [] + + query = query.order_by(Support.created_at.desc()) + supports = query.all() + return [_serialize_support(db, s) for s in supports] + + @router.get("/supports/{project_code}/{milestone_id}", tags=["Supports"]) def list_supports(project_code: str, milestone_id: int, db: Session = Depends(get_db)): project = db.query(models.Project).filter(models.Project.project_code == project_code).first() From 88931d822dbf595ea34f430202973f62c02caa90 Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 22 Mar 2026 05:39:03 +0000 Subject: [PATCH 10/13] Fix milestones 422 + acc-mgr user + reset-apikey endpoint - Fix: /milestones?project_id= now accepts project_code (str) not just int - Add: built-in acc-mgr user created on wizard init (account-manager role, no login, undeletable) - Add: POST /users/{id}/reset-apikey with permission-based access control - Add: GET /auth/me/apikey-permissions for frontend capability check - Add: user.reset-self-apikey and user.reset-apikey permissions - Protect admin and acc-mgr accounts from deletion - Block acc-mgr from login (/auth/token returns 403) --- app/api/routers/auth.py | 33 +++++++++++++++++++++ app/api/routers/misc.py | 15 ++++++++-- app/api/routers/users.py | 64 ++++++++++++++++++++++++++++++++++++++++ app/init_wizard.py | 45 ++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 2 deletions(-) diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index efd6196..825675c 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -24,6 +24,9 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = headers={"WWW-Authenticate": "Bearer"}) if not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") + # Built-in acc-mgr account cannot log in interactively + if user.username == "acc-mgr": + raise HTTPException(status_code=403, detail="This account cannot log in") access_token = create_access_token( data={"sub": str(user.id)}, expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) @@ -36,6 +39,36 @@ async def get_me(current_user: models.User = Depends(get_current_user)): return current_user +class ApiKeyPermissionResponse(BaseModel): + can_reset_self: bool + can_reset_any: bool + + +@router.get("/me/apikey-permissions", response_model=ApiKeyPermissionResponse) +async def get_apikey_permissions( + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Return the current user's API key reset capabilities.""" + def _has_perm(perm_name: str) -> bool: + if current_user.is_admin: + return True + if not current_user.role_id: + return False + perm = db.query(Permission).filter(Permission.name == perm_name).first() + if not perm: + return False + return db.query(RolePermission).filter( + RolePermission.role_id == current_user.role_id, + RolePermission.permission_id == perm.id, + ).first() is not None + + return ApiKeyPermissionResponse( + can_reset_self=_has_perm("user.reset-self-apikey"), + can_reset_any=_has_perm("user.reset-apikey"), + ) + + class PermissionIntrospectionResponse(BaseModel): username: str role_name: str | None diff --git a/app/api/routers/misc.py b/app/api/routers/misc.py index aa757d1..cfb37de 100644 --- a/app/api/routers/misc.py +++ b/app/api/routers/misc.py @@ -136,10 +136,21 @@ def create_milestone(ms: schemas.MilestoneCreate, db: Session = Depends(get_db), @router.get("/milestones", response_model=List[schemas.MilestoneResponse], tags=["Milestones"]) -def list_milestones(project_id: int = None, status_filter: str = None, db: Session = Depends(get_db)): +def list_milestones(project_id: str = None, status_filter: str = None, db: Session = Depends(get_db)): query = db.query(MilestoneModel) if project_id: - query = query.filter(MilestoneModel.project_id == project_id) + # Resolve project_id by numeric id or project_code + resolved_project = None + try: + pid = int(project_id) + resolved_project = db.query(models.Project).filter(models.Project.id == pid).first() + except (ValueError, TypeError): + pass + if not resolved_project: + resolved_project = db.query(models.Project).filter(models.Project.project_code == project_id).first() + if not resolved_project: + raise HTTPException(status_code=404, detail="Project not found") + query = query.filter(MilestoneModel.project_id == resolved_project.id) if status_filter: query = query.filter(MilestoneModel.status == status_filter) return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all() diff --git a/app/api/routers/users.py b/app/api/routers/users.py index fb764f3..0aefad1 100644 --- a/app/api/routers/users.py +++ b/app/api/routers/users.py @@ -173,6 +173,11 @@ def delete_user( raise HTTPException(status_code=404, detail="User not found") if current_user.id == user.id: raise HTTPException(status_code=400, detail="You cannot delete your own account") + # Protect built-in accounts from deletion + if user.is_admin: + raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted") + if user.username == "acc-mgr": + raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted") try: db.delete(user) db.commit() @@ -182,6 +187,65 @@ def delete_user( return None +@router.post("/{identifier}/reset-apikey") +def reset_user_apikey( + identifier: str, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + """Reset (regenerate) a user's API key. + + Permission rules: + - user.reset-apikey: can reset any user's API key + - user.reset-self-apikey: can reset only own API key + - admin: can reset any user's API key + """ + import secrets + from app.models.apikey import APIKey + + target_user = _find_user_by_id_or_username(db, identifier) + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + is_self = current_user.id == target_user.id + can_reset_any = _has_global_permission(db, current_user, "user.reset-apikey") + can_reset_self = _has_global_permission(db, current_user, "user.reset-self-apikey") + + if not (can_reset_any or (is_self and can_reset_self)): + raise HTTPException(status_code=403, detail="API key reset permission required") + + # Find existing active API key for target user, or create one + existing_key = db.query(APIKey).filter( + APIKey.user_id == target_user.id, + APIKey.is_active == True, + ).first() + + new_key_value = secrets.token_hex(32) + + if existing_key: + # Deactivate old key + existing_key.is_active = False + db.flush() + + # Create new key + new_key = APIKey( + key=new_key_value, + name=f"{target_user.username}-key", + user_id=target_user.id, + is_active=True, + ) + db.add(new_key) + db.commit() + db.refresh(new_key) + + return { + "user_id": target_user.id, + "username": target_user.username, + "api_key": new_key_value, + "message": "API key has been reset. Please save this key — it will not be shown again.", + } + + class WorkLogResponse(BaseModel): id: int task_id: int diff --git a/app/init_wizard.py b/app/init_wizard.py index 0f33ca6..d47e473 100644 --- a/app/init_wizard.py +++ b/app/init_wizard.py @@ -126,6 +126,9 @@ DEFAULT_PERMISSIONS = [ ("account.create", "Create HarborForge accounts", "account"), # User management ("user.manage", "Manage users", "admin"), + # API key management + ("user.reset-self-apikey", "Reset own API key", "user"), + ("user.reset-apikey", "Reset any user's API key", "admin"), # Monitor ("monitor.read", "View monitor", "monitor"), ("monitor.manage", "Manage monitor", "monitor"), @@ -165,6 +168,7 @@ _MGR_PERMISSIONS = { "task.close", "task.reopen_closed", "task.reopen_completed", "propose.accept", "propose.reject", "propose.reopen", "monitor.read", + "user.reset-self-apikey", } # dev: day-to-day development work — no freeze/start/close milestone, no accept/reject propose @@ -174,6 +178,7 @@ _DEV_PERMISSIONS = { "milestone.read", "task.close", "task.reopen_closed", "task.reopen_completed", "monitor.read", + "user.reset-self-apikey", } _ACCOUNT_MANAGER_PERMISSIONS = { @@ -246,6 +251,43 @@ def init_admin_role(db: Session, admin_user: models.User) -> None: logger.info("Default roles setup complete (admin, mgr, dev, guest)") +def init_acc_mgr_user(db: Session) -> models.User | None: + """Create the built-in acc-mgr user if not exists. + + This user: + - Has role 'account-manager' (can only create accounts) + - Cannot log in (no password, hashed_password=None) + - Cannot be deleted (enforced in delete endpoint) + - Is created automatically after wizard initialization + """ + username = "acc-mgr" + existing = db.query(models.User).filter(models.User.username == username).first() + if existing: + logger.info("acc-mgr user already exists (id=%d), skipping", existing.id) + return existing + + # Find account-manager role + acc_mgr_role = db.query(Role).filter(Role.name == "account-manager").first() + if not acc_mgr_role: + logger.warning("account-manager role not found, skipping acc-mgr user creation") + return None + + user = models.User( + username=username, + email="acc-mgr@harborforge.internal", + full_name="Account Manager", + hashed_password=None, # Cannot log in — no password + is_admin=False, + is_active=True, + role_id=acc_mgr_role.id, + ) + db.add(user) + db.commit() + db.refresh(user) + logger.info("Created acc-mgr user (id=%d) with account-manager role", user.id) + return user + + def run_init(db: Session) -> None: """Main initialization entry point. Reads config from shared volume.""" config = load_config() @@ -267,6 +309,9 @@ def run_init(db: Session) -> None: if admin_user: init_admin_role(db, admin_user) + # Built-in acc-mgr user (after roles are created) + init_acc_mgr_user(db) + # Default project project_cfg = config.get("default_project") if project_cfg and admin_user: From 8b357aabc419396dfe1467ccbc5a90803caf9127 Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 22 Mar 2026 10:06:27 +0000 Subject: [PATCH 11/13] Fix: accept task_code/milestone_code as identifiers, add /config/status endpoint - All /tasks/{task_id} endpoints now accept both numeric id and task_code string - All /milestones/{milestone_id} endpoints (misc.py) now accept both numeric id and milestone_code - Added _resolve_task() and _resolve_milestone() helpers - GET /config/status reads initialization state from config volume (no wizard dependency) - MilestoneResponse schema now includes milestone_code field - Comments and worklog endpoints also accept task_code --- app/api/routers/comments.py | 12 +++++-- app/api/routers/misc.py | 72 +++++++++++++++++++++++-------------- app/api/routers/tasks.py | 56 ++++++++++++++--------------- app/main.py | 20 +++++++++++ app/schemas/schemas.py | 1 + 5 files changed, 104 insertions(+), 57 deletions(-) diff --git a/app/api/routers/comments.py b/app/api/routers/comments.py index 39e7b05..876dcb8 100644 --- a/app/api/routers/comments.py +++ b/app/api/routers/comments.py @@ -51,8 +51,16 @@ def create_comment(comment: schemas.CommentCreate, db: Session = Depends(get_db) @router.get("/tasks/{task_id}/comments", response_model=List[schemas.CommentResponse]) -def list_comments(task_id: int, db: Session = Depends(get_db)): - return db.query(models.Comment).filter(models.Comment.task_id == task_id).all() +def list_comments(task_id: str, db: Session = Depends(get_db)): + """List comments for a task. task_id can be numeric id or task_code.""" + try: + tid = int(task_id) + except (ValueError, TypeError): + task = db.query(Task).filter(Task.task_code == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + tid = task.id + return db.query(models.Comment).filter(models.Comment.task_id == tid).all() @router.patch("/comments/{comment_id}", response_model=schemas.CommentResponse) diff --git a/app/api/routers/misc.py b/app/api/routers/misc.py index 4cb975d..321dc9d 100644 --- a/app/api/routers/misc.py +++ b/app/api/routers/misc.py @@ -28,6 +28,19 @@ from app.schemas import schemas router = APIRouter() +def _resolve_milestone(db: Session, identifier: str) -> MilestoneModel: + """Resolve a milestone by numeric id or milestone_code string. + Raises 404 if not found.""" + try: + ms_id = int(identifier) + ms = db.query(MilestoneModel).filter(MilestoneModel.id == ms_id).first() + except (ValueError, TypeError): + ms = db.query(MilestoneModel).filter(MilestoneModel.milestone_code == identifier).first() + if not ms: + raise HTTPException(status_code=404, detail="Milestone not found") + return ms + + # ============ API Keys ============ class APIKeyCreate(BaseModel): @@ -146,18 +159,13 @@ def list_milestones(project_id: int = None, status_filter: str = None, db: Sessi @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() - if not ms: - raise HTTPException(status_code=404, detail="Milestone not found") - return ms +def get_milestone(milestone_id: str, db: Session = Depends(get_db)): + return _resolve_milestone(db, milestone_id) @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() - if not ms: - raise HTTPException(status_code=404, detail="Milestone not found") +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 = _resolve_milestone(db, milestone_id) ensure_can_edit_milestone(db, current_user.id, ms) for field, value in ms_update.model_dump(exclude_unset=True).items(): setattr(ms, field, value) @@ -167,21 +175,17 @@ 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() - if not ms: - raise HTTPException(status_code=404, detail="Milestone not found") +def delete_milestone(milestone_id: str, db: Session = Depends(get_db)): + ms = _resolve_milestone(db, milestone_id) db.delete(ms) db.commit() return None @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() - if not ms: - raise HTTPException(status_code=404, detail="Milestone not found") - tasks = db.query(Task).filter(Task.milestone_id == milestone_id).all() +def milestone_progress(milestone_id: str, db: Session = Depends(get_db)): + ms = _resolve_milestone(db, milestone_id) + tasks = db.query(Task).filter(Task.milestone_id == ms.id).all() total = len(tasks) done = sum(1 for t in tasks if t.status == TaskStatus.CLOSED) @@ -193,7 +197,7 @@ def milestone_progress(milestone_id: int, db: Session = Depends(get_db)): time_progress = min(100, max(0, (elapsed / total_duration * 100))) return { - "milestone_id": milestone_id, + "milestone_id": ms.id, "title": ms.title, "total": total, "total_tasks": total, @@ -311,18 +315,34 @@ def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)): @router.get("/tasks/{task_id}/worklogs", response_model=List[WorkLogResponse], tags=["Time Tracking"]) -def list_task_worklogs(task_id: int, db: Session = Depends(get_db)): - return db.query(WorkLog).filter(WorkLog.task_id == task_id).order_by(WorkLog.logged_date.desc()).all() +def list_task_worklogs(task_id: str, db: Session = Depends(get_db)): + """List worklogs for a task. task_id can be numeric id or task_code.""" + try: + tid = int(task_id) + except (ValueError, TypeError): + task = db.query(Task).filter(Task.task_code == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + tid = task.id + return db.query(WorkLog).filter(WorkLog.task_id == tid).order_by(WorkLog.logged_date.desc()).all() @router.get("/tasks/{task_id}/worklogs/summary", tags=["Time Tracking"]) -def task_worklog_summary(task_id: int, db: Session = Depends(get_db)): - task = db.query(Task).filter(Task.id == task_id).first() +def task_worklog_summary(task_id: str, db: Session = Depends(get_db)): + """Worklog summary for a task. task_id can be numeric id or task_code.""" + try: + tid = int(task_id) + except (ValueError, TypeError): + t = db.query(Task).filter(Task.task_code == task_id).first() + if not t: + raise HTTPException(status_code=404, detail="Task not found") + tid = t.id + task = db.query(Task).filter(Task.id == tid).first() if not task: raise HTTPException(status_code=404, detail="Task not found") - total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.task_id == task_id).scalar() or 0 - count = db.query(WorkLog).filter(WorkLog.task_id == task_id).count() - return {"task_id": task_id, "total_hours": round(total, 2), "log_count": count} + total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.task_id == tid).scalar() or 0 + count = db.query(WorkLog).filter(WorkLog.task_id == tid).count() + return {"task_id": tid, "total_hours": round(total, 2), "log_count": count} @router.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT, tags=["Time Tracking"]) diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py index f29c75a..68c6bc4 100644 --- a/app/api/routers/tasks.py +++ b/app/api/routers/tasks.py @@ -20,6 +20,19 @@ from app.services.dependency_check import check_task_deps router = APIRouter(tags=["Tasks"]) + +def _resolve_task(db: Session, identifier: str) -> Task: + """Resolve a task by numeric id or task_code string. + Raises 404 if not found.""" + try: + task_id = int(identifier) + task = db.query(Task).filter(Task.id == task_id).first() + except (ValueError, TypeError): + task = db.query(Task).filter(Task.task_code == identifier).first() + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task + # ---- State-machine: valid transitions (P5.1-P5.6) ---- VALID_TRANSITIONS: dict[str, set[str]] = { "pending": {"open", "closed"}, @@ -187,18 +200,13 @@ def list_tasks( @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() - if not task: - raise HTTPException(status_code=404, detail="Task not found") - return task +def get_task(task_id: str, db: Session = Depends(get_db)): + return _resolve_task(db, task_id) @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() - if not task: - raise HTTPException(status_code=404, detail="Task not found") +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 = _resolve_task(db, task_id) # P5.7: status-based edit restrictions current_status = task.status.value if hasattr(task.status, 'value') else task.status @@ -272,10 +280,8 @@ def update_task(task_id: int, task_update: schemas.TaskUpdate, db: Session = Dep @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() - if not task: - raise HTTPException(status_code=404, detail="Task not found") +def delete_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)): + task = _resolve_task(db, task_id) check_project_role(db, current_user.id, task.project_id, min_role="mgr") log_activity(db, "task.deleted", "task", task.id, current_user.id, {"title": task.title}) db.delete(task) @@ -291,7 +297,7 @@ class TransitionBody(BaseModel): @router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse) def transition_task( - task_id: int, + task_id: str, new_status: str, bg: BackgroundTasks, body: TransitionBody = None, @@ -301,9 +307,7 @@ def transition_task( 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() - if not task: - raise HTTPException(status_code=404, detail="Task not found") + task = _resolve_task(db, task_id) old_status = task.status.value if hasattr(task.status, 'value') else task.status # P5.1: enforce state-machine @@ -391,10 +395,8 @@ def transition_task( # ---- Assignment ---- @router.post("/tasks/{task_id}/assign") -def assign_task(task_id: int, assignee_id: int, db: Session = Depends(get_db)): - task = db.query(Task).filter(Task.id == task_id).first() - if not task: - raise HTTPException(status_code=404, detail="Task not found") +def assign_task(task_id: str, assignee_id: int, db: Session = Depends(get_db)): + task = _resolve_task(db, task_id) user = db.query(models.User).filter(models.User.id == assignee_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") @@ -410,10 +412,8 @@ def assign_task(task_id: int, assignee_id: int, db: Session = Depends(get_db)): # ---- Tags ---- @router.post("/tasks/{task_id}/tags") -def add_tag(task_id: int, tag: str, db: Session = Depends(get_db)): - task = db.query(Task).filter(Task.id == task_id).first() - if not task: - raise HTTPException(status_code=404, detail="Task not found") +def add_tag(task_id: str, tag: str, db: Session = Depends(get_db)): + task = _resolve_task(db, task_id) current = set(task.tags.split(",")) if task.tags else set() current.add(tag.strip()) current.discard("") @@ -423,10 +423,8 @@ def add_tag(task_id: int, tag: str, db: Session = Depends(get_db)): @router.delete("/tasks/{task_id}/tags") -def remove_tag(task_id: int, tag: str, db: Session = Depends(get_db)): - task = db.query(Task).filter(Task.id == task_id).first() - if not task: - raise HTTPException(status_code=404, detail="Task not found") +def remove_tag(task_id: str, tag: str, db: Session = Depends(get_db)): + task = _resolve_task(db, task_id) current = set(task.tags.split(",")) if task.tags else set() current.discard(tag.strip()) current.discard("") diff --git a/app/main.py b/app/main.py index 524f46c..e3ec674 100644 --- a/app/main.py +++ b/app/main.py @@ -26,6 +26,26 @@ def health_check(): def version(): return {"name": "HarborForge", "version": "0.3.0", "description": "Agent/人类协同任务管理平台"} +@app.get("/config/status", tags=["System"]) +def config_status(): + """Check if HarborForge has been initialized (reads from config volume). + Frontend uses this instead of contacting the wizard directly.""" + import os, json + config_dir = os.getenv("CONFIG_DIR", "/config") + config_file = os.getenv("CONFIG_FILE", "harborforge.json") + config_path = os.path.join(config_dir, config_file) + if not os.path.exists(config_path): + return {"initialized": False} + try: + with open(config_path, "r") as f: + cfg = json.load(f) + return { + "initialized": cfg.get("initialized", False), + "backend_url": cfg.get("backend_url"), + } + except Exception: + return {"initialized": False} + # Register routers from app.api.routers.auth import router as auth_router from app.api.routers.tasks import router as tasks_router diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 08d4388..57bfec9 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -240,6 +240,7 @@ class MilestoneUpdate(BaseModel): class MilestoneResponse(MilestoneBase): id: int + milestone_code: Optional[str] = None project_id: int created_by_id: Optional[int] = None started_at: Optional[datetime] = None From 15126aa0e5a2835334ca90f924f23c2cad786f2c Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 22 Mar 2026 10:40:13 +0000 Subject: [PATCH 12/13] Apply fix: accept project_code as identifier in project endpoints --- app/api/routers/projects.py | 46 ++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index fa33080..8469939 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -15,6 +15,19 @@ from app.api.rbac import check_project_role, check_permission, ensure_can_edit_p router = APIRouter(prefix="/projects", tags=["Projects"]) +def _resolve_project(db: Session, identifier: str) -> models.Project: + """Resolve a project by numeric id or project_code string. + Raises 404 if not found.""" + try: + pid = int(identifier) + project = db.query(models.Project).filter(models.Project.id == pid).first() + except (ValueError, TypeError): + project = db.query(models.Project).filter(models.Project.project_code == identifier).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + def _validate_project_links(db, codes: list[str] | None, self_code: str | None = None) -> list[str] | None: if not codes: return None @@ -196,10 +209,7 @@ def _find_project_by_id_or_code(db, identifier) -> models.Project | None: @router.get("/{project_id}", response_model=schemas.ProjectResponse) 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 + return _resolve_project(db, project_id) @router.patch("/{project_id}", response_model=schemas.ProjectResponse) @@ -209,9 +219,7 @@ def update_project( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - project = _find_project_by_id_or_code(db, project_id) - if not project: - raise HTTPException(status_code=404, detail="Project not found") + project = _resolve_project(db, project_id) ensure_can_edit_project(db, current_user.id, project) update_data = project_update.model_dump(exclude_unset=True) update_data.pop("name", None) @@ -236,18 +244,16 @@ def delete_project( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - project = _find_project_by_id_or_code(db, project_id) - if not project: - raise HTTPException(status_code=404, detail="Project not found") - + project = _resolve_project(db, project_id) check_project_role(db, current_user.id, project.id, min_role="admin") project_code = project.project_code + project_id_val = project.id # 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_val).all() for ms in milestones: tasks = db.query(Task).filter(Task.milestone_id == ms.id).all() for task in tasks: @@ -287,9 +293,7 @@ def add_project_member( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - project = _find_project_by_id_or_code(db, project_id) - if not project: - raise HTTPException(status_code=404, detail="Project not found") + project = _resolve_project(db, project_id) 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: @@ -321,9 +325,7 @@ def add_project_member( @router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse]) 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") + project = _resolve_project(db, project_id) members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project.id).all() result = [] for m in members: @@ -351,9 +353,7 @@ def remove_project_member( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - project = _find_project_by_id_or_code(db, project_id) - if not project: - raise HTTPException(status_code=404, detail="Project not found") + project = _resolve_project(db, project_id) 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 @@ -386,9 +386,7 @@ from sqlalchemy import func as sqlfunc @router.get("/{project_id}/worklogs/summary") 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") + project = _resolve_project(db, project_id) resolved_project_id = project.id results = db.query( models.User.id, models.User.username, From 5ccd955a66b90c47c49b5361309615e76606cd18 Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 22 Mar 2026 11:17:51 +0000 Subject: [PATCH 13/13] Fix: use role name 'admin' instead of 'superadmin' for global admin check --- app/api/rbac.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/api/rbac.py b/app/api/rbac.py index 25c066f..39127a7 100644 --- a/app/api/rbac.py +++ b/app/api/rbac.py @@ -20,8 +20,8 @@ def get_user_role(db: Session, user_id: int, project_id: int) -> Role | None: # Check global admin user = db.query(models.User).filter(models.User.id == user_id).first() if user and user.is_admin: - # Return global admin role - return db.query(Role).filter(Role.is_global == True, Role.name == "superadmin").first() + # Return global admin role (name="admin") + return db.query(Role).filter(Role.is_global == True, Role.name == "admin").first() return None