diff --git a/app/api/routers/proposes.py b/app/api/routers/proposes.py new file mode 100644 index 0000000..a2829ca --- /dev/null +++ b/app/api/routers/proposes.py @@ -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 diff --git a/app/main.py b/app/main.py index 09ef894..3011d47 100644 --- a/app/main.py +++ b/app/main.py @@ -37,6 +37,7 @@ from app.api.routers.misc import router as misc_router from app.api.routers.monitor import router as monitor_router from app.api.routers.milestones import router as milestones_router from app.api.routers.roles import router as roles_router +from app.api.routers.proposes import router as proposes_router app.include_router(auth_router) app.include_router(tasks_router) @@ -48,6 +49,7 @@ app.include_router(misc_router) app.include_router(monitor_router) app.include_router(milestones_router) app.include_router(roles_router) +app.include_router(proposes_router) # Auto schema migration for lightweight deployments