Merge dev-2026-03-22 into main #12

Merged
hzhang merged 15 commits from dev-2026-03-22 into main 2026-03-22 14:12:43 +00:00
6 changed files with 442 additions and 124 deletions
Showing only changes of commit 43af5b29f6 - Show all commits

View File

@@ -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,

View File

@@ -145,17 +145,29 @@ def list_milestones(project_id: int = None, status_filter: str = None, db: Sessi
return query.order_by(MilestoneModel.due_date.is_(None), MilestoneModel.due_date.asc()).all()
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()

View File

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

View File

@@ -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()

View File

@@ -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,

View File

@@ -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