Skip to content

Commit 85f67a6

Browse files
authored
Merge branch 'main' into OPS-5571/history-ordering-ui-fixes
2 parents 602c6db + c382bfd commit 85f67a6

42 files changed

Lines changed: 4078 additions & 392 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/data_tools/data/agreements_and_blin_data.json5

Lines changed: 924 additions & 78 deletions
Large diffs are not rendered by default.

backend/ops_api/ops/resources/agreements.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,12 @@ def get(self) -> Response:
161161
request_schema = AgreementRequestSchema()
162162
data = request_schema.load(request.args.to_dict(flat=False))
163163

164+
include_procurement_list = data.pop("include_procurement", [False])
165+
include_procurement = include_procurement_list[0] if include_procurement_list else False
166+
164167
logger.debug("Beginning agreement queries")
165168
service = AgreementsService(current_app.db_session)
166-
agreements, metadata = service.get_list(agreement_classes, data)
169+
agreements, metadata = service.get_list(agreement_classes, data, include_procurement)
167170
logger.debug("Agreement queries complete")
168171

169172
logger.debug("Serializing results")
@@ -202,6 +205,8 @@ def get(self) -> Response:
202205
"limit": metadata["limit"],
203206
"offset": metadata["offset"],
204207
"totals": metadata["totals"],
208+
"procurement_overview": metadata["procurement_overview"],
209+
"procurement_step_summary": metadata["procurement_step_summary"],
205210
}
206211

207212
return make_response_with_headers(response_data)

backend/ops_api/ops/schemas/agreements.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ class Meta:
153153
only_my = fields.List(fields.Boolean(), required=False)
154154
award_type = fields.List(fields.Enum(AgreementClassification), required=False)
155155
exact_match = fields.List(fields.Boolean(), required=False, load_default=[True])
156+
include_procurement = fields.List(fields.Boolean(), required=False, load_default=[False])
156157

157158

158159
class AgreementFiltersQueryParametersSchema(Schema):
@@ -224,7 +225,7 @@ class AgreementListResponse(FyObligatedMixin, AgreementData):
224225
project = fields.Nested(ProjectSchema())
225226
product_service_code = fields.Nested(ProductServiceCodeSchema)
226227
budget_line_items = fields.List(
227-
fields.Nested(BudgetLineItemResponseSchema, only=["id", "amount", "fees", "status", "is_obe"]),
228+
fields.Nested(BudgetLineItemResponseSchema, only=["id", "amount", "fees", "status", "is_obe", "fiscal_year"]),
228229
allow_none=True,
229230
)
230231
procurement_shop = fields.Nested(ProcurementShopSchema)

backend/ops_api/ops/services/agreements.py

Lines changed: 146 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from loguru import logger
99
from sqlalchemy import Select, distinct, func, or_, select, union
1010
from sqlalchemy.exc import IntegrityError
11-
from sqlalchemy.orm import Session
11+
from sqlalchemy.orm import Session, selectinload
1212

1313
from models import (
1414
CAN,
@@ -25,6 +25,7 @@
2525
OpsEventType,
2626
Portfolio,
2727
PortfolioTeamLeaders,
28+
ProcurementShop,
2829
Project,
2930
ResearchMethodology,
3031
ServicesComponent,
@@ -33,6 +34,7 @@
3334
Vendor,
3435
)
3536
from models.agreements import AgreementType
37+
from models.procurement_tracker import ProcurementTrackerStatus
3638
from models.utils.fiscal_year import get_current_fiscal_year
3739
from ops_api.ops.schemas.agreements import AgreementListFilterOptionResponseSchema
3840
from ops_api.ops.services.change_requests import ChangeRequestService
@@ -414,14 +416,18 @@ def get(self, id: int) -> Agreement:
414416
return agreement
415417

416418
def get_list(
417-
self, agreement_classes: list[Type[Agreement]], data: dict[str, Any]
419+
self,
420+
agreement_classes: list[Type[Agreement]],
421+
data: dict[str, Any],
422+
include_procurement: bool = False,
418423
) -> tuple[list[Agreement], dict[str, Any]]:
419424
"""
420425
Get list of agreements with optional filtering and pagination.
421426
422427
Args:
423428
agreement_classes: List of Agreement subclasses to query (e.g., ContractAgreement, GrantAgreement)
424429
data: Dictionary containing filter parameters including limit and offset
430+
include_procurement: When True, eager-load BLIs/trackers and compute procurement metrics
425431
426432
Returns:
427433
Tuple of (paginated agreements list, metadata dict with count/limit/offset)
@@ -432,7 +438,7 @@ def get_list(
432438
# Collect all agreements across types using existing resource helpers
433439
all_results = []
434440
for agreement_cls in agreement_classes:
435-
agreements = _get_agreements(self.db_session, agreement_cls, data)
441+
agreements = _get_agreements(self.db_session, agreement_cls, data, include_procurement)
436442
all_results.extend(agreements)
437443

438444
# Filter by award_type (computed property, must be done post-query)
@@ -453,6 +459,16 @@ def get_list(
453459
# Calculate aggregate totals before pagination (for summary cards)
454460
totals = _compute_agreement_totals(all_results)
455461

462+
# Calculate procurement overview and step summary before pagination (only when requested)
463+
procurement_overview = None
464+
procurement_step_summary = None
465+
if include_procurement:
466+
overview_fiscal_year = (
467+
filters.fiscal_year[0] if filters.fiscal_year and len(filters.fiscal_year) == 1 else None
468+
)
469+
procurement_overview = _compute_procurement_overview(all_results, overview_fiscal_year)
470+
procurement_step_summary = _compute_procurement_step_summary(all_results, overview_fiscal_year)
471+
456472
# Apply pagination slicing
457473
if filters.limit is not None and filters.offset is not None:
458474
limit_value = filters.limit[0]
@@ -468,6 +484,8 @@ def get_list(
468484
"limit": limit_value,
469485
"offset": offset_value,
470486
"totals": totals,
487+
"procurement_overview": procurement_overview,
488+
"procurement_step_summary": procurement_step_summary,
471489
}
472490

473491
return paginated_results, metadata
@@ -828,6 +846,13 @@ def _validate_special_topics(db_session: Session, special_topics: List[SpecialTo
828846
raise ValidationError({"special_topics": [f"Special Topic IDs do not exist: {invalid_special_topic_ids}"]})
829847

830848

849+
def _percent(value, total):
850+
"""Return ``value / total`` as a rounded whole-number percentage, or 0.0 when *total* is zero."""
851+
if total == 0:
852+
return 0.0
853+
return float(round((float(value) / float(total)) * 100))
854+
855+
831856
def _compute_agreement_totals(all_results: list[Agreement]) -> dict[str, Any]:
832857
"""Compute aggregate totals across all filtered agreements for summary cards."""
833858
totals = {
@@ -879,8 +904,113 @@ def _compute_agreement_totals(all_results: list[Agreement]) -> dict[str, Any]:
879904
return totals
880905

881906

882-
def _get_agreements(session: Session, agreement_cls: Type[Agreement], data: dict[str, Any]) -> Sequence[Agreement]:
883-
query = _build_base_query(agreement_cls)
907+
def _compute_procurement_overview(all_results: list[Agreement], fiscal_year: int | None) -> dict[str, Any]:
908+
"""Compute procurement overview data grouped by BLI status for a given fiscal year.
909+
910+
Returns amount and agreement count breakdowns for PLANNED, IN_EXECUTION, and OBLIGATED statuses.
911+
"""
912+
tracked_statuses = [
913+
BudgetLineItemStatus.PLANNED,
914+
BudgetLineItemStatus.IN_EXECUTION,
915+
BudgetLineItemStatus.OBLIGATED,
916+
]
917+
918+
amount_by_status: dict[BudgetLineItemStatus, Decimal] = {s: Decimal("0") for s in tracked_statuses}
919+
agreements_by_status: dict[BudgetLineItemStatus, set[int]] = {s: set() for s in tracked_statuses}
920+
921+
for agreement in all_results:
922+
for bli in agreement.budget_line_items:
923+
if fiscal_year is not None and bli.fiscal_year != fiscal_year:
924+
continue
925+
if bli.status in amount_by_status:
926+
amount_by_status[bli.status] += (bli.amount or Decimal("0")) + bli.fees
927+
agreements_by_status[bli.status].add(agreement.id)
928+
929+
total_amount = sum(amount_by_status.values(), Decimal("0"))
930+
tracked_agreement_ids = set().union(*agreements_by_status.values())
931+
total_agreements = len(tracked_agreement_ids)
932+
933+
status_data = []
934+
for status in tracked_statuses:
935+
amount = amount_by_status[status]
936+
agreement_count = len(agreements_by_status[status])
937+
status_data.append(
938+
{
939+
"status": status.name,
940+
"label": status.value,
941+
"amount": float(amount),
942+
"amount_percent": _percent(amount, total_amount),
943+
"agreements": agreement_count,
944+
"agreements_percent": _percent(agreement_count, total_agreements),
945+
}
946+
)
947+
948+
return {
949+
"status_data": status_data,
950+
"total_amount": float(total_amount),
951+
"total_agreements": total_agreements,
952+
}
953+
954+
955+
def _get_active_step(agreement: Agreement) -> int | None:
956+
"""Return the active procurement tracker step number for an agreement, or None."""
957+
for tracker in agreement.procurement_trackers:
958+
if tracker.status == ProcurementTrackerStatus.ACTIVE and tracker.active_step_number is not None:
959+
return tracker.active_step_number
960+
return None
961+
962+
963+
def _compute_procurement_step_summary(all_results: list[Agreement], fiscal_year: int | None) -> dict[str, Any]:
964+
"""Compute procurement step summary: agreement counts and dollar amounts per step (1-6)."""
965+
amount_by_step: dict[int, Decimal] = {step: Decimal("0") for step in range(1, 7)}
966+
agreements_by_step: dict[int, int] = {step: 0 for step in range(1, 7)}
967+
968+
for agreement in all_results:
969+
active_step = _get_active_step(agreement)
970+
if active_step is None or active_step < 1 or active_step > 6:
971+
continue
972+
973+
executing_total = Decimal("0")
974+
has_executing_blis = False
975+
for bli in agreement.budget_line_items:
976+
if bli.status != BudgetLineItemStatus.IN_EXECUTION:
977+
continue
978+
if fiscal_year is not None and bli.fiscal_year != fiscal_year:
979+
continue
980+
has_executing_blis = True
981+
executing_total += (bli.amount or Decimal("0")) + bli.fees
982+
983+
if not has_executing_blis:
984+
continue
985+
986+
agreements_by_step[active_step] += 1
987+
amount_by_step[active_step] += executing_total
988+
989+
total_agreement_count = sum(agreements_by_step.values())
990+
991+
step_data = [
992+
{
993+
"step": step,
994+
"agreements": agreements_by_step[step],
995+
"agreements_percent": _percent(agreements_by_step[step], total_agreement_count),
996+
"amount": float(amount_by_step[step]),
997+
}
998+
for step in range(1, 7)
999+
]
1000+
1001+
return {
1002+
"step_data": step_data,
1003+
"total_agreement_count": total_agreement_count,
1004+
}
1005+
1006+
1007+
def _get_agreements(
1008+
session: Session,
1009+
agreement_cls: Type[Agreement],
1010+
data: dict[str, Any],
1011+
include_procurement: bool = False,
1012+
) -> Sequence[Agreement]:
1013+
query = _build_base_query(agreement_cls, include_procurement)
8841014
query = _apply_filters(query, agreement_cls, data)
8851015

8861016
logger.debug(f"query: {query}")
@@ -889,14 +1019,17 @@ def _get_agreements(session: Session, agreement_cls: Type[Agreement], data: dict
8891019
return _filter_by_ownership(all_results, data.get("only_my", []))
8901020

8911021

892-
def _build_base_query(agreement_cls: Type[Agreement]) -> Select[tuple[Agreement]]:
893-
return (
894-
select(agreement_cls)
895-
.distinct()
896-
.join(BudgetLineItem, isouter=True)
897-
.join(CAN, isouter=True)
898-
.order_by(agreement_cls.id)
899-
)
1022+
def _build_base_query(agreement_cls: Type[Agreement], include_procurement: bool = False) -> Select[tuple[Agreement]]:
1023+
query = select(agreement_cls).distinct().join(BudgetLineItem, isouter=True).join(CAN, isouter=True)
1024+
1025+
if include_procurement:
1026+
query = query.options(
1027+
selectinload(agreement_cls.budget_line_items).selectinload(BudgetLineItem.procurement_shop_fee),
1028+
selectinload(agreement_cls.procurement_trackers),
1029+
selectinload(agreement_cls.procurement_shop).selectinload(ProcurementShop.procurement_shop_fees),
1030+
)
1031+
1032+
return query.order_by(agreement_cls.id)
9001033

9011034

9021035
def _apply_filters(query: Select[Agreement], agreement_cls: Type[Agreement], data: dict[str, Any]) -> Select[Agreement]:

backend/ops_api/tests/ops/agreement/test_agreement.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def test_agreements_get_all_by_budget_line_status(auth_client, loaded_db, app_ct
118118
query_string={"budget_line_status": BudgetLineItemStatus.DRAFT.name},
119119
)
120120
assert response.status_code == 200
121-
assert len(response.json["data"]) == len(agreements)
121+
assert response.json["count"] == len(agreements)
122122

123123
# determine how many agreements in the DB are in budget line status "OBLIGATED"
124124
stmt = (
@@ -134,7 +134,7 @@ def test_agreements_get_all_by_budget_line_status(auth_client, loaded_db, app_ct
134134
query_string={"budget_line_status": BudgetLineItemStatus.OBLIGATED.name},
135135
)
136136
assert response.status_code == 200
137-
assert len(response.json["data"]) == len(agreements)
137+
assert response.json["count"] == len(agreements)
138138

139139

140140
def test_agreements_get_all_by_portfolio(auth_client, loaded_db, app_ctx):
@@ -594,20 +594,30 @@ def test_agreement_search(auth_client, loaded_db):
594594
assert response.status_code == 200
595595
assert len(response.json["data"]) == 0
596596

597+
# determine how many agreements match "contract" search
598+
stmt = select(Agreement).distinct().where(Agreement.name.ilike("%contract%"))
599+
expected_count = len(loaded_db.scalars(stmt).all())
600+
assert expected_count > 0
601+
597602
response = auth_client.get(
598603
url_for("api.agreements-group"),
599604
query_string={"search": "contract"},
600605
)
601606
assert response.status_code == 200
602-
assert len(response.json["data"]) == 5
607+
assert response.json["count"] == expected_count
608+
609+
# determine how many agreements match "Contract #" search
610+
stmt = select(Agreement).distinct().where(Agreement.name.ilike("%Contract #%"))
611+
expected_count = len(loaded_db.scalars(stmt).all())
612+
assert expected_count > 0
603613

604614
response = auth_client.get(
605615
url_for("api.agreements-group"),
606616
query_string={"search": "Contract #"},
607617
)
608618

609619
assert response.status_code == 200
610-
assert len(response.json["data"]) == 4
620+
assert response.json["count"] == expected_count
611621

612622

613623
def test_agreement_name_filter_partial_match(auth_client, loaded_db):
@@ -621,23 +631,31 @@ def test_agreement_name_filter_partial_match(auth_client, loaded_db):
621631
assert len(response.json["data"]) == 0
622632

623633
# Test partial match with lowercase "contract" should match agreements with "Contract" in name
634+
stmt = select(Agreement).distinct().where(Agreement.name.ilike("%contract%"))
635+
expected_count = len(loaded_db.scalars(stmt).all())
636+
assert expected_count > 0
637+
624638
response = auth_client.get(
625639
url_for("api.agreements-group"),
626640
query_string={"name": "contract", "exact_match": "false"},
627641
)
628642
assert response.status_code == 200
629-
assert len(response.json["data"]) == 5
643+
assert response.json["count"] == expected_count
630644
# Verify all results contain "contract" in their name (case-insensitive)
631645
for agreement in response.json["data"]:
632646
assert "contract" in agreement["name"].lower()
633647

634648
# Test partial match with "Contract #" should match agreements starting with "Contract #"
649+
stmt = select(Agreement).distinct().where(Agreement.name.ilike("%Contract #%"))
650+
expected_count = len(loaded_db.scalars(stmt).all())
651+
assert expected_count > 0
652+
635653
response = auth_client.get(
636654
url_for("api.agreements-group"),
637655
query_string={"name": "Contract #", "exact_match": "false"},
638656
)
639657
assert response.status_code == 200
640-
assert len(response.json["data"]) == 4
658+
assert response.json["count"] == expected_count
641659
# Verify all results contain "Contract #" in their name (case-insensitive)
642660
for agreement in response.json["data"]:
643661
assert "contract #" in agreement["name"].lower()

0 commit comments

Comments
 (0)