"""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, check_permission, 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 _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]) 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] @router.post("", response_model=schemas.ProposeResponse, status_code=status.HTTP_201_CREATED) def create_propose( project_id: str, propose_in: schemas.ProposeCreate, 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) @router.get("/{propose_id}", response_model=schemas.ProposeResponse) 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) @router.patch("/{propose_id}", response_model=schemas.ProposeResponse) def update_propose( 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), ): 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) # ---- Actions ---- class AcceptRequest(schemas.BaseModel): milestone_id: int @router.post("/{propose_id}/accept", response_model=schemas.ProposeResponse) def accept_propose( 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.""" 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) class RejectRequest(schemas.BaseModel): reason: str | None = None @router.post("/{propose_id}/reject", response_model=schemas.ProposeResponse) def reject_propose( 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.""" 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) @router.post("/{propose_id}/reopen", response_model=schemas.ProposeResponse) 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)