Skip to content

Commit 3342e19

Browse files
authored
Merge pull request #5613 from HHS/OPS-1639/budget-team-requisition-approval
feat: OPS-1639 PR1 - Budget Team Requisition Approval Backend
2 parents 4ddf5c4 + 05888e5 commit 3342e19

9 files changed

Lines changed: 1031 additions & 113 deletions
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Add pre_award requisition fields for budget team approval
2+
3+
Adds four new fields to support budget team requisition approval workflow (OPS-1639):
4+
- pre_award_requisition_number: Requisition number entered by budget team
5+
- pre_award_requisition_date: Date of requisition
6+
- pre_award_requisition_approved_by: FK to user who approved requisition
7+
- pre_award_requisition_approved_date: Date of requisition approval
8+
9+
Revision ID: b9c8d7e6f5a4
10+
Revises: f1a2b3c4d5e6
11+
Create Date: 2026-04-30 15:43:00.000000+00:00
12+
13+
"""
14+
from typing import Sequence, Union
15+
16+
from alembic import op
17+
import sqlalchemy as sa
18+
19+
20+
# revision identifiers, used by Alembic.
21+
revision: str = 'b9c8d7e6f5a4'
22+
down_revision: Union[str, None] = 'f1a2b3c4d5e6'
23+
branch_labels: Union[str, Sequence[str], None] = None
24+
depends_on: Union[str, Sequence[str], None] = None
25+
26+
27+
def upgrade() -> None:
28+
# Add columns to main table
29+
op.add_column('procurement_tracker_step',
30+
sa.Column('pre_award_requisition_number', sa.String(100), nullable=True))
31+
op.add_column('procurement_tracker_step',
32+
sa.Column('pre_award_requisition_date', sa.Date(), nullable=True))
33+
op.add_column('procurement_tracker_step',
34+
sa.Column('pre_award_requisition_approved_by', sa.Integer(), nullable=True))
35+
op.add_column('procurement_tracker_step',
36+
sa.Column('pre_award_requisition_approved_date', sa.Date(), nullable=True))
37+
38+
# Create foreign key constraint
39+
op.create_foreign_key(
40+
'fk_procurement_tracker_step_requisition_approved_by',
41+
'procurement_tracker_step',
42+
'ops_user',
43+
['pre_award_requisition_approved_by'],
44+
['id'],
45+
ondelete='SET NULL'
46+
)
47+
48+
# Add columns to version table (audit history)
49+
op.add_column('procurement_tracker_step_version',
50+
sa.Column('pre_award_requisition_number', sa.String(100),
51+
autoincrement=False, nullable=True))
52+
op.add_column('procurement_tracker_step_version',
53+
sa.Column('pre_award_requisition_date', sa.Date(),
54+
autoincrement=False, nullable=True))
55+
op.add_column('procurement_tracker_step_version',
56+
sa.Column('pre_award_requisition_approved_by', sa.Integer(),
57+
autoincrement=False, nullable=True))
58+
op.add_column('procurement_tracker_step_version',
59+
sa.Column('pre_award_requisition_approved_date', sa.Date(),
60+
autoincrement=False, nullable=True))
61+
62+
63+
def downgrade() -> None:
64+
# Drop from version table first
65+
op.drop_column('procurement_tracker_step_version', 'pre_award_requisition_approved_date')
66+
op.drop_column('procurement_tracker_step_version', 'pre_award_requisition_approved_by')
67+
op.drop_column('procurement_tracker_step_version', 'pre_award_requisition_date')
68+
op.drop_column('procurement_tracker_step_version', 'pre_award_requisition_number')
69+
70+
# Drop foreign key constraint
71+
op.drop_constraint('fk_procurement_tracker_step_requisition_approved_by',
72+
'procurement_tracker_step', type_='foreignkey')
73+
74+
# Drop from main table
75+
op.drop_column('procurement_tracker_step', 'pre_award_requisition_approved_date')
76+
op.drop_column('procurement_tracker_step', 'pre_award_requisition_approved_by')
77+
op.drop_column('procurement_tracker_step', 'pre_award_requisition_date')
78+
op.drop_column('procurement_tracker_step', 'pre_award_requisition_number')

backend/models/procurement_tracker.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,25 @@ class DefaultProcurementTrackerStep(ProcurementTrackerStep):
448448
nullable=True,
449449
)
450450

451+
# PRE_AWARD budget team requisition fields (OPS-1639)
452+
pre_award_requisition_number: Mapped[Optional[str]] = mapped_column(
453+
String(100),
454+
nullable=True,
455+
)
456+
pre_award_requisition_date: Mapped[Optional[date]] = mapped_column(
457+
Date,
458+
nullable=True,
459+
)
460+
pre_award_requisition_approved_by: Mapped[Optional[int]] = mapped_column(
461+
Integer,
462+
ForeignKey("ops_user.id"),
463+
nullable=True,
464+
)
465+
pre_award_requisition_approved_date: Mapped[Optional[date]] = mapped_column(
466+
Date,
467+
nullable=True,
468+
)
469+
451470
# Relationship for pre_award completed by user
452471
pre_award_completed_by_user: Mapped[Optional["User"]] = relationship(
453472
"User",
@@ -469,6 +488,13 @@ class DefaultProcurementTrackerStep(ProcurementTrackerStep):
469488
viewonly=True,
470489
)
471490

491+
# Relationship for pre_award requisition approved by user
492+
pre_award_requisition_approved_by_user: Mapped[Optional["User"]] = relationship(
493+
"User",
494+
foreign_keys=[pre_award_requisition_approved_by],
495+
viewonly=True,
496+
)
497+
472498
# Polymorphic configuration
473499
__mapper_args__ = {
474500
"polymorphic_identity": "default_step",

backend/ops_api/ops/schemas/procurement_tracker_steps.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ class ProcurementTrackerStepResponseSchema(Schema):
109109
approval_responded_date = fields.Date(allow_none=True)
110110
reviewer_notes = fields.String(allow_none=True)
111111

112+
# OPS-1639: PRE_AWARD budget team requisition fields
113+
requisition_number = fields.String(allow_none=True)
114+
requisition_date = fields.Date(allow_none=True)
115+
requisition_approved_by = fields.Integer(allow_none=True)
116+
requisition_approved_date = fields.Date(allow_none=True)
117+
112118
# BaseModel fields
113119
display_name = fields.String(dump_only=True)
114120
created_on = fields.DateTime(dump_only=True)
@@ -184,6 +190,11 @@ def map_step_specific_fields(self, obj, **_kwargs):
184190
data["approval_responded_by"] = getattr(obj, "pre_award_approval_responded_by", None)
185191
data["approval_responded_date"] = getattr(obj, "pre_award_approval_responded_date", None)
186192
data["reviewer_notes"] = getattr(obj, "pre_award_approval_reviewer_notes", None)
193+
# OPS-1639: Budget team requisition fields
194+
data["requisition_number"] = getattr(obj, "pre_award_requisition_number", None)
195+
data["requisition_date"] = getattr(obj, "pre_award_requisition_date", None)
196+
data["requisition_approved_by"] = getattr(obj, "pre_award_requisition_approved_by", None)
197+
data["requisition_approved_date"] = getattr(obj, "pre_award_requisition_approved_date", None)
187198

188199
return data
189200

@@ -264,6 +275,10 @@ def remove_none_values(self, data, **_kwargs):
264275
"approval_responded_by",
265276
"approval_responded_date",
266277
"reviewer_notes",
278+
"requisition_number",
279+
"requisition_date",
280+
"requisition_approved_by",
281+
"requisition_approved_date",
267282
}
268283
# Remove PRE_SOLICITATION-only fields
269284
data.pop("draft_solicitation_date", None)
@@ -324,6 +339,11 @@ class ProcurementTrackerStepPatchRequestSchema(Schema):
324339
# approval_responded_by and approval_responded_date are server-controlled - not accepted from client
325340
reviewer_notes = fields.String(required=False, allow_none=True, validate=validate.Length(max=150))
326341

342+
# OPS-1639: Budget team requisition fields (user-provided only)
343+
requisition_number = fields.String(required=False, allow_none=True, validate=validate.Length(max=100))
344+
requisition_date = fields.Date(required=False, allow_none=True)
345+
# requisition_approved_by and requisition_approved_date are SERVER-CONTROLLED - not accepted from client
346+
327347

328348
class ProcurementTrackerStepSchema(Schema):
329349
"""Schema for procurement tracker step serialization."""
@@ -361,6 +381,12 @@ class Meta:
361381
approval_responded_date = fields.Date(allow_none=True)
362382
reviewer_notes = fields.String(allow_none=True)
363383

384+
# OPS-1639: Budget team requisition fields
385+
requisition_number = fields.String(allow_none=True)
386+
requisition_date = fields.Date(allow_none=True)
387+
requisition_approved_by = fields.Integer(allow_none=True)
388+
requisition_approved_date = fields.Date(allow_none=True)
389+
364390
@pre_dump
365391
def map_step_specific_fields(self, obj, **_kwargs):
366392
"""
@@ -426,6 +452,11 @@ def map_step_specific_fields(self, obj, **_kwargs):
426452
data["approval_responded_by"] = getattr(obj, "pre_award_approval_responded_by", None)
427453
data["approval_responded_date"] = getattr(obj, "pre_award_approval_responded_date", None)
428454
data["reviewer_notes"] = getattr(obj, "pre_award_approval_reviewer_notes", None)
455+
# OPS-1639: Budget team requisition fields
456+
data["requisition_number"] = getattr(obj, "pre_award_requisition_number", None)
457+
data["requisition_date"] = getattr(obj, "pre_award_requisition_date", None)
458+
data["requisition_approved_by"] = getattr(obj, "pre_award_requisition_approved_by", None)
459+
data["requisition_approved_date"] = getattr(obj, "pre_award_requisition_approved_date", None)
429460

430461
return data
431462

@@ -513,6 +544,10 @@ def remove_none_step_specific_fields(self, data, **_kwargs):
513544
"approval_responded_by",
514545
"approval_responded_date",
515546
"reviewer_notes",
547+
"requisition_number",
548+
"requisition_date",
549+
"requisition_approved_by",
550+
"requisition_approved_date",
516551
}
517552
preserve_keys = base_fields | pre_award_fields
518553
# Remove PRE_SOLICITATION-only fields
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Notification title constants for pre-award approval workflow."""
2+
3+
4+
class PreAwardNotificationTitle:
5+
"""Notification titles for pre-award approval process."""
6+
7+
APPROVAL_REQUEST = "Pre-Award Approval Request"
8+
APPROVAL_DECLINED = "Pre-Award Approval Declined"
9+
REQUISITION_APPROVED = "Pre-Award Requisition Approved"
10+
BUDGET_TEAM_REVIEW_REQUIRED = "Budget Team Requisition Review Required"

backend/ops_api/ops/services/notifications.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,16 @@ class NotificationService(OpsService[Notification]):
1313
def __init__(self, db_session):
1414
self.db_session = db_session
1515

16-
def create(self, data: dict[str, Any]) -> Notification:
16+
def create(self, data: dict[str, Any], commit: bool = True) -> Notification:
1717
"""
1818
Create a new notification.
19+
20+
Args:
21+
data: Notification data dictionary
22+
commit: If True, commits immediately. If False, only flushes to get ID.
23+
24+
Returns:
25+
Created notification instance
1926
"""
2027
notification_type = data.get("notification_type", NotificationType.NOTIFICATION)
2128

@@ -30,7 +37,12 @@ def create(self, data: dict[str, Any]) -> Notification:
3037

3138
notification = cls(**data)
3239
self.db_session.add(notification)
33-
self.db_session.commit()
40+
41+
if commit:
42+
self.db_session.commit()
43+
else:
44+
self.db_session.flush() # Get ID without committing
45+
3446
return notification
3547

3648
def update(self, notification_id: int, updated_fields: dict[str, Any]) -> tuple[Notification, int]:

0 commit comments

Comments
 (0)