Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions backend/ops_api/ops/resources/procurement_tracker_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,34 @@ def get(self) -> Response:
serialized_data = response_schema.dump(pending_approvals)

return make_response_with_headers(serialized_data)


class ProcurementTrackerStepPendingRequisitionsAPI(BaseListAPI):
"""
GET /api/v1/procurement-tracker-steps/pending-requisitions
List pending budget team requisition reviews for 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 requisition reviews for budget team."""
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 budget team requisitions for user {user_id}")
service = ProcurementTrackerStepService(current_app.db_session)
pending_requisitions = service.get_pending_requisitions_for_user(user_id)

# Serialize response
response_schema = ProcurementTrackerStepResponseSchema(many=True)
serialized_data = response_schema.dump(pending_requisitions)

return make_response_with_headers(serialized_data)
12 changes: 12 additions & 0 deletions backend/ops_api/ops/schemas/procurement_tracker_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,31 @@

from marshmallow import EXCLUDE, Schema, fields, post_dump, pre_dump, validate

from models.budget_line_items import BudgetLineItemStatus
from models.procurement_tracker import (
ProcurementTrackerStepStatus,
ProcurementTrackerStepType,
)
from ops_api.ops.schemas.pagination import PaginationListSchema


class NestedBudgetLineItemSchema(Schema):
"""Minimal budget line item schema for nested responses."""

id = fields.Integer(required=True)
status = fields.Enum(BudgetLineItemStatus, by_value=True, allow_none=True)
amount = fields.Float(allow_none=True)
date_needed = fields.Date(allow_none=True)


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)
budget_line_items = fields.List(fields.Nested(NestedBudgetLineItemSchema), allow_none=True)
agreement_total = fields.Float(allow_none=True, dump_only=True)


class NestedProcurementTrackerSchema(Schema):
Expand Down
60 changes: 58 additions & 2 deletions backend/ops_api/ops/services/procurement_tracker_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@

from flask import current_app
from loguru import logger
from sqlalchemy import Select, func, select
from sqlalchemy import Select, and_, func, select
from sqlalchemy.orm import selectinload

from models import (
Agreement,
AwardType,
DefaultProcurementTrackerStep,
OpsEvent,
OpsEventStatus,
OpsEventType,
ProcurementAction,
ProcurementActionStatus,
ProcurementTracker,
ProcurementTrackerStatus,
ProcurementTrackerStep,
ProcurementTrackerStepStatus,
Expand Down Expand Up @@ -507,9 +510,13 @@ def _notify_budget_team_for_requisition_review(self, step, agreement, current_us
budget_team_query = select(User.id).join(User.roles).where(Role.name == "BUDGET_TEAM")
budget_team_ids = self.db_session.execute(budget_team_query).scalars().all()

fe_url = current_app.config.get("OPS_FRONTEND_URL", "http://localhost:3000")
# TODO (PR3/PR4): This URL points to budget requisition review page to be implemented
review_url = f"{fe_url}/agreements/{agreement.id}/review-budget-requisition"

message = (
f"{current_user.full_name} has approved the Pre-Award request for Agreement {agreement.display_name}. "
f"Budget Team review and requisition entry is now required."
f"Budget Team review and requisition entry is now required.\n\n[Review Agreement]({review_url})"
)

# Send notification to each budget team member
Expand Down Expand Up @@ -783,3 +790,52 @@ def get_pending_approvals_for_user(self, user_id: int) -> list[ProcurementTracke
)
results = self.db_session.execute(stmt.distinct()).scalars().all()
return list(results)

def get_pending_requisitions_for_user(self, user_id: int) -> list[ProcurementTrackerStep]:
"""
Get all pending budget team requisition reviews for a user.

Returns steps where:
- DD has approved (approval_status = 'APPROVED')
- Budget team hasn't entered requisition yet (requisition_number IS NULL)
- User has BUDGET_TEAM role

Args:
user_id: The user ID to check permissions for

Returns:
List of ProcurementTrackerStep objects with pending requisitions
"""
# Get user roles
user = self.db_session.get(User, user_id)
if not user:
return []

user_role_names = [role.name for role in user.roles]

# Only BUDGET_TEAM members see these
if "BUDGET_TEAM" not in user_role_names:
return []

# Query for steps awaiting budget team requisition entry
stmt = (
select(DefaultProcurementTrackerStep)
.join(DefaultProcurementTrackerStep.procurement_tracker)
.join(ProcurementTracker.agreement)
.options(
selectinload(DefaultProcurementTrackerStep.procurement_tracker)
.selectinload(ProcurementTracker.agreement)
.selectinload(Agreement.budget_line_items),
)
.where(
and_(
DefaultProcurementTrackerStep.step_type == ProcurementTrackerStepType.PRE_AWARD,
DefaultProcurementTrackerStep.pre_award_approval_status == "APPROVED",
DefaultProcurementTrackerStep.pre_award_requisition_number.is_(None),
DefaultProcurementTrackerStep.pre_award_requisition_date.is_(None),
)
)
.order_by(DefaultProcurementTrackerStep.pre_award_approval_responded_date.desc())
)

return list(self.db_session.scalars(stmt).all())
5 changes: 5 additions & 0 deletions backend/ops_api/ops/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
PROCUREMENT_TRACKER_STEP_ITEM_API_VIEW_FUNC,
PROCUREMENT_TRACKER_STEP_LIST_API_VIEW_FUNC,
PROCUREMENT_TRACKER_STEP_PENDING_APPROVALS_API_VIEW_FUNC,
PROCUREMENT_TRACKER_STEP_PENDING_REQUISITIONS_API_VIEW_FUNC,
PRODUCT_SERVICE_CODE_ITEM_API_VIEW_FUNC,
PRODUCT_SERVICE_CODE_LIST_API_VIEW_FUNC,
PROJECT_FUNDING_API_VIEW_FUNC,
Expand Down Expand Up @@ -172,6 +173,10 @@ def register_api(api_bp: Blueprint) -> None:
"/procurement-tracker-steps/pending-approvals/",
view_func=PROCUREMENT_TRACKER_STEP_PENDING_APPROVALS_API_VIEW_FUNC,
)
api_bp.add_url_rule(
"/procurement-tracker-steps/pending-requisitions/",
view_func=PROCUREMENT_TRACKER_STEP_PENDING_REQUISITIONS_API_VIEW_FUNC,
)
api_bp.add_url_rule(
"/procurement-tracker-steps/<int:id>",
view_func=PROCUREMENT_TRACKER_STEP_ITEM_API_VIEW_FUNC,
Expand Down
4 changes: 4 additions & 0 deletions backend/ops_api/ops/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
ProcurementTrackerStepItemAPI,
ProcurementTrackerStepListAPI,
ProcurementTrackerStepPendingApprovalsAPI,
ProcurementTrackerStepPendingRequisitionsAPI,
)
from ops_api.ops.resources.procurement_trackers import (
ProcurementTrackerItemAPI,
Expand Down Expand Up @@ -196,6 +197,9 @@
PROCUREMENT_TRACKER_STEP_PENDING_APPROVALS_API_VIEW_FUNC = ProcurementTrackerStepPendingApprovalsAPI.as_view(
"procurement-tracker-steps-pending-approvals", ProcurementTrackerStep
)
PROCUREMENT_TRACKER_STEP_PENDING_REQUISITIONS_API_VIEW_FUNC = ProcurementTrackerStepPendingRequisitionsAPI.as_view(
"procurement-tracker-steps-pending-requisitions", 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")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Tests for budget team requisition review card endpoint (OPS-1639 PR2)."""

from datetime import date

import pytest

from models.procurement_tracker import (
DefaultProcurementTrackerStep,
ProcurementTracker,
ProcurementTrackerStepStatus,
ProcurementTrackerStepType,
)


@pytest.fixture
def test_budget_team_requisition_step(app_ctx, loaded_db):
"""Create a test step where DD approved but budget team hasn't entered requisition."""
tracker = loaded_db.get(ProcurementTracker, 1)

# Capture original step 4 state before modification
step_4 = next((step for step in tracker.steps if step.step_number == 4), None)
step_4_existed = step_4 is not None
step_4_original_status = step_4.status if step_4_existed else None

# Ensure Step 4 (Evaluation) exists and is completed
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()
Comment on lines +32 to +36

# Create PRE_AWARD step where DD has approved
step = DefaultProcurementTrackerStep(
procurement_tracker=tracker,
step_number=997,
step_type=ProcurementTrackerStepType.PRE_AWARD,
status=ProcurementTrackerStepStatus.ACTIVE,
pre_award_approval_requested=True,
pre_award_approval_requested_by=500,
pre_award_approval_requested_date=date.today(),
pre_award_approval_status="APPROVED", # DD approved
pre_award_approval_responded_by=503,
pre_award_approval_responded_date=date.today(),
# requisition fields are NULL - budget team hasn't entered yet
)
loaded_db.add(step)
loaded_db.commit()

yield step

# Cleanup: restore step 4 to original state and delete test step
loaded_db.rollback()
try:
loaded_db.delete(step)
if not step_4_existed:
# Step 4 was created by this fixture, delete it
loaded_db.delete(step_4)
else:
# Step 4 existed before, restore its original status
step_4.status = step_4_original_status
loaded_db.commit()
except Exception:
loaded_db.rollback()


def test_get_pending_requisitions_returns_approved_without_requisition(
budget_team_auth_client, test_budget_team_requisition_step, loaded_db
):
"""Budget team sees steps where DD approved but requisition not entered."""
# Make request as budget team user
response = budget_team_auth_client.get("/api/v1/procurement-tracker-steps/pending-requisitions/")
assert response.status_code == 200

data = response.json
assert isinstance(data, list)

# Should include our test step
step_ids = [step["id"] for step in data]
assert test_budget_team_requisition_step.id in step_ids


def test_get_pending_requisitions_excludes_completed(
budget_team_auth_client, test_budget_team_requisition_step, loaded_db
):
"""Steps with requisition_number filled are excluded."""
# Set requisition number (budget team completed it)
test_budget_team_requisition_step.pre_award_requisition_number = "REQ-2026-12345"
loaded_db.commit()

response = budget_team_auth_client.get("/api/v1/procurement-tracker-steps/pending-requisitions/")
assert response.status_code == 200

data = response.json
step_ids = [step["id"] for step in data]
# Should NOT include our test step anymore
assert test_budget_team_requisition_step.id not in step_ids


def test_get_pending_requisitions_filters_by_budget_team_role(client, test_budget_team_requisition_step, loaded_db):
"""Unauthenticated users get 401."""
response = client.get("/api/v1/procurement-tracker-steps/pending-requisitions/")
assert response.status_code == 401 # Unauthenticated


def test_get_pending_requisitions_non_budget_team_gets_empty_list(
basic_user_auth_client, test_budget_team_requisition_step, loaded_db
):
"""Authenticated non-budget-team users get empty list, not 401."""
# Make request as basic user (not budget team)
response = basic_user_auth_client.get("/api/v1/procurement-tracker-steps/pending-requisitions/")
assert response.status_code == 200

data = response.json
assert isinstance(data, list)
# Non-budget-team user should see empty list
assert len(data) == 0


def test_get_pending_requisitions_includes_agreement_data(
budget_team_auth_client, test_budget_team_requisition_step, loaded_db
):
"""Response includes agreement with budget line items."""
response = budget_team_auth_client.get("/api/v1/procurement-tracker-steps/pending-requisitions/")
assert response.status_code == 200

data = response.json
if len(data) > 0:
# Find our test step
test_step_data = next((step for step in data if step["id"] == test_budget_team_requisition_step.id), None)
if test_step_data:
# Verify agreement data is included
assert "procurement_tracker" in test_step_data
assert "agreement" in test_step_data["procurement_tracker"]
agreement = test_step_data["procurement_tracker"]["agreement"]
assert "id" in agreement
assert "name" in agreement
10 changes: 8 additions & 2 deletions frontend/src/api/opsAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ export const opsApi = createApi({
"Documents",
"Cans",
"ProcurementTrackers",
"Procurement Tracker Steps"
"Procurement Tracker Steps",
"Budget Requisitions"
],
baseQuery: getBaseQueryWithReauth(baseQuery),
endpoints: (builder) => ({
Expand Down Expand Up @@ -1177,6 +1178,10 @@ export const opsApi = createApi({
getPendingPreAwardApprovals: builder.query({
query: () => `/procurement-tracker-steps/pending-approvals/`,
providesTags: ["Procurement Tracker Steps"]
}),
getPendingBudgetRequisitions: builder.query({
query: () => `/procurement-tracker-steps/pending-requisitions/`,
providesTags: ["Budget Requisitions"]
})
})
});
Expand Down Expand Up @@ -1285,5 +1290,6 @@ export const {
useGetProcurementTrackersByAgreementIdQuery,
useGetProcurementTrackersByAgreementIdsQuery,
useUpdateProcurementTrackerStepMutation,
useGetPendingPreAwardApprovalsQuery
useGetPendingPreAwardApprovalsQuery,
useGetPendingBudgetRequisitionsQuery
} = opsApi;
Loading