diff --git a/backend/alembic/versions/b051_add_test_hold_fields.py b/backend/alembic/versions/b051_add_test_hold_fields.py new file mode 100644 index 0000000..5ebd1b0 --- /dev/null +++ b/backend/alembic/versions/b051_add_test_hold_fields.py @@ -0,0 +1,25 @@ +"""Add is_on_hold, hold_reason, held_at to tests. + +Revision ID: b051 +Revises: b050 +Create Date: 2026-06-19 +""" +from alembic import op +import sqlalchemy as sa + +revision = "b051" +down_revision = "b050" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("tests", sa.Column("is_on_hold", sa.Boolean(), nullable=False, server_default="false")) + op.add_column("tests", sa.Column("hold_reason", sa.Text(), nullable=True)) + op.add_column("tests", sa.Column("held_at", sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("tests", "held_at") + op.drop_column("tests", "hold_reason") + op.drop_column("tests", "is_on_hold") diff --git a/backend/app/models/test.py b/backend/app/models/test.py index 764f182..77f65cd 100644 --- a/backend/app/models/test.py +++ b/backend/app/models/test.py @@ -134,6 +134,11 @@ class Test(Base): red_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) blue_tech_assignee = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + # ── On-hold fields ───────────────────────────────────────────── + is_on_hold = Column(Boolean, default=False, nullable=False, server_default="false") + hold_reason = Column(Text, nullable=True) + held_at = Column(DateTime, nullable=True) + red_tech_assigned_user = relationship("User", foreign_keys=[red_tech_assignee]) blue_tech_assigned_user = relationship("User", foreign_keys=[blue_tech_assignee]) diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index ee9679d..68885e3 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -56,6 +56,7 @@ from app.schemas.test import ( TestBlueValidate, TestClassificationUpdate, TestCreate, + TestHold, TestOut, TestRedUpdate, TestRedValidate, @@ -1120,6 +1121,79 @@ def assign_test_operators( return test +# --------------------------------------------------------------------------- +# POST /tests/{id}/hold — place a test on hold (red/blue techs and leads) +# --------------------------------------------------------------------------- + + +@router.post("/{test_id}/hold", response_model=TestOut) +def hold_test( + test_id: uuid.UUID, + payload: TestHold, + db: Session = Depends(get_db), + current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead", "admin")), +): + """Place a test on hold with a mandatory reason. Posts comment + transitions Jira.""" + from datetime import datetime as _dt + from app.services.jira_service import push_hold_event + + test = crud_get_test_or_raise(db, test_id) + + HOLDABLE_STATES = ("draft", "red_executing", "blue_evaluating") + if test.state not in HOLDABLE_STATES: + raise HTTPException( + status_code=400, + detail=f"Cannot hold a test in state '{test.state}'. Only pre-validation states can be held.", + ) + + if test.is_on_hold: + raise HTTPException(status_code=400, detail="Test is already on hold") + + test.is_on_hold = True + test.hold_reason = payload.reason + test.held_at = _dt.utcnow() + + log_action(db, current_user.id, "hold_test", str(test_id), {"reason": payload.reason}) + db.commit() + db.refresh(test) + + push_hold_event(db, test, current_user, resuming=False, reason=payload.reason) + + return test + + +# --------------------------------------------------------------------------- +# POST /tests/{id}/resume — resume a test that was on hold +# --------------------------------------------------------------------------- + + +@router.post("/{test_id}/resume", response_model=TestOut) +def resume_test( + test_id: uuid.UUID, + db: Session = Depends(get_db), + current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead", "admin")), +): + """Resume a test that was placed on hold.""" + from app.services.jira_service import push_hold_event + + test = crud_get_test_or_raise(db, test_id) + + if not test.is_on_hold: + raise HTTPException(status_code=400, detail="Test is not on hold") + + test.is_on_hold = False + test.hold_reason = None + test.held_at = None + + log_action(db, current_user.id, "resume_test", str(test_id), {}) + db.commit() + db.refresh(test) + + push_hold_event(db, test, current_user, resuming=True) + + return test + + # --------------------------------------------------------------------------- # PATCH /tests/{id}/remediation — update remediation fields # --------------------------------------------------------------------------- diff --git a/backend/app/schemas/test.py b/backend/app/schemas/test.py index f63d78b..3e030b1 100644 --- a/backend/app/schemas/test.py +++ b/backend/app/schemas/test.py @@ -141,6 +141,12 @@ class TestAssign(BaseModel): blue_tech_assignee: uuid.UUID | None = None +class TestHold(BaseModel): + """Payload for placing a test on hold.""" + + reason: str + + # ── Legacy validate (kept for backwards compat) ──────────────────── @@ -232,6 +238,11 @@ class TestOut(BaseModel): red_tech_assignee: uuid.UUID | None = None blue_tech_assignee: uuid.UUID | None = None + # On-hold fields + is_on_hold: bool = False + hold_reason: str | None = None + held_at: datetime | None = None + # Re-test fields retest_of: uuid.UUID | None = None # Assign retest_count = 0 diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index f0f65a4..1eafd24 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -633,6 +633,73 @@ def push_test_event( ) +# --------------------------------------------------------------------------- +# On-hold Jira notification +# --------------------------------------------------------------------------- + + +def push_hold_event( + db: Session, + test, + actor, + *, + resuming: bool = False, + reason: str | None = None, +) -> None: + """Post an on-hold / resume comment to the Jira issue linked to *test*. + + Non-fatal — any Jira error is logged and swallowed. + """ + if not has_jira_configured(actor, db): + return + + link = ( + db.query(JiraLink) + .filter( + JiraLink.entity_type == JiraLinkEntityType.test, + JiraLink.entity_id == test.id, + ) + .first() + ) + if not link: + return + + try: + jira = get_user_jira_client(actor, db) + ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + + if resuming: + comment = ( + f"h3. ▶ Test Resumed\n\n" + f"*Resumed by:* {actor.username}\n" + f"*At:* {ts}\n\n" + f"_The test has been taken off hold and is active again._" + ) + try: + jira.set_issue_status(link.jira_issue_key, "In Progress") + except Exception as exc_t: + logger.warning("Could not transition %s off hold: %s", link.jira_issue_key, exc_t) + else: + comment = ( + f"h3. ⏸ Test Placed On Hold\n\n" + f"*Placed on hold by:* {actor.username}\n" + f"*At:* {ts}\n" + f"*Reason:* {reason or 'No reason provided'}\n\n" + f"_This test has been paused. No action required until it is resumed._" + ) + try: + jira.set_issue_status(link.jira_issue_key, "On Hold") + except Exception as exc_t: + logger.warning("Could not transition %s to On Hold: %s", link.jira_issue_key, exc_t) + + jira.issue_add_comment(link.jira_issue_key, comment) + link.last_synced_at = datetime.utcnow() + db.flush() + logger.info("Posted hold event to Jira %s (resuming=%s)", link.jira_issue_key, resuming) + except Exception as exc: + logger.warning("Failed to push hold event for test %s: %s", test.id, exc, exc_info=True) + + # --------------------------------------------------------------------------- # Legacy / generic helpers (kept for existing routes) # --------------------------------------------------------------------------- diff --git a/frontend/src/api/tests.ts b/frontend/src/api/tests.ts index 1f70217..8eff4b9 100644 --- a/frontend/src/api/tests.ts +++ b/frontend/src/api/tests.ts @@ -236,6 +236,20 @@ export async function assignTest( return data; } +// ── On Hold ──────────────────────────────────────────────────────── + +/** Place a test on hold with a mandatory reason. */ +export async function holdTest(testId: string, reason: string): Promise { + const { data } = await client.post(`/tests/${testId}/hold`, { reason }); + return data; +} + +/** Resume a test that was placed on hold. */ +export async function resumeTest(testId: string): Promise { + const { data } = await client.post(`/tests/${testId}/resume`); + return data; +} + // ── Timeline ─────────────────────────────────────────────────────── /** Get the audit-log timeline for a test. */ diff --git a/frontend/src/components/test-detail/TestDetailHeader.tsx b/frontend/src/components/test-detail/TestDetailHeader.tsx index fd3bfee..5abdb9c 100644 --- a/frontend/src/components/test-detail/TestDetailHeader.tsx +++ b/frontend/src/components/test-detail/TestDetailHeader.tsx @@ -13,6 +13,8 @@ import { MessageSquare, X, UserCheck, + PauseCircle, + PlayCircle, } from "lucide-react"; import { useMutation } from "@tanstack/react-query"; import { requestDiscussion } from "../../api/tests"; @@ -66,6 +68,9 @@ interface TestDetailHeaderProps { onPauseTimer: () => void; onResumeTimer: () => void; isTogglingTimer: boolean; + onHold: () => void; + onResume: () => void; + isTogglingHold: boolean; } // ── Component ────────────────────────────────────────────────────── @@ -83,6 +88,9 @@ export default function TestDetailHeader({ onPauseTimer, onResumeTimer, isTogglingTimer, + onHold, + onResume, + isTogglingHold, }: TestDetailHeaderProps) { const role = user?.role ?? ""; const currentIdx = STATE_INDEX[test.state]; @@ -116,9 +124,30 @@ export default function TestDetailHeader({ // ── Contextual action buttons ──────────────────────────────────── + const HOLDABLE_STATES: TestState[] = ["draft", "red_executing", "blue_evaluating"]; + const canHold = + HOLDABLE_STATES.includes(test.state) && + (role === "red_tech" || role === "blue_tech" || role === "red_lead" || role === "blue_lead" || role === "admin"); + const renderActions = () => { const buttons: React.ReactNode[] = []; + // On Hold banner + Resume button (shown first when test is on hold) + if (test.is_on_hold && canHold) { + buttons.push( + , + ); + return
{buttons}
; + } + // Red Team in draft -> Start Execution if ( test.state === "draft" && @@ -327,6 +356,21 @@ export default function TestDetailHeader({ ); } + // On Hold button — appears alongside action buttons in pre-validation states + if (canHold && !test.is_on_hold) { + buttons.push( + , + ); + } + return buttons.length > 0 ? (
{buttons}
) : null; @@ -469,6 +513,24 @@ export default function TestDetailHeader({ )} + {/* On Hold banner */} + {test.is_on_hold && ( +
+ +
+

Test On Hold

+ {test.hold_reason && ( +

+ Reason: {test.hold_reason} +

+ )} +

+ This test is paused. No action required until it is resumed. +

+
+
+ )} + {/* Progress bar */} {test.state !== "rejected" && (
diff --git a/frontend/src/pages/TestDetailPage.tsx b/frontend/src/pages/TestDetailPage.tsx index de23ca5..b46e045 100644 --- a/frontend/src/pages/TestDetailPage.tsx +++ b/frontend/src/pages/TestDetailPage.tsx @@ -17,6 +17,8 @@ import { reopenTest, pauseTimer, resumeTimer, + holdTest, + resumeTest, getTestTimeline, getRetestChain, } from "../api/tests"; @@ -48,6 +50,8 @@ export default function TestDetailPage() { }>({ open: false, side: "red" }); const [confirmReopen, setConfirmReopen] = useState(false); + const [holdModal, setHoldModal] = useState(false); + const [holdReason, setHoldReason] = useState(""); const [redDraft, setRedDraft] = useState({ procedure_text: "", @@ -257,6 +261,26 @@ export default function TestDetailPage() { onError: (err: unknown) => showToast(extractError(err), "error"), }); + const holdMutation = useMutation({ + mutationFn: (reason: string) => holdTest(testId!, reason), + onSuccess: () => { + invalidateAll(); + setHoldModal(false); + setHoldReason(""); + showToast("Test placed on hold", "success"); + }, + onError: (err: unknown) => showToast(extractError(err), "error"), + }); + + const resumeHoldMutation = useMutation({ + mutationFn: () => resumeTest(testId!), + onSuccess: () => { + invalidateAll(); + showToast("Test resumed", "success"); + }, + onError: (err: unknown) => showToast(extractError(err), "error"), + }); + // Evidence upload const uploadMutation = useMutation({ mutationFn: ({ file, team }: { file: File; team: TeamSide }) => @@ -394,6 +418,9 @@ export default function TestDetailPage() { onPauseTimer={() => pauseTimerMutation.mutate()} onResumeTimer={() => resumeTimerMutation.mutate()} isTogglingTimer={pauseTimerMutation.isPending || resumeTimerMutation.isPending} + onHold={() => setHoldModal(true)} + onResume={() => resumeHoldMutation.mutate()} + isTogglingHold={holdMutation.isPending || resumeHoldMutation.isPending} /> {/* Content: Tabs + Sidebar */} @@ -584,6 +611,49 @@ export default function TestDetailPage() {
+ {/* On Hold Modal */} + {holdModal && ( +
+
+
+

Place Test On Hold

+ +
+

+ Provide a reason for placing this test on hold. This will be sent as a comment to the linked Jira ticket and the ticket will be transitioned to On Hold. +

+