"""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.essential import Essential 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_essential(e: Essential) -> dict: """Serialize an Essential for embedding in Proposal detail.""" return { "id": e.id, "essential_code": e.essential_code, "proposal_id": e.proposal_id, "type": e.type.value if hasattr(e.type, "value") else e.type, "title": e.title, "description": e.description, "created_by_id": e.created_by_id, "created_at": e.created_at, "updated_at": e.updated_at, } def _serialize_proposal(db: Session, proposal: Proposal, *, include_essentials: bool = False) -> 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 code = proposal.propose_code # DB column; also exposed as proposal_code result = { "id": proposal.id, "title": proposal.title, "description": proposal.description, "proposal_code": code, # preferred name "propose_code": code, # backward compat "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, # DEPRECATED — read-only compat "created_at": proposal.created_at, "updated_at": proposal.updated_at, } if include_essentials: essentials = ( db.query(Essential) .filter(Essential.proposal_id == proposal.id) .order_by(Essential.id.asc()) .all() ) result["essentials"] = [_serialize_essential(e) for e in essentials] return result 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.ProposalDetailResponse) def get_proposal( project_id: str, proposal_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """Get a single Proposal with its Essentials list embedded.""" 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, include_essentials=True) @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.ProposalAcceptResponse) 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: generate story tasks from all Essentials into the chosen milestone. Each Essential under the Proposal produces a corresponding ``story/*`` task: - feature → story/feature - improvement → story/improvement - refactor → story/refactor All tasks are created in a single transaction. The Proposal must have at least one Essential to be accepted. """ 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") # Fetch all Essentials for this Proposal essentials = ( db.query(Essential) .filter(Essential.proposal_id == proposal.id) .order_by(Essential.id.asc()) .all() ) if not essentials: raise HTTPException( status_code=400, detail="Proposal has no Essentials. Add at least one Essential before accepting.", ) # Map Essential type → task subtype ESSENTIAL_TYPE_TO_SUBTYPE = { "feature": "feature", "improvement": "improvement", "refactor": "refactor", } # Determine next task number in this milestone milestone_code = milestone.milestone_code or f"m{milestone.id}" max_task = ( db.query(sa_func.max(Task.id)) .filter(Task.milestone_id == milestone.id) .scalar() ) next_num = (max_task + 1) if max_task else 1 # Create one story task per Essential — all within the current transaction generated_tasks = [] for essential in essentials: etype = essential.type.value if hasattr(essential.type, "value") else essential.type task_subtype = ESSENTIAL_TYPE_TO_SUBTYPE.get(etype, "feature") task_code = f"{milestone_code}:T{next_num:05x}" task = Task( title=essential.title, description=essential.description, task_type="story", task_subtype=task_subtype, 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() # materialise task.id generated_tasks.append({ "task_id": task.id, "task_code": task_code, "task_type": "story", "task_subtype": task_subtype, "title": essential.title, "essential_id": essential.id, "essential_code": essential.essential_code, }) next_num = task.id + 1 # use real id for next code to stay consistent # Update proposal status (do NOT write feat_task_id — deprecated) proposal.status = ProposalStatus.ACCEPTED db.commit() db.refresh(proposal) log_activity(db, "accept", "proposal", proposal.id, user_id=current_user.id, details={ "milestone_id": milestone.id, "generated_tasks": [ {"task_id": t["task_id"], "task_code": t["task_code"], "essential_id": t["essential_id"]} for t in generated_tasks ], }) result = _serialize_proposal(db, proposal, include_essentials=True) result["generated_tasks"] = generated_tasks return result 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)