"""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 return { "id": proposal.id, "title": proposal.title, "description": proposal.description, "propose_code": proposal.propose_code, "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, "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)