- Add comprehensive docstring to Proposal model documenting all relationships - Add column comments for all fields (title, description, status, project_id, etc.) - Mark feat_task_id as DEPRECATED (will be replaced by Essential->task mapping in BE-PR-008) - Add proposal_code hybrid property as preferred alias for DB column propose_code - Update ProposalResponse schema to include proposal_code alongside propose_code - Update serializer to emit both proposal_code and propose_code for backward compat - No DB migration needed -- only Python-level changes
351 lines
13 KiB
Python
351 lines
13 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.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_proposal(db: Session, proposal: Proposal) -> 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
|
|
return {
|
|
"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 — read-only compat
|
|
"created_at": proposal.created_at,
|
|
"updated_at": proposal.updated_at,
|
|
}
|
|
|
|
|
|
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.ProposalResponse)
|
|
def get_proposal(
|
|
project_id: str,
|
|
proposal_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")
|
|
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)
|
|
|
|
|
|
@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)
|
|
# Never allow client to set feat_task_id
|
|
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.ProposalResponse)
|
|
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: create a feature story task in the chosen milestone."""
|
|
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")
|
|
|
|
# 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,
|
|
)
|
|
db.add(task)
|
|
db.flush() # get task.id
|
|
|
|
# Update proposal
|
|
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,
|
|
})
|
|
|
|
return _serialize_proposal(db, proposal)
|
|
|
|
|
|
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)
|