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,134 @@
|
||||
"""Procedure-improvement suggestion workflow.
|
||||
|
||||
When an operator submits a round with a filled-in procedure field, the
|
||||
command(s) in it are extracted heuristically (see
|
||||
``procedure_extraction_service``) and proposed as an update to the
|
||||
originating template's suggested-procedure field. A suggestion is only
|
||||
ever created when there's a real, novel improvement to propose — nothing
|
||||
is written to the template until a lead reviews and approves it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.domain.errors import EntityNotFoundError, InvalidOperationError
|
||||
from app.models.procedure_suggestion import ProcedureSuggestion
|
||||
from app.models.test import Test
|
||||
from app.models.test_template import TestTemplate
|
||||
from app.models.user import User
|
||||
from app.services.notification_service import notify_role
|
||||
from app.services.procedure_extraction_service import extract_commands
|
||||
|
||||
# Which Test field holds the operator's free-text procedure, and which
|
||||
# TestTemplate field it's proposed as an improvement to, per team.
|
||||
_SOURCE_FIELD = {"red": "procedure_text", "blue": "detect_procedure"}
|
||||
_TEMPLATE_FIELD = {"red": "attack_procedure", "blue": "detect_suggested_procedure"}
|
||||
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
|
||||
|
||||
|
||||
def create_suggestion_from_submission(db: Session, test: Test, team: str) -> ProcedureSuggestion | None:
|
||||
"""Propose a template procedure improvement from *test*'s just-submitted round.
|
||||
|
||||
Returns the created (unflushed-to-caller-commit) suggestion, or ``None``
|
||||
when there's nothing worth proposing: the test wasn't created from a
|
||||
template, no command could be extracted, the extraction matches what
|
||||
the template already suggests, or an identical suggestion is already
|
||||
pending review. Does not commit; caller uses UnitOfWork.
|
||||
"""
|
||||
if not test.source_template_id:
|
||||
return None
|
||||
|
||||
procedure_text = getattr(test, _SOURCE_FIELD[team])
|
||||
extracted = extract_commands(procedure_text)
|
||||
if not extracted:
|
||||
return None
|
||||
|
||||
template = db.query(TestTemplate).filter(TestTemplate.id == test.source_template_id).first()
|
||||
if template is None:
|
||||
return None
|
||||
|
||||
current = (getattr(template, _TEMPLATE_FIELD[team]) or "").strip()
|
||||
if extracted == current:
|
||||
return None
|
||||
|
||||
duplicate = (
|
||||
db.query(ProcedureSuggestion)
|
||||
.filter(
|
||||
ProcedureSuggestion.template_id == template.id,
|
||||
ProcedureSuggestion.team == team,
|
||||
ProcedureSuggestion.suggested_text == extracted,
|
||||
ProcedureSuggestion.status == "pending",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if duplicate is not None:
|
||||
return None
|
||||
|
||||
submitter_id = test.red_tech_assignee if team == "red" else test.blue_tech_assignee
|
||||
suggestion = ProcedureSuggestion(
|
||||
template_id=template.id,
|
||||
team=team,
|
||||
suggested_text=extracted,
|
||||
source_test_id=test.id,
|
||||
submitted_by=submitter_id,
|
||||
)
|
||||
db.add(suggestion)
|
||||
db.flush()
|
||||
|
||||
notify_role(
|
||||
db,
|
||||
role=_LEAD_ROLE[team],
|
||||
type="procedure_suggestion",
|
||||
title="New procedure suggestion for review",
|
||||
message=f'A {team} procedure improvement was proposed for template "{template.name}".',
|
||||
entity_type="procedure_suggestion",
|
||||
entity_id=suggestion.id,
|
||||
)
|
||||
|
||||
return suggestion
|
||||
|
||||
|
||||
def list_pending_suggestions(db: Session, *, team: str | None = None) -> list[ProcedureSuggestion]:
|
||||
"""List pending suggestions, optionally filtered to one team."""
|
||||
query = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.status == "pending")
|
||||
if team is not None:
|
||||
query = query.filter(ProcedureSuggestion.team == team)
|
||||
return query.order_by(ProcedureSuggestion.created_at.asc()).all()
|
||||
|
||||
|
||||
def _get_pending_suggestion_or_raise(db: Session, suggestion_id: uuid.UUID) -> ProcedureSuggestion:
|
||||
suggestion = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.id == suggestion_id).first()
|
||||
if suggestion is None:
|
||||
raise EntityNotFoundError("ProcedureSuggestion", str(suggestion_id))
|
||||
if suggestion.status != "pending":
|
||||
raise InvalidOperationError("This suggestion has already been reviewed.")
|
||||
return suggestion
|
||||
|
||||
|
||||
def approve_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> ProcedureSuggestion:
|
||||
"""Approve a suggestion: write it into the template and mark reviewed. Does not commit."""
|
||||
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
|
||||
template = db.query(TestTemplate).filter(TestTemplate.id == suggestion.template_id).first()
|
||||
if template is None:
|
||||
raise EntityNotFoundError("TestTemplate", str(suggestion.template_id))
|
||||
|
||||
setattr(template, _TEMPLATE_FIELD[suggestion.team], suggestion.suggested_text)
|
||||
suggestion.status = "approved"
|
||||
suggestion.reviewed_by = user.id
|
||||
suggestion.reviewed_at = datetime.utcnow()
|
||||
db.flush()
|
||||
return suggestion
|
||||
|
||||
|
||||
def reject_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> ProcedureSuggestion:
|
||||
"""Reject a suggestion without touching the template. Does not commit."""
|
||||
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
|
||||
suggestion.status = "rejected"
|
||||
suggestion.reviewed_by = user.id
|
||||
suggestion.reviewed_at = datetime.utcnow()
|
||||
db.flush()
|
||||
return suggestion
|
||||
@@ -340,6 +340,12 @@ def submit_red_evidence(db: Session, test: Test, user: User) -> Test:
|
||||
except Exception as e:
|
||||
logger.warning("Jira RT end date push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.procedure_suggestion_service import create_suggestion_from_submission
|
||||
create_suggestion_from_submission(db, test, "red")
|
||||
except Exception as e:
|
||||
logger.warning("Procedure suggestion extraction failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
@@ -577,6 +583,12 @@ def submit_blue_evidence(db: Session, test: Test, user: User) -> Test:
|
||||
except Exception as e:
|
||||
logger.warning("Jira BT end date push failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
try:
|
||||
from app.services.procedure_suggestion_service import create_suggestion_from_submission
|
||||
create_suggestion_from_submission(db, test, "blue")
|
||||
except Exception as e:
|
||||
logger.warning("Procedure suggestion extraction failed for test %s: %s", test.id, e, exc_info=True)
|
||||
|
||||
return test
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user