Skip to content

Delete Planned/Executing budget line items via change request#5833

Open
jonnalley wants to merge 1 commit into
OPS-5819/edit-executing-budget-linesfrom
OPS-5819/delete-budget-line-change-request
Open

Delete Planned/Executing budget line items via change request#5833
jonnalley wants to merge 1 commit into
OPS-5819/edit-executing-budget-linesfrom
OPS-5819/delete-budget-line-change-request

Conversation

@jonnalley

Copy link
Copy Markdown
Contributor

⚠️ Stacked PR — review/merge #5832 FIRST.
This branch (OPS-5819/delete-budget-line-change-request) is stacked on top of PR #5832
(OPS-5819/edit-executing-budget-lines), not main. Its base is set to the #5832 branch so
this PR's diff shows only the deletion work. Once #5832 merges, GitHub will auto-retarget this
PR's base to main (or retarget it manually). Do not merge this before #5832.

What changed

This is PR 2 of 2 for issue #5819. PR 1 (#5832) made IN_EXECUTION budget line items (BLIs)
editable. This PR makes deleting a PLANNED or IN_EXECUTION BLI go through a Division Director
approval change request instead of a hard delete. DRAFT BLIs — and any delete by a super user —
still delete immediately, unchanged.

Backend

  • models/change_requests.pyBudgetLineItemChangeRequest gains a has_delete_change hybrid
    (and exposes it in to_dict()). A deletion request is a normal BUDGET_LINE_ITEM_CHANGE_REQUEST
    carrying a {"delete": true} sentinel in requested_change_data, with
    requested_change_diff = {"delete": {"old": <bli amount>, "new": "Deleted"}}.
  • services/budget_line_items.pydelete() now returns (bli, status_code). DRAFT/super →
    200 immediate hard delete; PLANNED/IN_EXECUTION → 202 + a deletion CR, gated by the same
    shared compute_bli_editable rules as editing (blocked while in review, OBE, or once the agreement
    reaches Pre-Award/Award, i.e. procurement tracker step ≥ 5).
  • resources/budget_line_items.py — the delete handler unpacks (bli, status_code) and emits
    the DELETE_BLI audit event only on the immediate (200) path; the 202 path emits
    CREATE_CHANGE_REQUEST inside the service, so submitting a request does not write a spurious
    "Budget Line Deleted" history record.
  • services/change_requests.pyadd_bli_delete_change_request() creates the CR; the approval
    flow deletes the BLI last (see BLOCKER-1 below).
  • models/agreement_history.pycreate_change_request_history_event handles the "delete"
    sentinel for request-created / approved / declined, emitting BUDGET_LINE_ITEM_DELETED history.
  • utils/change_requests_helpers.pybuild_approve_url learns the delete case.
  • schemas/change_requests.py, resources/change_requests.py, openapi.yml — surface
    has_delete_change and document the DELETE 202 path.

Frontend

  • api/opsAPI.js — the delete mutation surfaces the HTTP status so callers distinguish 200
    (immediate) from 202 (request created); invalidates ChangeRequests.
  • AllBudgetLinesTable.hooks.js — on 202 shows the same "Changes Sent to Approval" alert the
    edit-via-CR path uses (verbatim); on 200 keeps "...successfully deleted."
  • CR-review card — the For-Review BudgetChangeReviewCard now renders deletion CRs (gate is
    has_budget_change || has_delete_change); changeToTypes.delete = "Deleted",
    KEY_NAMES.DELETE, and a renderChangeValues delete case render Change To = "Deleted",
    From = the BLI amount as currency, To = "Deleted" (matches the approved design).

Key design decisions / assumptions (for reviewers without context)

  • Reuse, don't fork. The deletion request reuses BudgetLineItemChangeRequest with a JSONB
    {"delete": true} sentinel — no new ChangeRequestType, no enum migration, and it rides the
    existing routing / approval / notification / in_review plumbing.
  • BLOCKER-1 (approval ordering). change_request.budget_line_item_id is ON DELETE CASCADE, and
    the resource re-serializes the CR (to_dict()) after approval. Naively deleting the BLI mid-flow
    would cascade-delete the CR row and crash the post-commit reads. So on approve we finalize the CR
    and notify the submitter while it still exists, snapshot to_dict() while the CR is still
    attached
    (capturing every relationship the auto-schema serializes, including the
    sqlalchemy-continuum versions dynamic relationship), detach the CR, then delete the BLI last and
    pin the CR's to_dict() to the snapshot. The response/event never serialize a detached instance.
  • BLOCKER-2 (audit trail). The user-facing "Budget Line Deleted" history is event/diff-driven, so
    create_change_request_history_event needed an explicit "delete" branch (it would otherwise hit
    an UnboundLocalError on the unset title/message). The requestor is resolved via the created_by
    column, not the relationship-derived dict (which is None once the CR is detached/cascade-deleted).
  • Deletability now mirrors editability (PR 1 introduced isDeletable as the seam): DRAFT /
    PLANNED / IN_EXECUTION are deletable when editable; OBLIGATED is not; super users always.
  • The deletion CR's notification row is intentionally cascade-removed with the CR when the BLI is
    deleted on approval. The durable record of the deletion is the AgreementHistory
    BUDGET_LINE_ITEM_DELETED entry plus the automatic OpsDBHistory trail.

Review process

Two rounds of adversarial multi-agent consensus review were run against this diff. Round 1 surfaced
1 MAJOR (the detached-CR versions serialization gap — fixed via the snapshot approach above) + 3
MINOR (history-message test-coverage gaps — all added) + 1 NIT (response-body assertion — added).
Round 2 returned zero confirmed issues.

Issue

#5819

How to test

Backend (from backend/ops_api/, with the test stack):

pytest tests/ops/budget_line_items/test_delete_budget_line_change_request.py \
       tests/ops/features/test_delete_budget_line_change_request.py

Covers: DRAFT → 200 immediate; PLANNED/IN_EXECUTION → 202 + deletion CR (BLI survives); blocked at
Pre-Award (400); blocked while a CR is pending (400); approving deletes the BLI + records the
"Budget Line Deleted" history (and the response/notification do not crash on the cascade — BLOCKER-1
guard); rejecting keeps the BLI + records "Budget Line Deletion Declined"; unauthorized → 403.

Frontend (from frontend/):

bun run test --watch=false src/components/ChangeRequests src/helpers/changeRequests.helpers.test.js \
  src/components/BudgetLineItems/AllBudgetLinesTable src/components/BudgetLineItems/ChangeIcons

Manual: as an associated user, on an agreement's budget-lines table, delete a PLANNED or EXECUTING
BLI → confirm the existing modal → expect the "Changes Sent to Approval" alert and the BLI still
present. As the Division Director, open For Review → the deletion shows on a Budget Change card
with Change To / To = "Deleted" and From = the BLI amount; approving removes the BLI.

A11y impact

  • No accessibility-impacting changes in this PR

(Frontend changes reuse existing components/alerts/cards; no new UI primitives or markup patterns.)

Storybook

  • N/A — change is page-specific or non-visual

Screenshots

N/A — reuses the existing confirmation modal, the existing "Changes Sent to Approval" alert, and the
existing BudgetChangeReviewCard.

Definition of Done Checklist

  • OESA: Code refactored for clarity
  • OESA: Dependency rules followed
  • Automated unit tests updated and passed
  • Automated integration tests updated and passed

Links

Deleting a PLANNED or IN_EXECUTION budget line item now routes through a
Division Director approval change request instead of a hard delete. DRAFT
BLIs (and any delete by a super user) still delete immediately.

Backend:
- BudgetLineItemChangeRequest gains a has_delete_change hybrid + to_dict
  exposure; a deletion request carries a {"delete": true} sentinel with diff
  {"delete": {"old": <amount>, "new": "Deleted"}}.
- service.delete() returns (bli, status_code): DRAFT/super -> 200 immediate;
  PLANNED/IN_EXECUTION -> 202 + deletion CR via the shared editability gate
  (blocks in-review, OBE, Pre-Award/Award step >= 5). Resource emits DELETE_BLI
  only on the immediate path.
- On approval the CR is finalized and the submitter notified first, then the
  CR's serialized form is snapshotted while still attached, the CR is detached,
  and the BLI is deleted last (cascade removes the CR row) so the post-commit
  re-serialization never touches a detached/deleted instance.
- create_change_request_history_event handles the "delete" sentinel (request /
  approved / declined) and emits BUDGET_LINE_ITEM_DELETED history.
- build_approve_url + change-request schema + openapi.yml updated.

Frontend:
- delete mutation surfaces the HTTP status; the budget-lines table shows the
  reused "Changes Sent to Approval" alert on 202.
- the For-Review BudgetChangeReviewCard renders deletion CRs (Change To/To =
  "Deleted", From = the BLI amount) via has_delete_change.

Tests: integration (immediate/CR/blocked/approve/reject/unauthorized) + BDD +
frontend card/helper/hook coverage, all with history-message assertions.

Issue: #5819
@jonnalley jonnalley self-assigned this Jun 19, 2026
@jonnalley jonnalley marked this pull request as ready for review June 19, 2026 08:18
@jonnalley jonnalley changed the title feat: delete Planned/Executing budget line items via change request Delete Planned/Executing budget line items via change request Jun 19, 2026

@josbell josbell left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Approved ✓

Reviewed implementation of deletion change request workflow. The code correctly handles the documented cascade-delete and audit history challenges (BLOCKER-1 & BLOCKER-2).

Optional Recommendations

Defense in depth: Consider adding a partial unique index on change_request(budget_line_item_id) WHERE status='IN_REVIEW' to prevent theoretical duplicate change requests during concurrent deletes. The edit path uses the same check-then-create pattern without known issues, so this is optional.

UX improvement: The delete error message is generic ("not in a deletable state"). Consider reusing the existing get_bli_locked_message() helper to provide specific reasons (e.g., "agreement has reached Pre-Award").

Both recommendations are non-blocking polish items. The PR is production-ready as-is.

@rajohnson90 rajohnson90 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Code Review suggests that you're missing a test for super user bypass in the test suite. I saw that it is covered in the code, but it should definitely be tested. Otherwise ready to approve.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants