feat: propose CRUD router + accept/reject/reopen actions (P6.1-P6.4)

This commit is contained in:
zhi
2026-03-17 03:01:49 +00:00
parent 2bea75e843
commit 75ccbcb362
2 changed files with 280 additions and 0 deletions

278
app/api/routers/proposes.py Normal file
View File

@@ -0,0 +1,278 @@
"""Proposes API router (project-scoped) — CRUD + accept/reject/reopen actions."""
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, is_global_admin
from app.models import models
from app.models.propose import Propose, ProposeStatus
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}/proposes", tags=["Proposes"])
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()
project_code = project.project_code if project and project.project_code else f"P{project_id}"
max_propose = (
db.query(Propose)
.filter(Propose.project_id == project_id)
.order_by(Propose.id.desc())
.first()
)
next_num = (max_propose.id + 1) if max_propose else 1
return f"{project_code}:P{next_num:05x}"
def _can_edit_propose(db: Session, user_id: int, propose: Propose) -> bool:
"""Only creator, project admin, or global admin can edit an open propose."""
if is_global_admin(db, user_id):
return True
if propose.created_by_id == user_id:
return True
project = db.query(models.Project).filter(models.Project.id == propose.project_id).first()
if project and project.owner_id == user_id:
return True
return False
# ---- CRUD ----
@router.get("", response_model=List[schemas.ProposeResponse])
def list_proposes(
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")
proposes = (
db.query(Propose)
.filter(Propose.project_id == project_id)
.order_by(Propose.id.desc())
.all()
)
return proposes
@router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED)
def create_propose(
project_id: int,
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")
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,
created_by_id=current_user.id,
propose_code=propose_code,
)
db.add(propose)
db.commit()
db.refresh(propose)
log_activity(db, "create", "propose", propose.id, user_id=current_user.id, details={"title": propose.title})
return propose
@router.get("/{propose_id}", response_model=schemas.ProposeResponse)
def get_propose(
project_id: int,
propose_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")
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first()
if not propose:
raise HTTPException(status_code=404, detail="Propose not found")
return propose
@router.patch("/{propose_id}", response_model=schemas.ProposeResponse)
def update_propose(
project_id: int,
propose_id: int,
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()
if not propose:
raise HTTPException(status_code=404, detail="Propose not found")
# Only open proposes can be edited
propose_status = propose.status.value if hasattr(propose.status, "value") else propose.status
if propose_status != "open":
raise HTTPException(status_code=400, detail="Only open proposes can be edited")
if not _can_edit_propose(db, current_user.id, propose):
raise HTTPException(status_code=403, detail="Propose edit permission denied")
data = propose_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(propose, key, value)
db.commit()
db.refresh(propose)
log_activity(db, "update", "propose", propose.id, user_id=current_user.id, details=data)
return propose
# ---- Actions ----
class AcceptRequest(schemas.BaseModel):
milestone_id: int
@router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse)
def accept_propose(
project_id: int,
propose_id: int,
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()
if not propose:
raise HTTPException(status_code=404, detail="Propose not found")
propose_status = propose.status.value if hasattr(propose.status, "value") else propose.status
if propose_status != "open":
raise HTTPException(status_code=400, detail="Only open proposes can be accepted")
# TODO: check 'accept propose' permission once P2 lands
check_project_role(db, current_user.id, project_id, min_role="mgr")
# 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=propose.title,
description=propose.description,
task_type="story",
task_subtype="feature",
status=TaskStatus.PENDING,
priority=TaskPriority.MEDIUM,
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,
task_code=task_code,
)
db.add(task)
db.flush() # get task.id
# Update propose
propose.status = ProposeStatus.ACCEPTED
propose.feat_task_id = str(task.id)
db.commit()
db.refresh(propose)
log_activity(db, "accept", "propose", propose.id, user_id=current_user.id, details={
"milestone_id": milestone.id,
"generated_task_id": task.id,
"task_code": task_code,
})
return propose
class RejectRequest(schemas.BaseModel):
reason: str | None = None
@router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse)
def reject_propose(
project_id: int,
propose_id: int,
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()
if not propose:
raise HTTPException(status_code=404, detail="Propose not found")
propose_status = propose.status.value if hasattr(propose.status, "value") else propose.status
if propose_status != "open":
raise HTTPException(status_code=400, detail="Only open proposes can be rejected")
# TODO: check 'reject propose' permission once P2 lands
check_project_role(db, current_user.id, project_id, min_role="mgr")
propose.status = ProposeStatus.REJECTED
db.commit()
db.refresh(propose)
log_activity(db, "reject", "propose", propose.id, user_id=current_user.id, details={
"reason": body.reason if body else None,
})
return propose
@router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse)
def reopen_propose(
project_id: int,
propose_id: int,
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()
if not propose:
raise HTTPException(status_code=404, detail="Propose not found")
propose_status = propose.status.value if hasattr(propose.status, "value") else propose.status
if propose_status != "rejected":
raise HTTPException(status_code=400, detail="Only rejected proposes can be reopened")
# TODO: check 'reopen rejected propose' permission once P2 lands
check_project_role(db, current_user.id, project_id, min_role="mgr")
propose.status = ProposeStatus.OPEN
db.commit()
db.refresh(propose)
log_activity(db, "reopen", "propose", propose.id, user_id=current_user.id)
return propose