"""Router for procedure-improvement suggestion review. Endpoints --------- GET /procedure-suggestions — list pending suggestions (lead/admin) POST /procedure-suggestions/{id}/approve — write suggestion into template (lead/admin) POST /procedure-suggestions/{id}/reject — dismiss suggestion (lead/admin) A red_lead only ever sees/acts on "red" team suggestions, a blue_lead only "blue" — admins can see and act on both. """ import uuid from fastapi import APIRouter, Depends, HTTPException from app.database import get_db from app.dependencies.auth import require_any_role_strict from app.domain.errors import EntityNotFoundError, InvalidOperationError from app.domain.unit_of_work import UnitOfWork from app.models.procedure_suggestion import ProcedureSuggestion from app.models.test_template import TestTemplate from app.models.user import User from app.schemas.procedure_suggestion import ProcedureSuggestionOut from app.services.audit_service import log_action from app.services.procedure_suggestion_service import ( approve_suggestion as approve_suggestion_svc, ) from app.services.procedure_suggestion_service import ( list_pending_suggestions, ) from app.services.procedure_suggestion_service import ( reject_suggestion as reject_suggestion_svc, ) from sqlalchemy.orm import Session router = APIRouter(prefix="/procedure-suggestions", tags=["procedure-suggestions"]) def _team_for_role(user: User) -> str | None: """Return the single team a non-admin lead is scoped to, or None for admin (both).""" if user.role == "red_lead": return "red" if user.role == "blue_lead": return "blue" return None def _to_out(suggestion, template_by_id: dict) -> ProcedureSuggestionOut: out = ProcedureSuggestionOut.model_validate(suggestion) template = template_by_id.get(suggestion.template_id) if template is not None: out.template_name = template.name out.template_current_text = ( template.attack_procedure if suggestion.team == "red" else template.expected_detection ) return out @router.get("", response_model=list[ProcedureSuggestionOut]) def list_suggestions( db: Session = Depends(get_db), current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")), ) -> list[ProcedureSuggestionOut]: """List pending procedure suggestions, scoped to the caller's team (admins see both).""" team = _team_for_role(current_user) suggestions = list_pending_suggestions(db, team=team) template_ids = {s.template_id for s in suggestions} templates = ( db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else [] ) template_by_id = {t.id: t for t in templates} return [_to_out(s, template_by_id) for s in suggestions] @router.get("/for-test/{test_id}", response_model=list[ProcedureSuggestionOut]) def list_suggestions_for_test( test_id: uuid.UUID, db: Session = Depends(get_db), current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")), ) -> list[ProcedureSuggestionOut]: """List pending suggestions tied to a specific test, scoped to the caller's team (admins see both) — powers the blocking review popup a lead sees when opening a test that has one awaiting them.""" team = _team_for_role(current_user) suggestions = list_pending_suggestions(db, team=team, source_test_id=test_id) template_ids = {s.template_id for s in suggestions} templates = ( db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else [] ) template_by_id = {t.id: t for t in templates} return [_to_out(s, template_by_id) for s in suggestions] @router.post("/{suggestion_id}/approve", response_model=ProcedureSuggestionOut) def approve_suggestion( suggestion_id: uuid.UUID, db: Session = Depends(get_db), current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")), ) -> ProcedureSuggestionOut: """Approve a suggestion, writing it into its template's suggested-procedure field.""" _check_team_permission(current_user, _team_or_404(db, suggestion_id)) try: with UnitOfWork(db) as uow: suggestion = approve_suggestion_svc(db, suggestion_id, current_user) log_action( db, user_id=current_user.id, action="approve_procedure_suggestion", entity_type="procedure_suggestion", entity_id=suggestion.id, details={"template_id": str(suggestion.template_id), "team": suggestion.team}, ) uow.commit() except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) from e except InvalidOperationError as e: raise HTTPException(status_code=400, detail=str(e)) from e db.refresh(suggestion) template = db.query(TestTemplate).filter(TestTemplate.id == suggestion.template_id).first() return _to_out(suggestion, {template.id: template} if template else {}) @router.post("/{suggestion_id}/reject", response_model=ProcedureSuggestionOut) def reject_suggestion( suggestion_id: uuid.UUID, db: Session = Depends(get_db), current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")), ) -> ProcedureSuggestionOut: """Reject a suggestion without touching the template.""" _check_team_permission(current_user, _team_or_404(db, suggestion_id)) try: with UnitOfWork(db) as uow: suggestion = reject_suggestion_svc(db, suggestion_id, current_user) log_action( db, user_id=current_user.id, action="reject_procedure_suggestion", entity_type="procedure_suggestion", entity_id=suggestion.id, details={"template_id": str(suggestion.template_id), "team": suggestion.team}, ) uow.commit() except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) from e except InvalidOperationError as e: raise HTTPException(status_code=400, detail=str(e)) from e db.refresh(suggestion) return _to_out(suggestion, {}) def _team_or_404(db: Session, suggestion_id: uuid.UUID) -> str: """Look up just the team of a suggestion, for a permission check before mutating it.""" suggestion = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.id == suggestion_id).first() if suggestion is None: raise HTTPException(status_code=404, detail="Procedure suggestion not found") return suggestion.team def _check_team_permission(user: User, team: str) -> None: """Raise 403 if a non-admin lead tries to act on the other team's suggestion.""" scoped_team = _team_for_role(user) if scoped_team is not None and scoped_team != team: raise HTTPException(status_code=403, detail=f"Not authorized to review {team} team suggestions")