feat(tests): auto-propose procedure suggestions from submitted rounds, add lead review workflow
When Red or Blue submits a round for a test created from a template, any extractable command in their procedure text is proposed as an update to the template's suggested procedure — never written automatically. Adds GET/approve/reject endpoints scoped so a lead only reviews their own team's suggestions; approving writes the suggested text into the template, rejecting leaves it untouched.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""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.detect_suggested_procedure
|
||||
)
|
||||
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.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")
|
||||
Reference in New Issue
Block a user