diff --git a/.gitignore b/.gitignore index d874433a01..2a64eea61b 100644 --- a/.gitignore +++ b/.gitignore @@ -204,6 +204,7 @@ yarn-error.log* *.drawio.bkp *.db claude.sh +.claude # Local .terraform directories **/.terraform/* 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/alembic/versions/2026_04_13_1940-c47768234303_add_perms_for_reviewer_approver.py b/backend/alembic/versions/2026_04_13_1940-c47768234303_add_perms_for_reviewer_approver.py index 9ece4e9836..38d174f093 100644 --- a/backend/alembic/versions/2026_04_13_1940-c47768234303_add_perms_for_reviewer_approver.py +++ b/backend/alembic/versions/2026_04_13_1940-c47768234303_add_perms_for_reviewer_approver.py @@ -1,7 +1,7 @@ """add perms for reviewer approver Revision ID: c47768234303 -Revises: c9a1b2d3e4f5 +Revises: d6e7f8a9b0c1 Create Date: 2026-04-13 19:40:42.146668+00:00 """ @@ -13,7 +13,7 @@ # revision identifiers, used by Alembic. revision: str = 'c47768234303' -down_revision: Union[str, None] = 'c9a1b2d3e4f5' +down_revision: Union[str, None] = 'd6e7f8a9b0c1' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None 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): diff --git a/backend/models/procurement_tracker.py b/backend/models/procurement_tracker.py index c64fe73784..207d38cb94 100644 --- a/backend/models/procurement_tracker.py +++ b/backend/models/procurement_tracker.py @@ -18,6 +18,17 @@ from models.base import BaseModel +__all__ = [ + "ProcurementTrackerStatus", + "ProcurementTrackerType", + "ProcurementTrackerStepType", + "ProcurementTrackerStepStatus", + "ProcurementTracker", + "ProcurementTrackerStep", + "DefaultProcurementTrackerStep", + "DefaultProcurementTracker", +] + # ============================================================================ # ENUMS # ============================================================================ @@ -392,6 +403,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 +436,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", @@ -478,6 +515,23 @@ def to_dict(self): data.pop("evaluation_notes", None) data.pop("evaluation_completed_by_user", None) + # Remove PRE_AWARD-specific fields + data.pop("pre_award_target_completion_date", None) + data.pop("pre_award_task_completed_by", None) + data.pop("pre_award_date_completed", None) + data.pop("pre_award_notes", None) + data.pop("pre_award_approval_requested", None) + 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_SOLICITATION-specific fields elif self.step_type == ProcurementTrackerStepType.PRE_SOLICITATION: # Map prefixed columns to API field names @@ -512,6 +566,23 @@ def to_dict(self): data.pop("evaluation_notes", None) data.pop("evaluation_completed_by_user", None) + # Remove PRE_AWARD-specific fields + data.pop("pre_award_target_completion_date", None) + data.pop("pre_award_task_completed_by", None) + data.pop("pre_award_date_completed", None) + data.pop("pre_award_notes", None) + data.pop("pre_award_approval_requested", None) + 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 SOLICITATION-specific fields elif self.step_type == ProcurementTrackerStepType.SOLICITATION: # Map prefixed columns to API field names @@ -546,6 +617,23 @@ def to_dict(self): data.pop("evaluation_notes", None) data.pop("evaluation_completed_by_user", None) + # Remove PRE_AWARD-specific fields + data.pop("pre_award_target_completion_date", None) + data.pop("pre_award_task_completed_by", None) + data.pop("pre_award_date_completed", None) + data.pop("pre_award_notes", None) + data.pop("pre_award_approval_requested", None) + 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 EVALUATION-specific fields elif self.step_type == ProcurementTrackerStepType.EVALUATION: # Map prefixed columns to API field names @@ -589,8 +677,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 +699,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 +713,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 +781,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/openapi.yml b/backend/openapi.yml index 516e5ebf1a..b42a0367c0 100644 --- a/backend/openapi.yml +++ b/backend/openapi.yml @@ -1043,6 +1043,54 @@ paths: description: Bad Request "500": description: Internal Server Error + /procurement-tracker-steps/pending-approvals/: + get: + tags: + - Procurement Tracker Steps + operationId: Get Pending Pre-Award Approvals + summary: Get pending pre-award approval requests for the current user + description: >- + List pending pre-award approval requests that require review by the current user. + Returns steps where the user has permission to approve (Division Director, Budget Team, or System Owner). + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ProcurementTrackerPreAwardStep" + examples: + "0": + summary: List of pending pre-award approvals + value: + - id: 25 + procurement_tracker_id: 5 + procurement_tracker: + id: 5 + agreement_id: 13 + agreement: + id: 13 + name: "Contract #13" + display_name: "Contract #13" + step_number: 5 + step_type: "PRE_AWARD" + status: "IN_PROGRESS" + step_start_date: "2026-04-05" + step_completed_date: null + approval_requested: true + approval_requested_date: "2026-04-05" + approval_requested_by: 42 + requestor_notes: "Urgent approval needed for Q2 budget execution" + approval_status: "PENDING" + approval_responded_by: null + approval_responded_date: null + reviewer_notes: null + "401": + description: Unauthorized + "500": + description: Internal Server Error /procurement-tracker-steps/{id}: get: tags: @@ -1067,13 +1115,20 @@ paths: - $ref: "#/components/schemas/ProcurementTrackerAcquisitionPlanningStep" - $ref: "#/components/schemas/ProcurementTrackerPreSolicitationStep" - $ref: "#/components/schemas/ProcurementTrackerSolicitationStep" + - $ref: "#/components/schemas/ProcurementTrackerEvaluationStep" + - $ref: "#/components/schemas/ProcurementTrackerPreAwardStep" description: >- Procurement tracker step. ACQUISITION_PLANNING steps include additional fields (task_completed_by, date_completed, notes). PRE_SOLICITATION steps include additional fields (target_completion_date, task_completed_by, date_completed, notes, - draft_solicitation_date). + draft_solicitation_date). PRE_AWARD steps include additional + fields (target_completion_date, task_completed_by, + date_completed, notes, approval_requested, + approval_requested_date, approval_requested_by, + requestor_notes, approval_status, approval_responded_by, + approval_responded_date, reviewer_notes). examples: "0": $ref: "#/components/examples/ProcurementTrackerStepExample" @@ -1083,6 +1138,8 @@ paths: $ref: "#/components/examples/ProcurementTrackerPreSolicitationStepExample" "3": $ref: "#/components/examples/ProcurementTrackerSolicitationStepExample" + "4": + $ref: "#/components/examples/ProcurementTrackerPreAwardStepExample" "404": description: Not Found "500": @@ -1098,7 +1155,9 @@ paths: (task_completed_by, date_completed, notes) can be updated. For PRE_SOLICITATION steps, additional fields (target_completion_date, task_completed_by, date_completed, - notes, draft_solicitation_date) can be updated. + notes, draft_solicitation_date) can be updated. For PRE_AWARD + steps, additional fields (approval_status, reviewer_notes) can + be updated by approvers. parameters: - in: path name: id @@ -1122,6 +1181,8 @@ paths: $ref: "#/components/examples/UpdateProcurementTrackerStepPreSolicitationRequest" "3": $ref: "#/components/examples/UpdateProcurementTrackerStepSolicitationRequest" + "4": + $ref: "#/components/examples/UpdateProcurementTrackerStepPreAwardRequest" responses: "200": description: Procurement tracker step updated successfully @@ -1133,13 +1194,17 @@ paths: - $ref: "#/components/schemas/ProcurementTrackerAcquisitionPlanningStep" - $ref: "#/components/schemas/ProcurementTrackerPreSolicitationStep" - $ref: "#/components/schemas/ProcurementTrackerSolicitationStep" + - $ref: "#/components/schemas/ProcurementTrackerEvaluationStep" + - $ref: "#/components/schemas/ProcurementTrackerPreAwardStep" description: >- Updated procurement tracker step. ACQUISITION_PLANNING steps include additional fields (task_completed_by, date_completed, notes). PRE_SOLICITATION steps include additional fields (target_completion_date, task_completed_by, date_completed, notes, - draft_solicitation_date). + draft_solicitation_date). PRE_AWARD steps include additional + fields (approval_status, approval_responded_by, + approval_responded_date, reviewer_notes). examples: "0": $ref: "#/components/examples/ProcurementTrackerStepExample" @@ -1149,6 +1214,8 @@ paths: $ref: "#/components/examples/ProcurementTrackerPreSolicitationStepExample" "3": $ref: "#/components/examples/ProcurementTrackerSolicitationStepExample" + "4": + $ref: "#/components/examples/ProcurementTrackerPreAwardStepExample" "400": description: Bad Request "404": @@ -7583,6 +7650,72 @@ components: type: string nullable: true description: Additional notes about this step + ProcurementTrackerPreAwardStep: + allOf: + - $ref: "#/components/schemas/ProcurementTrackerStep" + - type: object + description: Pre-Award step with additional fields for approval workflow + properties: + target_completion_date: + type: string + format: date + nullable: true + description: >- + Target date for completing pre-award activities. + Must be current date or future date. + task_completed_by: + type: integer + nullable: true + description: User ID of the person who completed this step + date_completed: + type: string + format: date + nullable: true + description: >- + Date when this step was completed. Must be current date + or past date. + notes: + type: string + nullable: true + description: Additional notes about this step + approval_requested: + type: boolean + nullable: true + description: Whether pre-award approval has been requested + approval_requested_date: + type: string + format: date + nullable: true + description: Date when pre-award approval was requested + approval_requested_by: + type: integer + nullable: true + description: User ID of the person who requested approval + requestor_notes: + type: string + nullable: true + description: Notes from the agreement manager requesting approval (max 150 characters) + approval_status: + type: string + nullable: true + enum: + - PENDING + - APPROVED + - DECLINED + description: Current status of the approval request + approval_responded_by: + type: integer + nullable: true + description: User ID of the approver who responded (Division Director, Budget Team, or System Owner) + approval_responded_date: + type: string + format: date + nullable: true + description: Date when the approval request was responded to + reviewer_notes: + type: string + nullable: true + description: Notes from the approver (max 150 characters, required when declining) UpdateProcurementTrackerStepRequest: type: object description: Request schema for updating a procurement tracker step. All fields are optional for PATCH operations. @@ -7639,6 +7772,21 @@ components: description: >- Date when the solicitation period ended (SOLICITATION steps only). Must be current date or past date. + approval_status: + type: string + nullable: true + enum: + - APPROVED + - DECLINED + description: >- + Approval status for PRE_AWARD steps. Server-controlled fields + (approval_responded_by, approval_responded_date) are set automatically. + reviewer_notes: + type: string + nullable: true + description: >- + Notes from the approver for PRE_AWARD steps (max 150 characters). + Required when approval_status is DECLINED. ProcurementTrackerResponse: type: object description: Procurement tracker tracking workflow progress. New trackers are initialized with active_step_number set to 1, step 1 in ACTIVE status, and step 1's step_start_date set to the current date. @@ -7670,7 +7818,8 @@ components: - $ref: "#/components/schemas/ProcurementTrackerPreSolicitationStep" - $ref: "#/components/schemas/ProcurementTrackerSolicitationStep" - $ref: "#/components/schemas/ProcurementTrackerEvaluationStep" - description: Array of workflow steps associated with this tracker. Different step types include additional fields - ACQUISITION_PLANNING (task_completed_by, date_completed, notes), PRE_SOLICITATION (target_completion_date, task_completed_by, date_completed, notes, draft_solicitation_date), SOLICITATION (task_completed_by, date_completed, notes, solicitation_period_start_date, solicitation_period_end_date), EVALUATION (target_completion_date, task_completed_by, date_completed, notes). For new trackers, step 1 is initialized to ACTIVE status with step_start_date set to the current date, while all other steps are PENDING. + - $ref: "#/components/schemas/ProcurementTrackerPreAwardStep" + description: Array of workflow steps associated with this tracker. Different step types include additional fields - ACQUISITION_PLANNING (task_completed_by, date_completed, notes), PRE_SOLICITATION (target_completion_date, task_completed_by, date_completed, notes, draft_solicitation_date), SOLICITATION (task_completed_by, date_completed, notes, solicitation_period_start_date, solicitation_period_end_date), EVALUATION (target_completion_date, task_completed_by, date_completed, notes), PRE_AWARD (target_completion_date, task_completed_by, date_completed, notes, approval_requested, approval_requested_date, approval_requested_by, requestor_notes, approval_status, approval_responded_by, approval_responded_date, reviewer_notes). For new trackers, step 1 is initialized to ACTIVE status with step_start_date set to the current date, while all other steps are PENDING. required: - id - agreement_id @@ -9043,6 +9192,33 @@ components: step_start_date: "2026-01-02" step_completed_date: null + ProcurementTrackerPreAwardStepExample: + summary: Pre-Award tracker step with approval workflow + description: >- + Example showing a pre-award step with approval request and response fields + (approval_requested, approval_requested_date, approval_requested_by, requestor_notes, + approval_status, approval_responded_by, approval_responded_date, reviewer_notes) + value: + id: 25 + procurement_tracker_id: 5 + step_number: 5 + step_type: "PRE_AWARD" + status: "IN_PROGRESS" + step_start_date: "2026-04-05" + step_completed_date: null + target_completion_date: "2026-04-15" + task_completed_by: null + date_completed: null + notes: null + approval_requested: true + approval_requested_date: "2026-04-05" + approval_requested_by: 42 + requestor_notes: "Urgent approval needed for Q2 budget execution" + approval_status: "APPROVED" + approval_responded_by: 100 + approval_responded_date: "2026-04-06" + reviewer_notes: "Approved - all budget lines reviewed and verified" + UpdateProcurementTrackerStepPreSolicitationRequest: summary: PATCH request to complete pre-solicitation step description: >- @@ -9075,6 +9251,19 @@ components: date_completed: "2026-02-10" notes: "Solicitation phase completed." + UpdateProcurementTrackerStepPreAwardRequest: + summary: PATCH request to approve/decline pre-award approval + description: >- + Example PATCH request body for approvers to respond to a pre-award + approval request. The approval_status field must be either APPROVED + or DECLINED. The reviewer_notes field is optional when approving + but required when declining (max 150 characters). Server-controlled + fields (approval_responded_by, approval_responded_date) are set + automatically from the current user and timestamp. + value: + approval_status: "APPROVED" + reviewer_notes: "All budget lines verified and approved for execution" + ProcurementTracker: summary: Single procurement tracker with mixed step types description: >- diff --git a/backend/ops_api/ops/resources/procurement_tracker_steps.py b/backend/ops_api/ops/resources/procurement_tracker_steps.py index 04ef833b17..1387f14802 100644 --- a/backend/ops_api/ops/resources/procurement_tracker_steps.py +++ b/backend/ops_api/ops/resources/procurement_tracker_steps.py @@ -117,3 +117,34 @@ def get(self) -> Response: } return make_response_with_headers(response_data) + + +class ProcurementTrackerStepPendingApprovalsAPI(BaseListAPI): + """ + GET /api/v1/procurement-tracker-steps/pending-approvals + + List pending pre-award approval requests for the current user. + """ + + def __init__(self, model: BaseModel = ProcurementTrackerStep): + super().__init__(model) + + @error_simulator + @is_authorized(PermissionType.GET, Permission.AGREEMENT) + def get(self) -> Response: + """Get list of pending pre-award approvals for the current user.""" + current_user = get_current_user() + + if not current_user or not hasattr(current_user, "id") or current_user.id is None: + return make_response_with_headers({"error": "Unable to determine current user"}, 401) + + user_id = current_user.id + logger.debug(f"Getting pending pre-award approvals for user {user_id}") + service = ProcurementTrackerStepService(current_app.db_session) + pending_approvals = service.get_pending_approvals_for_user(user_id) + + # Serialize response + response_schema = ProcurementTrackerStepResponseSchema(many=True) + serialized_data = response_schema.dump(pending_approvals) + + return make_response_with_headers(serialized_data) diff --git a/backend/ops_api/ops/schemas/procurement_tracker_steps.py b/backend/ops_api/ops/schemas/procurement_tracker_steps.py index ebea8e74c7..e997cb01aa 100644 --- a/backend/ops_api/ops/schemas/procurement_tracker_steps.py +++ b/backend/ops_api/ops/schemas/procurement_tracker_steps.py @@ -9,6 +9,22 @@ from ops_api.ops.schemas.pagination import PaginationListSchema +class NestedAgreementSchema(Schema): + """Minimal agreement schema for nested responses.""" + + id = fields.Integer(required=True) + name = fields.String(allow_none=True) + display_name = fields.String(dump_only=True) + + +class NestedProcurementTrackerSchema(Schema): + """Minimal procurement tracker schema for nested responses.""" + + id = fields.Integer(required=True) + agreement_id = fields.Integer(required=True) + agreement = fields.Nested(NestedAgreementSchema, allow_none=True) + + class ProcurementTrackerStepResponseSchema(Schema): """Schema for procurement tracker step responses. @@ -19,6 +35,7 @@ class ProcurementTrackerStepResponseSchema(Schema): id = fields.Integer(required=True) procurement_tracker_id = fields.Integer(required=True) + procurement_tracker = fields.Nested(NestedProcurementTrackerSchema, allow_none=True) step_number = fields.Integer(required=True) step_class = fields.String(required=True) step_type = fields.Enum(ProcurementTrackerStepType, required=True, by_value=False) @@ -46,6 +63,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", "PENDING"])) + 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) @@ -69,6 +92,7 @@ def map_step_specific_fields(self, obj, **_kwargs): data = { "id": obj.id, "procurement_tracker_id": obj.procurement_tracker_id, + "procurement_tracker": obj.procurement_tracker if hasattr(obj, "procurement_tracker") else None, "step_number": obj.step_number, "step_class": obj.step_class, "step_type": obj.step_type, # Keep as enum @@ -116,6 +140,10 @@ def map_step_specific_fields(self, obj, **_kwargs): data["approval_requested_date"] = getattr(obj, "pre_award_approval_requested_date", None) data["approval_requested_by"] = getattr(obj, "pre_award_approval_requested_by", None) data["requestor_notes"] = getattr(obj, "pre_award_requestor_notes", None) + data["approval_status"] = getattr(obj, "pre_award_approval_status", None) + data["approval_responded_by"] = getattr(obj, "pre_award_approval_responded_by", None) + data["approval_responded_date"] = getattr(obj, "pre_award_approval_responded_date", None) + data["reviewer_notes"] = getattr(obj, "pre_award_approval_reviewer_notes", None) return data @@ -192,6 +220,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 +245,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 +279,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 +315,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): """ @@ -335,6 +382,10 @@ def map_step_specific_fields(self, obj, **_kwargs): data["approval_requested_date"] = getattr(obj, "pre_award_approval_requested_date", None) data["approval_requested_by"] = getattr(obj, "pre_award_approval_requested_by", None) data["requestor_notes"] = getattr(obj, "pre_award_requestor_notes", None) + data["approval_status"] = getattr(obj, "pre_award_approval_status", None) + data["approval_responded_by"] = getattr(obj, "pre_award_approval_responded_by", None) + data["approval_responded_date"] = getattr(obj, "pre_award_approval_responded_date", None) + data["reviewer_notes"] = getattr(obj, "pre_award_approval_reviewer_notes", None) return data @@ -418,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", } preserve_keys = base_fields | pre_award_fields # Remove PRE_SOLICITATION-only fields @@ -440,6 +495,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) diff --git a/backend/ops_api/ops/services/procurement_tracker_steps.py b/backend/ops_api/ops/services/procurement_tracker_steps.py index bf12016f7d..46a6f85306 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. @@ -90,6 +92,10 @@ def update(self, id: int, data: Dict[str, Any], current_user: User) -> Tuple[Pro updated_fields=data, db_session=self.db_session, ) + # Capture old state of approval fields before any modifications + # Used to detect state transitions for notification logic + old_approval_requested = step.pre_award_approval_requested + old_approval_status = step.pre_award_approval_status # Map API field names to model field names for step-specific fields field_mapping = { "acquisition_planning": { @@ -126,6 +132,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", }, } @@ -159,9 +169,26 @@ def update(self, id: int, data: Dict[str, Any], current_user: User) -> Tuple[Pro # Always set approval_requested_by to current user when approval is requested # This is server-controlled and never accepted from the client if data.get("approval_requested") is True: + # Clear previous response fields to allow new review cycle (e.g., after decline) + step.pre_award_approval_status = None + step.pre_award_approval_responded_by = None + step.pre_award_approval_responded_date = None + step.pre_award_approval_reviewer_notes = None + logger.debug("Cleared previous approval response fields for new request") + 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) @@ -169,6 +196,15 @@ def update(self, id: int, data: Dict[str, Any], current_user: User) -> Tuple[Pro self.db_session.commit() self.db_session.refresh(step) + # Handle approval notifications after commit + self._handle_approval_notifications( + step, + data, + current_user, + old_approval_requested=old_approval_requested, + old_approval_status=old_approval_status, + ) + logger.debug(f"Successfully updated procurement tracker step {id}") return step, 200 @@ -253,6 +289,122 @@ 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, + old_approval_requested: Optional[bool] = None, + old_approval_status: Optional[str] = None, + ) -> None: + """ + Send notifications when approval is requested or responded to. + + Only sends notifications when state TRANSITIONS occur: + - Approval request: when pre_award_approval_requested changes from False/None → True + - Approval response: when pre_award_approval_status changes to APPROVED/DECLINED for first time + + Args: + step: The updated procurement tracker step + data: The update data + current_user: User making the update + old_approval_requested: Previous value of pre_award_approval_requested before update + old_approval_status: Previous value of pre_award_approval_status before 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 + # Only send if approval_requested changed from False/None → True + new_approval_requested = data.get("approval_requested") is True and step.pre_award_approval_requested + approval_request_transitioned = new_approval_requested and ( + old_approval_requested is None or old_approval_requested is False + ) + + if approval_request_transitioned: + recipient_ids = self._get_approval_reviewers(agreement) + + fe_url = current_app.config.get("OPS_FRONTEND_URL", "http://localhost:3000") + review_url = f"{fe_url}/agreements/{agreement.id}/review-pre-award" + + 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[Review Request]({review_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 + # Only send if approval_status changed from None → APPROVED/DECLINED + new_approval_status = data.get("approval_status") + approval_response_transitioned = new_approval_status in ["APPROVED", "DECLINED"] and old_approval_status is None + + if approval_response_transitioned: + status_text = "approved" if new_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: @@ -325,3 +477,96 @@ def get_list( } return list(results), metadata + + def get_pending_approvals_for_user(self, user_id: int) -> list[ProcurementTrackerStep]: + """ + Get all pending pre-award approval requests that a user can review. + + Args: + user_id: The user ID to check permissions for + + Returns: + List of ProcurementTrackerStep objects with pending approvals + """ + from sqlalchemy import and_, or_ + + from models import ( + CAN, + Agreement, + BudgetLineItem, + DefaultProcurementTrackerStep, + Division, + Portfolio, + ProcurementTracker, + ) + + # Get user roles first + user = self.db_session.get(User, user_id) + if not user: + return [] + + user_role_names = [role.name for role in user.roles] + + # Build base query for steps with pending pre-award approvals + # Use DefaultProcurementTrackerStep since pre_award_approval_requested is on the subclass + stmt = ( + select(DefaultProcurementTrackerStep) + .join(DefaultProcurementTrackerStep.procurement_tracker) + .join(ProcurementTracker.agreement) + .options( + selectinload(DefaultProcurementTrackerStep.procurement_tracker).selectinload( + ProcurementTracker.agreement + ), + ) + .where( + and_( + DefaultProcurementTrackerStep.step_type == ProcurementTrackerStepType.PRE_AWARD, + DefaultProcurementTrackerStep.pre_award_approval_requested.is_(True), + or_( + DefaultProcurementTrackerStep.pre_award_approval_status.is_(None), + DefaultProcurementTrackerStep.pre_award_approval_status == "PENDING", + ), + ) + ) + ) + + # If user is BUDGET_TEAM or SYSTEM_OWNER, they can see all pending approvals + if "BUDGET_TEAM" in user_role_names or "SYSTEM_OWNER" in user_role_names: + results = self.db_session.execute(stmt.distinct()).scalars().all() + return list(results) + + # For REVIEWER_APPROVER role or division directors/deputies, filter by division + if "REVIEWER_APPROVER" in user_role_names: + # Add joins to reach division table + stmt = ( + stmt.outerjoin(Agreement.budget_line_items) + .outerjoin(BudgetLineItem.can) + .outerjoin(CAN.portfolio) + .outerjoin(Portfolio.division) + .where( + or_( + Division.division_director_id == user_id, + Division.deputy_division_director_id == user_id, + ) + ) + ) + results = self.db_session.execute(stmt.distinct()).scalars().all() + return list(results) + + # For all other users, allow access when they are the division director/deputy + # for the related agreement. This keeps the pending approvals list aligned + # with reviewer notification recipients. + stmt = ( + stmt.outerjoin(Agreement.budget_line_items) + .outerjoin(BudgetLineItem.can) + .outerjoin(CAN.portfolio) + .outerjoin(Portfolio.division) + .where( + or_( + Division.division_director_id == user_id, + Division.deputy_division_director_id == user_id, + ) + ) + ) + results = self.db_session.execute(stmt.distinct()).scalars().all() + return list(results) diff --git a/backend/ops_api/ops/urls.py b/backend/ops_api/ops/urls.py index 7e8c484d12..d2e289403b 100644 --- a/backend/ops_api/ops/urls.py +++ b/backend/ops_api/ops/urls.py @@ -52,6 +52,7 @@ PROCUREMENT_TRACKER_LIST_API_VIEW_FUNC, PROCUREMENT_TRACKER_STEP_ITEM_API_VIEW_FUNC, PROCUREMENT_TRACKER_STEP_LIST_API_VIEW_FUNC, + PROCUREMENT_TRACKER_STEP_PENDING_APPROVALS_API_VIEW_FUNC, PRODUCT_SERVICE_CODE_ITEM_API_VIEW_FUNC, PRODUCT_SERVICE_CODE_LIST_API_VIEW_FUNC, PROJECT_FUNDING_API_VIEW_FUNC, @@ -166,6 +167,10 @@ def register_api(api_bp: Blueprint) -> None: view_func=PROCUREMENT_SHOPS_LIST_API_VIEW_FUNC, ) + api_bp.add_url_rule( + "/procurement-tracker-steps/pending-approvals/", + view_func=PROCUREMENT_TRACKER_STEP_PENDING_APPROVALS_API_VIEW_FUNC, + ) api_bp.add_url_rule( "/procurement-tracker-steps/", view_func=PROCUREMENT_TRACKER_STEP_ITEM_API_VIEW_FUNC, 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..3b8836f997 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,103 @@ 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 (only terminal states block re-response) + current_approval_status = procurement_tracker_step.pre_award_approval_status + if current_approval_status in ["APPROVED", "DECLINED"]: + raise ValidationError( + {"approval_status": f"This approval request has already been {current_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."} + ) diff --git a/backend/ops_api/ops/views.py b/backend/ops_api/ops/views.py index bd5e651302..cf22afe9d4 100644 --- a/backend/ops_api/ops/views.py +++ b/backend/ops_api/ops/views.py @@ -98,6 +98,7 @@ from ops_api.ops.resources.procurement_tracker_steps import ( ProcurementTrackerStepItemAPI, ProcurementTrackerStepListAPI, + ProcurementTrackerStepPendingApprovalsAPI, ) from ops_api.ops.resources.procurement_trackers import ( ProcurementTrackerItemAPI, @@ -190,6 +191,9 @@ PROCUREMENT_TRACKER_STEP_LIST_API_VIEW_FUNC = ProcurementTrackerStepListAPI.as_view( "procurement-tracker-steps-group", ProcurementTrackerStep ) +PROCUREMENT_TRACKER_STEP_PENDING_APPROVALS_API_VIEW_FUNC = ProcurementTrackerStepPendingApprovalsAPI.as_view( + "procurement-tracker-steps-pending-approvals", ProcurementTrackerStep +) # LOOKUP ENDPOINTS LOOKUP_AGREEMENT_REASON_LIST_API_VIEW_FUNC = AgreementReasonListAPI.as_view("lookups-agreement-reason-list") LOOKUP_AGREEMENT_TYPE_LIST_API_VIEW_FUNC = AgreementTypeListAPI.as_view("lookups-agreement-type-list") 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 diff --git a/backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_steps_duplicate_notifications.py b/backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_steps_duplicate_notifications.py new file mode 100644 index 0000000000..ab9ddb187f --- /dev/null +++ b/backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_steps_duplicate_notifications.py @@ -0,0 +1,205 @@ +"""Tests for preventing duplicate approval notifications in procurement tracker steps.""" + +from datetime import date + +import pytest +from sqlalchemy import func, select + +from models import Notification, ProcurementTracker, ProcurementTrackerStepStatus +from models.procurement_tracker import DefaultProcurementTrackerStep, ProcurementTrackerStepType + + +@pytest.fixture +def test_pre_award_step(app_ctx, loaded_db): + """Create a test PRE_AWARD step for approval notification testing.""" + # Get the procurement tracker first to ensure the relationship is valid + tracker = loaded_db.get(ProcurementTracker, 1) + + # Ensure Step 4 (Evaluation) exists and is completed (required for pre-award approval) + step_4 = next((step for step in tracker.steps if step.step_number == 4), None) + if not step_4: + step_4 = DefaultProcurementTrackerStep( + procurement_tracker=tracker, + step_number=4, + step_type=ProcurementTrackerStepType.EVALUATION, + status=ProcurementTrackerStepStatus.COMPLETED, + ) + loaded_db.add(step_4) + else: + step_4.status = ProcurementTrackerStepStatus.COMPLETED + loaded_db.commit() + + # Create a new PRE_AWARD step for testing + step = DefaultProcurementTrackerStep( + procurement_tracker=tracker, # Use object reference to preload the relationship + step_number=998, # Use a high number to avoid conflicts + step_type=ProcurementTrackerStepType.PRE_AWARD, + status=ProcurementTrackerStepStatus.ACTIVE, + pre_award_approval_requested=False, # Initially not requested + pre_award_approval_status=None, + ) + loaded_db.add(step) + loaded_db.commit() + loaded_db.refresh(step) + + yield step + + # Cleanup: rollback any changes and delete the test step + loaded_db.rollback() + try: + # Re-fetch the step to ensure we have the latest version + from models.procurement_tracker import ProcurementTrackerStep + + test_step = loaded_db.get(ProcurementTrackerStep, step.id) + if test_step: + loaded_db.delete(test_step) + loaded_db.commit() + except Exception: + loaded_db.rollback() + + +def test_initial_approval_request_sends_notification(auth_client, test_pre_award_step, loaded_db): + """Test that first-time approval request sends notification to reviewers.""" + # Get initial notification count + initial_notification_count = loaded_db.scalar(select(func.count()).select_from(Notification)) + + # Request approval for the first time + update_data = { + "approval_requested": True, + "approval_requested_date": date.today().isoformat(), + "requestor_notes": "Please review and approve", + } + + response = auth_client.patch(f"/api/v1/procurement-tracker-steps/{test_pre_award_step.id}", json=update_data) + assert response.status_code == 200 + + # Verify notifications were created (should send to multiple reviewers) + final_notification_count = loaded_db.scalar(select(func.count()).select_from(Notification)) + assert ( + final_notification_count > initial_notification_count + ), "Notifications should be sent on first approval request" + + +def test_duplicate_approval_request_no_notification(auth_client, test_pre_award_step, loaded_db): + """Test that duplicate approval request does NOT send notification.""" + # Setup: approval already requested + test_pre_award_step.pre_award_approval_requested = True + test_pre_award_step.pre_award_approval_requested_date = date.today() + test_pre_award_step.pre_award_approval_requested_by = 500 # Set to a user ID + loaded_db.commit() + + # Get notification count after initial setup + initial_notification_count = loaded_db.scalar(select(func.count()).select_from(Notification)) + + # Try to request approval again (duplicate/retry scenario) + update_data = { + "approval_requested": True, + "requestor_notes": "Please review and approve", # Same or different notes + } + + response = auth_client.patch(f"/api/v1/procurement-tracker-steps/{test_pre_award_step.id}", json=update_data) + assert response.status_code == 200 + + # Verify NO new notifications were created + final_notification_count = loaded_db.scalar(select(func.count()).select_from(Notification)) + assert ( + final_notification_count == initial_notification_count + ), "Duplicate approval request should NOT send notifications" + + +def test_initial_approval_response_sends_notification(auth_client, test_pre_award_step, loaded_db): + """Test that first approval response sends notification to submitter.""" + # Setup: approval was requested by a specific user + test_pre_award_step.pre_award_approval_requested = True + test_pre_award_step.pre_award_approval_requested_date = date.today() + test_pre_award_step.pre_award_approval_requested_by = 500 # Submitter user ID + test_pre_award_step.pre_award_approval_status = None # Not yet responded + loaded_db.commit() + + # Get initial notification count + initial_notification_count = loaded_db.scalar(select(func.count()).select_from(Notification)) + + # Approve the request for the first time + update_data = { + "approval_status": "APPROVED", + "reviewer_notes": "Looks good, approved", + } + + response = auth_client.patch(f"/api/v1/procurement-tracker-steps/{test_pre_award_step.id}", json=update_data) + assert response.status_code == 200 + + # Verify notification was sent to submitter + final_notification_count = loaded_db.scalar(select(func.count()).select_from(Notification)) + assert ( + final_notification_count == initial_notification_count + 1 + ), "Notification should be sent to submitter on first approval response" + + +def test_duplicate_approval_response_no_notification(auth_client, test_pre_award_step, loaded_db): + """Test that duplicate approval response does NOT send notification.""" + # Setup: approval was already approved + test_pre_award_step.pre_award_approval_requested = True + test_pre_award_step.pre_award_approval_requested_by = 500 + test_pre_award_step.pre_award_approval_status = "APPROVED" # Already approved + test_pre_award_step.pre_award_approval_responded_by = 503 + test_pre_award_step.pre_award_approval_responded_date = date.today() + loaded_db.commit() + + # Get notification count after setup + initial_notification_count = loaded_db.scalar(select(func.count()).select_from(Notification)) + + # Try to approve again (duplicate scenario - might happen if validation doesn't catch it) + update_data = { + "approval_status": "APPROVED", + "reviewer_notes": "Still approved", + } + + auth_client.patch(f"/api/v1/procurement-tracker-steps/{test_pre_award_step.id}", json=update_data) + # Might get 200 or 400 depending on validation, but either way... + + # Verify NO new notifications were created + final_notification_count = loaded_db.scalar(select(func.count()).select_from(Notification)) + assert ( + final_notification_count == initial_notification_count + ), "Duplicate approval response should NOT send notifications" + + +def test_approval_transitions_are_idempotent(auth_client, test_pre_award_step, loaded_db): + """Test that sending the same approval request multiple times only sends notification once.""" + # Get initial notification count + initial_notification_count = loaded_db.scalar(select(func.count()).select_from(Notification)) + + # Send the same approval request 3 times + update_data = { + "approval_requested": True, + "approval_requested_date": date.today().isoformat(), + "requestor_notes": "Please review", + } + + # First request + response1 = auth_client.patch(f"/api/v1/procurement-tracker-steps/{test_pre_award_step.id}", json=update_data) + assert response1.status_code == 200 + + count_after_first = loaded_db.scalar(select(func.count()).select_from(Notification)) + notifications_sent_first_time = count_after_first - initial_notification_count + assert notifications_sent_first_time > 0, "First request should send notifications" + + # Second request (duplicate) + response2 = auth_client.patch(f"/api/v1/procurement-tracker-steps/{test_pre_award_step.id}", json=update_data) + assert response2.status_code == 200 + + count_after_second = loaded_db.scalar(select(func.count()).select_from(Notification)) + assert count_after_second == count_after_first, "Second request should NOT send additional notifications" + + # Third request (another duplicate) + response3 = auth_client.patch(f"/api/v1/procurement-tracker-steps/{test_pre_award_step.id}", json=update_data) + assert response3.status_code == 200 + + count_after_third = loaded_db.scalar(select(func.count()).select_from(Notification)) + assert count_after_third == count_after_first, "Third request should NOT send additional notifications" + + # Verify total notifications sent is from first request only + total_notifications_sent = count_after_third - initial_notification_count + assert ( + total_notifications_sent == notifications_sent_first_time + ), "Only the first request should have sent notifications" diff --git a/frontend/src/api/opsAPI.js b/frontend/src/api/opsAPI.js index b4c437e382..f809ee0216 100644 --- a/frontend/src/api/opsAPI.js +++ b/frontend/src/api/opsAPI.js @@ -1057,6 +1057,10 @@ export const opsApi = createApi({ }; }, invalidatesTags: ["ProcurementTrackers", "Procurement Tracker Steps"] + }), + getPendingPreAwardApprovals: builder.query({ + query: () => `/procurement-tracker-steps/pending-approvals/`, + providesTags: ["Procurement Tracker Steps"] }) }) }); @@ -1159,5 +1163,6 @@ export const { useGetResearchMethodologiesQuery, useGetSpecialTopicsQuery, useGetProcurementTrackersByAgreementIdQuery, - useUpdateProcurementTrackerStepMutation + useUpdateProcurementTrackerStepMutation, + useGetPendingPreAwardApprovalsQuery } = opsApi; diff --git a/frontend/src/components/Agreements/AgreementChangesResponseAlert/AgreementChangesResponseAlert.jsx b/frontend/src/components/Agreements/AgreementChangesResponseAlert/AgreementChangesResponseAlert.jsx index a9b314a51e..11b8e700e3 100644 --- a/frontend/src/components/Agreements/AgreementChangesResponseAlert/AgreementChangesResponseAlert.jsx +++ b/frontend/src/components/Agreements/AgreementChangesResponseAlert/AgreementChangesResponseAlert.jsx @@ -53,10 +53,11 @@ function AgreementChangesResponseAlert({ setIsAlertVisible={setApproveAlertVisibleAndDismissRequest} isAlertVisible={isApproveAlertVisible} isClosable={true} + headingLevel={2} > {changeRequestNotifications?.length > 0 && ( <> -

Changes Approved:

+

Changes Approved: