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
This commit is contained in:
zhi
2026-03-30 07:46:20 +00:00
parent 431f4abe5a
commit cb0be05246
2 changed files with 101 additions and 28 deletions

View File

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

View File

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