diff --git a/backend/app/domain/test_entity.py b/backend/app/domain/test_entity.py index 7fdc9e6..de9ab24 100644 --- a/backend/app/domain/test_entity.py +++ b/backend/app/domain/test_entity.py @@ -672,6 +672,27 @@ class TestEntity: self._events.append(DomainEvent("dispute_resolved_to_rework", {"target": target})) + def resolve_dispute_by_manager(self, outcome: str) -> None: + """A manager makes the final call on a disputed test, ending the standoff. + + Unlike ``resolve_dispute_reject`` (a lead flipping their own vote), + this bypasses both leads entirely — the manager's decision is final + and terminal, going straight to ``validated`` or the normal + ``rejected`` dead-end (which any lead can later reopen via the + standard reopen-to-draft flow, same as any other rejection). + + Args: + outcome (str): ``"validated"`` or ``"rejected"``. + """ + if outcome not in ("validated", "rejected"): + raise InvalidOperationError("outcome must be 'validated' or 'rejected'") + + target_state = TestState.validated if outcome == "validated" else TestState.rejected + self._transition(target_state) + self._events.append( + DomainEvent("dual_validation_approved" if outcome == "validated" else "dual_validation_rejected") + ) + # -- Private ------------------------------------------------------- def _auto_resume(self) -> int: diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 41c5640..8362a41 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -65,6 +65,7 @@ from app.schemas.test import ( TestClassificationUpdate, TestCreate, TestHold, + TestManagerResolveDispute, TestOut, TestRedReview, TestRedUpdate, @@ -152,6 +153,7 @@ from app.services.test_workflow_service import ( validate_as_red_lead as wf_validate_red, validate_as_blue_lead as wf_validate_blue, resolve_dispute as wf_resolve_dispute, + resolve_dispute_by_manager as wf_resolve_dispute_by_manager, reopen_test as wf_reopen, handle_remediation_completed as wf_handle_remediation, get_retest_chain as wf_get_retest_chain, @@ -1192,6 +1194,78 @@ def resolve_dispute( return test +# --------------------------------------------------------------------------- +# POST /tests/{id}/escalate-to-manager — disputed: notify managers to step in +# --------------------------------------------------------------------------- + + +@router.post("/{test_id}/escalate-to-manager") +def escalate_to_manager( + test_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")), +): + """Either lead on a disputed test can escalate it for a manager to decide. + + Unlike the automatic manager notification sent when a test first becomes + disputed, this is an explicit, repeatable nudge — a lead pulls this when + peer discussion (Request Discussion) isn't going anywhere. + """ + from app.services.notification_service import notify_role_with_email + + test = crud_get_test_or_raise(db, test_id) + + if test.state.value != "disputed": + from app.domain.errors import BusinessRuleViolation + raise BusinessRuleViolation("Test is not in disputed state") + + try: + notify_role_with_email( + db, + role="manager", + type="validation_disputed", + title="Dispute escalated — needs your decision", + message=( + f'{current_user.username} escalated test "{test.name}" — the leads could not ' + f'resolve their disagreement. Please review and decide the final outcome.' + ), + entity_type="test", + entity_id=test.id, + ) + except Exception as e: + import logging + logging.getLogger(__name__).warning("Failed to notify managers of escalation: %s", e) + + log_action( + db, user_id=current_user.id, action="escalate_dispute_to_manager", + entity_type="test", entity_id=test.id, details={"test_name": test.name}, + ) + db.commit() + + return {"status": "escalated", "message": "Managers have been notified"} + + +# --------------------------------------------------------------------------- +# POST /tests/{id}/manager-resolve-dispute — manager makes the final call +# --------------------------------------------------------------------------- + + +@router.post("/{test_id}/manager-resolve-dispute", response_model=TestOut) +def manager_resolve_dispute( + test_id: uuid.UUID, + payload: TestManagerResolveDispute, + db: Session = Depends(get_db), + current_user: User = Depends(require_any_role_strict("manager")), +) -> TestOut: + """A manager decides the final outcome of a disputed test directly.""" + test = crud_get_test_with_technique(db, test_id) + with UnitOfWork(db) as uow: + test = wf_resolve_dispute_by_manager(db, test, current_user, payload.outcome, notes=payload.notes) + uow.commit() + db.refresh(test) + return test + + # --------------------------------------------------------------------------- # POST /tests/{id}/reopen — rejected → draft # --------------------------------------------------------------------------- diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py index cf9c913..268475c 100644 --- a/backend/app/schemas/test.py +++ b/backend/app/schemas/test.py @@ -185,6 +185,13 @@ class TestResolveDispute(BaseModel): notes: str | None = None +class TestManagerResolveDispute(BaseModel): + """Payload sent by a manager making the final call on a disputed test.""" + + outcome: str # "validated" | "rejected" + notes: str | None = None + + # ── Red Lead review gate (pre-Blue-Team) ──────────────────────────── diff --git a/backend/app/services/test_workflow_service.py b/backend/app/services/test_workflow_service.py index b19f5c2..b5f3386 100644 --- a/backend/app/services/test_workflow_service.py +++ b/backend/app/services/test_workflow_service.py @@ -1341,6 +1341,50 @@ def resolve_dispute(db: Session, test: Test, user: User, target_team: str, notes return test +def resolve_dispute_by_manager(db: Session, test: Test, user: User, outcome: str, notes: str | None = None) -> Test: + """A manager makes the final call on a disputed test — validated or rejected. + + Unlike ``resolve_dispute`` (only the approving lead flipping their own + vote), this is a manager override that ends the standoff directly, + without either lead having to concede. Reuses the same dual-validation + event dispatch as a normal in_review resolution so Jira/notifications + behave identically to an ordinary validated/rejected outcome. + """ + if test.state != TestState.disputed: + raise InvalidOperationError("Test is not in disputed state") + + entity = TestEntity.from_orm(test) + entity.resolve_dispute_by_manager(outcome) + entity.apply_to(test) + db.flush() + + log_action( + db, user_id=user.id, action="manager_resolve_dispute", + entity_type="test", entity_id=test.id, + details={"outcome": outcome, "notes": notes, "test_name": test.name}, + ) + + _dispatch_dual_validation_effects(db, test, entity, actor=user) + + # Let both leads know the manager made the final call, not just whichever + # side "won" — the losing side needs to know just as much. + for lead_id in filter(None, {test.red_validated_by, test.blue_validated_by}): + try: + create_notification( + db, user_id=lead_id, type="manager_dispute_resolution", + title=f"Manager resolved disputed test as {outcome}", + message=( + f'{user.username} resolved the dispute on "{test.name}" as {outcome}.' + + (f" Notes: {notes}" if notes else "") + ), + entity_type="test", entity_id=test.id, + ) + except Exception as e: + logger.warning("Notification failed for test %s: %s", test.id, e, exc_info=True) + + return test + + # Define function handle_remediation_completed def handle_remediation_completed(db: Session, test: Test, user: User) -> Test | None: """Create a re-test when remediation is completed. diff --git a/backend/tests/test_resolve_dispute_router.py b/backend/tests/test_resolve_dispute_router.py index 4b1757f..7bb7ebe 100644 --- a/backend/tests/test_resolve_dispute_router.py +++ b/backend/tests/test_resolve_dispute_router.py @@ -112,3 +112,96 @@ def test_resolve_dispute_routes_to_red_queue( assert body["state"] == "red_executing" assert body["red_validation_status"] is None assert body["blue_validation_status"] is None + + +def test_escalate_to_manager_notifies_managers( + client, db, api, auth_headers, red_tech_headers, red_lead_headers, + blue_tech_headers, blue_lead_headers, manager_headers, manager_user, technique, +): + test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers, + blue_tech_headers, blue_lead_headers, technique) + + resp = api("post", f"/api/v1/tests/{test_id}/escalate-to-manager", red_lead_headers) + assert resp.status_code == 200, resp.text + assert resp.json()["status"] == "escalated" + + notifications = api("get", "/api/v1/notifications", manager_headers).json() + assert any("escalated" in (n.get("title") or "").lower() for n in notifications) + + +def test_escalate_to_manager_requires_disputed_state( + client, db, api, auth_headers, red_tech_headers, red_lead_headers, technique, +): + resp = api( + "post", "/api/v1/tests", red_lead_headers, + json={"technique_id": technique, "name": "Not disputed"}, + ) + test_id = resp.json()["id"] + + resp = api("post", f"/api/v1/tests/{test_id}/escalate-to-manager", red_lead_headers) + assert resp.status_code == 400 + + +def test_manager_resolve_dispute_as_validated( + client, db, api, auth_headers, red_tech_headers, red_lead_headers, + blue_tech_headers, blue_lead_headers, manager_headers, technique, +): + test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers, + blue_tech_headers, blue_lead_headers, technique) + + resp = api( + "post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", manager_headers, + json={"outcome": "validated", "notes": "Detection was sufficient after all"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["state"] == "validated" + + red_notifs = api("get", "/api/v1/notifications", red_lead_headers).json() + assert any(n["type"] == "manager_dispute_resolution" for n in red_notifs) + blue_notifs = api("get", "/api/v1/notifications", blue_lead_headers).json() + assert any(n["type"] == "manager_dispute_resolution" for n in blue_notifs) + + +def test_manager_resolve_dispute_as_rejected( + client, db, api, auth_headers, red_tech_headers, red_lead_headers, + blue_tech_headers, blue_lead_headers, manager_headers, technique, +): + test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers, + blue_tech_headers, blue_lead_headers, technique) + + resp = api( + "post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", manager_headers, + json={"outcome": "rejected"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["state"] == "rejected" + + +def test_manager_resolve_dispute_forbidden_for_lead( + client, db, api, auth_headers, red_tech_headers, red_lead_headers, + blue_tech_headers, blue_lead_headers, technique, +): + test_id = _reach_disputed(client, db, api, auth_headers, red_tech_headers, red_lead_headers, + blue_tech_headers, blue_lead_headers, technique) + + resp = api( + "post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", red_lead_headers, + json={"outcome": "validated"}, + ) + assert resp.status_code == 403 + + +def test_manager_resolve_dispute_requires_disputed_state( + client, db, api, red_lead_headers, manager_headers, technique, +): + resp = api( + "post", "/api/v1/tests", red_lead_headers, + json={"technique_id": technique, "name": "Not disputed either"}, + ) + test_id = resp.json()["id"] + + resp = api( + "post", f"/api/v1/tests/{test_id}/manager-resolve-dispute", manager_headers, + json={"outcome": "validated"}, + ) + assert resp.status_code == 400 diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index 521b2f6..e4a6d34 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -319,6 +319,26 @@ export async function resolveDispute(testId: string, payload: ResolveDisputePayl return data; } +/** Either lead on a disputed test asks a manager to step in and decide. */ +export async function escalateToManager(testId: string): Promise<{ status: string; message: string }> { + const { data } = await client.post(`/tests/${testId}/escalate-to-manager`); + return data; +} + +export interface ManagerResolveDisputePayload { + outcome: "validated" | "rejected"; + notes?: string; +} + +/** A manager decides the final outcome of a disputed test directly. */ +export async function managerResolveDispute( + testId: string, + payload: ManagerResolveDisputePayload, +): Promise { + const { data } = await client.post(`/tests/${testId}/manager-resolve-dispute`, payload); + return data; +} + // ── Reopen ───────────────────────────────────────────────────────── /** Reopen a rejected test — moves back to draft. */ diff --git a/frontend/src/components/test-detail/ManagerDecisionModal.tsx b/frontend/src/components/test-detail/ManagerDecisionModal.tsx new file mode 100644 index 0000000..62ded8e --- /dev/null +++ b/frontend/src/components/test-detail/ManagerDecisionModal.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; +import { CheckCircle, XCircle, Loader2, X } from "lucide-react"; + +// ── Props ────────────────────────────────────────────────────────── + +interface ManagerDecisionModalProps { + outcome: "validated" | "rejected"; + isSubmitting: boolean; + onSubmit: (notes: string) => void; + onClose: () => void; +} + +// ── Component ────────────────────────────────────────────────────── + +export default function ManagerDecisionModal({ + outcome, + isSubmitting, + onSubmit, + onClose, +}: ManagerDecisionModalProps) { + const [notes, setNotes] = useState(""); + const isValidating = outcome === "validated"; + + return ( +
+
+ {/* Header */} +
+
+ {isValidating ? ( + + ) : ( + + )} +

+ {isValidating ? "Validate This Test" : "Reject This Test"} +

+
+ +
+ + {/* Body */} +
+

+ The two leads couldn't agree — you're making the final call as manager. + This ends the dispute immediately, without either lead having to change their vote. +

+ +
+ +