"""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, and approving one only ever appends new commands, never overwrites or drops whatever earlier approved rounds already contributed. """ 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": "expected_detection"} _LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"} def _new_lines(current: str | None, suggested: str) -> list[str]: """Lines in *suggested* not already present verbatim in *current*. Line-level, not whole-text: a later round's extraction is usually a different subset/superset of commands than an earlier one, so an exact string comparison would treat almost every resubmission as "novel" and then, on approval, blow away whatever an earlier approved round already contributed. """ existing = {line.strip() for line in (current or "").splitlines() if line.strip()} seen: set[str] = set() new: list[str] = [] for line in suggested.splitlines(): stripped = line.strip() if not stripped or stripped in existing or stripped in seen: continue new.append(stripped) seen.add(stripped) return new def _merge_procedure_text(current: str | None, suggested: str) -> str: """Append genuinely new lines from *suggested* onto *current*. Approving a suggestion must only ever grow a template's procedure, never replace it — otherwise a narrower extraction from a later round would silently erase commands an earlier approved round already added. """ current_stripped = (current or "").strip() new = _new_lines(current, suggested) if not new: return current_stripped if not current_stripped: return "\n".join(new) return current_stripped + "\n" + "\n".join(new) 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]) if not _new_lines(current, extracted): 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)) current_text = getattr(template, _TEMPLATE_FIELD[suggestion.team]) setattr(template, _TEMPLATE_FIELD[suggestion.team], _merge_procedure_text(current_text, 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