feat(disputed): Confirm My Validation button + discussion request modal
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Backend: POST /tests/{id}/request-discussion
- Only callable by the lead whose vote is 'approved' in a disputed test
- Sends notification to the rejecting lead: 'Lead X confirms their
approval and wants to discuss your rejection'
- Logs the action in audit trail
Frontend:
- 'Confirm My Validation' button (amber outline) alongside 'Change to Rejected'
- Opens a modal showing:
* Explanation: both leads must agree to finalise
* Other lead's rejection reason/notes
* What happens next (stays disputed, notification sent, either can change)
- 'Send Discussion Request' → calls the new endpoint → shows success state:
'Lead username has been notified...'
- Instruction to reach out via team channels to resolve offline
Flow summary for disputed tests:
Approving lead sees 2 options:
a) 'Confirm My Validation' → modal → send request → other lead notified
b) 'Change to Rejected' → validation modal → both agree to reject → rejected
This commit is contained in:
@@ -745,6 +745,97 @@ def sync_tempo(
|
|||||||
return {"results": results}
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /tests/{id}/request-discussion — disputed: confirm vote + notify other lead
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{test_id}/request-discussion")
|
||||||
|
def request_discussion(
|
||||||
|
test_id: uuid.UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||||
|
):
|
||||||
|
"""Called when the approving lead confirms their vote in a disputed test.
|
||||||
|
|
||||||
|
Sends a notification to the other lead (who rejected) asking them to
|
||||||
|
discuss and resolve the conflict. The test remains in 'disputed' state.
|
||||||
|
"""
|
||||||
|
from app.models.enums import TestState as ModelTestState
|
||||||
|
from app.models.user import User as UserModel
|
||||||
|
from app.services.notification_service import create_notification
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
role = current_user.role
|
||||||
|
|
||||||
|
# Identify who the "other lead" is (the one who rejected)
|
||||||
|
if role == "red_lead" and test.red_validation_status == "approved":
|
||||||
|
# Red approved, Blue rejected → notify Blue Lead who rejected
|
||||||
|
rejector_id = test.blue_validated_by
|
||||||
|
rejector_label = "Blue Lead"
|
||||||
|
requester_label = "Red Lead"
|
||||||
|
elif role == "blue_lead" and test.blue_validation_status == "approved":
|
||||||
|
# Blue approved, Red rejected → notify Red Lead who rejected
|
||||||
|
rejector_id = test.red_validated_by
|
||||||
|
rejector_label = "Red Lead"
|
||||||
|
requester_label = "Blue Lead"
|
||||||
|
else:
|
||||||
|
from app.domain.errors import BusinessRuleViolation
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
"You are not the approving lead in this conflict or the state is inconsistent"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Look up the rejecting lead's username for the message
|
||||||
|
rejector = (
|
||||||
|
db.query(UserModel).filter(UserModel.id == rejector_id).first()
|
||||||
|
if rejector_id else None
|
||||||
|
)
|
||||||
|
rejector_name = rejector.username if rejector else rejector_label
|
||||||
|
|
||||||
|
# Notify the rejecting lead
|
||||||
|
if rejector_id:
|
||||||
|
try:
|
||||||
|
create_notification(
|
||||||
|
db,
|
||||||
|
user_id=rejector_id,
|
||||||
|
type="validation_conflict",
|
||||||
|
title="Discussion requested on disputed test",
|
||||||
|
message=(
|
||||||
|
f"{requester_label} ({current_user.username}) is confirming their approval "
|
||||||
|
f"of test '{test.name}' and wants to discuss your rejection with you. "
|
||||||
|
f"Please reach out to resolve the disagreement."
|
||||||
|
),
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=str(test.id),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"Failed to send discussion notification: %s", e
|
||||||
|
)
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="request_dispute_discussion",
|
||||||
|
entity_type="test",
|
||||||
|
entity_id=test.id,
|
||||||
|
details={"test_name": test.name, "rejector": rejector_name},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "notification_sent",
|
||||||
|
"message": f"Discussion request sent to {rejector_name}",
|
||||||
|
"rejector_username": rejector_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# POST /tests/import-rt — bulk import from a real Red Team engagement
|
# POST /tests/import-rt — bulk import from a real Red Team engagement
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -308,6 +308,16 @@ export interface RTImportResult {
|
|||||||
engagement: string;
|
engagement: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Confirm approval in a disputed test and notify the rejecting lead to discuss. */
|
||||||
|
export async function requestDiscussion(testId: string): Promise<{
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
rejector_username: string;
|
||||||
|
}> {
|
||||||
|
const { data } = await client.post(`/tests/${testId}/request-discussion`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
/** Import results from a real Red Team engagement. */
|
/** Import results from a real Red Team engagement. */
|
||||||
export async function importRT(payload: RTImportPayload): Promise<RTImportResult> {
|
export async function importRT(payload: RTImportPayload): Promise<RTImportResult> {
|
||||||
const { data } = await client.post<RTImportResult>("/tests/import-rt", payload);
|
const { data } = await client.post<RTImportResult>("/tests/import-rt", payload);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
Play,
|
Play,
|
||||||
@@ -10,7 +11,11 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
X,
|
||||||
|
UserCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { requestDiscussion } from "../../api/tests";
|
||||||
import type { Test, TestState, User } from "../../types/models";
|
import type { Test, TestState, User } from "../../types/models";
|
||||||
import LiveTimer from "./LiveTimer";
|
import LiveTimer from "./LiveTimer";
|
||||||
|
|
||||||
@@ -81,6 +86,13 @@ export default function TestDetailHeader({
|
|||||||
}: TestDetailHeaderProps) {
|
}: TestDetailHeaderProps) {
|
||||||
const role = user?.role ?? "";
|
const role = user?.role ?? "";
|
||||||
const currentIdx = STATE_INDEX[test.state];
|
const currentIdx = STATE_INDEX[test.state];
|
||||||
|
const [showDiscussModal, setShowDiscussModal] = useState(false);
|
||||||
|
const [discussResult, setDiscussResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const discussMutation = useMutation({
|
||||||
|
mutationFn: () => requestDiscussion(test.id),
|
||||||
|
onSuccess: (data) => setDiscussResult(data.rejector_username),
|
||||||
|
});
|
||||||
|
|
||||||
const formatDate = (d: string | null) => {
|
const formatDate = (d: string | null) => {
|
||||||
if (!d) return null;
|
if (!d) return null;
|
||||||
@@ -227,14 +239,24 @@ export default function TestDetailHeader({
|
|||||||
const side = role === "red_lead" ? "red" : role === "blue_lead" ? "blue" : null;
|
const side = role === "red_lead" ? "red" : role === "blue_lead" ? "blue" : null;
|
||||||
if (side || role === "admin") {
|
if (side || role === "admin") {
|
||||||
buttons.push(
|
buttons.push(
|
||||||
<button
|
<div key="disputed-actions" className="flex items-center gap-2">
|
||||||
key="change-vote"
|
{/* Confirm keeping approval → open discussion modal */}
|
||||||
onClick={() => onOpenValidateModal(side ?? (test.red_validation_status === "approved" ? "red" : "blue"))}
|
<button
|
||||||
className="flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500 transition-colors"
|
onClick={() => { setShowDiscussModal(true); setDiscussResult(null); }}
|
||||||
>
|
className="flex items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/20 transition-colors"
|
||||||
<XCircle className="h-4 w-4" />
|
>
|
||||||
Change Vote to Rejected
|
<UserCheck className="h-4 w-4" />
|
||||||
</button>,
|
Confirm My Validation
|
||||||
|
</button>
|
||||||
|
{/* Change vote to rejected */}
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenValidateModal(side ?? (test.red_validation_status === "approved" ? "red" : "blue"))}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-red-700/80 px-4 py-2 text-sm font-medium text-white hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
Change to Rejected
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -458,6 +480,108 @@ export default function TestDetailHeader({
|
|||||||
This test was rejected and needs to be reopened to restart the workflow.
|
This test was rejected and needs to be reopened to restart the workflow.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Confirm My Validation — discussion request modal */}
|
||||||
|
{showDiscussModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-lg rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserCheck className="h-5 w-5 text-amber-400" />
|
||||||
|
<h3 className="text-lg font-semibold text-white">Confirm Your Validation</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setShowDiscussModal(false); setDiscussResult(null); }}
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-gray-800 hover:text-white">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!discussResult ? (
|
||||||
|
<div className="px-6 py-5 space-y-4">
|
||||||
|
{/* Conflict summary */}
|
||||||
|
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-4">
|
||||||
|
<p className="text-sm font-medium text-amber-300 mb-2">
|
||||||
|
⚠ Both leads must agree before a test can be finalised
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
You are confirming that your <strong className="text-white">approval is correct</strong> and
|
||||||
|
that the other lead's rejection should be reviewed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Other lead's rejection reason */}
|
||||||
|
{(test.red_validation_status === "rejected" && test.red_validation_notes) && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-gray-500">Red Lead's rejection reason</p>
|
||||||
|
<p className="rounded-lg border border-red-500/20 bg-red-900/10 p-3 text-sm italic text-gray-300">
|
||||||
|
"{test.red_validation_notes}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(test.blue_validation_status === "rejected" && test.blue_validation_notes) && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-gray-500">Blue Lead's rejection reason</p>
|
||||||
|
<p className="rounded-lg border border-red-500/20 bg-red-900/10 p-3 text-sm italic text-gray-300">
|
||||||
|
"{test.blue_validation_notes}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* What happens next */}
|
||||||
|
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 space-y-2">
|
||||||
|
<p className="text-xs font-semibold text-white">What happens when you send the discussion request:</p>
|
||||||
|
<ul className="space-y-1 text-xs text-gray-400 list-disc list-inside">
|
||||||
|
<li>The other lead receives a notification to contact you</li>
|
||||||
|
<li>The test <strong className="text-white">remains in Disputed</strong> state</li>
|
||||||
|
<li>Either lead can change their vote at any time to resolve it</li>
|
||||||
|
<li>If both reject → test becomes Rejected; if both approve → Validated</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{discussMutation.isError && (
|
||||||
|
<p className="text-sm text-red-400">Failed to send notification. Try again.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Success state */
|
||||||
|
<div className="px-6 py-8 text-center space-y-3">
|
||||||
|
<CheckCircle className="mx-auto h-12 w-12 text-green-400" />
|
||||||
|
<p className="text-lg font-semibold text-white">Discussion request sent</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
<strong className="text-white">{discussResult}</strong> has been notified
|
||||||
|
that you are standing by your approval and want to discuss their rejection.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Reach out directly via your team's communication channels to resolve this.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-3 border-t border-gray-800 px-6 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowDiscussModal(false); setDiscussResult(null); }}
|
||||||
|
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
{discussResult ? "Close" : "Cancel"}
|
||||||
|
</button>
|
||||||
|
{!discussResult && (
|
||||||
|
<button
|
||||||
|
onClick={() => discussMutation.mutate()}
|
||||||
|
disabled={discussMutation.isPending}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{discussMutation.isPending
|
||||||
|
? <><Loader2 className="h-4 w-4 animate-spin" /> Sending…</>
|
||||||
|
: <><MessageSquare className="h-4 w-4" /> Send Discussion Request</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user