From aaabf8260ed885b77a42af7759e8b15850ccd82c Mon Sep 17 00:00:00 2001 From: josbell Date: Mon, 23 Mar 2026 16:37:44 -0700 Subject: [PATCH 01/81] feat: add pre-award approval response fields to database Add database fields and model support for approval response workflow: - pre_award_approval_status (VARCHAR(20): APPROVED/DECLINED) - pre_award_approval_responded_by (FK to ops_user.id) - pre_award_approval_responded_date (DATE) - pre_award_approval_reviewer_notes (TEXT) Updates: - Add columns to procurement_tracker_step and _version tables - Add relationship pre_award_approval_responded_by_user - Update to_dict() to map new fields for PRE_AWARD steps - Add comprehensive tests for field existence, setting, and serialization Co-Authored-By: Claude Sonnet 4.5 --- ..._add_pre_award_approval_response_fields.py | 50 +++++++ backend/models/procurement_tracker.py | 46 +++++++ .../test_procurement_tracker_model.py | 124 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 backend/alembic/versions/2026_03_23_1600-d6e7f8a9b0c1_add_pre_award_approval_response_fields.py diff --git a/backend/alembic/versions/2026_03_23_1600-d6e7f8a9b0c1_add_pre_award_approval_response_fields.py b/backend/alembic/versions/2026_03_23_1600-d6e7f8a9b0c1_add_pre_award_approval_response_fields.py new file mode 100644 index 0000000000..1ae5b25b2a --- /dev/null +++ b/backend/alembic/versions/2026_03_23_1600-d6e7f8a9b0c1_add_pre_award_approval_response_fields.py @@ -0,0 +1,50 @@ +"""Add pre_award approval response fields to procurement tracker step + +Revision ID: d6e7f8a9b0c1 +Revises: c9a1b2d3e4f5 +Create Date: 2026-03-23 16:00:00.000000+00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd6e7f8a9b0c1' +down_revision: Union[str, None] = 'c9a1b2d3e4f5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('procurement_tracker_step', sa.Column('pre_award_approval_status', sa.String(length=20), nullable=True)) + op.add_column('procurement_tracker_step', sa.Column('pre_award_approval_responded_by', sa.Integer(), nullable=True)) + op.add_column('procurement_tracker_step', sa.Column('pre_award_approval_responded_date', sa.Date(), nullable=True)) + op.add_column('procurement_tracker_step', sa.Column('pre_award_approval_reviewer_notes', sa.Text(), nullable=True)) + op.create_foreign_key( + 'fk_pre_award_responded_by', + 'procurement_tracker_step', 'ops_user', + ['pre_award_approval_responded_by'], ['id'] + ) + op.add_column('procurement_tracker_step_version', sa.Column('pre_award_approval_status', sa.String(length=20), autoincrement=False, nullable=True)) + op.add_column('procurement_tracker_step_version', sa.Column('pre_award_approval_responded_by', sa.Integer(), autoincrement=False, nullable=True)) + op.add_column('procurement_tracker_step_version', sa.Column('pre_award_approval_responded_date', sa.Date(), autoincrement=False, nullable=True)) + op.add_column('procurement_tracker_step_version', sa.Column('pre_award_approval_reviewer_notes', sa.Text(), autoincrement=False, nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('procurement_tracker_step_version', 'pre_award_approval_reviewer_notes') + op.drop_column('procurement_tracker_step_version', 'pre_award_approval_responded_date') + op.drop_column('procurement_tracker_step_version', 'pre_award_approval_responded_by') + op.drop_column('procurement_tracker_step_version', 'pre_award_approval_status') + op.drop_constraint('fk_pre_award_responded_by', 'procurement_tracker_step', type_='foreignkey') + op.drop_column('procurement_tracker_step', 'pre_award_approval_reviewer_notes') + op.drop_column('procurement_tracker_step', 'pre_award_approval_responded_date') + op.drop_column('procurement_tracker_step', 'pre_award_approval_responded_by') + op.drop_column('procurement_tracker_step', 'pre_award_approval_status') + # ### end Alembic commands ### diff --git a/backend/models/procurement_tracker.py b/backend/models/procurement_tracker.py index c64fe73784..3747c553ad 100644 --- a/backend/models/procurement_tracker.py +++ b/backend/models/procurement_tracker.py @@ -392,6 +392,25 @@ class DefaultProcurementTrackerStep(ProcurementTrackerStep): nullable=True, ) + # PRE_AWARD approval response fields + pre_award_approval_status: Mapped[Optional[str]] = mapped_column( + String(20), + nullable=True, + ) + pre_award_approval_responded_by: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("ops_user.id"), + nullable=True, + ) + pre_award_approval_responded_date: Mapped[Optional[date]] = mapped_column( + Date, + nullable=True, + ) + pre_award_approval_reviewer_notes: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + ) + # Relationship for pre_award completed by user pre_award_completed_by_user: Mapped[Optional["User"]] = relationship( "User", @@ -406,6 +425,13 @@ class DefaultProcurementTrackerStep(ProcurementTrackerStep): viewonly=True, ) + # Relationship for pre_award approval responded by user + pre_award_approval_responded_by_user: Mapped[Optional["User"]] = relationship( + "User", + foreign_keys=[pre_award_approval_responded_by], + viewonly=True, + ) + # Polymorphic configuration __mapper_args__ = { "polymorphic_identity": "default_step", @@ -589,8 +615,13 @@ def to_dict(self): data.pop("pre_award_approval_requested_date", None) data.pop("pre_award_approval_requested_by", None) data.pop("pre_award_requestor_notes", None) + data.pop("pre_award_approval_status", None) + data.pop("pre_award_approval_responded_by", None) + data.pop("pre_award_approval_responded_date", None) + data.pop("pre_award_approval_reviewer_notes", None) data.pop("pre_award_completed_by_user", None) data.pop("pre_award_requested_by_user", None) + data.pop("pre_award_approval_responded_by_user", None) # Handle PRE_AWARD-specific fields elif self.step_type == ProcurementTrackerStepType.PRE_AWARD: @@ -606,6 +637,12 @@ def to_dict(self): data["approval_requested_by"] = data.pop("pre_award_approval_requested_by", None) data["requestor_notes"] = data.pop("pre_award_requestor_notes", None) + # Map approval response fields + data["approval_status"] = data.pop("pre_award_approval_status", None) + data["approval_responded_by"] = data.pop("pre_award_approval_responded_by", None) + data["approval_responded_date"] = data.pop("pre_award_approval_responded_date", None) + data["reviewer_notes"] = data.pop("pre_award_approval_reviewer_notes", None) + # Map the relationship if "pre_award_completed_by_user" in data: data["completed_by_user"] = data.pop("pre_award_completed_by_user", None) @@ -614,6 +651,10 @@ def to_dict(self): if "pre_award_requested_by_user" in data: data["requested_by_user"] = data.pop("pre_award_requested_by_user", None) + # Map the approval responded by user relationship + if "pre_award_approval_responded_by_user" in data: + data["approval_responded_by_user"] = data.pop("pre_award_approval_responded_by_user", None) + # Remove ACQUISITION_PLANNING-specific fields data.pop("acquisition_planning_task_completed_by", None) data.pop("acquisition_planning_date_completed", None) @@ -678,8 +719,13 @@ def to_dict(self): data.pop("pre_award_approval_requested_date", None) data.pop("pre_award_approval_requested_by", None) data.pop("pre_award_requestor_notes", None) + data.pop("pre_award_approval_status", None) + data.pop("pre_award_approval_responded_by", None) + data.pop("pre_award_approval_responded_date", None) + data.pop("pre_award_approval_reviewer_notes", None) data.pop("pre_award_completed_by_user", None) data.pop("pre_award_requested_by_user", None) + data.pop("pre_award_approval_responded_by_user", None) return data diff --git a/backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_model.py b/backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_model.py index 2a6401392e..963541f8d5 100644 --- a/backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_model.py +++ b/backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_model.py @@ -576,3 +576,127 @@ def test_step_to_dict_maps_pre_award_approval_fields(loaded_db, test_user): assert "pre_award_approval_requested_date" not in step_5_dict assert "pre_award_approval_requested_by" not in step_5_dict assert "pre_award_requestor_notes" not in step_5_dict + + +def test_pre_award_approval_response_fields_exist(loaded_db): + """Test that pre_award approval response fields exist on all steps.""" + tracker = DefaultProcurementTracker.create_with_steps(agreement_id=1) + loaded_db.add(tracker) + loaded_db.commit() + + # All steps have the prefixed approval response columns + for step in tracker.steps: + assert hasattr(step, "pre_award_approval_status") + assert hasattr(step, "pre_award_approval_responded_by") + assert hasattr(step, "pre_award_approval_responded_date") + assert hasattr(step, "pre_award_approval_reviewer_notes") + + +def test_pre_award_approval_response_fields_can_be_set(loaded_db, test_user): + """Test that pre_award approval response fields can be set and retrieved.""" + tracker = DefaultProcurementTracker.create_with_steps(agreement_id=1) + loaded_db.add(tracker) + loaded_db.commit() + + # Get step 5 (PRE_AWARD) + step_5 = tracker.steps[4] + step_5_id = step_5.id + + # Set approval response fields + step_5.pre_award_approval_status = "APPROVED" + step_5.pre_award_approval_responded_by = test_user.id + step_5.pre_award_approval_responded_date = date(2026, 3, 16) + step_5.pre_award_approval_reviewer_notes = "Approved - looks good" + loaded_db.commit() + + # Fetch it again by ID + from models import ProcurementTrackerStep + + updated_step = loaded_db.get(ProcurementTrackerStep, step_5_id) + assert updated_step.pre_award_approval_status == "APPROVED" + assert updated_step.pre_award_approval_responded_by == test_user.id + assert updated_step.pre_award_approval_responded_date == date(2026, 3, 16) + assert updated_step.pre_award_approval_reviewer_notes == "Approved - looks good" + + +def test_pre_award_approval_responded_by_user_relationship(loaded_db, test_user): + """Test the relationship to ops_user via pre_award_approval_responded_by_user.""" + tracker = DefaultProcurementTracker.create_with_steps(agreement_id=1) + loaded_db.add(tracker) + loaded_db.commit() + + # Get step 5 (PRE_AWARD) + step_5 = tracker.steps[4] + + # Set approval response user + step_5.pre_award_approval_responded_by = test_user.id + loaded_db.commit() + + # Refresh to load relationship + loaded_db.refresh(step_5) + + # Verify relationship + assert step_5.pre_award_approval_responded_by_user is not None + assert step_5.pre_award_approval_responded_by_user.id == test_user.id + + +def test_step_to_dict_maps_pre_award_approval_response_fields(loaded_db, test_user): + """Test that to_dict() maps approval response fields to API field names for PRE_AWARD.""" + tracker = DefaultProcurementTracker.create_with_steps(agreement_id=1) + loaded_db.add(tracker) + loaded_db.commit() + + # Update step 5 with both request and response data + step_5 = tracker.steps[4] + step_5.pre_award_approval_requested = True + step_5.pre_award_approval_requested_date = date(2026, 3, 15) + step_5.pre_award_approval_requested_by = test_user.id + step_5.pre_award_requestor_notes = "Please review" + step_5.pre_award_approval_status = "APPROVED" + step_5.pre_award_approval_responded_by = test_user.id + step_5.pre_award_approval_responded_date = date(2026, 3, 16) + step_5.pre_award_approval_reviewer_notes = "Approved - all good" + loaded_db.commit() + + # Step 5 (PRE_AWARD) should map to unprefixed names + step_5_dict = tracker.steps[4].to_dict() + + # Check approval request fields + assert "approval_requested" in step_5_dict + assert step_5_dict["approval_requested"] is True + assert "requestor_notes" in step_5_dict + assert step_5_dict["requestor_notes"] == "Please review" + + # Check approval response fields + assert "approval_status" in step_5_dict + assert "approval_responded_by" in step_5_dict + assert "approval_responded_date" in step_5_dict + assert "reviewer_notes" in step_5_dict + assert step_5_dict["approval_status"] == "APPROVED" + assert step_5_dict["approval_responded_by"] == test_user.id + assert step_5_dict["approval_responded_date"] == "2026-03-16" + assert step_5_dict["reviewer_notes"] == "Approved - all good" + + # Prefixed versions should be removed + assert "pre_award_approval_status" not in step_5_dict + assert "pre_award_approval_responded_by" not in step_5_dict + assert "pre_award_approval_responded_date" not in step_5_dict + assert "pre_award_approval_reviewer_notes" not in step_5_dict + + +def test_step_to_dict_excludes_pre_award_approval_response_fields_from_other_steps(loaded_db): + """Test that approval response fields are excluded from non-PRE_AWARD steps.""" + tracker = DefaultProcurementTracker.create_with_steps(agreement_id=1) + loaded_db.add(tracker) + loaded_db.commit() + + # Check step 1 (ACQUISITION_PLANNING) + step_1_dict = tracker.steps[0].to_dict() + assert "approval_status" not in step_1_dict + assert "approval_responded_by" not in step_1_dict + assert "approval_responded_date" not in step_1_dict + assert "reviewer_notes" not in step_1_dict + assert "pre_award_approval_status" not in step_1_dict + assert "pre_award_approval_responded_by" not in step_1_dict + assert "pre_award_approval_responded_date" not in step_1_dict + assert "pre_award_approval_reviewer_notes" not in step_1_dict From f9bf8ef775d8e18588ee040c44df0bd5b0e1357a Mon Sep 17 00:00:00 2001 From: josbell Date: Mon, 23 Mar 2026 16:40:06 -0700 Subject: [PATCH 02/81] feat: add approval response fields to procurement tracker step schema Update schemas to expose approval response fields in API: - Add approval_status (APPROVED/DECLINED validation) - Add approval_responded_by (read-only, server-controlled) - Add approval_responded_date (read-only, server-controlled) - Add reviewer_notes (max 150 chars, client-writable) Updates preserve_keys in post_dump to include new fields for PRE_AWARD steps and remove them for other step types. Co-Authored-By: Claude Sonnet 4.5 --- .../ops/schemas/procurement_tracker_steps.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/backend/ops_api/ops/schemas/procurement_tracker_steps.py b/backend/ops_api/ops/schemas/procurement_tracker_steps.py index ebea8e74c7..51b2138e35 100644 --- a/backend/ops_api/ops/schemas/procurement_tracker_steps.py +++ b/backend/ops_api/ops/schemas/procurement_tracker_steps.py @@ -46,6 +46,12 @@ class ProcurementTrackerStepResponseSchema(Schema): approval_requested_by = fields.Integer(allow_none=True) requestor_notes = fields.String(allow_none=True) + # PRE_AWARD approval response fields + approval_status = fields.String(allow_none=True, validate=validate.OneOf(["APPROVED", "DECLINED"])) + approval_responded_by = fields.Integer(allow_none=True) + approval_responded_date = fields.Date(allow_none=True) + reviewer_notes = fields.String(allow_none=True) + # BaseModel fields display_name = fields.String(dump_only=True) created_on = fields.DateTime(dump_only=True) @@ -192,6 +198,10 @@ def remove_none_values(self, data, **_kwargs): "approval_requested_date", "approval_requested_by", "requestor_notes", + "approval_status", + "approval_responded_by", + "approval_responded_date", + "reviewer_notes", } # Remove PRE_SOLICITATION-only fields data.pop("draft_solicitation_date", None) @@ -213,6 +223,10 @@ def remove_none_values(self, data, **_kwargs): "approval_requested_date", "approval_requested_by", "requestor_notes", + "approval_status", + "approval_responded_by", + "approval_responded_date", + "reviewer_notes", ]: data.pop(field, None) @@ -243,6 +257,11 @@ class ProcurementTrackerStepPatchRequestSchema(Schema): # approval_requested_by is server-controlled and derived from current_user - not accepted from client requestor_notes = fields.String(required=False, allow_none=True, validate=validate.Length(max=150)) + # Pre-Award approval response fields + approval_status = fields.String(required=False, allow_none=True, validate=validate.OneOf(["APPROVED", "DECLINED"])) + # approval_responded_by and approval_responded_date are server-controlled - not accepted from client + reviewer_notes = fields.String(required=False, allow_none=True, validate=validate.Length(max=150)) + class ProcurementTrackerStepSchema(Schema): """Schema for procurement tracker step serialization.""" @@ -274,6 +293,12 @@ class Meta: approval_requested_by = fields.Integer(allow_none=True) requestor_notes = fields.String(allow_none=True) + # PRE_AWARD approval response fields + approval_status = fields.String(allow_none=True) + approval_responded_by = fields.Integer(allow_none=True) + approval_responded_date = fields.Date(allow_none=True) + reviewer_notes = fields.String(allow_none=True) + @pre_dump def map_step_specific_fields(self, obj, **_kwargs): """ @@ -418,6 +443,10 @@ def remove_none_step_specific_fields(self, data, **_kwargs): "approval_requested_date", "approval_requested_by", "requestor_notes", + "approval_status", + "approval_responded_by", + "approval_responded_date", + "reviewer_notes", } preserve_keys = base_fields | pre_award_fields # Remove PRE_SOLICITATION-only fields @@ -440,6 +469,10 @@ def remove_none_step_specific_fields(self, data, **_kwargs): "approval_requested_date", "approval_requested_by", "requestor_notes", + "approval_status", + "approval_responded_by", + "approval_responded_date", + "reviewer_notes", ]: data.pop(field, None) From 2353942a3075b19ff4675442c6c4f14889022789 Mon Sep 17 00:00:00 2001 From: josbell Date: Mon, 23 Mar 2026 16:48:36 -0700 Subject: [PATCH 03/81] feat: add approval response field mapping and server-controlled values Update service layer to handle approval response workflow: - Add field mapping for approval_status, approval_responded_by, approval_responded_date, and reviewer_notes - Set server-controlled fields automatically when approval_status is APPROVED or DECLINED: - approval_responded_by = current_user.id - approval_responded_date = today Follows same pattern as approval_requested_by server control. Add noqa for update method complexity (simple business logic addition). Co-Authored-By: Claude Sonnet 4.5 --- .../ops/services/procurement_tracker_steps.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/ops_api/ops/services/procurement_tracker_steps.py b/backend/ops_api/ops/services/procurement_tracker_steps.py index 830887627e..028b8addb7 100644 --- a/backend/ops_api/ops/services/procurement_tracker_steps.py +++ b/backend/ops_api/ops/services/procurement_tracker_steps.py @@ -65,7 +65,9 @@ def get(self, id: int) -> ProcurementTrackerStep: else: raise ResourceNotFoundError("ProcurementTrackerStep", id) - def update(self, id: int, data: Dict[str, Any], current_user: User) -> Tuple[ProcurementTrackerStep, int]: + def update( # noqa: C901 + self, id: int, data: Dict[str, Any], current_user: User + ) -> Tuple[ProcurementTrackerStep, int]: """ Update a procurement tracker step. @@ -126,6 +128,10 @@ def update(self, id: int, data: Dict[str, Any], current_user: User) -> Tuple[Pro "approval_requested_date": "pre_award_approval_requested_date", "approval_requested_by": "pre_award_approval_requested_by", "requestor_notes": "pre_award_requestor_notes", + "approval_status": "pre_award_approval_status", + "approval_responded_by": "pre_award_approval_responded_by", + "approval_responded_date": "pre_award_approval_responded_date", + "reviewer_notes": "pre_award_approval_reviewer_notes", }, } @@ -162,6 +168,16 @@ def update(self, id: int, data: Dict[str, Any], current_user: User) -> Tuple[Pro step.pre_award_approval_requested_by = current_user.id logger.debug(f"Set pre_award_approval_requested_by = {current_user.id} (server-controlled)") + # Always set approval_responded_by and date when approval_status is set + # These are server-controlled and never accepted from the client + if data.get("approval_status") in ["APPROVED", "DECLINED"]: + step.pre_award_approval_responded_by = current_user.id + step.pre_award_approval_responded_date = date.today() + logger.debug( + f"Set pre_award_approval_responded_by = {current_user.id}, " + f"pre_award_approval_responded_date = {date.today()} (server-controlled)" + ) + # Handle COMPLETED status self._advance_active_step_if_needed(step, data, current_user) From 40cdf10b823aaa95b41864516a022ac7e87ea213 Mon Sep 17 00:00:00 2001 From: josbell Date: Mon, 23 Mar 2026 16:53:04 -0700 Subject: [PATCH 04/81] feat: add validation rules for pre-award approval responses Add comprehensive validation for approval response workflow: Authorization Rule (PreAwardApprovalResponseAuthorizationRule): - Checks if user is Division Director, Deputy Director, BUDGET_TEAM, or SYSTEM_OWNER - Uses get_division_directors_for_agreement helper - Only validates when approval_status is being updated Business Logic Rule (PreAwardApprovalResponseValidationRule): - Validates approval was requested before responding - Prevents double-response (already approved/declined) - Requires reviewer_notes when declining - Only validates when approval_status is being updated Update validator to include new rules in PRE_AWARD step validation. Co-Authored-By: Claude Sonnet 4.5 --- .../procurement_tracker_steps_validator.py | 4 + .../rules/procurement_tracker_step.py | 101 ++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/backend/ops_api/ops/validation/procurement_tracker_steps_validator.py b/backend/ops_api/ops/validation/procurement_tracker_steps_validator.py index 611ee14f48..5447536418 100644 --- a/backend/ops_api/ops/validation/procurement_tracker_steps_validator.py +++ b/backend/ops_api/ops/validation/procurement_tracker_steps_validator.py @@ -82,6 +82,8 @@ def _get_validators( NoPastTargetCompletionDateUpdateRule, NoUpdatingCompletedProcurementStepRule, PreAwardApprovalRequestAuthorizationRule, + PreAwardApprovalResponseAuthorizationRule, + PreAwardApprovalResponseValidationRule, PreAwardCompletionRequiredFieldsRule, PreSolicitationCompletionRequiredFieldsRule, ResourceExistsRule, @@ -136,6 +138,8 @@ def _get_validators( PreAwardApprovalRequestAuthorizationRule(), NoBLIsInReviewForApprovalRequestRule(), Step4CompletionRequiredForApprovalRequestRule(), + PreAwardApprovalResponseAuthorizationRule(), + PreAwardApprovalResponseValidationRule(), PreAwardCompletionRequiredFieldsRule(), CompletedByUpdateAuthorizationRule(), NoUpdatingCompletedProcurementStepRule(), diff --git a/backend/ops_api/ops/validation/rules/procurement_tracker_step.py b/backend/ops_api/ops/validation/rules/procurement_tracker_step.py index 0deec7156a..b65a733663 100644 --- a/backend/ops_api/ops/validation/rules/procurement_tracker_step.py +++ b/backend/ops_api/ops/validation/rules/procurement_tracker_step.py @@ -569,3 +569,104 @@ def validate(self, procurement_tracker_step: ProcurementTrackerStep, context: Va "approval_requested": f"Cannot request pre-award approval: Step 4 (Evaluation) must be completed first. Current status: {step_4.status}" } ) + + +class PreAwardApprovalResponseAuthorizationRule(ValidationRule): + """ + Validates that the user responding to pre-award approval is authorized. + Only checks when approval_status is being updated for PRE_AWARD steps. + + Authorized users are: + - Division Directors and Deputy Directors (for divisions linked to the agreement) + - BUDGET_TEAM role members + - SYSTEM_OWNER role members + """ + + @property + def name(self) -> str: + return "PRE_AWARD Approval Response Authorization" + + def validate(self, procurement_tracker_step: ProcurementTrackerStep, context: ValidationContext) -> None: + from ops_api.ops.utils.agreements_helpers import get_division_directors_for_agreement + + # Only validate if step type is PRE_AWARD + if procurement_tracker_step.step_type != ProcurementTrackerStepType.PRE_AWARD: + return + + updated_fields = context.updated_fields + + # Only validate if approval_status is being updated + if "approval_status" not in updated_fields: + return + + # Get the agreement + if ( + not procurement_tracker_step.procurement_tracker + or not procurement_tracker_step.procurement_tracker.agreement + ): + raise ValidationError({"agreement": "Procurement tracker step is not linked to a valid agreement."}) + + agreement = procurement_tracker_step.procurement_tracker.agreement + user_id = context.user.id + + # Check if user is a division director or deputy + directors, deputies = get_division_directors_for_agreement(agreement) + if user_id in directors or user_id in deputies: + return + + # Check if user has BUDGET_TEAM or SYSTEM_OWNER role + user = context.db_session.get(User, user_id) + if user: + user_role_names = {role.name for role in user.roles} + if "BUDGET_TEAM" in user_role_names or "SYSTEM_OWNER" in user_role_names: + return + + raise AuthorizationError( + f"User {user_id} is not authorized to respond to pre-award approval requests for procurement tracker step {procurement_tracker_step.id}.", + "ProcurementTrackerStep", + ) + + +class PreAwardApprovalResponseValidationRule(ValidationRule): + """ + Validates that approval responses are valid: + - Can only respond if approval has been requested + - Cannot respond if already responded + - Reviewer notes required when declining + """ + + @property + def name(self) -> str: + return "PRE_AWARD Approval Response Validation" + + def validate(self, procurement_tracker_step: ProcurementTrackerStep, context: ValidationContext) -> None: + # Only validate if step type is PRE_AWARD + if procurement_tracker_step.step_type != ProcurementTrackerStepType.PRE_AWARD: + return + + updated_fields = context.updated_fields + + # Only validate if approval_status is being updated + if "approval_status" not in updated_fields: + return + + # Check if approval was requested + if not procurement_tracker_step.pre_award_approval_requested: + raise ValidationError( + {"approval_status": "Cannot respond to approval request that has not been submitted."} + ) + + # Check if already responded + if procurement_tracker_step.pre_award_approval_status: + raise ValidationError( + { + "approval_status": f"This approval request has already been {procurement_tracker_step.pre_award_approval_status.lower()}." + } + ) + + # Require reviewer notes when declining + if updated_fields["approval_status"] == "DECLINED": + if not updated_fields.get("reviewer_notes") or not updated_fields["reviewer_notes"].strip(): + raise ValidationError( + {"reviewer_notes": "Reviewer notes are required when declining an approval request."} + ) From d095d5264390cd57e51061c9ce89e70d20215a8c Mon Sep 17 00:00:00 2001 From: josbell Date: Mon, 23 Mar 2026 16:56:07 -0700 Subject: [PATCH 05/81] feat: add notifications for pre-award approval workflow Add notification system for approval request and response: _handle_approval_notifications(): - Case 1: When approval_requested=True, notify all eligible reviewers (Division Directors, Deputies, BUDGET_TEAM, SYSTEM_OWNER) - Case 2: When approval_status=APPROVED/DECLINED, notify submitter - Include clickable links to agreement with procurement tracker _get_approval_reviewers(): - Get Division Directors and Deputies using get_division_directors_for_agreement - Get users with BUDGET_TEAM or SYSTEM_OWNER roles - Return set of eligible reviewer user IDs Notification messages: - Request: "A pre-award approval has been requested for Agreement X" - Approved: "Your request has been approved by [reviewer]" - Declined: "Your request has been declined by [reviewer]" Co-Authored-By: Claude Sonnet 4.5 --- .../ops/services/procurement_tracker_steps.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/backend/ops_api/ops/services/procurement_tracker_steps.py b/backend/ops_api/ops/services/procurement_tracker_steps.py index 028b8addb7..70ba3e9b03 100644 --- a/backend/ops_api/ops/services/procurement_tracker_steps.py +++ b/backend/ops_api/ops/services/procurement_tracker_steps.py @@ -185,6 +185,9 @@ def update( # noqa: C901 self.db_session.commit() self.db_session.refresh(step) + # Handle approval notifications after commit + self._handle_approval_notifications(step, data, current_user) + logger.debug(f"Successfully updated procurement tracker step {id}") return step, 200 @@ -267,6 +270,101 @@ def _create_update_procurement_tracker_event( f"(agreement {procurement_tracker.agreement_id})" ) + def _handle_approval_notifications( + self, step: ProcurementTrackerStep, data: Dict[str, Any], current_user: User + ) -> None: + """ + Send notifications when approval is requested or responded to. + + Args: + step: The updated procurement tracker step + data: The update data + current_user: User making the update + """ + from models import NotificationType + from ops_api.ops.services.notifications import NotificationService + + notification_service = NotificationService(self.db_session) + agreement = step.procurement_tracker.agreement + + # Case 1: Approval was just requested - notify reviewers + if data.get("approval_requested") is True and step.pre_award_approval_requested: + recipient_ids = self._get_approval_reviewers(agreement) + + fe_url = current_app.config.get("OPS_FRONTEND_URL", "http://localhost:3000") + agreement_url = f"{fe_url}/agreements/{agreement.id}?procurementTracker=true" + + for recipient_id in recipient_ids: + notification_service.create( + { + "title": "Pre-Award Approval Request", + "message": ( + f"A pre-award approval has been requested for Agreement {agreement.display_name}. " + f"Please review and respond.\n\n[View Agreement]({agreement_url})" + ), + "is_read": False, + "recipient_id": recipient_id, + "notification_type": NotificationType.NOTIFICATION, + } + ) + logger.debug(f"Created {len(recipient_ids)} pre-award approval request notifications") + + # Case 2: Approval was approved/declined - notify submitter + elif data.get("approval_status") in ["APPROVED", "DECLINED"]: + status_text = "approved" if data["approval_status"] == "APPROVED" else "declined" + + if step.pre_award_approval_requested_by: + notification_service.create( + { + "title": f"Pre-Award Approval {status_text.capitalize()}", + "message": ( + f"Your pre-award approval request for Agreement {agreement.display_name} " + f"has been {status_text} by {current_user.full_name}." + ), + "is_read": False, + "recipient_id": step.pre_award_approval_requested_by, + "notification_type": NotificationType.NOTIFICATION, + } + ) + logger.debug(f"Created pre-award approval {status_text} notification for submitter") + + def _get_approval_reviewers(self, agreement) -> set[int]: + """ + Get user IDs who can approve pre-award requests. + + Returns IDs of Division Directors, Deputy Division Directors, + Budget Team, and System Owners. + + Args: + agreement: The agreement to get reviewers for + + Returns: + Set of user IDs authorized to review + """ + from sqlalchemy import select + + from models import Role + from ops_api.ops.utils.agreements_helpers import get_division_directors_for_agreement + + reviewer_ids = set() + + # Get division directors for the agreement + directors, deputies = get_division_directors_for_agreement(agreement) + reviewer_ids.update(directors) + reviewer_ids.update(deputies) + + # Get BUDGET_TEAM and SYSTEM_OWNER users + role_based_users = ( + self.db_session.execute( + select(User.id).where(User.roles.any(Role.name.in_(["BUDGET_TEAM", "SYSTEM_OWNER"]))) + ) + .scalars() + .all() + ) + reviewer_ids.update(role_based_users) + + return reviewer_ids + def _apply_agreement_filter(self, stmt: Select[tuple[ProcurementTrackerStep]], agreement_id: list[int] | int): """Apply agreement_id filter to the query.""" if agreement_id: From fdd96270431aee859d545768a92805a2da95462c Mon Sep 17 00:00:00 2001 From: josbell Date: Mon, 23 Mar 2026 16:56:49 -0700 Subject: [PATCH 06/81] feat: add history tracking for pre-award approval events Update create_procurement_tracker_step_update_history_event to handle approval workflow events: 1. Approval Request: - Title: "Pre-Award Approval Requested" - Message: "[User] requested pre-award approval for step 5" 2. Approval Approved: - Title: "Pre-Award Approval Approved" - Message: "[Reviewer] approved the pre-award approval request" 3. Approval Declined: - Title: "Pre-Award Approval Declined" - Message: "[Reviewer] declined the pre-award approval request" History events are triggered by UPDATE_PROCUREMENT_TRACKER_STEP events and displayed in the agreement history panel. Co-Authored-By: Claude Sonnet 4.5 --- backend/models/agreement_history.py | 43 ++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/backend/models/agreement_history.py b/backend/models/agreement_history.py index ce1b58c9aa..52d847074a 100644 --- a/backend/models/agreement_history.py +++ b/backend/models/agreement_history.py @@ -992,9 +992,50 @@ def create_procurement_tracker_step_update_history_event( """A method that generates an AgreementHistory event for an updated procurement tracker step.""" procurement_tracker_step = event.event_details["procurement_tracker_step"] updates = event.event_details["procurement_tracker_step_updates"]["changes"] + procurement_tracker = session.get(ProcurementTracker, procurement_tracker_step["procurement_tracker_id"]) + + # Handle approval request events + if "approval_requested" in updates and updates["approval_requested"]["new_value"] is True: + return AgreementHistory( + agreement_id=procurement_tracker.agreement_id, + agreement_id_record=procurement_tracker.agreement_id, + ops_event_id=event.id, + history_title="Pre-Award Approval Requested", + history_message=f"{event_user.full_name} requested pre-award approval for step 5 of the Procurement Tracker.", + timestamp=event.created_on.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + history_type=AgreementHistoryType.PROCUREMENT_TRACKER_STEP_UPDATED, + ) + + # Handle approval response events + if "approval_status" in updates: + status = updates["approval_status"]["new_value"] + if status == "APPROVED": + history_title = "Pre-Award Approval Approved" + history_message = ( + f"{event_user.full_name} approved the pre-award approval request for step 5 of the Procurement Tracker." + ) + elif status == "DECLINED": + history_title = "Pre-Award Approval Declined" + history_message = ( + f"{event_user.full_name} declined the pre-award approval request for step 5 of the Procurement Tracker." + ) + else: + history_title = None + + if history_title: + return AgreementHistory( + agreement_id=procurement_tracker.agreement_id, + agreement_id_record=procurement_tracker.agreement_id, + ops_event_id=event.id, + history_title=history_title, + history_message=history_message, + timestamp=event.created_on.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + history_type=AgreementHistoryType.PROCUREMENT_TRACKER_STEP_UPDATED, + ) + + # Handle status change events (step completion) if "status" not in updates: return None # Only create history event for status changes - procurement_tracker = session.get(ProcurementTracker, procurement_tracker_step["procurement_tracker_id"]) new_value = updates["status"]["new_value"] step_type = procurement_tracker_step["step_type"] if new_value != str(ProcurementTrackerStepStatus.COMPLETED): From d3ffc38613cf0e26861f9b0d3b5c37a8fcc0c0b5 Mon Sep 17 00:00:00 2001 From: josbell Date: Mon, 23 Mar 2026 17:07:53 -0700 Subject: [PATCH 07/81] feat: add pre-award approval review page component Create ApprovePreAwardApproval component for reviewers to approve/decline pre-award requests: Page structure: - PageHeader with agreement name - AgreementMetaAccordion (agreement details) - AgreementBLIAccordion (executing budget lines grouped by services component) - AgreementCANReviewAccordion (CAN impact review) - Final Consensus Memo documents accordion (if uploaded) - Notes section (submitter notes read-only, reviewer notes input) - Action buttons: Cancel, Decline, Approve Features: - Permission check (shows access denied if unauthorized) - Already processed alert (prevents duplicate responses) - Confirmation modal for approve/decline actions - TextArea for reviewer notes (max 150 chars) - Disabled state while submitting or already processed - Error alert display Follows pattern from ApproveAgreement.jsx for consistency. Co-Authored-By: Claude Sonnet 4.5 --- .../ApprovePreAwardApproval.jsx | 255 ++++++++++++++++++ .../agreements/pre-award-approval/index.js | 1 + 2 files changed, 256 insertions(+) create mode 100644 frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.jsx diff --git a/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.jsx b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.jsx new file mode 100644 index 0000000000..f545d6127e --- /dev/null +++ b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.jsx @@ -0,0 +1,255 @@ +import { useParams } from "react-router-dom"; +import App from "../../../App"; +import PageHeader from "../../../components/UI/PageHeader"; +import AgreementMetaAccordion from "../../../components/Agreements/AgreementMetaAccordion"; +import AgreementBLIAccordion from "../../../components/Agreements/AgreementBLIAccordion"; +import AgreementCANReviewAccordion from "../../../components/Agreements/AgreementCANReviewAccordion"; +import AgreementBLIReviewTable from "../../../components/BudgetLineItems/BLIReviewTable"; +import ServicesComponentAccordion from "../../../components/ServicesComponents/ServicesComponentAccordion"; +import Accordion from "../../../components/UI/Accordion"; +import TextArea from "../../../components/UI/Form/TextArea"; +import SimpleAlert from "../../../components/UI/Alert/SimpleAlert"; +import ConfirmationModal from "../../../components/UI/Modals/ConfirmationModal"; +import { convertCodeForDisplay } from "../../../helpers/utils"; +import { + findDescription, + findIfOptional, + findPeriodEnd, + findPeriodStart +} from "../../../helpers/servicesComponent.helpers"; +import useApprovePreAwardApproval from "./ApprovePreAwardApproval.hooks"; + +/** + * @component - Renders a page for Division Directors to approve/decline pre-award approval requests. + * @returns {React.ReactElement} - The rendered component. + */ +export const ApprovePreAwardApproval = () => { + const { id } = useParams(); + const agreementId = Number(id); + + const { + agreement, + isLoading, + executingBudgetLines, + reviewerNotes, + setReviewerNotes, + requestorNotes, + handleApprove, + handleDecline, + handleCancel, + projectOfficerName, + alternateProjectOfficerName, + servicesComponents, + groupedBudgetLinesByServicesComponent, + preAwardMemoDocuments, + showModal, + setShowModal, + modalProps, + isSubmitting, + submitError, + hasPermission, + approvalAlreadyProcessed + } = useApprovePreAwardApproval(agreementId); + + if (isLoading) { + return

Loading...

; + } + + if (!hasPermission) { + return ( + + + + ); + } + + return ( + + {showModal && ( + + )} + + + + {approvalAlreadyProcessed && ( + + )} + + {/* Agreement Details */} + + + {/* Budget Lines (Executing Status) */} + {}} + action="" + > + {groupedBudgetLinesByServicesComponent && + groupedBudgetLinesByServicesComponent.length > 0 && + groupedBudgetLinesByServicesComponent.map((group, index) => { + const budgetLineScGroupingLabel = group.serviceComponentGroupingLabel + ? group.serviceComponentGroupingLabel + : group.servicesComponentNumber; + return ( + + {group.budgetLines.length > 0 ? ( + + ) : ( +

+ No budget lines in this services component. +

+ )} +
+ ); + })} +
+ + {/* CAN Impact */} + {}} + action="" + changeRequestType="" + /> + + {/* Pre-Award Documents */} + {preAwardMemoDocuments && preAwardMemoDocuments.length > 0 && ( + +

The submitter uploaded the following documents:

+ {preAwardMemoDocuments.map((doc) => ( +
+

+ {doc.document_name} + {doc.document_size && ({doc.document_size} MB)} +

+
+ ))} +
+ )} + + {/* Notes Section */} + +

Notes can be shared between the Submitter and Reviewer, if needed.

+ + {requestorNotes && ( + <> +

Submitter's Notes

+

+ {requestorNotes} +

+ + )} + +
+

Reviewer's Notes

+