From cb0be052468252a4034ec5b62788840985394f56 Mon Sep 17 00:00:00 2001 From: zhi Date: Mon, 30 Mar 2026 07:46:20 +0000 Subject: [PATCH] BE-PR-007: refactor Proposal Accept to generate story tasks from all Essentials - Removed old logic that created a single story/feature task on accept - Accept now iterates all Essentials under the Proposal - Each Essential.type maps to a story/* task (feature/improvement/refactor) - All tasks created in a single transaction - Added ProposalAcceptResponse and GeneratedTaskSummary schemas - Proposal must have at least one Essential to be accepted - No longer writes to deprecated feat_task_id field --- app/api/routers/proposals.py | 108 ++++++++++++++++++++++++++--------- app/schemas/schemas.py | 21 +++++++ 2 files changed, 101 insertions(+), 28 deletions(-) diff --git a/app/api/routers/proposals.py b/app/api/routers/proposals.py index 5c5056f..46e1488 100644 --- a/app/api/routers/proposals.py +++ b/app/api/routers/proposals.py @@ -236,7 +236,7 @@ class AcceptRequest(schemas.BaseModel): milestone_id: int -@router.post("/{proposal_id}/accept", response_model=schemas.ProposalResponse) +@router.post("/{proposal_id}/accept", response_model=schemas.ProposalAcceptResponse) def accept_proposal( project_id: str, proposal_id: str, @@ -244,7 +244,16 @@ def accept_proposal( db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): - """Accept a proposal: create a feature story task in the chosen milestone.""" + """Accept a proposal: generate story tasks from all Essentials into the chosen milestone. + + Each Essential under the Proposal produces a corresponding ``story/*`` task: + - feature → story/feature + - improvement → story/improvement + - refactor → story/refactor + + All tasks are created in a single transaction. The Proposal must have at + least one Essential to be accepted. + """ project = _find_project(db, project_id) if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -270,43 +279,86 @@ def accept_proposal( if ms_status != "open": raise HTTPException(status_code=400, detail="Target milestone must be in 'open' status") - # Generate task code - milestone_code = milestone.milestone_code or f"m{milestone.id}" - max_task = db.query(Task).filter(Task.milestone_id == milestone.id).order_by(Task.id.desc()).first() - next_num = (max_task.id + 1) if max_task else 1 - task_code = f"{milestone_code}:T{next_num:05x}" - - # Create feature story task - task = Task( - title=proposal.title, - description=proposal.description, - task_type="story", - task_subtype="feature", - status=TaskStatus.PENDING, - priority=TaskPriority.MEDIUM, - project_id=project.id, - milestone_id=milestone.id, - reporter_id=proposal.created_by_id or current_user.id, - created_by_id=proposal.created_by_id or current_user.id, - task_code=task_code, + # Fetch all Essentials for this Proposal + essentials = ( + db.query(Essential) + .filter(Essential.proposal_id == proposal.id) + .order_by(Essential.id.asc()) + .all() ) - db.add(task) - db.flush() # get task.id + if not essentials: + raise HTTPException( + status_code=400, + detail="Proposal has no Essentials. Add at least one Essential before accepting.", + ) - # Update proposal + # Map Essential type → task subtype + ESSENTIAL_TYPE_TO_SUBTYPE = { + "feature": "feature", + "improvement": "improvement", + "refactor": "refactor", + } + + # Determine next task number in this milestone + milestone_code = milestone.milestone_code or f"m{milestone.id}" + max_task = ( + db.query(sa_func.max(Task.id)) + .filter(Task.milestone_id == milestone.id) + .scalar() + ) + next_num = (max_task + 1) if max_task else 1 + + # Create one story task per Essential — all within the current transaction + generated_tasks = [] + for essential in essentials: + etype = essential.type.value if hasattr(essential.type, "value") else essential.type + task_subtype = ESSENTIAL_TYPE_TO_SUBTYPE.get(etype, "feature") + task_code = f"{milestone_code}:T{next_num:05x}" + + task = Task( + title=essential.title, + description=essential.description, + task_type="story", + task_subtype=task_subtype, + status=TaskStatus.PENDING, + priority=TaskPriority.MEDIUM, + project_id=project.id, + milestone_id=milestone.id, + reporter_id=proposal.created_by_id or current_user.id, + created_by_id=proposal.created_by_id or current_user.id, + task_code=task_code, + ) + db.add(task) + db.flush() # materialise task.id + + generated_tasks.append({ + "task_id": task.id, + "task_code": task_code, + "task_type": "story", + "task_subtype": task_subtype, + "title": essential.title, + "essential_id": essential.id, + "essential_code": essential.essential_code, + }) + next_num = task.id + 1 # use real id for next code to stay consistent + + # Update proposal status (do NOT write feat_task_id — deprecated) proposal.status = ProposalStatus.ACCEPTED - proposal.feat_task_id = str(task.id) db.commit() db.refresh(proposal) log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={ "milestone_id": milestone.id, - "generated_task_id": task.id, - "task_code": task_code, + "generated_tasks": [ + {"task_id": t["task_id"], "task_code": t["task_code"], "essential_id": t["essential_id"]} + for t in generated_tasks + ], }) - return _serialize_proposal(db, proposal) + result = _serialize_proposal(db, proposal, include_essentials=True) + result["generated_tasks"] = generated_tasks + return result class RejectRequest(schemas.BaseModel): diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 40ccd97..7d2f511 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -353,6 +353,27 @@ class ProposalDetailResponse(ProposalResponse): from_attributes = True +class GeneratedTaskSummary(BaseModel): + """Brief summary of a task generated from a Proposal Essential.""" + task_id: int + task_code: str + task_type: str + task_subtype: str + title: str + essential_id: int + essential_code: str + + +class ProposalAcceptResponse(ProposalResponse): + """Response for Proposal Accept — includes the generated story tasks.""" + + essentials: List[EssentialResponse] = [] + generated_tasks: List[GeneratedTaskSummary] = [] + + class Config: + from_attributes = True + + # Backward-compatible aliases ProposeStatusEnum = ProposalStatusEnum ProposeBase = ProposalBase