BE-PR-001: Rename Propose -> Proposal across backend
- New canonical model: Proposal, ProposalStatus (app/models/proposal.py)
- New canonical router: /projects/{id}/proposals (app/api/routers/proposals.py)
- Schemas renamed: ProposalCreate, ProposalUpdate, ProposalResponse, etc.
- Old propose.py and proposes.py kept as backward-compat shims
- Legacy /proposes API still works (delegates to /proposals handlers)
- DB table name (proposes), column (propose_code), and permission names
(propose.*) kept unchanged for zero-migration compat
- Updated init_wizard.py comments
This commit is contained in:
@@ -1,210 +1,81 @@
|
||||
"""Proposes API router (project-scoped) — CRUD + accept/reject/reopen actions."""
|
||||
"""Backward-compatibility shim — mounts legacy /proposes routes alongside /proposals.
|
||||
|
||||
This keeps old API consumers working while the canonical path is now /proposals.
|
||||
"""
|
||||
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.propose import Propose, ProposeStatus
|
||||
from app.schemas import schemas
|
||||
|
||||
# Import all handler functions from the canonical proposals router
|
||||
from app.api.routers.proposals import (
|
||||
_find_project,
|
||||
_find_proposal,
|
||||
_serialize_proposal,
|
||||
_generate_proposal_code,
|
||||
_can_edit_proposal,
|
||||
AcceptRequest,
|
||||
RejectRequest,
|
||||
)
|
||||
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.api.rbac import check_project_role, check_permission, is_global_admin
|
||||
from app.services.activity import log_activity
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes"])
|
||||
# Legacy router — same logic, old URL prefix
|
||||
router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes (legacy)"])
|
||||
|
||||
|
||||
def _serialize_propose(db: Session, propose: Propose) -> dict:
|
||||
"""Serialize propose with created_by_username."""
|
||||
creator = db.query(models.User).filter(models.User.id == propose.created_by_id).first() if propose.created_by_id else None
|
||||
return {
|
||||
"id": propose.id,
|
||||
"title": propose.title,
|
||||
"description": propose.description,
|
||||
"propose_code": propose.propose_code,
|
||||
"status": propose.status.value if hasattr(propose.status, "value") else propose.status,
|
||||
"project_id": propose.project_id,
|
||||
"created_by_id": propose.created_by_id,
|
||||
"created_by_username": creator.username if creator else None,
|
||||
"feat_task_id": propose.feat_task_id,
|
||||
"created_at": propose.created_at,
|
||||
"updated_at": propose.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_propose(db, identifier, project_id: int = None) -> Propose | None:
|
||||
"""Look up propose by numeric id or propose_code."""
|
||||
try:
|
||||
pid = int(identifier)
|
||||
q = db.query(Propose).filter(Propose.id == pid)
|
||||
if project_id:
|
||||
q = q.filter(Propose.project_id == project_id)
|
||||
p = q.first()
|
||||
if p:
|
||||
return p
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
q = db.query(Propose).filter(Propose.propose_code == str(identifier))
|
||||
if project_id:
|
||||
q = q.filter(Propose.project_id == project_id)
|
||||
return q.first()
|
||||
|
||||
|
||||
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])
|
||||
@router.get("", response_model=List[schemas.ProposalResponse])
|
||||
def list_proposes(
|
||||
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")
|
||||
proposes = (
|
||||
db.query(Propose)
|
||||
.filter(Propose.project_id == project.id)
|
||||
.order_by(Propose.id.desc())
|
||||
.all()
|
||||
)
|
||||
return [_serialize_propose(db, p) for p in proposes]
|
||||
from app.api.routers.proposals import list_proposals
|
||||
return list_proposals(project_id=project_id, db=db, current_user=current_user)
|
||||
|
||||
|
||||
@router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED)
|
||||
@router.post("", response_model=schemas.ProposalResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_propose(
|
||||
project_id: str,
|
||||
propose_in: schemas.ProposeCreate,
|
||||
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")
|
||||
|
||||
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 _serialize_propose(db, propose)
|
||||
from app.api.routers.proposals import create_proposal
|
||||
return create_proposal(project_id=project_id, proposal_in=proposal_in, db=db, current_user=current_user)
|
||||
|
||||
|
||||
@router.get("/{propose_id}", response_model=schemas.ProposeResponse)
|
||||
@router.get("/{propose_id}", response_model=schemas.ProposalResponse)
|
||||
def get_propose(
|
||||
project_id: str,
|
||||
propose_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")
|
||||
propose = _find_propose(db, propose_id, project.id)
|
||||
if not propose:
|
||||
raise HTTPException(status_code=404, detail="Propose not found")
|
||||
return _serialize_propose(db, propose)
|
||||
from app.api.routers.proposals import get_proposal
|
||||
return get_proposal(project_id=project_id, proposal_id=propose_id, db=db, current_user=current_user)
|
||||
|
||||
|
||||
@router.patch("/{propose_id}", response_model=schemas.ProposeResponse)
|
||||
@router.patch("/{propose_id}", response_model=schemas.ProposalResponse)
|
||||
def update_propose(
|
||||
project_id: str,
|
||||
propose_id: str,
|
||||
propose_in: schemas.ProposeUpdate,
|
||||
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")
|
||||
propose = _find_propose(db, propose_id, project.id)
|
||||
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 _serialize_propose(db, propose)
|
||||
from app.api.routers.proposals import update_proposal
|
||||
return update_proposal(project_id=project_id, proposal_id=propose_id, proposal_in=proposal_in, db=db, current_user=current_user)
|
||||
|
||||
|
||||
# ---- Actions ----
|
||||
|
||||
class AcceptRequest(schemas.BaseModel):
|
||||
milestone_id: int
|
||||
|
||||
|
||||
@router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse)
|
||||
@router.post("/{propose_id}/accept", response_model=schemas.ProposalResponse)
|
||||
def accept_propose(
|
||||
project_id: str,
|
||||
propose_id: str,
|
||||
@@ -212,76 +83,11 @@ def accept_propose(
|
||||
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."""
|
||||
project = _find_project(db, project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
propose = _find_propose(db, propose_id, project.id)
|
||||
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")
|
||||
|
||||
check_permission(db, current_user.id, project.id, "propose.accept")
|
||||
|
||||
# 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 _serialize_propose(db, propose)
|
||||
from app.api.routers.proposals import accept_proposal
|
||||
return accept_proposal(project_id=project_id, proposal_id=propose_id, body=body, db=db, current_user=current_user)
|
||||
|
||||
|
||||
class RejectRequest(schemas.BaseModel):
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
@router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse)
|
||||
@router.post("/{propose_id}/reject", response_model=schemas.ProposalResponse)
|
||||
def reject_propose(
|
||||
project_id: str,
|
||||
propose_id: str,
|
||||
@@ -289,56 +95,16 @@ def reject_propose(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
"""Reject a propose."""
|
||||
project = _find_project(db, project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
propose = _find_propose(db, propose_id, project.id)
|
||||
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")
|
||||
|
||||
check_permission(db, current_user.id, project.id, "propose.reject")
|
||||
|
||||
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 _serialize_propose(db, propose)
|
||||
from app.api.routers.proposals import reject_proposal
|
||||
return reject_proposal(project_id=project_id, proposal_id=propose_id, body=body, db=db, current_user=current_user)
|
||||
|
||||
|
||||
@router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse)
|
||||
@router.post("/{propose_id}/reopen", response_model=schemas.ProposalResponse)
|
||||
def reopen_propose(
|
||||
project_id: str,
|
||||
propose_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
"""Reopen a rejected propose back to open."""
|
||||
project = _find_project(db, project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
propose = _find_propose(db, propose_id, project.id)
|
||||
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")
|
||||
|
||||
check_permission(db, current_user.id, project.id, "propose.reopen")
|
||||
|
||||
propose.status = ProposeStatus.OPEN
|
||||
db.commit()
|
||||
db.refresh(propose)
|
||||
|
||||
log_activity(db, "reopen", "propose", propose.id, user_id=current_user.id)
|
||||
|
||||
return _serialize_propose(db, propose)
|
||||
from app.api.routers.proposals import reopen_proposal
|
||||
return reopen_proposal(project_id=project_id, proposal_id=propose_id, db=db, current_user=current_user)
|
||||
|
||||
Reference in New Issue
Block a user