7ec3c5bc8b
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.
135 lines
5.2 KiB
Python
135 lines
5.2 KiB
Python
"""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
|