Files
HarborForge.Backend/app/api/routers/proposals.py
zhi 90d1f22267 BE-PR-010: deprecate feat_task_id — retain column, read-only compat
- Updated model docstring with full deprecation strategy
- Updated column comment to mark as deprecated (BE-PR-010)
- Updated schema/router comments for deprecation clarity
- Added deprecation doc: docs/BE-PR-010-feat-task-id-deprecation.md
- feat_task_id superseded by Task.source_proposal_id (BE-PR-008)
2026-03-30 12:49:52 +00:00

452 lines
16 KiB
Python

"""Proposals API router (project-scoped) — CRUD + accept/reject/reopen actions.
Renamed from 'Proposes' to 'Proposals'. DB table name and permission names
kept as-is for backward compatibility.
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func as sa_func
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role, check_permission, is_global_admin
from app.models import models
from app.models.proposal import Proposal, ProposalStatus
from app.models.essential import Essential
from app.models.milestone import Milestone, MilestoneStatus
from app.models.task import Task, TaskStatus, TaskPriority
from app.schemas import schemas
from app.services.activity import log_activity
router = APIRouter(prefix="/projects/{project_id}/proposals", tags=["Proposals"])
def _serialize_essential(e: Essential) -> dict:
"""Serialize an Essential for embedding in Proposal detail."""
return {
"id": e.id,
"essential_code": e.essential_code,
"proposal_id": e.proposal_id,
"type": e.type.value if hasattr(e.type, "value") else e.type,
"title": e.title,
"description": e.description,
"created_by_id": e.created_by_id,
"created_at": e.created_at,
"updated_at": e.updated_at,
}
def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials: bool = False) -> dict:
"""Serialize proposal with created_by_username."""
creator = db.query(models.User).filter(models.User.id == proposal.created_by_id).first() if proposal.created_by_id else None
code = proposal.propose_code # DB column; also exposed as proposal_code
result = {
"id": proposal.id,
"title": proposal.title,
"description": proposal.description,
"proposal_code": code, # preferred name
"propose_code": code, # backward compat
"status": proposal.status.value if hasattr(proposal.status, "value") else proposal.status,
"project_id": proposal.project_id,
"created_by_id": proposal.created_by_id,
"created_by_username": creator.username if creator else None,
"feat_task_id": proposal.feat_task_id, # DEPRECATED (BE-PR-010): read-only for legacy rows. Clients should use generated_tasks.
"created_at": proposal.created_at,
"updated_at": proposal.updated_at,
}
if include_essentials:
essentials = (
db.query(Essential)
.filter(Essential.proposal_id == proposal.id)
.order_by(Essential.id.asc())
.all()
)
result["essentials"] = [_serialize_essential(e) for e in essentials]
# BE-PR-008: include tasks generated from this Proposal via Accept
gen_tasks = (
db.query(Task)
.filter(Task.source_proposal_id == proposal.id)
.order_by(Task.id.asc())
.all()
)
result["generated_tasks"] = [
{
"task_id": t.id,
"task_code": t.task_code,
"task_type": t.task_type or "story",
"task_subtype": t.task_subtype,
"title": t.title,
"status": t.status.value if hasattr(t.status, "value") else t.status,
"source_essential_id": t.source_essential_id,
}
for t in gen_tasks
]
return result
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_proposal(db, identifier, project_id: int = None) -> Proposal | None:
"""Look up proposal by numeric id or propose_code."""
try:
pid = int(identifier)
q = db.query(Proposal).filter(Proposal.id == pid)
if project_id:
q = q.filter(Proposal.project_id == project_id)
p = q.first()
if p:
return p
except (ValueError, TypeError):
pass
q = db.query(Proposal).filter(Proposal.propose_code == str(identifier))
if project_id:
q = q.filter(Proposal.project_id == project_id)
return q.first()
def _generate_proposal_code(db: Session, project_id: int) -> str:
"""Generate next proposal code: {proj_code}:P{i:05x}"""
project = db.query(models.Project).filter(models.Project.id == project_id).first()
project_code = project.project_code if project and project.project_code else f"P{project_id}"
max_proposal = (
db.query(Proposal)
.filter(Proposal.project_id == project_id)
.order_by(Proposal.id.desc())
.first()
)
next_num = (max_proposal.id + 1) if max_proposal else 1
return f"{project_code}:P{next_num:05x}"
def _can_edit_proposal(db: Session, user_id: int, proposal: Proposal) -> bool:
"""Only creator, project admin, or global admin can edit an open proposal."""
if is_global_admin(db, user_id):
return True
if proposal.created_by_id == user_id:
return True
project = db.query(models.Project).filter(models.Project.id == proposal.project_id).first()
if project and project.owner_id == user_id:
return True
return False
# ---- CRUD ----
@router.get("", response_model=List[schemas.ProposalResponse])
def list_proposals(
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")
proposals = (
db.query(Proposal)
.filter(Proposal.project_id == project.id)
.order_by(Proposal.id.desc())
.all()
)
return [_serialize_proposal(db, p) for p in proposals]
@router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED)
def create_proposal(
project_id: str,
proposal_in: schemas.ProposalCreate,
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")
proposal_code = _generate_proposal_code(db, project.id)
proposal = Proposal(
title=proposal_in.title,
description=proposal_in.description,
status=ProposalStatus.OPEN,
project_id=project.id,
created_by_id=current_user.id,
propose_code=proposal_code,
)
db.add(proposal)
db.commit()
db.refresh(proposal)
log_activity(db, "create", "proposal", proposal.id, user_id=current_user.id, details={"title": proposal.title})
return _serialize_proposal(db, proposal)
@router.get("/{proposal_id}", response_model=schemas.ProposalDetailResponse)
def get_proposal(
project_id: str,
proposal_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Get a single Proposal with its Essentials list embedded."""
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")
proposal = _find_proposal(db, proposal_id, project.id)
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
return _serialize_proposal(db, proposal, include_essentials=True)
@router.patch("/{proposal_id}", response_model=schemas.ProposalResponse)
def update_proposal(
project_id: str,
proposal_id: str,
proposal_in: schemas.ProposalUpdate,
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")
proposal = _find_proposal(db, proposal_id, project.id)
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
# Only open proposals can be edited
proposal_status = proposal.status.value if hasattr(proposal.status, "value") else proposal.status
if proposal_status != "open":
raise HTTPException(status_code=400, detail="Only open proposals can be edited")
if not _can_edit_proposal(db, current_user.id, proposal):
raise HTTPException(status_code=403, detail="Proposal edit permission denied")
data = proposal_in.model_dump(exclude_unset=True)
# DEPRECATED (BE-PR-010): feat_task_id is read-only; strip from client input
data.pop("feat_task_id", None)
for key, value in data.items():
setattr(proposal, key, value)
db.commit()
db.refresh(proposal)
log_activity(db, "update", "proposal", proposal.id, user_id=current_user.id, details=data)
return _serialize_proposal(db, proposal)
# ---- Actions ----
class AcceptRequest(schemas.BaseModel):
milestone_id: int
@router.post("/{proposal_id}/accept", response_model=schemas.ProposalAcceptResponse)
def accept_proposal(
project_id: str,
proposal_id: str,
body: AcceptRequest,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""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")
proposal = _find_proposal(db, proposal_id, project.id)
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
proposal_status = proposal.status.value if hasattr(proposal.status, "value") else proposal.status
if proposal_status != "open":
raise HTTPException(status_code=400, detail="Only open proposals can be accepted")
check_permission(db, current_user.id, project.id, "propose.accept") # permission name kept for DB compat
# Validate milestone
milestone = db.query(Milestone).filter(
Milestone.id == body.milestone_id,
Milestone.project_id == project.id,
).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found in this project")
ms_status = milestone.status.value if hasattr(milestone.status, "value") else milestone.status
if ms_status != "open":
raise HTTPException(status_code=400, detail="Target milestone must be in 'open' status")
# Fetch all Essentials for this Proposal
essentials = (
db.query(Essential)
.filter(Essential.proposal_id == proposal.id)
.order_by(Essential.id.asc())
.all()
)
if not essentials:
raise HTTPException(
status_code=400,
detail="Proposal has no Essentials. Add at least one Essential before accepting.",
)
# 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,
# BE-PR-008: track which Proposal/Essential generated this task
source_proposal_id=proposal.id,
source_essential_id=essential.id,
)
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 — feat_task_id is NOT written (deprecated per BE-PR-010)
proposal.status = ProposalStatus.ACCEPTED
db.commit()
db.refresh(proposal)
log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={
"milestone_id": milestone.id,
"generated_tasks": [
{"task_id": t["task_id"], "task_code": t["task_code"], "essential_id": t["essential_id"]}
for t in generated_tasks
],
})
result = _serialize_proposal(db, proposal, include_essentials=True)
result["generated_tasks"] = generated_tasks
return result
class RejectRequest(schemas.BaseModel):
reason: str | None = None
@router.post("/{proposal_id}/reject", response_model=schemas.ProposalResponse)
def reject_proposal(
project_id: str,
proposal_id: str,
body: RejectRequest | None = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Reject a proposal."""
project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
proposal = _find_proposal(db, proposal_id, project.id)
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
proposal_status = proposal.status.value if hasattr(proposal.status, "value") else proposal.status
if proposal_status != "open":
raise HTTPException(status_code=400, detail="Only open proposals can be rejected")
check_permission(db, current_user.id, project.id, "propose.reject") # permission name kept for DB compat
proposal.status = ProposalStatus.REJECTED
db.commit()
db.refresh(proposal)
log_activity(db, "reject", "proposal", proposal.id, user_id=current_user.id, details={
"reason": body.reason if body else None,
})
return _serialize_proposal(db, proposal)
@router.post("/{proposal_id}/reopen", response_model=schemas.ProposalResponse)
def reopen_proposal(
project_id: str,
proposal_id: str,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
"""Reopen a rejected proposal back to open."""
project = _find_project(db, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
proposal = _find_proposal(db, proposal_id, project.id)
if not proposal:
raise HTTPException(status_code=404, detail="Proposal not found")
proposal_status = proposal.status.value if hasattr(proposal.status, "value") else proposal.status
if proposal_status != "rejected":
raise HTTPException(status_code=400, detail="Only rejected proposals can be reopened")
check_permission(db, current_user.id, project.id, "propose.reopen") # permission name kept for DB compat
proposal.status = ProposalStatus.OPEN
db.commit()
db.refresh(proposal)
log_activity(db, "reopen", "proposal", proposal.id, user_id=current_user.id)
return _serialize_proposal(db, proposal)