feat: add code-first API support for projects, milestones, proposes, tasks
- Projects: get/update/delete/members endpoints now accept project_code - Milestones: all project-scoped and top-level endpoints accept milestone_code - Proposes: all endpoints accept project_code and propose_code - Tasks: code-first support for all CRUD + transition + take + search - Schemas: add code/type/due_date/project_code/milestone_code/taken_by fields - All endpoints use id-or-code lookup helpers for backward compatibility - Milestone serializer now includes milestone_code and code fields - Task serializer enriches responses with project_code, milestone_code, taken_by Addresses TODO §2.1: code-first API support across CLI-targeted resources
This commit is contained in:
@@ -17,6 +17,36 @@ from app.services.activity import log_activity
|
||||
router = APIRouter(prefix="/projects/{project_id}/proposes", tags=["Proposes"])
|
||||
|
||||
|
||||
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()
|
||||
@@ -48,14 +78,17 @@ def _can_edit_propose(db: Session, user_id: int, propose: Propose) -> bool:
|
||||
|
||||
@router.get("", response_model=List[schemas.ProposeResponse])
|
||||
def list_proposes(
|
||||
project_id: int,
|
||||
project_id: str,
|
||||
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")
|
||||
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)
|
||||
.filter(Propose.project_id == project.id)
|
||||
.order_by(Propose.id.desc())
|
||||
.all()
|
||||
)
|
||||
@@ -64,20 +97,23 @@ def list_proposes(
|
||||
|
||||
@router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_propose(
|
||||
project_id: int,
|
||||
project_id: str,
|
||||
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")
|
||||
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_code = _generate_propose_code(db, project.id)
|
||||
|
||||
propose = Propose(
|
||||
title=propose_in.title,
|
||||
description=propose_in.description,
|
||||
status=ProposeStatus.OPEN,
|
||||
project_id=project_id,
|
||||
project_id=project.id,
|
||||
created_by_id=current_user.id,
|
||||
propose_code=propose_code,
|
||||
)
|
||||
@@ -92,13 +128,16 @@ def create_propose(
|
||||
|
||||
@router.get("/{propose_id}", response_model=schemas.ProposeResponse)
|
||||
def get_propose(
|
||||
project_id: int,
|
||||
propose_id: int,
|
||||
project_id: str,
|
||||
propose_id: str,
|
||||
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()
|
||||
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 propose
|
||||
@@ -106,13 +145,16 @@ def get_propose(
|
||||
|
||||
@router.patch("/{propose_id}", response_model=schemas.ProposeResponse)
|
||||
def update_propose(
|
||||
project_id: int,
|
||||
propose_id: int,
|
||||
project_id: str,
|
||||
propose_id: str,
|
||||
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()
|
||||
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")
|
||||
|
||||
@@ -146,14 +188,17 @@ class AcceptRequest(schemas.BaseModel):
|
||||
|
||||
@router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse)
|
||||
def accept_propose(
|
||||
project_id: int,
|
||||
propose_id: int,
|
||||
project_id: str,
|
||||
propose_id: str,
|
||||
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()
|
||||
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")
|
||||
|
||||
@@ -161,12 +206,12 @@ def accept_propose(
|
||||
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")
|
||||
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,
|
||||
Milestone.project_id == project.id,
|
||||
).first()
|
||||
if not milestone:
|
||||
raise HTTPException(status_code=404, detail="Milestone not found in this project")
|
||||
@@ -189,7 +234,7 @@ def accept_propose(
|
||||
task_subtype="feature",
|
||||
status=TaskStatus.PENDING,
|
||||
priority=TaskPriority.MEDIUM,
|
||||
project_id=project_id,
|
||||
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,
|
||||
@@ -220,14 +265,17 @@ class RejectRequest(schemas.BaseModel):
|
||||
|
||||
@router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse)
|
||||
def reject_propose(
|
||||
project_id: int,
|
||||
propose_id: int,
|
||||
project_id: str,
|
||||
propose_id: str,
|
||||
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()
|
||||
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")
|
||||
|
||||
@@ -235,7 +283,7 @@ def reject_propose(
|
||||
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")
|
||||
check_permission(db, current_user.id, project.id, "propose.reject")
|
||||
|
||||
propose.status = ProposeStatus.REJECTED
|
||||
db.commit()
|
||||
@@ -250,13 +298,16 @@ def reject_propose(
|
||||
|
||||
@router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse)
|
||||
def reopen_propose(
|
||||
project_id: int,
|
||||
propose_id: int,
|
||||
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."""
|
||||
propose = db.query(Propose).filter(Propose.id == propose_id, Propose.project_id == project_id).first()
|
||||
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")
|
||||
|
||||
@@ -264,7 +315,7 @@ def reopen_propose(
|
||||
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")
|
||||
check_permission(db, current_user.id, project.id, "propose.reopen")
|
||||
|
||||
propose.status = ProposeStatus.OPEN
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user