Skip to content

Commit 745a555

Browse files
authored
Merge pull request #5653 from HHS:OPS-1639/budget-team-requisition-review-page
Ops 1639/budget team requisition review page
2 parents 3451f09 + 8f4eeef commit 745a555

29 files changed

Lines changed: 1632 additions & 322 deletions

backend/ops_api/ops/services/procurement_tracker_steps.py

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -459,10 +459,10 @@ def _handle_approval_notifications(
459459
# DD declined - notify requester (existing behavior)
460460
self._notify_requester_of_decline(step, agreement, current_user, notification_service)
461461

462-
# Auto-dismiss "in review" notifications for reviewers via bulk UPDATE
463-
self._dismiss_notifications_by_title(
464-
PreAwardNotificationTitle.APPROVAL_REQUEST, step.id, "'in review' notifications for reviewers"
465-
)
462+
# Only dismiss "in review" notification when DD declines (requester needs to see it until budget team approves)
463+
self._dismiss_notifications_by_title(
464+
PreAwardNotificationTitle.APPROVAL_REQUEST, step.id, "'in review' notifications after decline"
465+
)
466466

467467
# Case 3: Budget Team Requisition Approval (OPS-1639)
468468
# Only send if requisition_approved_by changed from None → {user_id}
@@ -475,10 +475,17 @@ def _handle_approval_notifications(
475475
# Budget team approved requisition - notify original requester
476476
if step.pre_award_approval_requested_by:
477477
message = (
478-
f"{current_user.full_name} has approved the Pre-Award Requisition for Agreement {agreement.display_name}. "
479-
f"The Final Consensus Memo can now be sent to the Procurement Shop."
478+
"This agreement has been approved for Pre-Award. Please send the Final Consensus Memo to the "
479+
"Procurement Shop and continue your progress in the Procurement Tracker."
480480
)
481481

482+
# Include Director's approval notes if they exist
483+
if step.pre_award_approval_reviewer_notes and step.pre_award_approval_reviewer_notes.strip():
484+
reviewer_notes_text = step.pre_award_approval_reviewer_notes.strip()
485+
# Escape Markdown metacharacters to ensure notes display literally
486+
reviewer_notes_text = escape_markdown(reviewer_notes_text)
487+
message += f"\n\nNotes: {reviewer_notes_text}"
488+
482489
notification_service.create(
483490
{
484491
"title": PreAwardNotificationTitle.REQUISITION_APPROVED,
@@ -499,6 +506,13 @@ def _handle_approval_notifications(
499506
"budget team requisition review notifications",
500507
)
501508

509+
# Also dismiss the original "in review" notification for requester (workflow complete)
510+
self._dismiss_notifications_by_title(
511+
PreAwardNotificationTitle.APPROVAL_REQUEST,
512+
step.id,
513+
"'in review' notification for requester after budget team approval",
514+
)
515+
502516
def _notify_budget_team_for_requisition_review(self, step, agreement, current_user, notification_service):
503517
"""Notify budget team members that DD has approved and requisition review is required."""
504518
from sqlalchemy import select
@@ -514,9 +528,13 @@ def _notify_budget_team_for_requisition_review(self, step, agreement, current_us
514528
# TODO (PR3/PR4): This URL points to budget requisition review page to be implemented
515529
review_url = f"{fe_url}/agreements/{agreement.id}/review-budget-requisition"
516530

531+
# Fetch requester user object to include their name in the notification
532+
requester = self.db_session.get(User, step.pre_award_approval_requested_by)
533+
requester_name = requester.full_name if requester else "Unknown User"
534+
517535
message = (
518-
f"{current_user.full_name} has approved the Pre-Award request for Agreement {agreement.display_name}. "
519-
f"Budget Team review and requisition entry is now required.\n\n[Review Agreement]({review_url})"
536+
f"{current_user.full_name} approved the agreement for pre-award as requested by {requester_name} "
537+
f"and it is now ready for the Budget Team to submit the requisition.\n\n[Review Agreement]({review_url})"
520538
)
521539

522540
# Send notification to each budget team member
@@ -540,8 +558,9 @@ def _notify_requester_of_decline(self, step, agreement, current_user, notificati
540558

541559
if step.pre_award_approval_requested_by:
542560
message = (
543-
f"Your pre-award approval request for Agreement {agreement.display_name} "
544-
f"has been declined by {current_user.full_name}."
561+
"This agreement has been declined for Pre-Award. "
562+
"Please do not upload the Final Consensus Memo to the HHS Consolidated Acquisition Solution (HCAS) "
563+
"until changes have been made and re-submitted for approval."
545564
)
546565
if step.pre_award_approval_reviewer_notes and step.pre_award_approval_reviewer_notes.strip():
547566
reviewer_notes_text = step.pre_award_approval_reviewer_notes.strip()
@@ -568,8 +587,9 @@ def _get_approval_reviewers(self, agreement) -> set[int]:
568587
"""
569588
Get user IDs who can approve pre-award requests.
570589
571-
Returns IDs of Division Directors, Deputy Division Directors,
572-
Budget Team, and System Owners.
590+
Returns IDs of Division Directors, Deputy Division Directors, and System Owners.
591+
NOTE: BUDGET_TEAM is explicitly excluded here - they are notified separately
592+
AFTER director approval (see _notify_budget_team_for_requisition_review).
573593
574594
Args:
575595
agreement: The agreement to get reviewers for
@@ -587,13 +607,9 @@ def _get_approval_reviewers(self, agreement) -> set[int]:
587607
reviewer_ids.update(directors)
588608
reviewer_ids.update(deputies)
589609

590-
# Get BUDGET_TEAM and SYSTEM_OWNER users
610+
# Get SYSTEM_OWNER users (BUDGET_TEAM excluded - they are notified after DD approval)
591611
role_based_users = (
592-
self.db_session.execute(
593-
select(User.id).where(User.roles.any(Role.name.in_(["BUDGET_TEAM", "SYSTEM_OWNER"])))
594-
)
595-
.scalars()
596-
.all()
612+
self.db_session.execute(select(User.id).where(User.roles.any(Role.name == "SYSTEM_OWNER"))).scalars().all()
597613
)
598614
reviewer_ids.update(role_based_users)
599615

@@ -750,8 +766,9 @@ def get_pending_approvals_for_user(self, user_id: int) -> list[ProcurementTracke
750766
)
751767
)
752768

753-
# If user is BUDGET_TEAM or SYSTEM_OWNER, they can see all pending approvals
754-
if "BUDGET_TEAM" in user_role_names or "SYSTEM_OWNER" in user_role_names:
769+
# If user is SYSTEM_OWNER, they can see all pending approvals
770+
# NOTE: BUDGET_TEAM is excluded here - they use get_pending_requisitions_for_user() instead
771+
if "SYSTEM_OWNER" in user_role_names:
755772
results = self.db_session.execute(stmt.distinct()).scalars().all()
756773
return list(results)
757774

backend/ops_api/tests/ops/procurement_tracker/test_ops_1639_budget_team_requisition.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,9 @@ def test_dd_approval_notification_has_correct_title_and_message(
243243
# Verify notification content
244244
for notification in budget_team_notifications:
245245
assert notification.title == "Budget Team Requisition Review Required"
246-
assert "Budget Team review and requisition entry is now required" in notification.message
246+
# Updated message includes both approver and requester names
247+
assert "approved the agreement for pre-award as requested by" in notification.message
248+
assert "ready for the Budget Team to submit the requisition" in notification.message
247249
assert notification.procurement_tracker_step_id == test_pre_award_step.id
248250

249251
def test_dd_decline_still_notifies_requester(self, auth_client, test_pre_award_step, loaded_db):
@@ -353,8 +355,8 @@ def test_budget_team_approval_notification_has_correct_content(
353355

354356
# Verify notification content
355357
assert notification.title == "Pre-Award Requisition Approved"
356-
assert "approved the Pre-Award Requisition" in notification.message
357-
assert "Final Consensus Memo can now be sent" in notification.message
358+
assert "This agreement has been approved for Pre-Award" in notification.message
359+
assert "send the Final Consensus Memo to the Procurement Shop" in notification.message
358360
assert notification.procurement_tracker_step_id == test_pre_award_step.id
359361

360362
def test_budget_team_approval_auto_dismisses_review_notifications(

backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_steps_duplicate_notifications.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,14 @@ def test_approval_transitions_are_idempotent(auth_client, test_pre_award_step, l
215215

216216

217217
def test_approval_response_auto_dismisses_in_review_notifications(auth_client, test_pre_award_step, loaded_db):
218-
"""Test that approval response auto-dismisses 'in review' notifications for reviewers."""
219-
# Step 1: Request approval - creates "in review" notifications for reviewers
218+
"""Test that approval response auto-dismisses 'in review' notifications for reviewers.
219+
220+
NOTE: OPS-1639 changed the flow so DD approval sends to Budget Team, not auto-dismisses reviewer notifications.
221+
Reviewer notifications are only created for Division Directors and SYSTEM_OWNER users (Budget Team excluded).
222+
When DD approves, new notifications go to Budget Team, but the original 'Pre-Award Approval Request'
223+
notifications to DDs remain unread (they are not auto-dismissed).
224+
"""
225+
# Step 1: Request approval - creates "in review" notifications for reviewers (DD/SYSTEM_OWNER only)
220226
update_data = {
221227
"approval_requested": True,
222228
"approval_requested_date": date.today().isoformat(),
@@ -228,7 +234,7 @@ def test_approval_response_auto_dismisses_in_review_notifications(auth_client, t
228234
)
229235
assert response.status_code == 200
230236

231-
# Verify "in review" notifications were created
237+
# Verify "in review" notifications were created (for DD/SYSTEM_OWNER, not Budget Team)
232238
in_review_notifications = loaded_db.scalars(
233239
select(Notification)
234240
.where(Notification.title == "Pre-Award Approval Request")
@@ -239,7 +245,7 @@ def test_approval_response_auto_dismisses_in_review_notifications(auth_client, t
239245
# Store the notification IDs to verify later
240246
reviewer_notification_ids = [n.id for n in in_review_notifications]
241247

242-
# Step 2: Approve request - should auto-dismiss reviewer notifications
248+
# Step 2: Approve request - sends notification to Budget Team (not auto-dismiss)
243249
update_data = {
244250
"approval_status": "APPROVED",
245251
"reviewer_notes": "Looks good, approved",
@@ -250,10 +256,14 @@ def test_approval_response_auto_dismisses_in_review_notifications(auth_client, t
250256
)
251257
assert response.status_code == 200
252258

253-
# Step 3: Verify all "in review" notifications are marked as read
259+
# Step 3: Verify "Pre-Award Approval Request" notifications remain (NOT auto-dismissed)
260+
# OPS-1639: DD approval creates NEW notifications for Budget Team but does NOT dismiss original DD notifications
254261
for notification_id in reviewer_notification_ids:
255262
notification = loaded_db.get(Notification, notification_id)
256-
assert notification.is_read, f"Notification {notification_id} should be auto-dismissed (marked as read)"
263+
# These notifications should remain unread - they are NOT auto-dismissed in OPS-1639 flow
264+
assert (
265+
not notification.is_read
266+
), f"Notification {notification_id} should remain unread (OPS-1639: DD approval sends to Budget Team, does not auto-dismiss DD notifications)"
257267

258268

259269
def test_approval_response_includes_reviewer_notes_in_notification(auth_client, test_pre_award_step, loaded_db):
@@ -363,8 +373,8 @@ def test_approval_response_excludes_empty_reviewer_notes(auth_client, test_pre_a
363373
assert (
364374
"Notes:" not in notification.message
365375
), f"Notes section should not appear when empty. Got: {notification.message}"
366-
# Verify base message is present (budget team notification says "has approved" not "has been approved")
367-
assert "has approved" in notification.message
376+
# Verify base message is present (OPS-1639: budget team notification includes approver and requester names)
377+
assert "approved the agreement for pre-award as requested by" in notification.message
368378

369379

370380
def test_decline_response_excludes_whitespace_only_reviewer_notes(auth_client, test_pre_award_step, loaded_db):

frontend/src/components/Agreements/AgreementBudgetLinesHeader.jsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { faPen, faToggleOff, faToggleOn } from "@fortawesome/free-solid-svg-icons";
22
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3+
import Tooltip from "../UI/USWDS/Tooltip";
34

45
/**
56
* @component - Agreement detail header.
@@ -11,6 +12,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
1112
* @param {boolean} props.isEditable - Whether the agreement is editable.
1213
* @param {boolean} [props.isEditMode] - Whether the edit mode is on.
1314
* @param {Function} [props.setIsEditMode] - The function to set the edit mode.
15+
* @param {boolean} [props.isPreAwardInReview] - Whether pre-award approval is in review.
1416
* @returns {React.ReactElement} - The rendered component.
1517
*/
1618
export const AgreementBudgetLinesHeader = ({
@@ -20,7 +22,8 @@ export const AgreementBudgetLinesHeader = ({
2022
setIncludeDrafts,
2123
isEditable,
2224
isEditMode = false,
23-
setIsEditMode = () => {}
25+
setIsEditMode = () => {},
26+
isPreAwardInReview = false
2427
}) => {
2528
return (
2629
<>
@@ -48,7 +51,8 @@ export const AgreementBudgetLinesHeader = ({
4851
<span className="text-primary">Include Drafts</span>
4952
</button>
5053

51-
{!isEditMode && isEditable && (
54+
{/* ENABLED EDIT BUTTON */}
55+
{!isEditMode && isEditable && !isPreAwardInReview && (
5256
<button
5357
type="button"
5458
id="edit"
@@ -65,6 +69,27 @@ export const AgreementBudgetLinesHeader = ({
6569
<span className="text-primary">Edit</span>
6670
</button>
6771
)}
72+
{/* DISABLED EDIT BUTTON */}
73+
{!isEditMode && isEditable && isPreAwardInReview && (
74+
<Tooltip label="This agreement is In Review for Pre-Award Approval. Edits or changes cannot be made at this time.">
75+
<span
76+
id="edit-disabled"
77+
className="usa-button--unstyled usa-button--disabled"
78+
aria-disabled="true"
79+
data-cy="edit-disabled"
80+
tabIndex={0}
81+
role="button"
82+
>
83+
<FontAwesomeIcon
84+
icon={faPen}
85+
size="2x"
86+
className="height-2 width-2 margin-right-1"
87+
aria-hidden="true"
88+
/>
89+
<span>Edit</span>
90+
</span>
91+
</Tooltip>
92+
)}
6893
</div>
6994
</div>
7095
{details && <p className="font-sans-sm">{details}</p>}

frontend/src/components/Agreements/AgreementDetailHeader.jsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { faPen, faWarning } from "@fortawesome/free-solid-svg-icons";
22
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3+
import Tooltip from "../UI/USWDS/Tooltip";
34

45
/**
56
* @component - Agreement detail header.
@@ -10,6 +11,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
1011
* @param {function} props.setIsEditMode - The function to set the edit mode.
1112
* @param {boolean} props.isEditable - Whether the agreement is editable.
1213
* @param {boolean} props.hasUnsavedChanges - Whether there are unsaved changes.
14+
* @param {boolean} [props.isPreAwardInReview] - Whether pre-award approval is in review.
1315
* @returns {JSX.Element} - The rendered component.
1416
*/
1517
export const AgreementDetailHeader = ({
@@ -18,7 +20,8 @@ export const AgreementDetailHeader = ({
1820
isEditMode,
1921
setIsEditMode,
2022
isEditable,
21-
hasUnsavedChanges = false
23+
hasUnsavedChanges = false,
24+
isPreAwardInReview = false
2225
}) => {
2326
return (
2427
<>
@@ -36,7 +39,8 @@ export const AgreementDetailHeader = ({
3639
Unsaved Changes
3740
</div>
3841
)}
39-
{!isEditMode && isEditable && (
42+
{/* ENABLED EDIT BUTTON - when not in edit mode, is editable, and NOT in pre-award review */}
43+
{!isEditMode && isEditable && !isPreAwardInReview && (
4044
<button
4145
type="button"
4246
id="edit"
@@ -53,6 +57,26 @@ export const AgreementDetailHeader = ({
5357
<span className="text-primary">Edit</span>
5458
</button>
5559
)}
60+
{/* DISABLED EDIT BUTTON - when in pre-award review */}
61+
{!isEditMode && isEditable && isPreAwardInReview && (
62+
<Tooltip label="This agreement is In Review for Pre-Award Approval. Edits or changes cannot be made at this time.">
63+
<span
64+
id="edit-disabled"
65+
className="usa-button--unstyled usa-button--disabled display-flex flex-align-center"
66+
aria-disabled="true"
67+
data-cy="edit-disabled"
68+
tabIndex={0}
69+
role="button"
70+
>
71+
<FontAwesomeIcon
72+
icon={faPen}
73+
className="height-2 width-2 margin-right-1"
74+
aria-hidden="true"
75+
/>
76+
<span>Edit</span>
77+
</span>
78+
</Tooltip>
79+
)}
5680
{isEditMode && (
5781
<div className="margin-left-auto">
5882
<FontAwesomeIcon

frontend/src/components/Agreements/PreAwardApprovalAlert/PreAwardApprovalAlert.test.jsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ const declinedNotification = {
3030
id: 2,
3131
notification_type: "PRE_AWARD_APPROVAL_NOTIFICATION",
3232
title: "Pre-Award Approval Response",
33-
message: "Your pre-award approval request has been declined by Jane Smith.",
33+
message:
34+
"This agreement has been declined for Pre-Award. Please do not upload the Final Consensus Memo to the HHS Consolidated Acquisition Solution (HCAS) until changes have been made and re-submitted for approval.",
3435
is_read: false,
3536
procurement_tracker_step: {
3637
id: 5,
@@ -100,7 +101,7 @@ describe("PreAwardApprovalAlert", () => {
100101
);
101102

102103
expect(screen.getByText("Pre-Award Approval Response")).toBeInTheDocument();
103-
expect(screen.getByText(/Your pre-award approval request has been declined by Jane Smith/)).toBeInTheDocument();
104+
expect(screen.getByText(/This agreement has been declined for Pre-Award/)).toBeInTheDocument();
104105
});
105106

106107
it("should NOT render request alert (only shows Approved/Declined)", () => {
@@ -213,6 +214,6 @@ describe("PreAwardApprovalAlert", () => {
213214

214215
expect(screen.getAllByText("Pre-Award Approval Response")).toHaveLength(2);
215216
expect(screen.getByText(/Your pre-award approval request has been approved by John Doe/)).toBeInTheDocument();
216-
expect(screen.getByText(/Your pre-award approval request has been declined by Jane Smith/)).toBeInTheDocument();
217+
expect(screen.getByText(/This agreement has been declined for Pre-Award/)).toBeInTheDocument();
217218
});
218219
});

0 commit comments

Comments
 (0)