From 7b3f3b8b40c780d0fcc902797835233d28ffc30d Mon Sep 17 00:00:00 2001 From: maiyerlee Date: Thu, 14 Nov 2024 04:21:56 -0600 Subject: [PATCH 01/15] feat: add many cans and filter support to can budget summary endpoint --- backend/openapi.yml | 121 ++-- .../ops/resources/can_funding_summary.py | 31 +- backend/ops_api/ops/urls.py | 6 +- backend/ops_api/ops/utils/cans.py | 131 ++++- backend/ops_api/tests/conftest.py | 7 + .../test_can_funding_summary.py | 521 +++++++++++++----- 6 files changed, 607 insertions(+), 210 deletions(-) diff --git a/backend/openapi.yml b/backend/openapi.yml index c898ee4547..24a28c00de 100644 --- a/backend/openapi.yml +++ b/backend/openapi.yml @@ -1146,84 +1146,93 @@ paths: schema: type: object properties: + available_funding: + type: string + carry_forward_funding: + type: integer can: type: object properties: - appropriation_term: - type: integer - authorizer_id: - type: integer - arrangement_type_id: - type: integer - number: + can: + type: array + items: + type: object + properties: + appropriation_term: + type: integer + authorizer_id: + type: integer + arrangement_type_id: + type: integer + number: + type: string + purpose: + type: string + managing_portfolio_id: + type: integer + nickname: + type: string + appropriation_date: + type: string + description: + type: string + id: + type: integer + expiration_date: + type: string + managing_project_id: + type: integer + carry_forward_label: type: string - purpose: - type: string - managing_portfolio_id: - type: integer - nickname: - type: string - appropriation_date: - type: string - description: - type: string - id: - type: integer + example: "" expiration_date: type: string - managing_project_id: - type: integer - received_funding: - type: string - planned_funding: - type: integer - obligated_funding: - type: integer - available_funding: - type: string - total_funding: - type: string - expiration_date: + example: "" + expected_funding: type: string in_execution_funding: type: string - carry_forward_funding: - type: integer - carry_forward_label: + new_funding: type: string - expected_funding: + obligated_funding: + type: integer + planned_funding: + type: integer + received_funding: type: string - new_funding: + total_funding: type: string examples: "0": value: | { "available_funding": "8000000.00", - "can": { - "appropriation_date": "01/10/2022", - "appropriation_term": 1, - "arrangement_type_id": 4, - "authorizer_id": 26, - "description": "Child Development Research Fellowship Grant Program", - "expiration_date": "01/09/2023", - "id": 4, - "managing_portfolio_id": 1, - "managing_project_id": 1, - "nickname": "ASPE SRCD-IDDA", - "number": "G990136", - "purpose": "" - }, "carry_forward_funding": 0, - "carry_forward_label": "Carry-Forward", - "received_funding": "6000000.00", + "cans": [{ + "can":{ + "appropriation_date": "01/10/2022", + "appropriation_term": 1, + "arrangement_type_id": 4, + "authorizer_id": 26, + "description": "Child Development Research Fellowship Grant Program", + "expiration_date": "01/09/2023", + "id": 4, + "managing_portfolio_id": 1, + "managing_project_id": 1, + "nickname": "ASPE SRCD-IDDA", + "number": "G990136", + "purpose": "" + }, + "carry_forward_label": "Carry-Forward", + "expiration_date": "09/01/2023", + }], "expected_funding": "4000000.00", - "expiration_date": "09/01/2023", "in_execution_funding": "2000000.00", + "new_funding": "2000000.00", "obligated_funding": 0, "planned_funding": 0, - "total_funding": "10000000.00", - "new_funding": "2000000.00" + "received_funding": "6000000.00", + "total_funding": "10000000.00" } /api/v1/users/: get: diff --git a/backend/ops_api/ops/resources/can_funding_summary.py b/backend/ops_api/ops/resources/can_funding_summary.py index 4710463274..51a95cc713 100644 --- a/backend/ops_api/ops/resources/can_funding_summary.py +++ b/backend/ops_api/ops/resources/can_funding_summary.py @@ -4,7 +4,7 @@ from ops_api.ops.auth.auth_types import Permission, PermissionType from ops_api.ops.auth.decorators import is_authorized from ops_api.ops.base_views import BaseItemAPI -from ops_api.ops.utils.cans import get_can_funding_summary +from ops_api.ops.utils.cans import aggregate_funding_summaries, filter_cans, get_can_funding_summary from ops_api.ops.utils.response import make_response_with_headers @@ -13,8 +13,29 @@ def __init__(self, model: BaseModel): super().__init__(model) @is_authorized(PermissionType.GET, Permission.CAN) - def get(self, id: int) -> Response: + def get(self) -> Response: + can_ids = request.args.getlist("can_ids") fiscal_year = request.args.get("fiscal_year") - can = self._get_item(id) - can_funding_summary = get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) - return make_response_with_headers(can_funding_summary) + active_period = request.args.getlist("active_period", type=int) + transfer = request.args.getlist("transfer") + portfolio = request.args.getlist("portfolio") + fy_budget = request.args.getlist("fy_budget", type=int) + + if not can_ids: + return make_response_with_headers({"error": "'can_ids' parameter is required"}, 400) + + if len(can_ids) == 1 and not active_period and not transfer and not portfolio and not fy_budget: + can = self._get_item(can_ids[0]) + can_funding_summary = get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) + return make_response_with_headers(can_funding_summary) + + cans = filter_cans( + [self._get_item(can_id) for can_id in can_ids], active_period, transfer, portfolio, fy_budget + ) + + can_funding_summaries = [ + get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) for can in cans + ] + aggregated_summary = aggregate_funding_summaries(can_funding_summaries) + + return make_response_with_headers(aggregated_summary) diff --git a/backend/ops_api/ops/urls.py b/backend/ops_api/ops/urls.py index 077e61bc31..e23321d77d 100644 --- a/backend/ops_api/ops/urls.py +++ b/backend/ops_api/ops/urls.py @@ -11,12 +11,12 @@ AZURE_SAS_TOKEN_VIEW_FUNC, BUDGET_LINE_ITEMS_ITEM_API_VIEW_FUNC, BUDGET_LINE_ITEMS_LIST_API_VIEW_FUNC, - CAN_FUNDING_RECEIVED_ITEM_API_VIEW_FUNC, - CAN_FUNDING_RECEIVED_LIST_API_VIEW_FUNC, CAN_FUNDING_BUDGET_ITEM_API_VIEW_FUNC, CAN_FUNDING_BUDGET_LIST_API_VIEW_FUNC, CAN_FUNDING_DETAILS_ITEM_API_VIEW_FUNC, CAN_FUNDING_DETAILS_LIST_API_VIEW_FUNC, + CAN_FUNDING_RECEIVED_ITEM_API_VIEW_FUNC, + CAN_FUNDING_RECEIVED_LIST_API_VIEW_FUNC, CAN_FUNDING_SUMMARY_ITEM_API_VIEW_FUNC, CAN_ITEM_API_VIEW_FUNC, CAN_LIST_API_VIEW_FUNC, @@ -172,7 +172,7 @@ def register_api(api_bp: Blueprint) -> None: ) api_bp.add_url_rule( - "/can-funding-summary/", + "/can-funding-summary", view_func=CAN_FUNDING_SUMMARY_ITEM_API_VIEW_FUNC, ) api_bp.add_url_rule( diff --git a/backend/ops_api/ops/utils/cans.py b/backend/ops_api/ops/utils/cans.py index cb7ab6b55f..46ba4e26e0 100644 --- a/backend/ops_api/ops/utils/cans.py +++ b/backend/ops_api/ops/utils/cans.py @@ -1,22 +1,28 @@ -from typing import Optional, TypedDict +from decimal import Decimal +from typing import List, Optional, TypedDict from models import CAN, BudgetLineItemStatus +class CanObject(TypedDict): + can: dict + carry_forward_label: str + expiration_date: str + + class CanFundingSummary(TypedDict): """Dict type hint for total funding""" - can: CAN + available_funding: float + cans: list[CanObject] + carry_forward_funding: float received_funding: float expected_funding: float - total_funding: float - carry_forward_funding: float - carry_forward_label: str - planned_funding: float - obligated_funding: float in_execution_funding: float - available_funding: float - expiration_date: str + obligated_funding: float + planned_funding: float + total_funding: float + new_funding: float def get_can_funding_summary(can: CAN, fiscal_year: Optional[int] = None) -> CanFundingSummary: @@ -87,15 +93,106 @@ def get_can_funding_summary(can: CAN, fiscal_year: Optional[int] = None) -> CanF available_funding = total_funding - sum([planned_funding, obligated_funding, in_execution_funding]) or 0 return { - "can": can.to_dict(), + "available_funding": available_funding, + "cans": [ + { + "can": can.to_dict(), + "carry_forward_label": carry_forward_label, + "expiration_date": f"10/01/{can.obligate_by}" if can.obligate_by else "", + } + ], + "carry_forward_funding": carry_forward_funding, "received_funding": received_funding, "expected_funding": total_funding - received_funding, - "total_funding": total_funding, - "carry_forward_funding": carry_forward_funding, - "carry_forward_label": carry_forward_label, - "planned_funding": planned_funding, - "obligated_funding": obligated_funding, "in_execution_funding": in_execution_funding, - "available_funding": available_funding, - "expiration_date": f"10/01/{can.obligate_by}" if can.obligate_by else "", + "obligated_funding": obligated_funding, + "planned_funding": planned_funding, + "total_funding": total_funding, + "new_funding": available_funding + carry_forward_funding, + } + + +def get_nested_attribute(obj, attribute_path): + """ + Given an object and a string representing a dot-separated attribute path, + returns the value of the attribute. + """ + attributes = attribute_path.split(".") + for attr in attributes: + obj = getattr(obj, attr, None) # Get the attribute dynamically + if obj is None: + return None # If any attribute is None, return None + return obj # Return the final value + + +def filter_cans_by_attribute(cans: list[CAN], attribute_search: str, attribute_list) -> list[CAN]: + """ + Filters the list of cans based on a nested attribute search. + The attribute search is a string that can specify nested attributes, separated by dots. + """ + return [can for can in cans if get_nested_attribute(can, attribute_search) in attribute_list] + + +def filter_cans_by_fiscal_year_budget(cans: list[CAN], fiscal_year_budget: list[int]) -> list[CAN]: + return [ + can + for can in cans + if any(fiscal_year_budget[0] <= budget.budget <= fiscal_year_budget[1] for budget in can.funding_budgets) + ] + + +def filter_cans(cans, active_period=None, transfer=None, portfolio=None, fy_budget=None): + """ + Filters the given list of 'cans' based on the provided attributes. + + :param cans: List of cans to be filtered + :param active_period: Value to filter by 'active_period' attribute + :param transfer: Value to filter by 'funding_details.method_of_transfer' attribute + :param portfolio: Value to filter by 'portfolios.abbr' attribute + :param fy_budget: Value to filter by fiscal year budget + :return: Filtered list of cans + """ + if active_period: + cans = filter_cans_by_attribute(cans, "active_period", active_period) + if transfer: + cans = filter_cans_by_attribute(cans, "funding_details.method_of_transfer", transfer) + if portfolio: + cans = filter_cans_by_attribute(cans, "portfolios.abbr", portfolio) + if fy_budget: + cans = filter_cans_by_fiscal_year_budget(cans, fy_budget) + return cans + + +def aggregate_funding_summaries(funding_summaries: List[CanFundingSummary]) -> dict: + totals = { + "available_funding": Decimal("0.0"), + "carry_forward_funding": Decimal("0.0"), + "cans": [], + "expected_funding": Decimal("0.0"), + "in_execution_funding": Decimal("0.0"), + "new_funding": Decimal("0.0"), + "obligated_funding": Decimal("0.0"), + "planned_funding": Decimal("0.0"), + "received_funding": Decimal("0.0"), + "total_funding": Decimal("0.0"), } + + for summary in funding_summaries: + for key in totals: + if key != "cans": + totals[key] += ( + Decimal(summary.get(key, "0.0")) + if isinstance(summary.get(key), (int, float, Decimal)) + else Decimal("0.0") + ) + + for can in summary["cans"]: + totals["cans"].append( + { + "can": can["can"], + "carry_forward_label": can["carry_forward_label"], + "expiration_date": can["expiration_date"], + } + ) + + return totals diff --git a/backend/ops_api/tests/conftest.py b/backend/ops_api/tests/conftest.py index 28c2529d2c..da2958a77e 100644 --- a/backend/ops_api/tests/conftest.py +++ b/backend/ops_api/tests/conftest.py @@ -3,6 +3,7 @@ import subprocess from collections.abc import Generator from datetime import datetime, timezone +from typing import Type import pytest from flask import Flask @@ -251,6 +252,12 @@ def test_can(loaded_db) -> CAN | None: return loaded_db.get(CAN, 500) +@pytest.fixture() +def test_cans(loaded_db) -> list[Type[CAN] | None]: + """Get two test CANs.""" + return [loaded_db.get(CAN, 500), loaded_db.get(CAN, 501)] + + @pytest.fixture() def unadded_can(): new_can = CAN( diff --git a/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py b/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py index 120c8a8364..e45e40e828 100644 --- a/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py +++ b/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py @@ -1,13 +1,32 @@ from decimal import Decimal +from typing import Type +from unittest.mock import MagicMock import pytest from flask.testing import FlaskClient from models.cans import CAN -from ops_api.ops.utils.cans import get_can_funding_summary +from ops_api.ops.utils.cans import ( + aggregate_funding_summaries, + filter_cans, + filter_cans_by_attribute, + filter_cans_by_fiscal_year_budget, + get_can_funding_summary, + get_nested_attribute, +) from ops_api.tests.utils import remove_keys +class DummyObject: + def __init__(self): + self.nested = DummyNestedObject() + + +class DummyNestedObject: + def __init__(self): + self.value = "test_value" + + @pytest.mark.usefixtures("app_ctx") def test_get_can_funding_summary_no_fiscal_year(loaded_db, test_can) -> None: result = get_can_funding_summary(test_can) @@ -17,81 +36,86 @@ def test_get_can_funding_summary_no_fiscal_year(loaded_db, test_can) -> None: assert result == { "available_funding": Decimal("-860000.00"), - "can": { - "active_period": 1, - "appropriation_date": 2023, - "budget_line_items": [15008], - "created_by": None, - "created_by_user": None, - "description": "Healthy Marriages Responsible Fatherhood - OPRE", - "display_name": "G99HRF2", - "expiration_date": 2024, - "funding_budgets": [ - { - "budget": "1140000.0", - "can": 500, - "can_id": 500, + "cans": [ + { + "can": { + "active_period": 1, + "appropriation_date": 2023, + "budget_line_items": [15008], "created_by": None, "created_by_user": None, - "display_name": "CANFundingBudget#1", - "fiscal_year": 2023, - "id": 1, - "notes": None, - "updated_by": None, - "updated_by_user": None, - } - ], - "funding_details": { - "allotment": None, - "allowance": None, - "appropriation": None, - "created_by": None, - "created_by_user": None, - "display_name": "CANFundingDetails#1", - "fiscal_year": 2023, - "fund_code": "AAXXXX20231DAD", - "funding_partner": None, - "funding_source": "OPRE", - "id": 1, - "method_of_transfer": "DIRECT", - "sub_allowance": None, - "updated_by": None, - "updated_by_user": None, - }, - "funding_details_id": 1, - "funding_received": [ - { - "can": 500, - "can_id": 500, - "created_by": None, - "created_by_user": None, - "display_name": "CANFundingReceived#500", - "fiscal_year": 2023, - "funding": "880000.0", + "description": "Healthy Marriages Responsible Fatherhood - OPRE", + "display_name": "G99HRF2", + "expiration_date": 2024, + "funding_budgets": [ + { + "budget": "1140000.0", + "can": 500, + "can_id": 500, + "created_by": None, + "created_by_user": None, + "display_name": "CANFundingBudget#1", + "fiscal_year": 2023, + "id": 1, + "notes": None, + "updated_by": None, + "updated_by_user": None, + } + ], + "funding_details": { + "allotment": None, + "allowance": None, + "appropriation": None, + "created_by": None, + "created_by_user": None, + "display_name": "CANFundingDetails#1", + "fiscal_year": 2023, + "fund_code": "AAXXXX20231DAD", + "funding_partner": None, + "funding_source": "OPRE", + "id": 1, + "method_of_transfer": "DIRECT", + "sub_allowance": None, + "updated_by": None, + "updated_by_user": None, + }, + "funding_details_id": 1, + "funding_received": [ + { + "can": 500, + "can_id": 500, + "created_by": None, + "created_by_user": None, + "display_name": "CANFundingReceived#500", + "fiscal_year": 2023, + "funding": "880000.0", + "id": 500, + "notes": None, + "updated_by": None, + "updated_by_user": None, + } + ], "id": 500, - "notes": None, + "nick_name": "HMRF-OPRE", + "number": "G99HRF2", + "portfolio": 6, + "portfolio_id": 6, + "projects": [1000], "updated_by": None, "updated_by_user": None, - } - ], - "id": 500, - "nick_name": "HMRF-OPRE", - "number": "G99HRF2", - "portfolio": 6, - "portfolio_id": 6, - "projects": [1000], - "updated_by": None, - "updated_by_user": None, - }, + }, + "carry_forward_label": " Carry-Forward", + "expiration_date": "10/01/2024", + } + ], "carry_forward_funding": 0, - "carry_forward_label": " Carry-Forward", "expected_funding": Decimal("260000.0"), - "expiration_date": "10/01/2024", "in_execution_funding": Decimal("2000000.00"), "obligated_funding": 0, "planned_funding": 0, "received_funding": Decimal("880000.0"), "total_funding": Decimal("1140000.0"), + "new_funding": Decimal("-860000.00") - 0, } @@ -105,87 +129,326 @@ def test_get_can_funding_summary_with_fiscal_year(loaded_db, test_can) -> None: assert result == { "available_funding": Decimal("1140000.0"), - "can": { - "active_period": 1, - "appropriation_date": 2023, - "budget_line_items": [15008], - "created_by": None, - "created_by_user": None, - "description": "Healthy Marriages Responsible Fatherhood - OPRE", - "display_name": "G99HRF2", - "expiration_date": 2024, - "funding_budgets": [ - { - "budget": "1140000.0", - "can": 500, - "can_id": 500, - "created_by": None, - "created_by_user": None, - "display_name": "CANFundingBudget#1", - "fiscal_year": 2023, - "id": 1, - "notes": None, - "updated_by": None, - "updated_by_user": None, - } - ], - "funding_details": { - "allotment": None, - "allowance": None, - "appropriation": None, - "created_by": None, - "created_by_user": None, - "display_name": "CANFundingDetails#1", - "fiscal_year": 2023, - "fund_code": "AAXXXX20231DAD", - "funding_partner": None, - "funding_source": "OPRE", - "id": 1, - "method_of_transfer": "DIRECT", - "sub_allowance": None, - "updated_by": None, - "updated_by_user": None, - }, - "funding_details_id": 1, - "funding_received": [ - { - "can": 500, - "can_id": 500, + "cans": [ + { + "can": { + "active_period": 1, + "appropriation_date": 2023, + "budget_line_items": [15008], "created_by": None, "created_by_user": None, - "display_name": "CANFundingReceived#500", - "fiscal_year": 2023, - "funding": "880000.0", + "description": "Healthy Marriages Responsible Fatherhood - OPRE", + "display_name": "G99HRF2", + "expiration_date": 2024, + "funding_budgets": [ + { + "budget": "1140000.0", + "can": 500, + "can_id": 500, + "created_by": None, + "created_by_user": None, + "display_name": "CANFundingBudget#1", + "fiscal_year": 2023, + "id": 1, + "notes": None, + "updated_by": None, + "updated_by_user": None, + } + ], + "funding_details": { + "allotment": None, + "allowance": None, + "appropriation": None, + "created_by": None, + "created_by_user": None, + "display_name": "CANFundingDetails#1", + "fiscal_year": 2023, + "fund_code": "AAXXXX20231DAD", + "funding_partner": None, + "funding_source": "OPRE", + "id": 1, + "method_of_transfer": "DIRECT", + "sub_allowance": None, + "updated_by": None, + "updated_by_user": None, + }, + "funding_details_id": 1, + "funding_received": [ + { + "can": 500, + "can_id": 500, + "created_by": None, + "created_by_user": None, + "display_name": "CANFundingReceived#500", + "fiscal_year": 2023, + "funding": "880000.0", + "id": 500, + "notes": None, + "updated_by": None, + "updated_by_user": None, + } + ], "id": 500, - "notes": None, + "nick_name": "HMRF-OPRE", + "number": "G99HRF2", + "portfolio": 6, + "portfolio_id": 6, + "projects": [1000], "updated_by": None, "updated_by_user": None, - } - ], - "id": 500, - "nick_name": "HMRF-OPRE", - "number": "G99HRF2", - "portfolio": 6, - "portfolio_id": 6, - "projects": [1000], - "updated_by": None, - "updated_by_user": None, - }, + }, + "carry_forward_label": " Carry-Forward", + "expiration_date": "10/01/2024", + } + ], "carry_forward_funding": 0, - "carry_forward_label": " Carry-Forward", "expected_funding": Decimal("260000.0"), - "expiration_date": "10/01/2024", "in_execution_funding": 0, "obligated_funding": 0, "planned_funding": 0, "received_funding": Decimal("880000.0"), "total_funding": Decimal("1140000.0"), + "new_funding": Decimal("1140000.0") - 0, } @pytest.mark.usefixtures("app_ctx") @pytest.mark.usefixtures("loaded_db") def test_can_get_can_funding_summary(auth_client: FlaskClient, test_can: CAN) -> None: - response = auth_client.get(f"/api/v1/can-funding-summary/{test_can.id}") + query_params = f"can_ids={test_can.id}" + + response = auth_client.get(f"/api/v1/can-funding-summary?{query_params}") + + assert response.status_code == 200 + assert response.json["cans"][0]["can"]["id"] == test_can.id + assert "new_funding" in response.json + assert isinstance(response.json["new_funding"], str) + assert "expiration_date" in response.json["cans"][0] + assert "carry_forward_label" in response.json["cans"][0] + + +@pytest.mark.usefixtures("app_ctx") +@pytest.mark.usefixtures("loaded_db") +def test_cans_get_can_funding_summary(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: + url = f"/api/v1/can-funding-summary?can_ids={test_cans[0].id}&can_ids={test_cans[1].id}" + + response = auth_client.get(url) + + available_funding = response.json["available_funding"] + carry_forward_funding = response.json["carry_forward_funding"] + + assert response.status_code == 200 + assert len(response.json["cans"]) == 2 + + assert available_funding == "3340000.00" + assert carry_forward_funding == "10000000.0" + assert response.json["new_funding"] == "13340000.00" + + +@pytest.mark.usefixtures("app_ctx") +@pytest.mark.usefixtures("loaded_db") +def test_can_get_can_funding_summary_filter(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: + url = f"/api/v1/can-funding-summary?" f"can_ids={test_cans[0].id}&can_ids={test_cans[1].id}&" f"active_period=1" + + response = auth_client.get(url) + assert response.status_code == 200 - assert response.json["can"]["id"] == test_can.id + assert len(response.json["cans"]) == 1 + assert response.json["cans"][0]["can"]["active_period"] == 1 + + +@pytest.mark.usefixtures("app_ctx") +@pytest.mark.usefixtures("loaded_db") +def test_can_get_can_funding_summary_complete_filter(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: + url = ( + f"/api/v1/can-funding-summary?" + f"can_ids={test_cans[0].id}&can_ids={test_cans[1].id}&" + f"fiscal_year=2024&" + f"active_period=1&active_period=5&" + f"transfer=DIRECT&transfer=IAA&" + f"portfolio=HS&portfolio=HMRF&" + f"fy_budget=50000&fy_budget=100000" + ) + + response = auth_client.get(url) + + assert response.status_code == 200 + assert len(response.json["cans"]) == 0 + assert "new_funding" in response.json + assert response.json["obligated_funding"] == "0.0" + + +def test_get_nested_attribute_existing_attribute(): + obj = DummyObject() + result = get_nested_attribute(obj, "nested.value") + assert result == "test_value" + + +def test_get_nested_attribute_non_existing_attribute(): + obj = DummyObject() + result = get_nested_attribute(obj, "nested.non_existing") + assert result is None + + +def test_get_nested_attribute_non_existing_top_level(): + obj = DummyObject() + result = get_nested_attribute(obj, "non_existing") + assert result is None + + +def test_filter_cans_by_attribute(): + cans = [ + MagicMock(active_period=1, funding_details=MagicMock(method_of_transfer="DIRECT")), + MagicMock(active_period=2, funding_details=MagicMock(method_of_transfer="IAA")), + MagicMock(active_period=1, funding_details=MagicMock(method_of_transfer="DIRECT")), + ] + + filtered_cans = filter_cans_by_attribute(cans, "funding_details.method_of_transfer", ["DIRECT"]) + + assert len(filtered_cans) == 2 + + +def test_filter_cans_by_fiscal_year_budget(): + cans = [ + MagicMock(funding_budgets=[MagicMock(budget=1000001.0)]), + MagicMock(funding_budgets=[MagicMock(budget=2000000.0)]), + MagicMock(funding_budgets=[MagicMock(budget=500000.0)]), + ] + + fiscal_year_budget = [1000000, 2000000] + filtered_cans = filter_cans_by_fiscal_year_budget(cans, fiscal_year_budget) + + assert len(filtered_cans) == 2 + + +def test_filter_cans_by_fiscal_year_budget_no_match(): + cans = [ + MagicMock(funding_budgets=[MagicMock(budget=500000.0, fiscal_year=2023)]), + MagicMock(funding_budgets=[MagicMock(budget=7000000.0, fiscal_year=2024)]), + ] + + fiscal_year_budget = [1000000, 2000000] + filtered_cans = filter_cans_by_fiscal_year_budget(cans, fiscal_year_budget) + + assert len(filtered_cans) == 0 + + +@pytest.mark.parametrize( + "active_period, transfer, portfolio, fy_budget, expected_count", + [ + (None, None, None, None, 3), + ([1], None, None, None, 2), + (None, ["DIRECT"], None, None, 1), + (None, None, None, [100000, 200000], 2), + ([1], ["IAA"], ["HS"], [100000, 200000], 0), + ], +) +def test_filter_cans(active_period, transfer, portfolio, fy_budget, expected_count): + cans = [ + MagicMock( + active_period=1, + funding_details=MagicMock(method_of_transfer="DIRECT"), + portfolios=[MagicMock(abbr="HS")], + funding_budgets=[MagicMock(budget=150000)], + ), + MagicMock( + active_period=2, + funding_details=MagicMock(method_of_transfer="IAA"), + portfolios=[MagicMock(abbr="HS")], + funding_budgets=[MagicMock(budget=50000)], + ), + MagicMock( + active_period=1, + funding_details=MagicMock(method_of_transfer="IAA"), + portfolios=[MagicMock(abbr="HMRF")], + funding_budgets=[MagicMock(budget=200000)], + ), + ] + + filtered_cans = filter_cans( + cans, active_period=active_period, transfer=transfer, portfolio=portfolio, fy_budget=fy_budget + ) + + assert len(filtered_cans) == expected_count + + +def test_aggregate_funding_summaries(): + funding_sums = [ + { + "available_funding": 100000, + "cans": [ + { + "can": { + "id": 1, + "description": "Grant for educational projects", + "amount": 50000, + "obligate_by": 2025, + }, + "carry_forward_label": "2024 Carry Forward", + "expiration_date": "10/01/2025", + } + ], + "carry_forward_funding": 20000, + "received_funding": 75000, + "expected_funding": 125000 - 75000, + "in_execution_funding": 50000, + "obligated_funding": 30000, + "planned_funding": 120000, + "total_funding": 125000, + "new_funding": 100000 + 20000, + }, + { + "available_funding": 150000, + "cans": [ + { + "can": { + "id": 2, + "description": "Infrastructure development grant", + "amount": 70000, + "obligate_by": 2026, + }, + "carry_forward_label": "2025 Carry Forward", + "expiration_date": "10/01/2026", + } + ], + "carry_forward_funding": 30000, + "received_funding": 100000, + "expected_funding": 180000 - 100000, + "in_execution_funding": 80000, + "obligated_funding": 50000, + "planned_funding": 160000, + "total_funding": 180000, + "new_funding": 150000 + 30000, + }, + ] + + result = aggregate_funding_summaries(funding_sums) + + assert result == { + "available_funding": Decimal("250000.0"), + "cans": [ + { + "can": {"amount": 50000, "description": "Grant for educational projects", "id": 1, "obligate_by": 2025}, + "carry_forward_label": "2024 Carry Forward", + "expiration_date": "10/01/2025", + }, + { + "can": { + "amount": 70000, + "description": "Infrastructure development grant", + "id": 2, + "obligate_by": 2026, + }, + "carry_forward_label": "2025 Carry Forward", + "expiration_date": "10/01/2026", + }, + ], + "carry_forward_funding": Decimal("50000.0"), + "expected_funding": Decimal("130000.0"), + "in_execution_funding": Decimal("130000.0"), + "new_funding": Decimal("300000.0"), + "obligated_funding": Decimal("80000.0"), + "planned_funding": Decimal("280000.0"), + "received_funding": Decimal("175000.0"), + "total_funding": Decimal("305000.0"), + } From 894596a43775bd423186222b7b6c1a6a41b2a10e Mon Sep 17 00:00:00 2001 From: maiyerlee Date: Mon, 18 Nov 2024 05:58:57 -0600 Subject: [PATCH 02/15] feat: update new_funding calc, refactor for all cans, fix FE endpoint usage --- backend/openapi.yml | 9 +- .../ops/resources/can_funding_summary.py | 42 +++- backend/ops_api/ops/utils/cans.py | 125 ++++++---- .../test_can_funding_summary.py | 23 +- frontend/src/api/getCanFundingSummary.js | 2 +- frontend/src/api/opsAPI.js | 2 +- .../AgreementCANReviewAccordion.test.jsx | 224 +++++++++--------- .../CANs/CANFundingCard/CANFundingCard.jsx | 2 +- .../CANFundingCard/CanFundingCard.test.jsx | 76 +++--- .../src/components/CANs/CanCard/CanCard.jsx | 7 +- .../components/CANs/CanCard/CanCard.test.jsx | 76 +++--- 11 files changed, 330 insertions(+), 258 deletions(-) diff --git a/backend/openapi.yml b/backend/openapi.yml index 24a28c00de..bbb16111bb 100644 --- a/backend/openapi.yml +++ b/backend/openapi.yml @@ -1080,13 +1080,13 @@ paths: get: tags: - CAN Funding Summary - operationId: getFundingTotals - description: Get Funding Totals + operationId: getFundingSummary + description: Get Funding Summary parameters: - $ref: "#/components/parameters/simulatedError" - name: can_ids in: query - description: List of CAN Ids (required) + description: List of CAN Ids (required), Use an integer of 0 to get funding summary for all CANs. required: true schema: type: array @@ -1190,6 +1190,8 @@ paths: example: "" expected_funding: type: string + in_draft_funding: + type: string in_execution_funding: type: string new_funding: @@ -1227,6 +1229,7 @@ paths: "expiration_date": "09/01/2023", }], "expected_funding": "4000000.00", + "in_draft_funding": "500000.00", "in_execution_funding": "2000000.00", "new_funding": "2000000.00", "obligated_funding": 0, diff --git a/backend/ops_api/ops/resources/can_funding_summary.py b/backend/ops_api/ops/resources/can_funding_summary.py index 51a95cc713..e84193da53 100644 --- a/backend/ops_api/ops/resources/can_funding_summary.py +++ b/backend/ops_api/ops/resources/can_funding_summary.py @@ -4,7 +4,7 @@ from ops_api.ops.auth.auth_types import Permission, PermissionType from ops_api.ops.auth.decorators import is_authorized from ops_api.ops.base_views import BaseItemAPI -from ops_api.ops.utils.cans import aggregate_funding_summaries, filter_cans, get_can_funding_summary +from ops_api.ops.utils.cans import aggregate_funding_summaries, get_can_funding_summary, get_filtered_cans from ops_api.ops.utils.response import make_response_with_headers @@ -14,6 +14,7 @@ def __init__(self, model: BaseModel): @is_authorized(PermissionType.GET, Permission.CAN) def get(self) -> Response: + # Get query parameters can_ids = request.args.getlist("can_ids") fiscal_year = request.args.get("fiscal_year") active_period = request.args.getlist("active_period", type=int) @@ -21,21 +22,44 @@ def get(self) -> Response: portfolio = request.args.getlist("portfolio") fy_budget = request.args.getlist("fy_budget", type=int) + # Check if required 'can_ids' parameter is provided if not can_ids: return make_response_with_headers({"error": "'can_ids' parameter is required"}, 400) - if len(can_ids) == 1 and not active_period and not transfer and not portfolio and not fy_budget: - can = self._get_item(can_ids[0]) - can_funding_summary = get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) - return make_response_with_headers(can_funding_summary) + # Handle case when a single 'can_id' is provided with no additional filters + if len(can_ids) == 1 and not (active_period or transfer or portfolio or fy_budget): + return self._handle_single_can_no_filters(can_ids[0], fiscal_year) - cans = filter_cans( - [self._get_item(can_id) for can_id in can_ids], active_period, transfer, portfolio, fy_budget - ) + # If 'can_ids' is 0 or multiple ids are provided, filter and aggregate + return self._handle_cans_with_filters(can_ids, fiscal_year, active_period, transfer, portfolio, fy_budget) + + def _handle_single_can_no_filters(self, can_id: str, fiscal_year: str = None) -> Response: + """Helper method for handling a single 'can_id' with no filters.""" + can = self._get_item(can_id) + can_funding_summary = get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) + return make_response_with_headers(can_funding_summary) + + def _get_cans(self, can_ids: list) -> list: + """Helper method to get CANS.""" + if can_ids == [0]: + return self._get_all_items() + return [self._get_item(can_id) for can_id in can_ids] + + def _handle_cans_with_filters( + self, + can_ids: list, + fiscal_year: str = None, + active_period: list = None, + transfer: list = None, + portfolio: list = None, + fy_budget: list = None, + ) -> Response: + cans_with_filters = get_filtered_cans(self._get_cans(can_ids), active_period, transfer, portfolio, fy_budget) can_funding_summaries = [ - get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) for can in cans + get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) for can in cans_with_filters ] + aggregated_summary = aggregate_funding_summaries(can_funding_summaries) return make_response_with_headers(aggregated_summary) diff --git a/backend/ops_api/ops/utils/cans.py b/backend/ops_api/ops/utils/cans.py index 46ba4e26e0..ccd17a3666 100644 --- a/backend/ops_api/ops/utils/cans.py +++ b/backend/ops_api/ops/utils/cans.py @@ -13,11 +13,12 @@ class CanObject(TypedDict): class CanFundingSummary(TypedDict): """Dict type hint for total funding""" - available_funding: float + available_funding: float # remaining cans: list[CanObject] carry_forward_funding: float received_funding: float expected_funding: float + in_draft_funding: float in_execution_funding: float obligated_funding: float planned_funding: float @@ -25,6 +26,51 @@ class CanFundingSummary(TypedDict): new_funding: float +def get_funding_by_budget_line_item_status( + can: CAN, status: BudgetLineItemStatus, fiscal_year: Optional[int] = None +) -> float: + if fiscal_year: + return ( + sum( + [bli.amount for bli in can.budget_line_items if bli.status == status and bli.fiscal_year == fiscal_year] + ) + or 0 + ) + else: + return sum([bli.amount for bli in can.budget_line_items if bli.status == status]) or 0 + + +def get_new_funding_by_fiscal_year_on_funding_details(can: CAN): + # Get the fiscal year on the can's FundingDetails object + fiscal_year = can.funding_details.fiscal_year + + if not fiscal_year: + return None # If the fiscal year is missing or empty, return None + + # Filter the funding budgets that match the fiscal year + matching_budgets = [budget for budget in can.funding_budgets if budget.fiscal_year == fiscal_year] + + # If no matching budgets exist, return None + if not matching_budgets: + return None # If no budget exist for the fiscal year the can was created, return None + + # Sum the budgets for the matching fiscal year, return 0 if there are no budgets + total_funding = sum(budget.budget for budget in matching_budgets) or 0 + + return total_funding or None # Return the total, or None if it's zero + + +def get_new_funding_by_fiscal_year(can: CAN, fiscal_year: Optional[int] = None) -> float: + # check to see if the CAN has an active period of 1 + if can.active_period == 1: + return sum([c.budget for c in can.funding_budgets if c.fiscal_year == fiscal_year]) or 0 + elif can.active_period == 5: + # Check to see if the CAN is in it first year + return get_new_funding_by_fiscal_year_on_funding_details(can) + else: + pass + + def get_can_funding_summary(can: CAN, fiscal_year: Optional[int] = None) -> CanFundingSummary: if fiscal_year: received_funding = sum([c.funding for c in can.funding_received if c.fiscal_year == fiscal_year]) or 0 @@ -33,38 +79,15 @@ def get_can_funding_summary(can: CAN, fiscal_year: Optional[int] = None) -> CanF carry_forward_funding = sum([c.budget for c in can.funding_budgets[1:] if c.fiscal_year == fiscal_year]) or 0 - planned_funding = ( - sum( - [ - bli.amount - for bli in can.budget_line_items - if bli.status == BudgetLineItemStatus.PLANNED and bli.fiscal_year == fiscal_year - ] - ) - or 0 + planned_funding = get_funding_by_budget_line_item_status(can, BudgetLineItemStatus.PLANNED, fiscal_year) + obligated_funding = get_funding_by_budget_line_item_status(can, BudgetLineItemStatus.OBLIGATED, fiscal_year) + in_execution_funding = get_funding_by_budget_line_item_status( + can, BudgetLineItemStatus.IN_EXECUTION, fiscal_year ) + in_draft_funding = get_funding_by_budget_line_item_status(can, BudgetLineItemStatus.DRAFT, fiscal_year) - obligated_funding = ( - sum( - [ - bli.amount - for bli in can.budget_line_items - if bli.status == BudgetLineItemStatus.OBLIGATED and bli.fiscal_year == fiscal_year - ] - ) - or 0 - ) + new_funding = get_new_funding_by_fiscal_year(can, fiscal_year) - in_execution_funding = ( - sum( - [ - bli.amount - for bli in can.budget_line_items - if bli.status == BudgetLineItemStatus.IN_EXECUTION and bli.fiscal_year == fiscal_year - ] - ) - or 0 - ) else: received_funding = sum([c.funding for c in can.funding_received]) or 0 @@ -72,17 +95,12 @@ def get_can_funding_summary(can: CAN, fiscal_year: Optional[int] = None) -> CanF carry_forward_funding = sum([c.budget for c in can.funding_budgets[1:]]) or 0 - planned_funding = ( - sum([bli.amount for bli in can.budget_line_items if bli.status == BudgetLineItemStatus.PLANNED]) or 0 - ) - - obligated_funding = ( - sum([bli.amount for bli in can.budget_line_items if bli.status == BudgetLineItemStatus.OBLIGATED]) or 0 - ) + new_funding = sum([c.budget for c in can.funding_budgets[:1]]) or 0 - in_execution_funding = ( - sum([bli.amount for bli in can.budget_line_items if bli.status == BudgetLineItemStatus.IN_EXECUTION]) or 0 - ) + planned_funding = get_funding_by_budget_line_item_status(can, BudgetLineItemStatus.PLANNED, None) + obligated_funding = get_funding_by_budget_line_item_status(can, BudgetLineItemStatus.OBLIGATED, None) + in_execution_funding = get_funding_by_budget_line_item_status(can, BudgetLineItemStatus.IN_EXECUTION, None) + in_draft_funding = get_funding_by_budget_line_item_status(can, BudgetLineItemStatus.DRAFT, None) carry_forward_label = ( "Carry-Forward" @@ -104,36 +122,38 @@ def get_can_funding_summary(can: CAN, fiscal_year: Optional[int] = None) -> CanF "carry_forward_funding": carry_forward_funding, "received_funding": received_funding, "expected_funding": total_funding - received_funding, + "in_draft_funding": in_draft_funding, "in_execution_funding": in_execution_funding, + "new_funding": new_funding, "obligated_funding": obligated_funding, "planned_funding": planned_funding, "total_funding": total_funding, - "new_funding": available_funding + carry_forward_funding, } def get_nested_attribute(obj, attribute_path): """ Given an object and a string representing a dot-separated attribute path, - returns the value of the attribute. + returns the value of the attribute dynamically. If any attribute is None, return None. """ attributes = attribute_path.split(".") for attr in attributes: - obj = getattr(obj, attr, None) # Get the attribute dynamically + obj = getattr(obj, attr, None) if obj is None: - return None # If any attribute is None, return None - return obj # Return the final value + return None + return obj -def filter_cans_by_attribute(cans: list[CAN], attribute_search: str, attribute_list) -> list[CAN]: +def filter_by_attribute(cans: list[CAN], attribute_search: str, attribute_list) -> list[CAN]: """ Filters the list of cans based on a nested attribute search. The attribute search is a string that can specify nested attributes, separated by dots. + For example, "portfolios.abbr" would search for the 'abbr' attribute in the 'portfolios' attribute. """ return [can for can in cans if get_nested_attribute(can, attribute_search) in attribute_list] -def filter_cans_by_fiscal_year_budget(cans: list[CAN], fiscal_year_budget: list[int]) -> list[CAN]: +def filter_by_fiscal_year_budget(cans: list[CAN], fiscal_year_budget: list[int]) -> list[CAN]: return [ can for can in cans @@ -141,7 +161,7 @@ def filter_cans_by_fiscal_year_budget(cans: list[CAN], fiscal_year_budget: list[ ] -def filter_cans(cans, active_period=None, transfer=None, portfolio=None, fy_budget=None): +def get_filtered_cans(cans, active_period=None, transfer=None, portfolio=None, fy_budget=None): """ Filters the given list of 'cans' based on the provided attributes. @@ -153,13 +173,13 @@ def filter_cans(cans, active_period=None, transfer=None, portfolio=None, fy_budg :return: Filtered list of cans """ if active_period: - cans = filter_cans_by_attribute(cans, "active_period", active_period) + cans = filter_by_attribute(cans, "active_period", active_period) if transfer: - cans = filter_cans_by_attribute(cans, "funding_details.method_of_transfer", transfer) + cans = filter_by_attribute(cans, "funding_details.method_of_transfer", transfer) if portfolio: - cans = filter_cans_by_attribute(cans, "portfolios.abbr", portfolio) + cans = filter_by_attribute(cans, "portfolios.abbr", portfolio) if fy_budget: - cans = filter_cans_by_fiscal_year_budget(cans, fy_budget) + cans = filter_by_fiscal_year_budget(cans, fy_budget) return cans @@ -169,6 +189,7 @@ def aggregate_funding_summaries(funding_summaries: List[CanFundingSummary]) -> d "carry_forward_funding": Decimal("0.0"), "cans": [], "expected_funding": Decimal("0.0"), + "in_draft_funding": Decimal("0.0"), "in_execution_funding": Decimal("0.0"), "new_funding": Decimal("0.0"), "obligated_funding": Decimal("0.0"), diff --git a/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py b/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py index e45e40e828..5e0c7d98f8 100644 --- a/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py +++ b/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py @@ -8,10 +8,10 @@ from models.cans import CAN from ops_api.ops.utils.cans import ( aggregate_funding_summaries, - filter_cans, - filter_cans_by_attribute, - filter_cans_by_fiscal_year_budget, + filter_by_attribute, + filter_by_fiscal_year_budget, get_can_funding_summary, + get_filtered_cans, get_nested_attribute, ) from ops_api.tests.utils import remove_keys @@ -110,12 +110,13 @@ def test_get_can_funding_summary_no_fiscal_year(loaded_db, test_can) -> None: ], "carry_forward_funding": 0, "expected_funding": Decimal("260000.0"), + "in_draft_funding": 0, "in_execution_funding": Decimal("2000000.00"), + "new_funding": Decimal("1140000.0"), "obligated_funding": 0, "planned_funding": 0, "received_funding": Decimal("880000.0"), "total_funding": Decimal("1140000.0"), - "new_funding": Decimal("-860000.00") - 0, } @@ -202,6 +203,7 @@ def test_get_can_funding_summary_with_fiscal_year(loaded_db, test_can) -> None: } ], "carry_forward_funding": 0, + "in_draft_funding": Decimal("0.0"), "expected_funding": Decimal("260000.0"), "in_execution_funding": 0, "obligated_funding": 0, @@ -242,7 +244,7 @@ def test_cans_get_can_funding_summary(auth_client: FlaskClient, test_cans: list[ assert available_funding == "3340000.00" assert carry_forward_funding == "10000000.0" - assert response.json["new_funding"] == "13340000.00" + assert response.json["new_funding"] == "1340000.0" @pytest.mark.usefixtures("app_ctx") @@ -303,7 +305,7 @@ def test_filter_cans_by_attribute(): MagicMock(active_period=1, funding_details=MagicMock(method_of_transfer="DIRECT")), ] - filtered_cans = filter_cans_by_attribute(cans, "funding_details.method_of_transfer", ["DIRECT"]) + filtered_cans = filter_by_attribute(cans, "funding_details.method_of_transfer", ["DIRECT"]) assert len(filtered_cans) == 2 @@ -316,7 +318,7 @@ def test_filter_cans_by_fiscal_year_budget(): ] fiscal_year_budget = [1000000, 2000000] - filtered_cans = filter_cans_by_fiscal_year_budget(cans, fiscal_year_budget) + filtered_cans = filter_by_fiscal_year_budget(cans, fiscal_year_budget) assert len(filtered_cans) == 2 @@ -328,7 +330,7 @@ def test_filter_cans_by_fiscal_year_budget_no_match(): ] fiscal_year_budget = [1000000, 2000000] - filtered_cans = filter_cans_by_fiscal_year_budget(cans, fiscal_year_budget) + filtered_cans = filter_by_fiscal_year_budget(cans, fiscal_year_budget) assert len(filtered_cans) == 0 @@ -365,7 +367,7 @@ def test_filter_cans(active_period, transfer, portfolio, fy_budget, expected_cou ), ] - filtered_cans = filter_cans( + filtered_cans = get_filtered_cans( cans, active_period=active_period, transfer=transfer, portfolio=portfolio, fy_budget=fy_budget ) @@ -391,6 +393,7 @@ def test_aggregate_funding_summaries(): "carry_forward_funding": 20000, "received_funding": 75000, "expected_funding": 125000 - 75000, + "in_draft_funding": 0, "in_execution_funding": 50000, "obligated_funding": 30000, "planned_funding": 120000, @@ -414,6 +417,7 @@ def test_aggregate_funding_summaries(): "carry_forward_funding": 30000, "received_funding": 100000, "expected_funding": 180000 - 100000, + "in_draft_funding": 0, "in_execution_funding": 80000, "obligated_funding": 50000, "planned_funding": 160000, @@ -445,6 +449,7 @@ def test_aggregate_funding_summaries(): ], "carry_forward_funding": Decimal("50000.0"), "expected_funding": Decimal("130000.0"), + "in_draft_funding": Decimal("0.0"), "in_execution_funding": Decimal("130000.0"), "new_funding": Decimal("300000.0"), "obligated_funding": Decimal("80000.0"), diff --git a/frontend/src/api/getCanFundingSummary.js b/frontend/src/api/getCanFundingSummary.js index add10cc97d..9a7630408d 100644 --- a/frontend/src/api/getCanFundingSummary.js +++ b/frontend/src/api/getCanFundingSummary.js @@ -5,7 +5,7 @@ export const getPortfolioCansFundingDetails = async (item) => { const api_version = ApplicationContext.get().helpers().backEndConfig.apiVersion; const responseData = await ApplicationContext.get() .helpers() - .callBackend(`/api/${api_version}/can-funding-summary/${item.id}?fiscal_year=${item.fiscalYear}`, "get"); + .callBackend(`/api/${api_version}/can-funding-summary?can_id=${item.id}?fiscal_year=${item.fiscalYear}`, "get"); return responseData; } return {}; diff --git a/frontend/src/api/opsAPI.js b/frontend/src/api/opsAPI.js index 690d659a61..97f5b0de95 100644 --- a/frontend/src/api/opsAPI.js +++ b/frontend/src/api/opsAPI.js @@ -204,7 +204,7 @@ export const opsApi = createApi({ providesTags: ["Cans"] }), getCanFundingSummary: builder.query({ - query: (id) => `/can-funding-summary/${id}`, + query: (id) => `/can-funding-summary?can_ids=${id}`, providesTags: ["CanFunding"] }), getNotificationsByUserId: builder.query({ diff --git a/frontend/src/components/Agreements/AgreementCANReviewAccordion/AgreementCANReviewAccordion.test.jsx b/frontend/src/components/Agreements/AgreementCANReviewAccordion/AgreementCANReviewAccordion.test.jsx index f01ef0479b..f0d977bfa3 100644 --- a/frontend/src/components/Agreements/AgreementCANReviewAccordion/AgreementCANReviewAccordion.test.jsx +++ b/frontend/src/components/Agreements/AgreementCANReviewAccordion/AgreementCANReviewAccordion.test.jsx @@ -1238,45 +1238,49 @@ const canData = [ const canFundingCardData = { available_funding: "14300000.00", - can: { - appropriation_date: "2023-10-01T00:00:00.000000Z", - active_period: 1, - arrangement_type: "OPRE_APPROPRIATION", - authorizer: 26, - authorizer_id: 26, - budget_line_items: [15011, 15017, 15020], - can_type: null, - created_by: null, - created_by_user: null, - created_on: "2024-07-29T14:44:58.757452Z", - description: "Social Science Research and Development", - display_name: "G99PHS9", - division_id: 6, - expiration_date: "2024-09-01T00:00:00.000000Z", - external_authorizer_id: null, - funding_sources: [26], - id: 502, - managing_portfolio: 8, - portfolio_id: 8, - nick_name: "SSRD", - number: "G99PHS9", - projects: [], - shared_portfolios: [], - updated_by: null, - updated_by_user: null, - updated_on: "2024-07-29T14:44:58.757452Z", - versions: [ - { - id: 502, - transaction_id: 208 - } - ] - }, + cans: [{ + can: { + appropriation_date: "2023-10-01T00:00:00.000000Z", + active_period: 1, + arrangement_type: "OPRE_APPROPRIATION", + authorizer: 26, + authorizer_id: 26, + budget_line_items: [15011, 15017, 15020], + can_type: null, + created_by: null, + created_by_user: null, + created_on: "2024-07-29T14:44:58.757452Z", + description: "Social Science Research and Development", + display_name: "G99PHS9", + division_id: 6, + expiration_date: "2024-09-01T00:00:00.000000Z", + external_authorizer_id: null, + funding_sources: [26], + id: 502, + managing_portfolio: 8, + portfolio_id: 8, + nick_name: "SSRD", + number: "G99PHS9", + projects: [], + shared_portfolios: [], + updated_by: null, + updated_by_user: null, + updated_on: "2024-07-29T14:44:58.757452Z", + versions: [ + { + id: 502, + transaction_id: 208 + } + ] + }, + carry_forward_label: "Carry-Forward", + expiration_date: "09/01/2024", + }], carry_forward_funding: 0, - carry_forward_label: "Carry-Forward", expected_funding: "5000000.00", - expiration_date: "09/01/2024", + in_draft_funding: 0, in_execution_funding: "2000000.00", + new_funding: 0, obligated_funding: 0, planned_funding: "7700000.00", received_funding: "19000000.00", @@ -1285,44 +1289,46 @@ const canFundingCardData = { const canFundingCardData2 = { available_funding: "1979500.00", - can: { - appropriation_date: "2022-10-01T00:00:00.000000Z", - active_period: 1, - arrangement_type: "OPRE_APPROPRIATION", - authorizer: 26, - authorizer_id: 26, - budget_line_items: [15018, 15021], - can_type: null, - created_by: null, - created_by_user: null, - created_on: "2024-07-29T14:44:58.941288Z", - description: "Example CAN", - display_name: "G99XXX8", - division_id: 4, - expiration_date: "2023-09-01T00:00:00.000000Z", - external_authorizer_id: null, - funding_sources: [26], - id: 512, - managing_portfolio: 3, - portfolio_id: 3, - nick_name: "", - number: "G99XXX8", - projects: [1000], - shared_portfolios: [], - updated_by: null, - updated_by_user: null, - updated_on: "2024-07-29T14:44:58.941288Z", - versions: [ - { - id: 512, - transaction_id: 229 - } - ] - }, + cans: [{ + can: { + appropriation_date: "2022-10-01T00:00:00.000000Z", + active_period: 1, + arrangement_type: "OPRE_APPROPRIATION", + authorizer: 26, + authorizer_id: 26, + budget_line_items: [15018, 15021], + can_type: null, + created_by: null, + created_by_user: null, + created_on: "2024-07-29T14:44:58.941288Z", + description: "Example CAN", + display_name: "G99XXX8", + division_id: 4, + expiration_date: "2023-09-01T00:00:00.000000Z", + external_authorizer_id: null, + funding_sources: [26], + id: 512, + managing_portfolio: 3, + portfolio_id: 3, + nick_name: "", + number: "G99XXX8", + projects: [1000], + shared_portfolios: [], + updated_by: null, + updated_by_user: null, + updated_on: "2024-07-29T14:44:58.941288Z", + versions: [ + { + id: 512, + transaction_id: 229 + } + ] + }, + carry_forward_label: "Carry-Forward", + expiration_date: "09/01/2023", + }], carry_forward_funding: 0, - carry_forward_label: "Carry-Forward", expected_funding: "520000.00", - expiration_date: "09/01/2023", in_execution_funding: 0, obligated_funding: "500.00", planned_funding: "300000.00", @@ -1332,44 +1338,46 @@ const canFundingCardData2 = { const canFundingCard_G994426 = { available_funding: "37000000.00", - can: { - appropriation_date: "2023-10-01T00:00:00.000000Z", - active_period: 1, - arrangement_type: "OPRE_APPROPRIATION", - authorizer: 26, - authorizer_id: 26, - budget_line_items: [15000, 15001, 15012, 15022, 15023], - can_type: null, - created_by: null, - created_by_user: null, - created_on: "2024-08-02T13:45:56.155989Z", - description: "Head Start Research", - display_name: "G994426", - division_id: 4, - expiration_date: "2024-09-01T00:00:00.000000Z", - external_authorizer_id: null, - funding_sources: [26], - id: 504, - managing_portfolio: 2, - portfolio_id: 2, - nick_name: "HS", - number: "G994426", - projects: [], - shared_portfolios: [], - updated_by: null, - updated_by_user: null, - updated_on: "2024-08-02T13:45:56.155989Z", - versions: [ - { - id: 504, - transaction_id: 212 - } - ] - }, + cans: [{ + can: { + appropriation_date: "2023-10-01T00:00:00.000000Z", + active_period: 1, + arrangement_type: "OPRE_APPROPRIATION", + authorizer: 26, + authorizer_id: 26, + budget_line_items: [15000, 15001, 15012, 15022, 15023], + can_type: null, + created_by: null, + created_by_user: null, + created_on: "2024-08-02T13:45:56.155989Z", + description: "Head Start Research", + display_name: "G994426", + division_id: 4, + expiration_date: "2024-09-01T00:00:00.000000Z", + external_authorizer_id: null, + funding_sources: [26], + id: 504, + managing_portfolio: 2, + portfolio_id: 2, + nick_name: "HS", + number: "G994426", + projects: [], + shared_portfolios: [], + updated_by: null, + updated_by_user: null, + updated_on: "2024-08-02T13:45:56.155989Z", + versions: [ + { + id: 504, + transaction_id: 212 + } + ] + }, + carry_forward_label: "Carry-Forward", + expiration_date: "09/01/2024", + }], carry_forward_funding: 0, - carry_forward_label: "Carry-Forward", expected_funding: "16000000.00", - expiration_date: "09/01/2024", in_execution_funding: "2000000.00", obligated_funding: 0, planned_funding: "1000000.00", diff --git a/frontend/src/components/CANs/CANFundingCard/CANFundingCard.jsx b/frontend/src/components/CANs/CANFundingCard/CANFundingCard.jsx index d2dade6706..a86922acb8 100644 --- a/frontend/src/components/CANs/CANFundingCard/CANFundingCard.jsx +++ b/frontend/src/components/CANs/CANFundingCard/CANFundingCard.jsx @@ -29,7 +29,7 @@ const CANFundingCard = ({ can, pendingAmount, afterApproval }) => { return
An error occurred loading CAN funding data
; } - const title = `${data.can.number}-${data.can.active_period}Y`; + const title = `${data?.cans?.[0]?.can?.number}-${data?.cans?.[0]?.can?.active_period}Y`; const totalFunding = Number(data.total_funding); const availableFunding = Number(data.available_funding); const totalAccountedFor = totalFunding - availableFunding; // same as adding planned, obligated, in_execution diff --git a/frontend/src/components/CANs/CANFundingCard/CanFundingCard.test.jsx b/frontend/src/components/CANs/CANFundingCard/CanFundingCard.test.jsx index 39e8a3bfbc..481d780bb1 100644 --- a/frontend/src/components/CANs/CANFundingCard/CanFundingCard.test.jsx +++ b/frontend/src/components/CANs/CANFundingCard/CanFundingCard.test.jsx @@ -55,45 +55,49 @@ const canData = { const canFundingCardData = { available_funding: "14300000.00", - can: { - appropriation_date: "2023-10-01T00:00:00.000000Z", - active_period: 1, - arrangement_type: "OPRE_APPROPRIATION", - authorizer: 26, - authorizer_id: 26, - budget_line_items: [15011, 15017, 15020], - can_type: null, - created_by: null, - created_by_user: null, - created_on: "2024-07-29T14:44:58.757452Z", - description: "Social Science Research and Development", - display_name: "G99PHS9", - division_id: 6, - expiration_date: "2024-09-01T00:00:00.000000Z", - external_authorizer_id: null, - funding_sources: [26], - id: 502, - managing_portfolio: 8, - portfolio_id: 8, - nick_name: "SSRD", - number: "G99PHS9", - projects: [], - shared_portfolios: [], - updated_by: null, - updated_by_user: null, - updated_on: "2024-07-29T14:44:58.757452Z", - versions: [ - { - id: 502, - transaction_id: 208 - } - ] - }, + cans: [{ + can: { + appropriation_date: "2023-10-01T00:00:00.000000Z", + active_period: 1, + arrangement_type: "OPRE_APPROPRIATION", + authorizer: 26, + authorizer_id: 26, + budget_line_items: [15011, 15017, 15020], + can_type: null, + created_by: null, + created_by_user: null, + created_on: "2024-07-29T14:44:58.757452Z", + description: "Social Science Research and Development", + display_name: "G99PHS9", + division_id: 6, + expiration_date: "2024-09-01T00:00:00.000000Z", + external_authorizer_id: null, + funding_sources: [26], + id: 502, + managing_portfolio: 8, + portfolio_id: 8, + nick_name: "SSRD", + number: "G99PHS9", + projects: [], + shared_portfolios: [], + updated_by: null, + updated_by_user: null, + updated_on: "2024-07-29T14:44:58.757452Z", + versions: [ + { + id: 502, + transaction_id: 208 + } + ] + }, + carry_forward_label: "Carry-Forward", + expiration_date: "09/01/2024", + }], carry_forward_funding: 0, - carry_forward_label: "Carry-Forward", expected_funding: "5000000.00", - expiration_date: "09/01/2024", + in_draft_funding: 0, in_execution_funding: "2000000.00", + new_funding: 0, obligated_funding: 0, planned_funding: "7700000.00", received_funding: "19000000.00", diff --git a/frontend/src/components/CANs/CanCard/CanCard.jsx b/frontend/src/components/CANs/CanCard/CanCard.jsx index b6ad0825d2..200b80074b 100644 --- a/frontend/src/components/CANs/CanCard/CanCard.jsx +++ b/frontend/src/components/CANs/CanCard/CanCard.jsx @@ -26,6 +26,7 @@ const CanCard = ({ can, fiscalYear }) => { /* State */ const [canFundingData, setCanFundingDataLocal] = useState({}); + const [canLocalData, setCanLocalData] = useState({}); const [percent, setPercent] = useState(""); const [hoverId, setHoverId] = useState(""); @@ -63,12 +64,14 @@ const CanCard = ({ can, fiscalYear }) => { const getCanTotalFundingandSetState = async () => { const results = await getPortfolioCansFundingDetails({ id: can.id, fiscalYear: fiscalYear }); setCanFundingDataLocal(results); + setCanLocalData(results.cans[0]); }; getCanTotalFundingandSetState().catch(console.error); return () => { setCanFundingDataLocal({}); + setCanLocalData({}); }; }, [can.id, fiscalYear]); @@ -127,7 +130,7 @@ const CanCard = ({ can, fiscalYear }) => {
Expiration
-
{canFundingData?.expiration_date || "---"}
+
{canLocalData?.expiration_date || "---"}
@@ -140,7 +143,7 @@ const CanCard = ({ can, fiscalYear }) => { received_funding={canFundingData?.received_funding} expected_funding={canFundingData?.expected_funding} carry_forward_funding={canFundingData?.carry_forward_funding} - carry_forward_label={canFundingData?.carry_forward_label} + carry_forward_label={canLocalData?.carry_forward_label} />
{/* NOTE: RIGHT SIDE */} diff --git a/frontend/src/components/CANs/CanCard/CanCard.test.jsx b/frontend/src/components/CANs/CanCard/CanCard.test.jsx index 5bb8a197b7..806e812fb3 100644 --- a/frontend/src/components/CANs/CanCard/CanCard.test.jsx +++ b/frontend/src/components/CANs/CanCard/CanCard.test.jsx @@ -92,45 +92,49 @@ const mockFiscalYear = 2023; const mockCanFundingData = { available_funding: "7000000.00", - can: { - appropriation_date: "2023-10-01T00:00:00.000000Z", - active_period: 1, - arrangement_type: "OPRE_APPROPRIATION", - authorizer: 26, - authorizer_id: 26, - budget_line_items: [15012, 15022, 15023, 15001, 15000], - can_type: null, - created_by: null, - created_by_user: null, - created_on: "2024-08-22T13:24:43.428178Z", - description: "Head Start Research", - display_name: "G994426", - division_id: 4, - expiration_date: "2024-09-01T00:00:00.000000Z", - external_authorizer_id: null, - funding_sources: [26], - id: 504, - managing_portfolio: 2, - portfolio_id: 2, - nick_name: "HS", - number: "G994426", - projects: [], - shared_portfolios: [], - updated_by: null, - updated_by_user: null, - updated_on: "2024-08-22T13:24:43.428178Z", - versions: [ - { - id: 504, - transaction_id: 216 - } - ] - }, + cans: [{ + can: { + appropriation_date: "2023-10-01T00:00:00.000000Z", + active_period: 1, + arrangement_type: "OPRE_APPROPRIATION", + authorizer: 26, + authorizer_id: 26, + budget_line_items: [15012, 15022, 15023, 15001, 15000], + can_type: null, + created_by: null, + created_by_user: null, + created_on: "2024-08-22T13:24:43.428178Z", + description: "Head Start Research", + display_name: "G994426", + division_id: 4, + expiration_date: "2024-09-01T00:00:00.000000Z", + external_authorizer_id: null, + funding_sources: [26], + id: 504, + managing_portfolio: 2, + portfolio_id: 2, + nick_name: "HS", + number: "G994426", + projects: [], + shared_portfolios: [], + updated_by: null, + updated_by_user: null, + updated_on: "2024-08-22T13:24:43.428178Z", + versions: [ + { + id: 504, + transaction_id: 216 + } + ] + }, + carry_forward_label: "Carry-Forward", + expiration_date: "09/01/2024", + }], carry_forward_funding: 0, - carry_forward_label: "Carry-Forward", expected_funding: "4000000.00", - expiration_date: "09/01/2024", + in_draft_funding: 0, in_execution_funding: "2000000.00", + new_funding: 0, obligated_funding: 0, planned_funding: "1000000.00", received_funding: "6000000.00", From e68f1d6017fa9adc4ba91be55fc8a18a1a4a24bd Mon Sep 17 00:00:00 2001 From: maiyerlee Date: Mon, 18 Nov 2024 11:36:41 -0600 Subject: [PATCH 03/15] feat: use funding details to get new funding w/o given FY --- backend/ops_api/ops/utils/cans.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/ops_api/ops/utils/cans.py b/backend/ops_api/ops/utils/cans.py index ccd17a3666..62eef07069 100644 --- a/backend/ops_api/ops/utils/cans.py +++ b/backend/ops_api/ops/utils/cans.py @@ -40,7 +40,7 @@ def get_funding_by_budget_line_item_status( return sum([bli.amount for bli in can.budget_line_items if bli.status == status]) or 0 -def get_new_funding_by_fiscal_year_on_funding_details(can: CAN): +def get_new_funding_by_funding_details(can: CAN) -> Optional[float]: # Get the fiscal year on the can's FundingDetails object fiscal_year = can.funding_details.fiscal_year @@ -60,13 +60,13 @@ def get_new_funding_by_fiscal_year_on_funding_details(can: CAN): return total_funding or None # Return the total, or None if it's zero -def get_new_funding_by_fiscal_year(can: CAN, fiscal_year: Optional[int] = None) -> float: +def get_new_funding_by_fiscal_year(can: CAN, fiscal_year: Optional[int] = None) -> Optional[float]: # check to see if the CAN has an active period of 1 if can.active_period == 1: return sum([c.budget for c in can.funding_budgets if c.fiscal_year == fiscal_year]) or 0 elif can.active_period == 5: # Check to see if the CAN is in it first year - return get_new_funding_by_fiscal_year_on_funding_details(can) + return get_new_funding_by_funding_details(can) else: pass @@ -79,15 +79,14 @@ def get_can_funding_summary(can: CAN, fiscal_year: Optional[int] = None) -> CanF carry_forward_funding = sum([c.budget for c in can.funding_budgets[1:] if c.fiscal_year == fiscal_year]) or 0 + new_funding = get_new_funding_by_fiscal_year(can, fiscal_year) + planned_funding = get_funding_by_budget_line_item_status(can, BudgetLineItemStatus.PLANNED, fiscal_year) obligated_funding = get_funding_by_budget_line_item_status(can, BudgetLineItemStatus.OBLIGATED, fiscal_year) in_execution_funding = get_funding_by_budget_line_item_status( can, BudgetLineItemStatus.IN_EXECUTION, fiscal_year ) in_draft_funding = get_funding_by_budget_line_item_status(can, BudgetLineItemStatus.DRAFT, fiscal_year) - - new_funding = get_new_funding_by_fiscal_year(can, fiscal_year) - else: received_funding = sum([c.funding for c in can.funding_received]) or 0 @@ -95,7 +94,7 @@ def get_can_funding_summary(can: CAN, fiscal_year: Optional[int] = None) -> CanF carry_forward_funding = sum([c.budget for c in can.funding_budgets[1:]]) or 0 - new_funding = sum([c.budget for c in can.funding_budgets[:1]]) or 0 + new_funding = get_new_funding_by_funding_details(can) planned_funding = get_funding_by_budget_line_item_status(can, BudgetLineItemStatus.PLANNED, None) obligated_funding = get_funding_by_budget_line_item_status(can, BudgetLineItemStatus.OBLIGATED, None) From b7f1898c8799a8cbd3c25e6bca63db8abf4d5497 Mon Sep 17 00:00:00 2001 From: maiyerlee Date: Mon, 18 Nov 2024 11:55:50 -0600 Subject: [PATCH 04/15] feat: handle new funding for multiyear cans --- backend/ops_api/ops/utils/cans.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/ops_api/ops/utils/cans.py b/backend/ops_api/ops/utils/cans.py index 62eef07069..71fc69ecc4 100644 --- a/backend/ops_api/ops/utils/cans.py +++ b/backend/ops_api/ops/utils/cans.py @@ -64,11 +64,9 @@ def get_new_funding_by_fiscal_year(can: CAN, fiscal_year: Optional[int] = None) # check to see if the CAN has an active period of 1 if can.active_period == 1: return sum([c.budget for c in can.funding_budgets if c.fiscal_year == fiscal_year]) or 0 - elif can.active_period == 5: + else: # Check to see if the CAN is in it first year return get_new_funding_by_funding_details(can) - else: - pass def get_can_funding_summary(can: CAN, fiscal_year: Optional[int] = None) -> CanFundingSummary: From e61e48cf71a35357b5dbf4b4018fe7b4ff1846f4 Mon Sep 17 00:00:00 2001 From: maiyerlee Date: Wed, 20 Nov 2024 00:40:13 -0600 Subject: [PATCH 05/15] fix: remove extra ? --- frontend/src/api/opsAPI.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/api/opsAPI.js b/frontend/src/api/opsAPI.js index 5dda9bcd22..081ba007b2 100644 --- a/frontend/src/api/opsAPI.js +++ b/frontend/src/api/opsAPI.js @@ -205,7 +205,7 @@ export const opsApi = createApi({ }), getCanFundingSummary: builder.query({ query: ({ id, fiscalYear }) => - `/can-funding-summary?can_ids=${id}?${fiscalYear ? `?fiscal_year=${fiscalYear}` : ""}`, + `/can-funding-summary?can_ids=${id}${fiscalYear ? `?fiscal_year=${fiscalYear}` : ""}`, providesTags: ["CanFunding"] }), getNotificationsByUserId: builder.query({ From 2dbe997e35adc5de0b6142822cfec9de6e892a55 Mon Sep 17 00:00:00 2001 From: maiyerlee Date: Wed, 20 Nov 2024 01:00:50 -0600 Subject: [PATCH 06/15] fix: use correct symbol in query param chaining --- frontend/src/api/opsAPI.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/api/opsAPI.js b/frontend/src/api/opsAPI.js index 081ba007b2..0880bea49c 100644 --- a/frontend/src/api/opsAPI.js +++ b/frontend/src/api/opsAPI.js @@ -205,7 +205,7 @@ export const opsApi = createApi({ }), getCanFundingSummary: builder.query({ query: ({ id, fiscalYear }) => - `/can-funding-summary?can_ids=${id}${fiscalYear ? `?fiscal_year=${fiscalYear}` : ""}`, + `/can-funding-summary?can_ids=${id}${fiscalYear ? `&fiscal_year=${fiscalYear}` : ""}`, providesTags: ["CanFunding"] }), getNotificationsByUserId: builder.query({ From 946f83456200d11590b46ed3e30768a1bb6748c1 Mon Sep 17 00:00:00 2001 From: Guillermo Santiago III <115905549+Santi-3rd@users.noreply.github.com> Date: Wed, 20 Nov 2024 15:45:50 -0700 Subject: [PATCH 07/15] chore: integrate can-funding-summary endpoint (#3112) --- backend/openapi.yml | 2 +- .../ops/resources/can_funding_summary.py | 11 + .../test_can_funding_summary.py | 879 +++++++++--------- frontend/src/api/opsAPI.js | 13 +- .../components/CANs/CANTable/CANTableRow.jsx | 2 +- frontend/src/pages/cans/list/CanList.jsx | 15 +- 6 files changed, 489 insertions(+), 433 deletions(-) diff --git a/backend/openapi.yml b/backend/openapi.yml index bbb16111bb..dff576c2b9 100644 --- a/backend/openapi.yml +++ b/backend/openapi.yml @@ -711,7 +711,7 @@ paths: summary: Get a list of all the CAN funding budgets parameters: - $ref: "#/components/parameters/simulatedError" - description: Get CANFundingBudgetss + description: Get CANFundingBudgets responses: "200": description: OK diff --git a/backend/ops_api/ops/resources/can_funding_summary.py b/backend/ops_api/ops/resources/can_funding_summary.py index e84193da53..c564427604 100644 --- a/backend/ops_api/ops/resources/can_funding_summary.py +++ b/backend/ops_api/ops/resources/can_funding_summary.py @@ -26,6 +26,17 @@ def get(self) -> Response: if not can_ids: return make_response_with_headers({"error": "'can_ids' parameter is required"}, 400) + if can_ids == ["0"]: + cans = self._get_all_items() + cans_with_filters = get_filtered_cans(cans, active_period, transfer, portfolio, fy_budget) + can_funding_summaries = [ + get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) for can in cans_with_filters + ] + + aggregated_summary = aggregate_funding_summaries(can_funding_summaries) + + return make_response_with_headers(aggregated_summary) + # Handle case when a single 'can_id' is provided with no additional filters if len(can_ids) == 1 and not (active_period or transfer or portfolio or fy_budget): return self._handle_single_can_no_filters(can_ids[0], fiscal_year) diff --git a/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py b/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py index 5e0c7d98f8..be22a0b118 100644 --- a/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py +++ b/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py @@ -1,20 +1,23 @@ -from decimal import Decimal +# from decimal import Decimal from typing import Type -from unittest.mock import MagicMock import pytest from flask.testing import FlaskClient from models.cans import CAN -from ops_api.ops.utils.cans import ( - aggregate_funding_summaries, - filter_by_attribute, - filter_by_fiscal_year_budget, - get_can_funding_summary, - get_filtered_cans, - get_nested_attribute, -) -from ops_api.tests.utils import remove_keys + +# from unittest.mock import MagicMock + + +# from ops_api.ops.utils.cans import ( +# aggregate_funding_summaries, +# filter_by_attribute, +# filter_by_fiscal_year_budget, +# get_can_funding_summary, +# get_filtered_cans, +# get_nested_attribute, +# ) +# from ops_api.tests.utils import remove_keys class DummyObject: @@ -27,433 +30,457 @@ def __init__(self): self.value = "test_value" -@pytest.mark.usefixtures("app_ctx") -def test_get_can_funding_summary_no_fiscal_year(loaded_db, test_can) -> None: - result = get_can_funding_summary(test_can) - - # Remove these because they are set according to when the test was run - remove_keys(result, ["created_on", "updated_on", "versions"]) - - assert result == { - "available_funding": Decimal("-860000.00"), - "cans": [ - { - "can": { - "active_period": 1, - "appropriation_date": 2023, - "budget_line_items": [15008], - "created_by": None, - "created_by_user": None, - "description": "Healthy Marriages Responsible Fatherhood - OPRE", - "display_name": "G99HRF2", - "expiration_date": 2024, - "funding_budgets": [ - { - "budget": "1140000.0", - "can": 500, - "can_id": 500, - "created_by": None, - "created_by_user": None, - "display_name": "CANFundingBudget#1", - "fiscal_year": 2023, - "id": 1, - "notes": None, - "updated_by": None, - "updated_by_user": None, - } - ], - "funding_details": { - "allotment": None, - "allowance": None, - "appropriation": None, - "created_by": None, - "created_by_user": None, - "display_name": "CANFundingDetails#1", - "fiscal_year": 2023, - "fund_code": "AAXXXX20231DAD", - "funding_partner": None, - "funding_source": "OPRE", - "id": 1, - "method_of_transfer": "DIRECT", - "sub_allowance": None, - "updated_by": None, - "updated_by_user": None, - }, - "funding_details_id": 1, - "funding_received": [ - { - "can": 500, - "can_id": 500, - "created_by": None, - "created_by_user": None, - "display_name": "CANFundingReceived#500", - "fiscal_year": 2023, - "funding": "880000.0", - "id": 500, - "notes": None, - "updated_by": None, - "updated_by_user": None, - } - ], - "id": 500, - "nick_name": "HMRF-OPRE", - "number": "G99HRF2", - "portfolio": 6, - "portfolio_id": 6, - "projects": [1000], - "updated_by": None, - "updated_by_user": None, - }, - "carry_forward_label": " Carry-Forward", - "expiration_date": "10/01/2024", - } - ], - "carry_forward_funding": 0, - "expected_funding": Decimal("260000.0"), - "in_draft_funding": 0, - "in_execution_funding": Decimal("2000000.00"), - "new_funding": Decimal("1140000.0"), - "obligated_funding": 0, - "planned_funding": 0, - "received_funding": Decimal("880000.0"), - "total_funding": Decimal("1140000.0"), - } - - -@pytest.mark.usefixtures("app_ctx") -@pytest.mark.usefixtures("loaded_db") -def test_get_can_funding_summary_with_fiscal_year(loaded_db, test_can) -> None: - result = get_can_funding_summary(test_can, 2023) - - # Remove these because they are set according to when the test was run - remove_keys(result, ["created_on", "updated_on", "versions"]) - - assert result == { - "available_funding": Decimal("1140000.0"), - "cans": [ - { - "can": { - "active_period": 1, - "appropriation_date": 2023, - "budget_line_items": [15008], - "created_by": None, - "created_by_user": None, - "description": "Healthy Marriages Responsible Fatherhood - OPRE", - "display_name": "G99HRF2", - "expiration_date": 2024, - "funding_budgets": [ - { - "budget": "1140000.0", - "can": 500, - "can_id": 500, - "created_by": None, - "created_by_user": None, - "display_name": "CANFundingBudget#1", - "fiscal_year": 2023, - "id": 1, - "notes": None, - "updated_by": None, - "updated_by_user": None, - } - ], - "funding_details": { - "allotment": None, - "allowance": None, - "appropriation": None, - "created_by": None, - "created_by_user": None, - "display_name": "CANFundingDetails#1", - "fiscal_year": 2023, - "fund_code": "AAXXXX20231DAD", - "funding_partner": None, - "funding_source": "OPRE", - "id": 1, - "method_of_transfer": "DIRECT", - "sub_allowance": None, - "updated_by": None, - "updated_by_user": None, - }, - "funding_details_id": 1, - "funding_received": [ - { - "can": 500, - "can_id": 500, - "created_by": None, - "created_by_user": None, - "display_name": "CANFundingReceived#500", - "fiscal_year": 2023, - "funding": "880000.0", - "id": 500, - "notes": None, - "updated_by": None, - "updated_by_user": None, - } - ], - "id": 500, - "nick_name": "HMRF-OPRE", - "number": "G99HRF2", - "portfolio": 6, - "portfolio_id": 6, - "projects": [1000], - "updated_by": None, - "updated_by_user": None, - }, - "carry_forward_label": " Carry-Forward", - "expiration_date": "10/01/2024", - } - ], - "carry_forward_funding": 0, - "in_draft_funding": Decimal("0.0"), - "expected_funding": Decimal("260000.0"), - "in_execution_funding": 0, - "obligated_funding": 0, - "planned_funding": 0, - "received_funding": Decimal("880000.0"), - "total_funding": Decimal("1140000.0"), - "new_funding": Decimal("1140000.0") - 0, - } +# @pytest.mark.usefixtures("app_ctx") +# def test_get_can_funding_summary_no_fiscal_year(loaded_db, test_can) -> None: +# result = get_can_funding_summary(test_can) +# +# # Remove these because they are set according to when the test was run +# remove_keys(result, ["created_on", "updated_on", "versions"]) +# +# assert result == { +# "available_funding": Decimal("-860000.00"), +# "cans": [ +# { +# "can": { +# "active_period": 1, +# "appropriation_date": 2023, +# "budget_line_items": [15008], +# "created_by": None, +# "created_by_user": None, +# "description": "Healthy Marriages Responsible Fatherhood - OPRE", +# "display_name": "G99HRF2", +# "expiration_date": 2024, +# "funding_budgets": [ +# { +# "budget": "1140000.0", +# "can": 500, +# "can_id": 500, +# "created_by": None, +# "created_by_user": None, +# "display_name": "CANFundingBudget#1", +# "fiscal_year": 2023, +# "id": 1, +# "notes": None, +# "updated_by": None, +# "updated_by_user": None, +# } +# ], +# "funding_details": { +# "allotment": None, +# "allowance": None, +# "appropriation": None, +# "created_by": None, +# "created_by_user": None, +# "display_name": "CANFundingDetails#1", +# "fiscal_year": 2023, +# "fund_code": "AAXXXX20231DAD", +# "funding_partner": None, +# "funding_source": "OPRE", +# "id": 1, +# "method_of_transfer": "DIRECT", +# "sub_allowance": None, +# "updated_by": None, +# "updated_by_user": None, +# }, +# "funding_details_id": 1, +# "funding_received": [ +# { +# "can": 500, +# "can_id": 500, +# "created_by": None, +# "created_by_user": None, +# "display_name": "CANFundingReceived#500", +# "fiscal_year": 2023, +# "funding": "880000.0", +# "id": 500, +# "notes": None, +# "updated_by": None, +# "updated_by_user": None, +# } +# ], +# "id": 500, +# "nick_name": "HMRF-OPRE", +# "number": "G99HRF2", +# "portfolio": 6, +# "portfolio_id": 6, +# "projects": [1000], +# "updated_by": None, +# "updated_by_user": None, +# }, +# "carry_forward_label": " Carry-Forward", +# "expiration_date": "10/01/2024", +# } +# ], +# "carry_forward_funding": 0, +# "expected_funding": Decimal("260000.0"), +# "in_draft_funding": 0, +# "in_execution_funding": Decimal("2000000.00"), +# "new_funding": Decimal("1140000.0"), +# "obligated_funding": 0, +# "planned_funding": 0, +# "received_funding": Decimal("880000.0"), +# "total_funding": Decimal("1140000.0"), +# } +# +# +# @pytest.mark.usefixtures("app_ctx") +# @pytest.mark.usefixtures("loaded_db") +# def test_get_can_funding_summary_with_fiscal_year(loaded_db, test_can) -> None: +# result = get_can_funding_summary(test_can, 2023) +# +# # Remove these because they are set according to when the test was run +# remove_keys(result, ["created_on", "updated_on", "versions"]) +# +# assert result == { +# "available_funding": Decimal("1140000.0"), +# "cans": [ +# { +# "can": { +# "active_period": 1, +# "appropriation_date": 2023, +# "budget_line_items": [15008], +# "created_by": None, +# "created_by_user": None, +# "description": "Healthy Marriages Responsible Fatherhood - OPRE", +# "display_name": "G99HRF2", +# "expiration_date": 2024, +# "funding_budgets": [ +# { +# "budget": "1140000.0", +# "can": 500, +# "can_id": 500, +# "created_by": None, +# "created_by_user": None, +# "display_name": "CANFundingBudget#1", +# "fiscal_year": 2023, +# "id": 1, +# "notes": None, +# "updated_by": None, +# "updated_by_user": None, +# } +# ], +# "funding_details": { +# "allotment": None, +# "allowance": None, +# "appropriation": None, +# "created_by": None, +# "created_by_user": None, +# "display_name": "CANFundingDetails#1", +# "fiscal_year": 2023, +# "fund_code": "AAXXXX20231DAD", +# "funding_partner": None, +# "funding_source": "OPRE", +# "id": 1, +# "method_of_transfer": "DIRECT", +# "sub_allowance": None, +# "updated_by": None, +# "updated_by_user": None, +# }, +# "funding_details_id": 1, +# "funding_received": [ +# { +# "can": 500, +# "can_id": 500, +# "created_by": None, +# "created_by_user": None, +# "display_name": "CANFundingReceived#500", +# "fiscal_year": 2023, +# "funding": "880000.0", +# "id": 500, +# "notes": None, +# "updated_by": None, +# "updated_by_user": None, +# } +# ], +# "id": 500, +# "nick_name": "HMRF-OPRE", +# "number": "G99HRF2", +# "portfolio": 6, +# "portfolio_id": 6, +# "projects": [1000], +# "updated_by": None, +# "updated_by_user": None, +# }, +# "carry_forward_label": " Carry-Forward", +# "expiration_date": "10/01/2024", +# } +# ], +# "carry_forward_funding": 0, +# "in_draft_funding": Decimal("0.0"), +# "expected_funding": Decimal("260000.0"), +# "in_execution_funding": 0, +# "obligated_funding": 0, +# "planned_funding": 0, +# "received_funding": Decimal("880000.0"), +# "total_funding": Decimal("1140000.0"), +# "new_funding": Decimal("1140000.0") - 0, +# } +# +# +# @pytest.mark.usefixtures("app_ctx") +# @pytest.mark.usefixtures("loaded_db") +# def test_can_get_can_funding_summary(auth_client: FlaskClient, test_can: CAN) -> None: +# query_params = f"can_ids={test_can.id}" +# +# response = auth_client.get(f"/api/v1/can-funding-summary?{query_params}") +# +# assert response.status_code == 200 +# assert response.json["cans"][0]["can"]["id"] == test_can.id +# assert "new_funding" in response.json +# assert isinstance(response.json["new_funding"], str) +# assert "expiration_date" in response.json["cans"][0] +# assert "carry_forward_label" in response.json["cans"][0] +# +# +# @pytest.mark.usefixtures("app_ctx") +# @pytest.mark.usefixtures("loaded_db") +# def test_cans_get_can_funding_summary(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: +# url = f"/api/v1/can-funding-summary?can_ids={test_cans[0].id}&can_ids={test_cans[1].id}" +# +# response = auth_client.get(url) +# +# available_funding = response.json["available_funding"] +# carry_forward_funding = response.json["carry_forward_funding"] +# +# assert response.status_code == 200 +# assert len(response.json["cans"]) == 2 +# +# assert available_funding == "3340000.00" +# assert carry_forward_funding == "10000000.0" +# assert response.json["new_funding"] == "1340000.0" +# +# +# @pytest.mark.usefixtures("app_ctx") +# @pytest.mark.usefixtures("loaded_db") +# def test_can_get_can_funding_summary_filter(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: +# url = f"/api/v1/can-funding-summary?" f"can_ids={test_cans[0].id}&can_ids={test_cans[1].id}&" f"active_period=1" +# +# response = auth_client.get(url) +# +# assert response.status_code == 200 +# assert len(response.json["cans"]) == 1 +# assert response.json["cans"][0]["can"]["active_period"] == 1 +# +# +# @pytest.mark.usefixtures("app_ctx") +# @pytest.mark.usefixtures("loaded_db") +# def test_can_get_can_funding_summary_complete_filter(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: +# url = ( +# f"/api/v1/can-funding-summary?" +# f"can_ids={test_cans[0].id}&can_ids={test_cans[1].id}&" +# f"fiscal_year=2024&" +# f"active_period=1&active_period=5&" +# f"transfer=DIRECT&transfer=IAA&" +# f"portfolio=HS&portfolio=HMRF&" +# f"fy_budget=50000&fy_budget=100000" +# ) +# +# response = auth_client.get(url) +# +# assert response.status_code == 200 +# assert len(response.json["cans"]) == 0 +# assert "new_funding" in response.json +# assert response.json["obligated_funding"] == "0.0" +# +# +# def test_get_nested_attribute_existing_attribute(): +# obj = DummyObject() +# result = get_nested_attribute(obj, "nested.value") +# assert result == "test_value" +# +# +# def test_get_nested_attribute_non_existing_attribute(): +# obj = DummyObject() +# result = get_nested_attribute(obj, "nested.non_existing") +# assert result is None +# +# +# def test_get_nested_attribute_non_existing_top_level(): +# obj = DummyObject() +# result = get_nested_attribute(obj, "non_existing") +# assert result is None +# +# +# def test_filter_cans_by_attribute(): +# cans = [ +# MagicMock(active_period=1, funding_details=MagicMock(method_of_transfer="DIRECT")), +# MagicMock(active_period=2, funding_details=MagicMock(method_of_transfer="IAA")), +# MagicMock(active_period=1, funding_details=MagicMock(method_of_transfer="DIRECT")), +# ] +# +# filtered_cans = filter_by_attribute(cans, "funding_details.method_of_transfer", ["DIRECT"]) +# +# assert len(filtered_cans) == 2 +# +# +# def test_filter_cans_by_fiscal_year_budget(): +# cans = [ +# MagicMock(funding_budgets=[MagicMock(budget=1000001.0)]), +# MagicMock(funding_budgets=[MagicMock(budget=2000000.0)]), +# MagicMock(funding_budgets=[MagicMock(budget=500000.0)]), +# ] +# +# fiscal_year_budget = [1000000, 2000000] +# filtered_cans = filter_by_fiscal_year_budget(cans, fiscal_year_budget) +# +# assert len(filtered_cans) == 2 +# +# +# def test_filter_cans_by_fiscal_year_budget_no_match(): +# cans = [ +# MagicMock(funding_budgets=[MagicMock(budget=500000.0, fiscal_year=2023)]), +# MagicMock(funding_budgets=[MagicMock(budget=7000000.0, fiscal_year=2024)]), +# ] +# +# fiscal_year_budget = [1000000, 2000000] +# filtered_cans = filter_by_fiscal_year_budget(cans, fiscal_year_budget) +# +# assert len(filtered_cans) == 0 +# +# +# @pytest.mark.parametrize( +# "active_period, transfer, portfolio, fy_budget, expected_count", +# [ +# (None, None, None, None, 3), +# ([1], None, None, None, 2), +# (None, ["DIRECT"], None, None, 1), +# (None, None, None, [100000, 200000], 2), +# ([1], ["IAA"], ["HS"], [100000, 200000], 0), +# ], +# ) +# def test_filter_cans(active_period, transfer, portfolio, fy_budget, expected_count): +# cans = [ +# MagicMock( +# active_period=1, +# funding_details=MagicMock(method_of_transfer="DIRECT"), +# portfolios=[MagicMock(abbr="HS")], +# funding_budgets=[MagicMock(budget=150000)], +# ), +# MagicMock( +# active_period=2, +# funding_details=MagicMock(method_of_transfer="IAA"), +# portfolios=[MagicMock(abbr="HS")], +# funding_budgets=[MagicMock(budget=50000)], +# ), +# MagicMock( +# active_period=1, +# funding_details=MagicMock(method_of_transfer="IAA"), +# portfolios=[MagicMock(abbr="HMRF")], +# funding_budgets=[MagicMock(budget=200000)], +# ), +# ] +# +# filtered_cans = get_filtered_cans( +# cans, active_period=active_period, transfer=transfer, portfolio=portfolio, fy_budget=fy_budget +# ) +# +# assert len(filtered_cans) == expected_count +# +# +# def test_aggregate_funding_summaries(): +# funding_sums = [ +# { +# "available_funding": 100000, +# "cans": [ +# { +# "can": { +# "id": 1, +# "description": "Grant for educational projects", +# "amount": 50000, +# "obligate_by": 2025, +# }, +# "carry_forward_label": "2024 Carry Forward", +# "expiration_date": "10/01/2025", +# } +# ], +# "carry_forward_funding": 20000, +# "received_funding": 75000, +# "expected_funding": 125000 - 75000, +# "in_draft_funding": 0, +# "in_execution_funding": 50000, +# "obligated_funding": 30000, +# "planned_funding": 120000, +# "total_funding": 125000, +# "new_funding": 100000 + 20000, +# }, +# { +# "available_funding": 150000, +# "cans": [ +# { +# "can": { +# "id": 2, +# "description": "Infrastructure development grant", +# "amount": 70000, +# "obligate_by": 2026, +# }, +# "carry_forward_label": "2025 Carry Forward", +# "expiration_date": "10/01/2026", +# } +# ], +# "carry_forward_funding": 30000, +# "received_funding": 100000, +# "expected_funding": 180000 - 100000, +# "in_draft_funding": 0, +# "in_execution_funding": 80000, +# "obligated_funding": 50000, +# "planned_funding": 160000, +# "total_funding": 180000, +# "new_funding": 150000 + 30000, +# }, +# ] +# +# result = aggregate_funding_summaries(funding_sums) +# +# assert result == { +# "available_funding": Decimal("250000.0"), +# "cans": [ +# { +# "can": {"amount": 50000, "description": "Grant for educational projects", "id": 1, "obligate_by": 2025}, +# "carry_forward_label": "2024 Carry Forward", +# "expiration_date": "10/01/2025", +# }, +# { +# "can": { +# "amount": 70000, +# "description": "Infrastructure development grant", +# "id": 2, +# "obligate_by": 2026, +# }, +# "carry_forward_label": "2025 Carry Forward", +# "expiration_date": "10/01/2026", +# }, +# ], +# "carry_forward_funding": Decimal("50000.0"), +# "expected_funding": Decimal("130000.0"), +# "in_draft_funding": Decimal("0.0"), +# "in_execution_funding": Decimal("130000.0"), +# "new_funding": Decimal("300000.0"), +# "obligated_funding": Decimal("80000.0"), +# "planned_funding": Decimal("280000.0"), +# "received_funding": Decimal("175000.0"), +# "total_funding": Decimal("305000.0"), +# } @pytest.mark.usefixtures("app_ctx") @pytest.mark.usefixtures("loaded_db") -def test_can_get_can_funding_summary(auth_client: FlaskClient, test_can: CAN) -> None: - query_params = f"can_ids={test_can.id}" +def test_can_get_can_funding_summary_all_cans(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: + query_params = f"can_ids={0}" response = auth_client.get(f"/api/v1/can-funding-summary?{query_params}") assert response.status_code == 200 - assert response.json["cans"][0]["can"]["id"] == test_can.id - assert "new_funding" in response.json - assert isinstance(response.json["new_funding"], str) - assert "expiration_date" in response.json["cans"][0] - assert "carry_forward_label" in response.json["cans"][0] - - -@pytest.mark.usefixtures("app_ctx") -@pytest.mark.usefixtures("loaded_db") -def test_cans_get_can_funding_summary(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: - url = f"/api/v1/can-funding-summary?can_ids={test_cans[0].id}&can_ids={test_cans[1].id}" - - response = auth_client.get(url) - - available_funding = response.json["available_funding"] - carry_forward_funding = response.json["carry_forward_funding"] - - assert response.status_code == 200 - assert len(response.json["cans"]) == 2 - - assert available_funding == "3340000.00" - assert carry_forward_funding == "10000000.0" - assert response.json["new_funding"] == "1340000.0" - - -@pytest.mark.usefixtures("app_ctx") -@pytest.mark.usefixtures("loaded_db") -def test_can_get_can_funding_summary_filter(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: - url = f"/api/v1/can-funding-summary?" f"can_ids={test_cans[0].id}&can_ids={test_cans[1].id}&" f"active_period=1" - - response = auth_client.get(url) - - assert response.status_code == 200 - assert len(response.json["cans"]) == 1 - assert response.json["cans"][0]["can"]["active_period"] == 1 + assert len(response.json["cans"]) == 17 @pytest.mark.usefixtures("app_ctx") @pytest.mark.usefixtures("loaded_db") -def test_can_get_can_funding_summary_complete_filter(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: - url = ( - f"/api/v1/can-funding-summary?" - f"can_ids={test_cans[0].id}&can_ids={test_cans[1].id}&" - f"fiscal_year=2024&" - f"active_period=1&active_period=5&" - f"transfer=DIRECT&transfer=IAA&" - f"portfolio=HS&portfolio=HMRF&" - f"fy_budget=50000&fy_budget=100000" - ) +def test_can_get_can_funding_summary_all_cans_with_out_of_bound_fiscal_year( + auth_client: FlaskClient, test_cans: list[Type[CAN]] +) -> None: + query_params = f"can_ids={0}&fiscal_year=2100" - response = auth_client.get(url) + response = auth_client.get(f"/api/v1/can-funding-summary?{query_params}") assert response.status_code == 200 - assert len(response.json["cans"]) == 0 - assert "new_funding" in response.json - assert response.json["obligated_funding"] == "0.0" - - -def test_get_nested_attribute_existing_attribute(): - obj = DummyObject() - result = get_nested_attribute(obj, "nested.value") - assert result == "test_value" - - -def test_get_nested_attribute_non_existing_attribute(): - obj = DummyObject() - result = get_nested_attribute(obj, "nested.non_existing") - assert result is None - - -def test_get_nested_attribute_non_existing_top_level(): - obj = DummyObject() - result = get_nested_attribute(obj, "non_existing") - assert result is None - - -def test_filter_cans_by_attribute(): - cans = [ - MagicMock(active_period=1, funding_details=MagicMock(method_of_transfer="DIRECT")), - MagicMock(active_period=2, funding_details=MagicMock(method_of_transfer="IAA")), - MagicMock(active_period=1, funding_details=MagicMock(method_of_transfer="DIRECT")), - ] - - filtered_cans = filter_by_attribute(cans, "funding_details.method_of_transfer", ["DIRECT"]) - - assert len(filtered_cans) == 2 - - -def test_filter_cans_by_fiscal_year_budget(): - cans = [ - MagicMock(funding_budgets=[MagicMock(budget=1000001.0)]), - MagicMock(funding_budgets=[MagicMock(budget=2000000.0)]), - MagicMock(funding_budgets=[MagicMock(budget=500000.0)]), - ] - - fiscal_year_budget = [1000000, 2000000] - filtered_cans = filter_by_fiscal_year_budget(cans, fiscal_year_budget) - - assert len(filtered_cans) == 2 - - -def test_filter_cans_by_fiscal_year_budget_no_match(): - cans = [ - MagicMock(funding_budgets=[MagicMock(budget=500000.0, fiscal_year=2023)]), - MagicMock(funding_budgets=[MagicMock(budget=7000000.0, fiscal_year=2024)]), - ] - - fiscal_year_budget = [1000000, 2000000] - filtered_cans = filter_by_fiscal_year_budget(cans, fiscal_year_budget) - - assert len(filtered_cans) == 0 - - -@pytest.mark.parametrize( - "active_period, transfer, portfolio, fy_budget, expected_count", - [ - (None, None, None, None, 3), - ([1], None, None, None, 2), - (None, ["DIRECT"], None, None, 1), - (None, None, None, [100000, 200000], 2), - ([1], ["IAA"], ["HS"], [100000, 200000], 0), - ], -) -def test_filter_cans(active_period, transfer, portfolio, fy_budget, expected_count): - cans = [ - MagicMock( - active_period=1, - funding_details=MagicMock(method_of_transfer="DIRECT"), - portfolios=[MagicMock(abbr="HS")], - funding_budgets=[MagicMock(budget=150000)], - ), - MagicMock( - active_period=2, - funding_details=MagicMock(method_of_transfer="IAA"), - portfolios=[MagicMock(abbr="HS")], - funding_budgets=[MagicMock(budget=50000)], - ), - MagicMock( - active_period=1, - funding_details=MagicMock(method_of_transfer="IAA"), - portfolios=[MagicMock(abbr="HMRF")], - funding_budgets=[MagicMock(budget=200000)], - ), - ] - - filtered_cans = get_filtered_cans( - cans, active_period=active_period, transfer=transfer, portfolio=portfolio, fy_budget=fy_budget - ) - - assert len(filtered_cans) == expected_count - - -def test_aggregate_funding_summaries(): - funding_sums = [ - { - "available_funding": 100000, - "cans": [ - { - "can": { - "id": 1, - "description": "Grant for educational projects", - "amount": 50000, - "obligate_by": 2025, - }, - "carry_forward_label": "2024 Carry Forward", - "expiration_date": "10/01/2025", - } - ], - "carry_forward_funding": 20000, - "received_funding": 75000, - "expected_funding": 125000 - 75000, - "in_draft_funding": 0, - "in_execution_funding": 50000, - "obligated_funding": 30000, - "planned_funding": 120000, - "total_funding": 125000, - "new_funding": 100000 + 20000, - }, - { - "available_funding": 150000, - "cans": [ - { - "can": { - "id": 2, - "description": "Infrastructure development grant", - "amount": 70000, - "obligate_by": 2026, - }, - "carry_forward_label": "2025 Carry Forward", - "expiration_date": "10/01/2026", - } - ], - "carry_forward_funding": 30000, - "received_funding": 100000, - "expected_funding": 180000 - 100000, - "in_draft_funding": 0, - "in_execution_funding": 80000, - "obligated_funding": 50000, - "planned_funding": 160000, - "total_funding": 180000, - "new_funding": 150000 + 30000, - }, - ] - - result = aggregate_funding_summaries(funding_sums) - - assert result == { - "available_funding": Decimal("250000.0"), - "cans": [ - { - "can": {"amount": 50000, "description": "Grant for educational projects", "id": 1, "obligate_by": 2025}, - "carry_forward_label": "2024 Carry Forward", - "expiration_date": "10/01/2025", - }, - { - "can": { - "amount": 70000, - "description": "Infrastructure development grant", - "id": 2, - "obligate_by": 2026, - }, - "carry_forward_label": "2025 Carry Forward", - "expiration_date": "10/01/2026", - }, - ], - "carry_forward_funding": Decimal("50000.0"), - "expected_funding": Decimal("130000.0"), - "in_draft_funding": Decimal("0.0"), - "in_execution_funding": Decimal("130000.0"), - "new_funding": Decimal("300000.0"), - "obligated_funding": Decimal("80000.0"), - "planned_funding": Decimal("280000.0"), - "received_funding": Decimal("175000.0"), - "total_funding": Decimal("305000.0"), - } + assert len(response.json["cans"]) == 17 diff --git a/frontend/src/api/opsAPI.js b/frontend/src/api/opsAPI.js index 0880bea49c..ae862f4112 100644 --- a/frontend/src/api/opsAPI.js +++ b/frontend/src/api/opsAPI.js @@ -204,8 +204,17 @@ export const opsApi = createApi({ providesTags: ["Cans"] }), getCanFundingSummary: builder.query({ - query: ({ id, fiscalYear }) => - `/can-funding-summary?can_ids=${id}${fiscalYear ? `&fiscal_year=${fiscalYear}` : ""}`, + query: () => + // `/can-funding-summary?can_ids=${ids} + // ${fiscalYear ? `&fiscal_year=${fiscalYear}` : ""} + // ${activePeriod ? `&active_period=${activePeriod}` : ""} + // ${transfer ? `&transfer=${transfer}` : ""} + // ${portfolio ? `&portfolio=${portfolio}` : ""} + // ${fyBudgets ? `&fy_budget=${fyBudgets[0]}` : ""} + // ${fyBudgets ? `&fy_budget=${fyBudgets[1]}` : ""} + // `, + `/can-funding-summary?can_ids=0&fiscal_year=2025`, + providesTags: ["CanFunding"] }), getNotificationsByUserId: builder.query({ diff --git a/frontend/src/components/CANs/CANTable/CANTableRow.jsx b/frontend/src/components/CANs/CANTable/CANTableRow.jsx index f4f795119c..236ca5299b 100644 --- a/frontend/src/components/CANs/CANTable/CANTableRow.jsx +++ b/frontend/src/components/CANs/CANTable/CANTableRow.jsx @@ -37,7 +37,7 @@ const CANTableRow = ({ data: fundingSummary, isError, isLoading - } = useGetCanFundingSummaryQuery({ id: canId, fiscalYear: fiscalYear }); + } = useGetCanFundingSummaryQuery({ ids: canId, fiscalYear: fiscalYear }); const availableFunds = fundingSummary?.available_funding ?? 0; if (isLoading) diff --git a/frontend/src/pages/cans/list/CanList.jsx b/frontend/src/pages/cans/list/CanList.jsx index 89e39b03b9..888828aa9a 100644 --- a/frontend/src/pages/cans/list/CanList.jsx +++ b/frontend/src/pages/cans/list/CanList.jsx @@ -1,7 +1,7 @@ import React from "react"; import { useSelector } from "react-redux"; import { useSearchParams } from "react-router-dom"; -import { useGetCansQuery } from "../../../api/opsAPI"; +import { useGetCanFundingSummaryQuery, useGetCansQuery } from "../../../api/opsAPI"; import App from "../../../App"; import CANSummaryCards from "../../../components/CANs/CANSummaryCards"; import CANTable from "../../../components/CANs/CANTable"; @@ -13,6 +13,7 @@ import CANFilterButton from "./CANFilterButton"; import CANFilterTags from "./CANFilterTags"; import CANFiscalYearSelect from "./CANFiscalYearSelect"; import { getPortfolioOptions, getSortedFYBudgets, sortAndFilterCANs } from "./CanList.helpers"; +import DebugCode from "../../../components/DebugCode"; /** * Page for the CAN List. @@ -23,10 +24,14 @@ import { getPortfolioOptions, getSortedFYBudgets, sortAndFilterCANs } from "./Ca const CanList = () => { const [searchParams] = useSearchParams(); const myCANsUrl = searchParams.get("filter") === "my-cans"; - const { data: canList, isError, isLoading } = useGetCansQuery({}); const activeUser = useSelector((state) => state.auth.activeUser); const selectedFiscalYear = useSelector((state) => state.canDetail.selectedFiscalYear); const fiscalYear = Number(selectedFiscalYear.value); + const { data: canList, isError, isLoading } = useGetCansQuery({}); + const { data: fundingSummaryData, isLoading: fundingSummaryisLoading } = useGetCanFundingSummaryQuery({ + ids: 0, + fiscalYear: 2025 + }); const [filters, setFilters] = React.useState({ activePeriod: [], transfer: [], @@ -45,7 +50,7 @@ const CanList = () => { const sortedFYBudgets = getSortedFYBudgets(filteredCANsByFiscalYear); const [minFYBudget, maxFYBudget] = [sortedFYBudgets[0], sortedFYBudgets[sortedFYBudgets.length - 1]]; - if (isLoading) { + if (isLoading || fundingSummaryisLoading) { return (

Loading...

@@ -99,6 +104,10 @@ const CanList = () => { } SummaryCardsSection={} /> +
) ); From 9258d2a381d703439831c8a39a99f48228dbae77 Mon Sep 17 00:00:00 2001 From: Mai Yer Lee Date: Wed, 20 Nov 2024 19:06:41 -0600 Subject: [PATCH 08/15] feat: add fy as filter option, fix fe url (#3113) * fix: ops api url for funding sums * refactor: be logic * feat: apply fy to filter --- .../ops/resources/can_funding_summary.py | 37 +- backend/ops_api/ops/utils/cans.py | 5 +- .../test_can_funding_summary.py | 909 +++++++++--------- frontend/src/api/opsAPI.js | 39 +- frontend/src/pages/cans/list/CanList.jsx | 5 +- 5 files changed, 506 insertions(+), 489 deletions(-) diff --git a/backend/ops_api/ops/resources/can_funding_summary.py b/backend/ops_api/ops/resources/can_funding_summary.py index c564427604..dc1f8e0c38 100644 --- a/backend/ops_api/ops/resources/can_funding_summary.py +++ b/backend/ops_api/ops/resources/can_funding_summary.py @@ -22,55 +22,40 @@ def get(self) -> Response: portfolio = request.args.getlist("portfolio") fy_budget = request.args.getlist("fy_budget", type=int) - # Check if required 'can_ids' parameter is provided + # Ensure required 'can_ids' parameter is provided if not can_ids: return make_response_with_headers({"error": "'can_ids' parameter is required"}, 400) + # When 'can_ids' is 0 (all CANS) if can_ids == ["0"]: cans = self._get_all_items() - cans_with_filters = get_filtered_cans(cans, active_period, transfer, portfolio, fy_budget) - can_funding_summaries = [ - get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) for can in cans_with_filters - ] + return self._apply_filters_and_return(cans, fiscal_year, active_period, transfer, portfolio, fy_budget) - aggregated_summary = aggregate_funding_summaries(can_funding_summaries) - - return make_response_with_headers(aggregated_summary) - - # Handle case when a single 'can_id' is provided with no additional filters + # Single 'can_id' without additional filters if len(can_ids) == 1 and not (active_period or transfer or portfolio or fy_budget): return self._handle_single_can_no_filters(can_ids[0], fiscal_year) - # If 'can_ids' is 0 or multiple ids are provided, filter and aggregate - return self._handle_cans_with_filters(can_ids, fiscal_year, active_period, transfer, portfolio, fy_budget) + # Multiple 'can_ids' with filters + cans = [self._get_item(can_id) for can_id in can_ids] + return self._apply_filters_and_return(cans, fiscal_year, active_period, transfer, portfolio, fy_budget) def _handle_single_can_no_filters(self, can_id: str, fiscal_year: str = None) -> Response: - """Helper method for handling a single 'can_id' with no filters.""" can = self._get_item(can_id) can_funding_summary = get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) return make_response_with_headers(can_funding_summary) - def _get_cans(self, can_ids: list) -> list: - """Helper method to get CANS.""" - if can_ids == [0]: - return self._get_all_items() - return [self._get_item(can_id) for can_id in can_ids] - - def _handle_cans_with_filters( - self, - can_ids: list, + @staticmethod + def _apply_filters_and_return( + cans: list, fiscal_year: str = None, active_period: list = None, transfer: list = None, portfolio: list = None, fy_budget: list = None, ) -> Response: - cans_with_filters = get_filtered_cans(self._get_cans(can_ids), active_period, transfer, portfolio, fy_budget) - + cans_with_filters = get_filtered_cans(cans, fiscal_year, active_period, transfer, portfolio, fy_budget) can_funding_summaries = [ get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) for can in cans_with_filters ] - aggregated_summary = aggregate_funding_summaries(can_funding_summaries) - return make_response_with_headers(aggregated_summary) diff --git a/backend/ops_api/ops/utils/cans.py b/backend/ops_api/ops/utils/cans.py index 71fc69ecc4..cb34fd3822 100644 --- a/backend/ops_api/ops/utils/cans.py +++ b/backend/ops_api/ops/utils/cans.py @@ -158,17 +158,20 @@ def filter_by_fiscal_year_budget(cans: list[CAN], fiscal_year_budget: list[int]) ] -def get_filtered_cans(cans, active_period=None, transfer=None, portfolio=None, fy_budget=None): +def get_filtered_cans(cans, fiscal_year=None, active_period=None, transfer=None, portfolio=None, fy_budget=None): """ Filters the given list of 'cans' based on the provided attributes. :param cans: List of cans to be filtered + :param fiscal_year: Value to filter by fiscal year :param active_period: Value to filter by 'active_period' attribute :param transfer: Value to filter by 'funding_details.method_of_transfer' attribute :param portfolio: Value to filter by 'portfolios.abbr' attribute :param fy_budget: Value to filter by fiscal year budget :return: Filtered list of cans """ + if fiscal_year: + cans = filter_by_attribute(cans, "funding_details.fiscal_year", [fiscal_year]) if active_period: cans = filter_by_attribute(cans, "active_period", active_period) if transfer: diff --git a/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py b/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py index be22a0b118..d18d483371 100644 --- a/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py +++ b/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py @@ -1,23 +1,20 @@ -# from decimal import Decimal +from decimal import Decimal from typing import Type +from unittest.mock import MagicMock import pytest from flask.testing import FlaskClient from models.cans import CAN - -# from unittest.mock import MagicMock - - -# from ops_api.ops.utils.cans import ( -# aggregate_funding_summaries, -# filter_by_attribute, -# filter_by_fiscal_year_budget, -# get_can_funding_summary, -# get_filtered_cans, -# get_nested_attribute, -# ) -# from ops_api.tests.utils import remove_keys +from ops_api.ops.utils.cans import ( + aggregate_funding_summaries, + filter_by_attribute, + filter_by_fiscal_year_budget, + get_can_funding_summary, + get_filtered_cans, + get_nested_attribute, +) +from ops_api.tests.utils import remove_keys class DummyObject: @@ -30,455 +27,465 @@ def __init__(self): self.value = "test_value" -# @pytest.mark.usefixtures("app_ctx") -# def test_get_can_funding_summary_no_fiscal_year(loaded_db, test_can) -> None: -# result = get_can_funding_summary(test_can) -# -# # Remove these because they are set according to when the test was run -# remove_keys(result, ["created_on", "updated_on", "versions"]) -# -# assert result == { -# "available_funding": Decimal("-860000.00"), -# "cans": [ -# { -# "can": { -# "active_period": 1, -# "appropriation_date": 2023, -# "budget_line_items": [15008], -# "created_by": None, -# "created_by_user": None, -# "description": "Healthy Marriages Responsible Fatherhood - OPRE", -# "display_name": "G99HRF2", -# "expiration_date": 2024, -# "funding_budgets": [ -# { -# "budget": "1140000.0", -# "can": 500, -# "can_id": 500, -# "created_by": None, -# "created_by_user": None, -# "display_name": "CANFundingBudget#1", -# "fiscal_year": 2023, -# "id": 1, -# "notes": None, -# "updated_by": None, -# "updated_by_user": None, -# } -# ], -# "funding_details": { -# "allotment": None, -# "allowance": None, -# "appropriation": None, -# "created_by": None, -# "created_by_user": None, -# "display_name": "CANFundingDetails#1", -# "fiscal_year": 2023, -# "fund_code": "AAXXXX20231DAD", -# "funding_partner": None, -# "funding_source": "OPRE", -# "id": 1, -# "method_of_transfer": "DIRECT", -# "sub_allowance": None, -# "updated_by": None, -# "updated_by_user": None, -# }, -# "funding_details_id": 1, -# "funding_received": [ -# { -# "can": 500, -# "can_id": 500, -# "created_by": None, -# "created_by_user": None, -# "display_name": "CANFundingReceived#500", -# "fiscal_year": 2023, -# "funding": "880000.0", -# "id": 500, -# "notes": None, -# "updated_by": None, -# "updated_by_user": None, -# } -# ], -# "id": 500, -# "nick_name": "HMRF-OPRE", -# "number": "G99HRF2", -# "portfolio": 6, -# "portfolio_id": 6, -# "projects": [1000], -# "updated_by": None, -# "updated_by_user": None, -# }, -# "carry_forward_label": " Carry-Forward", -# "expiration_date": "10/01/2024", -# } -# ], -# "carry_forward_funding": 0, -# "expected_funding": Decimal("260000.0"), -# "in_draft_funding": 0, -# "in_execution_funding": Decimal("2000000.00"), -# "new_funding": Decimal("1140000.0"), -# "obligated_funding": 0, -# "planned_funding": 0, -# "received_funding": Decimal("880000.0"), -# "total_funding": Decimal("1140000.0"), -# } -# -# -# @pytest.mark.usefixtures("app_ctx") -# @pytest.mark.usefixtures("loaded_db") -# def test_get_can_funding_summary_with_fiscal_year(loaded_db, test_can) -> None: -# result = get_can_funding_summary(test_can, 2023) -# -# # Remove these because they are set according to when the test was run -# remove_keys(result, ["created_on", "updated_on", "versions"]) -# -# assert result == { -# "available_funding": Decimal("1140000.0"), -# "cans": [ -# { -# "can": { -# "active_period": 1, -# "appropriation_date": 2023, -# "budget_line_items": [15008], -# "created_by": None, -# "created_by_user": None, -# "description": "Healthy Marriages Responsible Fatherhood - OPRE", -# "display_name": "G99HRF2", -# "expiration_date": 2024, -# "funding_budgets": [ -# { -# "budget": "1140000.0", -# "can": 500, -# "can_id": 500, -# "created_by": None, -# "created_by_user": None, -# "display_name": "CANFundingBudget#1", -# "fiscal_year": 2023, -# "id": 1, -# "notes": None, -# "updated_by": None, -# "updated_by_user": None, -# } -# ], -# "funding_details": { -# "allotment": None, -# "allowance": None, -# "appropriation": None, -# "created_by": None, -# "created_by_user": None, -# "display_name": "CANFundingDetails#1", -# "fiscal_year": 2023, -# "fund_code": "AAXXXX20231DAD", -# "funding_partner": None, -# "funding_source": "OPRE", -# "id": 1, -# "method_of_transfer": "DIRECT", -# "sub_allowance": None, -# "updated_by": None, -# "updated_by_user": None, -# }, -# "funding_details_id": 1, -# "funding_received": [ -# { -# "can": 500, -# "can_id": 500, -# "created_by": None, -# "created_by_user": None, -# "display_name": "CANFundingReceived#500", -# "fiscal_year": 2023, -# "funding": "880000.0", -# "id": 500, -# "notes": None, -# "updated_by": None, -# "updated_by_user": None, -# } -# ], -# "id": 500, -# "nick_name": "HMRF-OPRE", -# "number": "G99HRF2", -# "portfolio": 6, -# "portfolio_id": 6, -# "projects": [1000], -# "updated_by": None, -# "updated_by_user": None, -# }, -# "carry_forward_label": " Carry-Forward", -# "expiration_date": "10/01/2024", -# } -# ], -# "carry_forward_funding": 0, -# "in_draft_funding": Decimal("0.0"), -# "expected_funding": Decimal("260000.0"), -# "in_execution_funding": 0, -# "obligated_funding": 0, -# "planned_funding": 0, -# "received_funding": Decimal("880000.0"), -# "total_funding": Decimal("1140000.0"), -# "new_funding": Decimal("1140000.0") - 0, -# } -# -# -# @pytest.mark.usefixtures("app_ctx") -# @pytest.mark.usefixtures("loaded_db") -# def test_can_get_can_funding_summary(auth_client: FlaskClient, test_can: CAN) -> None: -# query_params = f"can_ids={test_can.id}" -# -# response = auth_client.get(f"/api/v1/can-funding-summary?{query_params}") -# -# assert response.status_code == 200 -# assert response.json["cans"][0]["can"]["id"] == test_can.id -# assert "new_funding" in response.json -# assert isinstance(response.json["new_funding"], str) -# assert "expiration_date" in response.json["cans"][0] -# assert "carry_forward_label" in response.json["cans"][0] -# -# -# @pytest.mark.usefixtures("app_ctx") -# @pytest.mark.usefixtures("loaded_db") -# def test_cans_get_can_funding_summary(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: -# url = f"/api/v1/can-funding-summary?can_ids={test_cans[0].id}&can_ids={test_cans[1].id}" -# -# response = auth_client.get(url) -# -# available_funding = response.json["available_funding"] -# carry_forward_funding = response.json["carry_forward_funding"] -# -# assert response.status_code == 200 -# assert len(response.json["cans"]) == 2 -# -# assert available_funding == "3340000.00" -# assert carry_forward_funding == "10000000.0" -# assert response.json["new_funding"] == "1340000.0" -# -# -# @pytest.mark.usefixtures("app_ctx") -# @pytest.mark.usefixtures("loaded_db") -# def test_can_get_can_funding_summary_filter(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: -# url = f"/api/v1/can-funding-summary?" f"can_ids={test_cans[0].id}&can_ids={test_cans[1].id}&" f"active_period=1" -# -# response = auth_client.get(url) -# -# assert response.status_code == 200 -# assert len(response.json["cans"]) == 1 -# assert response.json["cans"][0]["can"]["active_period"] == 1 -# -# -# @pytest.mark.usefixtures("app_ctx") -# @pytest.mark.usefixtures("loaded_db") -# def test_can_get_can_funding_summary_complete_filter(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: -# url = ( -# f"/api/v1/can-funding-summary?" -# f"can_ids={test_cans[0].id}&can_ids={test_cans[1].id}&" -# f"fiscal_year=2024&" -# f"active_period=1&active_period=5&" -# f"transfer=DIRECT&transfer=IAA&" -# f"portfolio=HS&portfolio=HMRF&" -# f"fy_budget=50000&fy_budget=100000" -# ) -# -# response = auth_client.get(url) -# -# assert response.status_code == 200 -# assert len(response.json["cans"]) == 0 -# assert "new_funding" in response.json -# assert response.json["obligated_funding"] == "0.0" -# -# -# def test_get_nested_attribute_existing_attribute(): -# obj = DummyObject() -# result = get_nested_attribute(obj, "nested.value") -# assert result == "test_value" -# -# -# def test_get_nested_attribute_non_existing_attribute(): -# obj = DummyObject() -# result = get_nested_attribute(obj, "nested.non_existing") -# assert result is None -# -# -# def test_get_nested_attribute_non_existing_top_level(): -# obj = DummyObject() -# result = get_nested_attribute(obj, "non_existing") -# assert result is None -# -# -# def test_filter_cans_by_attribute(): -# cans = [ -# MagicMock(active_period=1, funding_details=MagicMock(method_of_transfer="DIRECT")), -# MagicMock(active_period=2, funding_details=MagicMock(method_of_transfer="IAA")), -# MagicMock(active_period=1, funding_details=MagicMock(method_of_transfer="DIRECT")), -# ] -# -# filtered_cans = filter_by_attribute(cans, "funding_details.method_of_transfer", ["DIRECT"]) -# -# assert len(filtered_cans) == 2 -# -# -# def test_filter_cans_by_fiscal_year_budget(): -# cans = [ -# MagicMock(funding_budgets=[MagicMock(budget=1000001.0)]), -# MagicMock(funding_budgets=[MagicMock(budget=2000000.0)]), -# MagicMock(funding_budgets=[MagicMock(budget=500000.0)]), -# ] -# -# fiscal_year_budget = [1000000, 2000000] -# filtered_cans = filter_by_fiscal_year_budget(cans, fiscal_year_budget) -# -# assert len(filtered_cans) == 2 -# -# -# def test_filter_cans_by_fiscal_year_budget_no_match(): -# cans = [ -# MagicMock(funding_budgets=[MagicMock(budget=500000.0, fiscal_year=2023)]), -# MagicMock(funding_budgets=[MagicMock(budget=7000000.0, fiscal_year=2024)]), -# ] -# -# fiscal_year_budget = [1000000, 2000000] -# filtered_cans = filter_by_fiscal_year_budget(cans, fiscal_year_budget) -# -# assert len(filtered_cans) == 0 -# -# -# @pytest.mark.parametrize( -# "active_period, transfer, portfolio, fy_budget, expected_count", -# [ -# (None, None, None, None, 3), -# ([1], None, None, None, 2), -# (None, ["DIRECT"], None, None, 1), -# (None, None, None, [100000, 200000], 2), -# ([1], ["IAA"], ["HS"], [100000, 200000], 0), -# ], -# ) -# def test_filter_cans(active_period, transfer, portfolio, fy_budget, expected_count): -# cans = [ -# MagicMock( -# active_period=1, -# funding_details=MagicMock(method_of_transfer="DIRECT"), -# portfolios=[MagicMock(abbr="HS")], -# funding_budgets=[MagicMock(budget=150000)], -# ), -# MagicMock( -# active_period=2, -# funding_details=MagicMock(method_of_transfer="IAA"), -# portfolios=[MagicMock(abbr="HS")], -# funding_budgets=[MagicMock(budget=50000)], -# ), -# MagicMock( -# active_period=1, -# funding_details=MagicMock(method_of_transfer="IAA"), -# portfolios=[MagicMock(abbr="HMRF")], -# funding_budgets=[MagicMock(budget=200000)], -# ), -# ] -# -# filtered_cans = get_filtered_cans( -# cans, active_period=active_period, transfer=transfer, portfolio=portfolio, fy_budget=fy_budget -# ) -# -# assert len(filtered_cans) == expected_count -# -# -# def test_aggregate_funding_summaries(): -# funding_sums = [ -# { -# "available_funding": 100000, -# "cans": [ -# { -# "can": { -# "id": 1, -# "description": "Grant for educational projects", -# "amount": 50000, -# "obligate_by": 2025, -# }, -# "carry_forward_label": "2024 Carry Forward", -# "expiration_date": "10/01/2025", -# } -# ], -# "carry_forward_funding": 20000, -# "received_funding": 75000, -# "expected_funding": 125000 - 75000, -# "in_draft_funding": 0, -# "in_execution_funding": 50000, -# "obligated_funding": 30000, -# "planned_funding": 120000, -# "total_funding": 125000, -# "new_funding": 100000 + 20000, -# }, -# { -# "available_funding": 150000, -# "cans": [ -# { -# "can": { -# "id": 2, -# "description": "Infrastructure development grant", -# "amount": 70000, -# "obligate_by": 2026, -# }, -# "carry_forward_label": "2025 Carry Forward", -# "expiration_date": "10/01/2026", -# } -# ], -# "carry_forward_funding": 30000, -# "received_funding": 100000, -# "expected_funding": 180000 - 100000, -# "in_draft_funding": 0, -# "in_execution_funding": 80000, -# "obligated_funding": 50000, -# "planned_funding": 160000, -# "total_funding": 180000, -# "new_funding": 150000 + 30000, -# }, -# ] -# -# result = aggregate_funding_summaries(funding_sums) -# -# assert result == { -# "available_funding": Decimal("250000.0"), -# "cans": [ -# { -# "can": {"amount": 50000, "description": "Grant for educational projects", "id": 1, "obligate_by": 2025}, -# "carry_forward_label": "2024 Carry Forward", -# "expiration_date": "10/01/2025", -# }, -# { -# "can": { -# "amount": 70000, -# "description": "Infrastructure development grant", -# "id": 2, -# "obligate_by": 2026, -# }, -# "carry_forward_label": "2025 Carry Forward", -# "expiration_date": "10/01/2026", -# }, -# ], -# "carry_forward_funding": Decimal("50000.0"), -# "expected_funding": Decimal("130000.0"), -# "in_draft_funding": Decimal("0.0"), -# "in_execution_funding": Decimal("130000.0"), -# "new_funding": Decimal("300000.0"), -# "obligated_funding": Decimal("80000.0"), -# "planned_funding": Decimal("280000.0"), -# "received_funding": Decimal("175000.0"), -# "total_funding": Decimal("305000.0"), -# } +@pytest.mark.usefixtures("app_ctx") +@pytest.mark.usefixtures("loaded_db") +def test_can_get_can_funding_summary_all_cans_no_fiscal_year_match( + auth_client: FlaskClient, test_cans: list[Type[CAN]] +) -> None: + query_params = f"can_ids={0}&fiscal_year=2025" + + response = auth_client.get(f"/api/v1/can-funding-summary?{query_params}") + + assert response.status_code == 200 + assert len(response.json["cans"]) == 0 + assert response.json["available_funding"] == "0.0" + assert response.json["carry_forward_funding"] == "0.0" + assert response.json["expected_funding"] == "0.0" + assert response.json["in_draft_funding"] == "0.0" + assert response.json["in_execution_funding"] == "0.0" + assert response.json["new_funding"] == "0.0" + assert response.json["obligated_funding"] == "0.0" + assert response.json["planned_funding"] == "0.0" + assert response.json["received_funding"] == "0.0" + assert response.json["total_funding"] == "0.0" + + +@pytest.mark.usefixtures("app_ctx") +def test_get_can_funding_summary_no_fiscal_year(loaded_db, test_can) -> None: + result = get_can_funding_summary(test_can) + + # Remove these because they are set according to when the test was run + remove_keys(result, ["created_on", "updated_on", "versions"]) + + assert result == { + "available_funding": Decimal("-860000.00"), + "cans": [ + { + "can": { + "active_period": 1, + "appropriation_date": 2023, + "budget_line_items": [15008], + "created_by": None, + "created_by_user": None, + "description": "Healthy Marriages Responsible Fatherhood - OPRE", + "display_name": "G99HRF2", + "expiration_date": 2024, + "funding_budgets": [ + { + "budget": "1140000.0", + "can": 500, + "can_id": 500, + "created_by": None, + "created_by_user": None, + "display_name": "CANFundingBudget#1", + "fiscal_year": 2023, + "id": 1, + "notes": None, + "updated_by": None, + "updated_by_user": None, + } + ], + "funding_details": { + "allotment": None, + "allowance": None, + "appropriation": None, + "created_by": None, + "created_by_user": None, + "display_name": "CANFundingDetails#1", + "fiscal_year": 2023, + "fund_code": "AAXXXX20231DAD", + "funding_partner": None, + "funding_source": "OPRE", + "id": 1, + "method_of_transfer": "DIRECT", + "sub_allowance": None, + "updated_by": None, + "updated_by_user": None, + }, + "funding_details_id": 1, + "funding_received": [ + { + "can": 500, + "can_id": 500, + "created_by": None, + "created_by_user": None, + "display_name": "CANFundingReceived#500", + "fiscal_year": 2023, + "funding": "880000.0", + "id": 500, + "notes": None, + "updated_by": None, + "updated_by_user": None, + } + ], + "id": 500, + "nick_name": "HMRF-OPRE", + "number": "G99HRF2", + "portfolio": 6, + "portfolio_id": 6, + "projects": [1000], + "updated_by": None, + "updated_by_user": None, + }, + "carry_forward_label": " Carry-Forward", + "expiration_date": "10/01/2024", + } + ], + "carry_forward_funding": 0, + "expected_funding": Decimal("260000.0"), + "in_draft_funding": 0, + "in_execution_funding": Decimal("2000000.00"), + "new_funding": Decimal("1140000.0"), + "obligated_funding": 0, + "planned_funding": 0, + "received_funding": Decimal("880000.0"), + "total_funding": Decimal("1140000.0"), + } @pytest.mark.usefixtures("app_ctx") @pytest.mark.usefixtures("loaded_db") -def test_can_get_can_funding_summary_all_cans(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: - query_params = f"can_ids={0}" +def test_get_can_funding_summary_with_fiscal_year(loaded_db, test_can) -> None: + result = get_can_funding_summary(test_can, 2023) + + # Remove these because they are set according to when the test was run + remove_keys(result, ["created_on", "updated_on", "versions"]) + + assert result == { + "available_funding": Decimal("1140000.0"), + "cans": [ + { + "can": { + "active_period": 1, + "appropriation_date": 2023, + "budget_line_items": [15008], + "created_by": None, + "created_by_user": None, + "description": "Healthy Marriages Responsible Fatherhood - OPRE", + "display_name": "G99HRF2", + "expiration_date": 2024, + "funding_budgets": [ + { + "budget": "1140000.0", + "can": 500, + "can_id": 500, + "created_by": None, + "created_by_user": None, + "display_name": "CANFundingBudget#1", + "fiscal_year": 2023, + "id": 1, + "notes": None, + "updated_by": None, + "updated_by_user": None, + } + ], + "funding_details": { + "allotment": None, + "allowance": None, + "appropriation": None, + "created_by": None, + "created_by_user": None, + "display_name": "CANFundingDetails#1", + "fiscal_year": 2023, + "fund_code": "AAXXXX20231DAD", + "funding_partner": None, + "funding_source": "OPRE", + "id": 1, + "method_of_transfer": "DIRECT", + "sub_allowance": None, + "updated_by": None, + "updated_by_user": None, + }, + "funding_details_id": 1, + "funding_received": [ + { + "can": 500, + "can_id": 500, + "created_by": None, + "created_by_user": None, + "display_name": "CANFundingReceived#500", + "fiscal_year": 2023, + "funding": "880000.0", + "id": 500, + "notes": None, + "updated_by": None, + "updated_by_user": None, + } + ], + "id": 500, + "nick_name": "HMRF-OPRE", + "number": "G99HRF2", + "portfolio": 6, + "portfolio_id": 6, + "projects": [1000], + "updated_by": None, + "updated_by_user": None, + }, + "carry_forward_label": " Carry-Forward", + "expiration_date": "10/01/2024", + } + ], + "carry_forward_funding": 0, + "in_draft_funding": Decimal("0.0"), + "expected_funding": Decimal("260000.0"), + "in_execution_funding": 0, + "obligated_funding": 0, + "planned_funding": 0, + "received_funding": Decimal("880000.0"), + "total_funding": Decimal("1140000.0"), + "new_funding": Decimal("1140000.0") - 0, + } + + +@pytest.mark.usefixtures("app_ctx") +@pytest.mark.usefixtures("loaded_db") +def test_can_get_can_funding_summary(auth_client: FlaskClient, test_can: CAN) -> None: + query_params = f"can_ids={test_can.id}" response = auth_client.get(f"/api/v1/can-funding-summary?{query_params}") assert response.status_code == 200 - assert len(response.json["cans"]) == 17 + assert response.json["cans"][0]["can"]["id"] == test_can.id + assert "new_funding" in response.json + assert isinstance(response.json["new_funding"], str) + assert "expiration_date" in response.json["cans"][0] + assert "carry_forward_label" in response.json["cans"][0] @pytest.mark.usefixtures("app_ctx") @pytest.mark.usefixtures("loaded_db") -def test_can_get_can_funding_summary_all_cans_with_out_of_bound_fiscal_year( - auth_client: FlaskClient, test_cans: list[Type[CAN]] -) -> None: - query_params = f"can_ids={0}&fiscal_year=2100" +def test_cans_get_can_funding_summary(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: + url = f"/api/v1/can-funding-summary?can_ids={test_cans[0].id}&can_ids={test_cans[1].id}" + + response = auth_client.get(url) + + available_funding = response.json["available_funding"] + carry_forward_funding = response.json["carry_forward_funding"] + + assert response.status_code == 200 + assert len(response.json["cans"]) == 2 + + assert available_funding == "3340000.00" + assert carry_forward_funding == "10000000.0" + assert response.json["new_funding"] == "1340000.0" + + +@pytest.mark.usefixtures("app_ctx") +@pytest.mark.usefixtures("loaded_db") +def test_can_get_can_funding_summary_filter(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: + url = f"/api/v1/can-funding-summary?" f"can_ids={test_cans[0].id}&can_ids={test_cans[1].id}&" f"active_period=1" + + response = auth_client.get(url) + + assert response.status_code == 200 + assert len(response.json["cans"]) == 1 + assert response.json["cans"][0]["can"]["active_period"] == 1 + + +@pytest.mark.usefixtures("app_ctx") +@pytest.mark.usefixtures("loaded_db") +def test_can_get_can_funding_summary_complete_filter(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: + url = ( + f"/api/v1/can-funding-summary?" + f"can_ids={test_cans[0].id}&can_ids={test_cans[1].id}&" + f"fiscal_year=2024&" + f"active_period=1&active_period=5&" + f"transfer=DIRECT&transfer=IAA&" + f"portfolio=HS&portfolio=HMRF&" + f"fy_budget=50000&fy_budget=100000" + ) + + response = auth_client.get(url) + + assert response.status_code == 200 + assert len(response.json["cans"]) == 0 + assert "new_funding" in response.json + assert response.json["obligated_funding"] == "0.0" + + +def test_get_nested_attribute_existing_attribute(): + obj = DummyObject() + result = get_nested_attribute(obj, "nested.value") + assert result == "test_value" + + +def test_get_nested_attribute_non_existing_attribute(): + obj = DummyObject() + result = get_nested_attribute(obj, "nested.non_existing") + assert result is None + + +def test_get_nested_attribute_non_existing_top_level(): + obj = DummyObject() + result = get_nested_attribute(obj, "non_existing") + assert result is None + + +def test_filter_cans_by_attribute(): + cans = [ + MagicMock(active_period=1, funding_details=MagicMock(method_of_transfer="DIRECT")), + MagicMock(active_period=2, funding_details=MagicMock(method_of_transfer="IAA")), + MagicMock(active_period=1, funding_details=MagicMock(method_of_transfer="DIRECT")), + ] + + filtered_cans = filter_by_attribute(cans, "funding_details.method_of_transfer", ["DIRECT"]) + + assert len(filtered_cans) == 2 + + +def test_filter_cans_by_fiscal_year_budget(): + cans = [ + MagicMock(funding_budgets=[MagicMock(budget=1000001.0)]), + MagicMock(funding_budgets=[MagicMock(budget=2000000.0)]), + MagicMock(funding_budgets=[MagicMock(budget=500000.0)]), + ] + + fiscal_year_budget = [1000000, 2000000] + filtered_cans = filter_by_fiscal_year_budget(cans, fiscal_year_budget) + + assert len(filtered_cans) == 2 + + +def test_filter_cans_by_fiscal_year_budget_no_match(): + cans = [ + MagicMock(funding_budgets=[MagicMock(budget=500000.0, fiscal_year=2023)]), + MagicMock(funding_budgets=[MagicMock(budget=7000000.0, fiscal_year=2024)]), + ] + + fiscal_year_budget = [1000000, 2000000] + filtered_cans = filter_by_fiscal_year_budget(cans, fiscal_year_budget) + + assert len(filtered_cans) == 0 + + +@pytest.mark.parametrize( + "active_period, transfer, portfolio, fy_budget, expected_count", + [ + (None, None, None, None, 3), + ([1], None, None, None, 2), + (None, ["DIRECT"], None, None, 1), + (None, None, None, [100000, 200000], 2), + ([1], ["IAA"], ["HS"], [100000, 200000], 0), + ], +) +def test_filter_cans(active_period, transfer, portfolio, fy_budget, expected_count): + cans = [ + MagicMock( + active_period=1, + funding_details=MagicMock(method_of_transfer="DIRECT"), + portfolios=[MagicMock(abbr="HS")], + funding_budgets=[MagicMock(budget=150000)], + ), + MagicMock( + active_period=2, + funding_details=MagicMock(method_of_transfer="IAA"), + portfolios=[MagicMock(abbr="HS")], + funding_budgets=[MagicMock(budget=50000)], + ), + MagicMock( + active_period=1, + funding_details=MagicMock(method_of_transfer="IAA"), + portfolios=[MagicMock(abbr="HMRF")], + funding_budgets=[MagicMock(budget=200000)], + ), + ] + + filtered_cans = get_filtered_cans( + cans, active_period=active_period, transfer=transfer, portfolio=portfolio, fy_budget=fy_budget + ) + + assert len(filtered_cans) == expected_count + + +def test_aggregate_funding_summaries(): + funding_sums = [ + { + "available_funding": 100000, + "cans": [ + { + "can": { + "id": 1, + "description": "Grant for educational projects", + "amount": 50000, + "obligate_by": 2025, + }, + "carry_forward_label": "2024 Carry Forward", + "expiration_date": "10/01/2025", + } + ], + "carry_forward_funding": 20000, + "received_funding": 75000, + "expected_funding": 125000 - 75000, + "in_draft_funding": 0, + "in_execution_funding": 50000, + "obligated_funding": 30000, + "planned_funding": 120000, + "total_funding": 125000, + "new_funding": 100000 + 20000, + }, + { + "available_funding": 150000, + "cans": [ + { + "can": { + "id": 2, + "description": "Infrastructure development grant", + "amount": 70000, + "obligate_by": 2026, + }, + "carry_forward_label": "2025 Carry Forward", + "expiration_date": "10/01/2026", + } + ], + "carry_forward_funding": 30000, + "received_funding": 100000, + "expected_funding": 180000 - 100000, + "in_draft_funding": 0, + "in_execution_funding": 80000, + "obligated_funding": 50000, + "planned_funding": 160000, + "total_funding": 180000, + "new_funding": 150000 + 30000, + }, + ] + + result = aggregate_funding_summaries(funding_sums) + + assert result == { + "available_funding": Decimal("250000.0"), + "cans": [ + { + "can": {"amount": 50000, "description": "Grant for educational projects", "id": 1, "obligate_by": 2025}, + "carry_forward_label": "2024 Carry Forward", + "expiration_date": "10/01/2025", + }, + { + "can": { + "amount": 70000, + "description": "Infrastructure development grant", + "id": 2, + "obligate_by": 2026, + }, + "carry_forward_label": "2025 Carry Forward", + "expiration_date": "10/01/2026", + }, + ], + "carry_forward_funding": Decimal("50000.0"), + "expected_funding": Decimal("130000.0"), + "in_draft_funding": Decimal("0.0"), + "in_execution_funding": Decimal("130000.0"), + "new_funding": Decimal("300000.0"), + "obligated_funding": Decimal("80000.0"), + "planned_funding": Decimal("280000.0"), + "received_funding": Decimal("175000.0"), + "total_funding": Decimal("305000.0"), + } + + +@pytest.mark.usefixtures("app_ctx") +@pytest.mark.usefixtures("loaded_db") +def test_can_get_can_funding_summary_all_cans(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: + query_params = f"can_ids={0}" response = auth_client.get(f"/api/v1/can-funding-summary?{query_params}") diff --git a/frontend/src/api/opsAPI.js b/frontend/src/api/opsAPI.js index ae862f4112..b30b17dce1 100644 --- a/frontend/src/api/opsAPI.js +++ b/frontend/src/api/opsAPI.js @@ -204,17 +204,36 @@ export const opsApi = createApi({ providesTags: ["Cans"] }), getCanFundingSummary: builder.query({ - query: () => - // `/can-funding-summary?can_ids=${ids} - // ${fiscalYear ? `&fiscal_year=${fiscalYear}` : ""} - // ${activePeriod ? `&active_period=${activePeriod}` : ""} - // ${transfer ? `&transfer=${transfer}` : ""} - // ${portfolio ? `&portfolio=${portfolio}` : ""} - // ${fyBudgets ? `&fy_budget=${fyBudgets[0]}` : ""} - // ${fyBudgets ? `&fy_budget=${fyBudgets[1]}` : ""} - // `, - `/can-funding-summary?can_ids=0&fiscal_year=2025`, + query: ({ ids, fiscalYear, activePeriod, transfer, portfolio, fyBudgets }) => { + const queryParams = []; + if (ids && ids.length > 0) { + ids.forEach(id => queryParams.push(`can_ids=${id}`)); + } + + if (fiscalYear) { + queryParams.push(`fiscal_year=${fiscalYear}`); + } + + if (activePeriod && activePeriod.length > 0) { + activePeriod.forEach(period => queryParams.push(`active_period=${period}`)); + } + + if (transfer && transfer.length > 0) { + transfer.forEach(t => queryParams.push(`transfer=${t}`)); + } + + if (portfolio && portfolio.length > 0) { + portfolio.forEach(p => queryParams.push(`portfolio=${p}`)); + } + + if (fyBudgets && fyBudgets.length === 2) { + queryParams.push(`fy_budget=${fyBudgets[0]}`); + queryParams.push(`fy_budget=${fyBudgets[1]}`); + } + + return `/can-funding-summary?${queryParams.join("&")}`; + }, providesTags: ["CanFunding"] }), getNotificationsByUserId: builder.query({ diff --git a/frontend/src/pages/cans/list/CanList.jsx b/frontend/src/pages/cans/list/CanList.jsx index 888828aa9a..27b8b2409d 100644 --- a/frontend/src/pages/cans/list/CanList.jsx +++ b/frontend/src/pages/cans/list/CanList.jsx @@ -28,10 +28,12 @@ const CanList = () => { const selectedFiscalYear = useSelector((state) => state.canDetail.selectedFiscalYear); const fiscalYear = Number(selectedFiscalYear.value); const { data: canList, isError, isLoading } = useGetCansQuery({}); + const { data: fundingSummaryData, isLoading: fundingSummaryisLoading } = useGetCanFundingSummaryQuery({ - ids: 0, + ids: [0], fiscalYear: 2025 }); + const [filters, setFilters] = React.useState({ activePeriod: [], transfer: [], @@ -51,6 +53,7 @@ const CanList = () => { const [minFYBudget, maxFYBudget] = [sortedFYBudgets[0], sortedFYBudgets[sortedFYBudgets.length - 1]]; if (isLoading || fundingSummaryisLoading) { + console.log({ fundingSummaryData }) return (

Loading...

From c555b068584c239162449a9f14bc48b67861c662 Mon Sep 17 00:00:00 2001 From: maiyerlee Date: Wed, 20 Nov 2024 21:02:08 -0600 Subject: [PATCH 09/15] refactor: rename view for consistency --- backend/ops_api/ops/resources/can_funding_summary.py | 2 +- backend/ops_api/ops/urls.py | 4 ++-- backend/ops_api/ops/views.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/ops_api/ops/resources/can_funding_summary.py b/backend/ops_api/ops/resources/can_funding_summary.py index dc1f8e0c38..7c2bd6feec 100644 --- a/backend/ops_api/ops/resources/can_funding_summary.py +++ b/backend/ops_api/ops/resources/can_funding_summary.py @@ -8,7 +8,7 @@ from ops_api.ops.utils.response import make_response_with_headers -class CANFundingSummaryItemAPI(BaseItemAPI): +class CANFundingSummaryListAPI(BaseItemAPI): def __init__(self, model: BaseModel): super().__init__(model) diff --git a/backend/ops_api/ops/urls.py b/backend/ops_api/ops/urls.py index e23321d77d..efdcafd95e 100644 --- a/backend/ops_api/ops/urls.py +++ b/backend/ops_api/ops/urls.py @@ -17,7 +17,7 @@ CAN_FUNDING_DETAILS_LIST_API_VIEW_FUNC, CAN_FUNDING_RECEIVED_ITEM_API_VIEW_FUNC, CAN_FUNDING_RECEIVED_LIST_API_VIEW_FUNC, - CAN_FUNDING_SUMMARY_ITEM_API_VIEW_FUNC, + CAN_FUNDING_SUMMARY_LIST_API_VIEW_FUNC, CAN_ITEM_API_VIEW_FUNC, CAN_LIST_API_VIEW_FUNC, CANS_BY_PORTFOLIO_API_VIEW_FUNC, @@ -173,7 +173,7 @@ def register_api(api_bp: Blueprint) -> None: api_bp.add_url_rule( "/can-funding-summary", - view_func=CAN_FUNDING_SUMMARY_ITEM_API_VIEW_FUNC, + view_func=CAN_FUNDING_SUMMARY_LIST_API_VIEW_FUNC, ) api_bp.add_url_rule( "/portfolio-funding-summary/", diff --git a/backend/ops_api/ops/views.py b/backend/ops_api/ops/views.py index 117506ce1a..89b5647a43 100644 --- a/backend/ops_api/ops/views.py +++ b/backend/ops_api/ops/views.py @@ -44,10 +44,10 @@ ) from ops_api.ops.resources.azure import SasToken from ops_api.ops.resources.budget_line_items import BudgetLineItemsItemAPI, BudgetLineItemsListAPI -from ops_api.ops.resources.can_funding_received import CANFundingReceivedItemAPI, CANFundingReceivedListAPI from ops_api.ops.resources.can_funding_budget import CANFundingBudgetItemAPI, CANFundingBudgetListAPI from ops_api.ops.resources.can_funding_details import CANFundingDetailsItemAPI, CANFundingDetailsListAPI -from ops_api.ops.resources.can_funding_summary import CANFundingSummaryItemAPI +from ops_api.ops.resources.can_funding_received import CANFundingReceivedItemAPI, CANFundingReceivedListAPI +from ops_api.ops.resources.can_funding_summary import CANFundingSummaryListAPI from ops_api.ops.resources.cans import CANItemAPI, CANListAPI, CANsByPortfolioAPI from ops_api.ops.resources.change_requests import ChangeRequestListAPI, ChangeRequestReviewAPI from ops_api.ops.resources.contract import ContractItemAPI, ContractListAPI @@ -147,7 +147,7 @@ USERS_LIST_API_VIEW_FUNC = UsersListAPI.as_view("users-group", User) # FUNDING SUMMARY ENDPOINTS -CAN_FUNDING_SUMMARY_ITEM_API_VIEW_FUNC = CANFundingSummaryItemAPI.as_view("can-funding-summary-item", CAN) +CAN_FUNDING_SUMMARY_LIST_API_VIEW_FUNC = CANFundingSummaryListAPI.as_view("can-funding-summary-list", CAN) PORTFOLIO_FUNDING_SUMMARY_ITEM_API_VIEW_FUNC = PortfolioFundingSummaryItemAPI.as_view( "portfolio-funding-summary-item", Portfolio ) From cb42a460ec174992f0f8e945e54dd98479ecfb04 Mon Sep 17 00:00:00 2001 From: maiyerlee Date: Thu, 21 Nov 2024 00:00:55 -0600 Subject: [PATCH 10/15] chore: add Marshmallow schema --- .../ops/resources/can_funding_summary.py | 12 ++++-- .../ops/schemas/can_funding_summary.py | 38 +++++++++++++++++++ .../components/CANs/CANTable/CANTableRow.jsx | 2 +- frontend/src/pages/cans/list/CanList.jsx | 2 +- 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 backend/ops_api/ops/schemas/can_funding_summary.py diff --git a/backend/ops_api/ops/resources/can_funding_summary.py b/backend/ops_api/ops/resources/can_funding_summary.py index 7c2bd6feec..187f92bfac 100644 --- a/backend/ops_api/ops/resources/can_funding_summary.py +++ b/backend/ops_api/ops/resources/can_funding_summary.py @@ -4,6 +4,7 @@ from ops_api.ops.auth.auth_types import Permission, PermissionType from ops_api.ops.auth.decorators import is_authorized from ops_api.ops.base_views import BaseItemAPI +from ops_api.ops.schemas.can_funding_summary import CANFundingSummaryResponseSchema from ops_api.ops.utils.cans import aggregate_funding_summaries, get_can_funding_summary, get_filtered_cans from ops_api.ops.utils.response import make_response_with_headers @@ -42,10 +43,10 @@ def get(self) -> Response: def _handle_single_can_no_filters(self, can_id: str, fiscal_year: str = None) -> Response: can = self._get_item(can_id) can_funding_summary = get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) - return make_response_with_headers(can_funding_summary) + return self._make_response(can_funding_summary) - @staticmethod def _apply_filters_and_return( + self, cans: list, fiscal_year: str = None, active_period: list = None, @@ -58,4 +59,9 @@ def _apply_filters_and_return( get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) for can in cans_with_filters ] aggregated_summary = aggregate_funding_summaries(can_funding_summaries) - return make_response_with_headers(aggregated_summary) + return self._make_response(aggregated_summary) + + @staticmethod + def _make_response(result) -> Response: + schema = CANFundingSummaryResponseSchema(many=False) + return make_response_with_headers(schema.dump(result)) diff --git a/backend/ops_api/ops/schemas/can_funding_summary.py b/backend/ops_api/ops/schemas/can_funding_summary.py new file mode 100644 index 0000000000..933001bdd6 --- /dev/null +++ b/backend/ops_api/ops/schemas/can_funding_summary.py @@ -0,0 +1,38 @@ +from marshmallow import Schema, fields, validate +from ops_api.ops.schemas.cans import CANSchema + + +class FiscalYearBudgetSchema(Schema): + min_fy_budget = fields.Integer(allow_none=True) + max_fy_budget = fields.Integer(allow_none=True) + + +class GetCANFundingSummaryRequestSchema(Schema): + can_ids = fields.List(fields.Integer(), required=False) + fiscal_year = fields.String(allow_none=True) + active_period = fields.List(fields.Integer(), allow_none=True) + transfer = fields.List( + fields.String(validate=validate.OneOf(["DIRECT", "COST_SHARE", "IAA", "IDDA"])), allow_none=True + ) + portfolio = fields.List(fields.String(), allow_none=True) + fy_budget = fields.List(fields.Integer(), min_items=2, max_items=2, allow_none=True) + + +class CANSFundingSourceSchema(Schema): + can = fields.Nested(CANSchema()) + carry_forward_label = fields.String(allow_none=False) + expiration_date = fields.String(allow_none=False) + + +class CANFundingSummaryResponseSchema(Schema): + available_funding = fields.String(allow_none=False) + cans = fields.Nested(CANSFundingSourceSchema()) + carry_forward_funding = fields.String(allow_none=False) + received_funding = fields.String(allow_none=False) + expected_funding = fields.String(allow_none=False) + in_draft_funding = fields.String(allow_none=False) + in_execution_funding = fields.String(allow_none=False) + obligated_funding = fields.String(allow_none=False) + planned_funding = fields.String(allow_none=False) + total_funding = fields.String(allow_none=False) + new_funding = fields.String(allow_none=False) diff --git a/frontend/src/components/CANs/CANTable/CANTableRow.jsx b/frontend/src/components/CANs/CANTable/CANTableRow.jsx index 236ca5299b..d7a8257367 100644 --- a/frontend/src/components/CANs/CANTable/CANTableRow.jsx +++ b/frontend/src/components/CANs/CANTable/CANTableRow.jsx @@ -37,7 +37,7 @@ const CANTableRow = ({ data: fundingSummary, isError, isLoading - } = useGetCanFundingSummaryQuery({ ids: canId, fiscalYear: fiscalYear }); + } = useGetCanFundingSummaryQuery({ ids: [canId], fiscalYear: fiscalYear }); const availableFunds = fundingSummary?.available_funding ?? 0; if (isLoading) diff --git a/frontend/src/pages/cans/list/CanList.jsx b/frontend/src/pages/cans/list/CanList.jsx index 27b8b2409d..cdc8488990 100644 --- a/frontend/src/pages/cans/list/CanList.jsx +++ b/frontend/src/pages/cans/list/CanList.jsx @@ -31,7 +31,7 @@ const CanList = () => { const { data: fundingSummaryData, isLoading: fundingSummaryisLoading } = useGetCanFundingSummaryQuery({ ids: [0], - fiscalYear: 2025 + fiscalYear: fiscalYear }); const [filters, setFilters] = React.useState({ From f25ece42fab270d2150da7460820b292264c31e4 Mon Sep 17 00:00:00 2001 From: maiyerlee Date: Thu, 21 Nov 2024 01:02:48 -0600 Subject: [PATCH 11/15] chore: renaming, update can schema to basic --- .../ops/resources/can_funding_summary.py | 21 +++++++---- .../ops/schemas/can_funding_summary.py | 36 +++++++++---------- .../test_can_funding_summary.py | 13 ++++++- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/backend/ops_api/ops/resources/can_funding_summary.py b/backend/ops_api/ops/resources/can_funding_summary.py index 187f92bfac..aaa7eecf29 100644 --- a/backend/ops_api/ops/resources/can_funding_summary.py +++ b/backend/ops_api/ops/resources/can_funding_summary.py @@ -1,10 +1,11 @@ from flask import Response, request +from marshmallow import ValidationError from models.base import BaseModel from ops_api.ops.auth.auth_types import Permission, PermissionType from ops_api.ops.auth.decorators import is_authorized from ops_api.ops.base_views import BaseItemAPI -from ops_api.ops.schemas.can_funding_summary import CANFundingSummaryResponseSchema +from ops_api.ops.schemas.can_funding_summary import GetCANFundingSummaryResponseSchema from ops_api.ops.utils.cans import aggregate_funding_summaries, get_can_funding_summary, get_filtered_cans from ops_api.ops.utils.response import make_response_with_headers @@ -43,7 +44,7 @@ def get(self) -> Response: def _handle_single_can_no_filters(self, can_id: str, fiscal_year: str = None) -> Response: can = self._get_item(can_id) can_funding_summary = get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) - return self._make_response(can_funding_summary) + return self._create_can_funding_budget_response(can_funding_summary) def _apply_filters_and_return( self, @@ -54,14 +55,20 @@ def _apply_filters_and_return( portfolio: list = None, fy_budget: list = None, ) -> Response: - cans_with_filters = get_filtered_cans(cans, fiscal_year, active_period, transfer, portfolio, fy_budget) + cans_with_filters = get_filtered_cans( + cans, int(fiscal_year) if fiscal_year else None, active_period, transfer, portfolio, fy_budget + ) can_funding_summaries = [ get_can_funding_summary(can, int(fiscal_year) if fiscal_year else None) for can in cans_with_filters ] aggregated_summary = aggregate_funding_summaries(can_funding_summaries) - return self._make_response(aggregated_summary) + return self._create_can_funding_budget_response(aggregated_summary) @staticmethod - def _make_response(result) -> Response: - schema = CANFundingSummaryResponseSchema(many=False) - return make_response_with_headers(schema.dump(result)) + def _create_can_funding_budget_response(result) -> Response: + try: + schema = GetCANFundingSummaryResponseSchema(many=False) + result = schema.dump(result) + return make_response_with_headers(result) + except ValidationError as e: + return make_response_with_headers({"Validation Error": str(e)}, 500) diff --git a/backend/ops_api/ops/schemas/can_funding_summary.py b/backend/ops_api/ops/schemas/can_funding_summary.py index 933001bdd6..97828c6425 100644 --- a/backend/ops_api/ops/schemas/can_funding_summary.py +++ b/backend/ops_api/ops/schemas/can_funding_summary.py @@ -1,5 +1,5 @@ from marshmallow import Schema, fields, validate -from ops_api.ops.schemas.cans import CANSchema +from ops_api.ops.schemas.cans import BasicCANSchema class FiscalYearBudgetSchema(Schema): @@ -19,20 +19,20 @@ class GetCANFundingSummaryRequestSchema(Schema): class CANSFundingSourceSchema(Schema): - can = fields.Nested(CANSchema()) - carry_forward_label = fields.String(allow_none=False) - expiration_date = fields.String(allow_none=False) - - -class CANFundingSummaryResponseSchema(Schema): - available_funding = fields.String(allow_none=False) - cans = fields.Nested(CANSFundingSourceSchema()) - carry_forward_funding = fields.String(allow_none=False) - received_funding = fields.String(allow_none=False) - expected_funding = fields.String(allow_none=False) - in_draft_funding = fields.String(allow_none=False) - in_execution_funding = fields.String(allow_none=False) - obligated_funding = fields.String(allow_none=False) - planned_funding = fields.String(allow_none=False) - total_funding = fields.String(allow_none=False) - new_funding = fields.String(allow_none=False) + can = fields.Nested(BasicCANSchema()) + carry_forward_label = fields.String(allow_none=True) + expiration_date = fields.String(allow_none=True) + + +class GetCANFundingSummaryResponseSchema(Schema): + available_funding = fields.String(allow_none=True) + cans = fields.List(fields.Nested(CANSFundingSourceSchema), default=[]) + carry_forward_funding = fields.String(allow_none=True) + received_funding = fields.String(allow_none=True) + expected_funding = fields.String(allow_none=True) + in_draft_funding = fields.String(allow_none=True) + in_execution_funding = fields.String(allow_none=True) + obligated_funding = fields.String(allow_none=True) + planned_funding = fields.String(allow_none=True) + total_funding = fields.String(allow_none=True) + new_funding = fields.String(allow_none=True) diff --git a/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py b/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py index d18d483371..0f3c925324 100644 --- a/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py +++ b/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py @@ -27,6 +27,17 @@ def __init__(self): self.value = "test_value" +@pytest.mark.usefixtures("app_ctx") +@pytest.mark.usefixtures("loaded_db") +def test_can_get_can_funding_summary_all_cans_fiscal_year_match(auth_client: FlaskClient) -> None: + query_params = f"can_ids={0}&fiscal_year=2023" + + response = auth_client.get(f"/api/v1/can-funding-summary?{query_params}") + + assert response.status_code == 200 + assert len(response.json["cans"]) == 11 + + @pytest.mark.usefixtures("app_ctx") @pytest.mark.usefixtures("loaded_db") def test_can_get_can_funding_summary_all_cans_no_fiscal_year_match( @@ -484,7 +495,7 @@ def test_aggregate_funding_summaries(): @pytest.mark.usefixtures("app_ctx") @pytest.mark.usefixtures("loaded_db") -def test_can_get_can_funding_summary_all_cans(auth_client: FlaskClient, test_cans: list[Type[CAN]]) -> None: +def test_can_get_can_funding_summary_all_cans(auth_client: FlaskClient) -> None: query_params = f"can_ids={0}" response = auth_client.get(f"/api/v1/can-funding-summary?{query_params}") From 7db63b3ac717bbd1f5f64e5e2f54ea2da0c59866 Mon Sep 17 00:00:00 2001 From: maiyerlee Date: Thu, 21 Nov 2024 01:14:50 -0600 Subject: [PATCH 12/15] feat: pass funding totals to summary card --- .../ops_api/ops/schemas/can_funding_summary.py | 18 +----------------- .../CANs/CANSummaryCards/CANSummaryCards.jsx | 10 +++++----- frontend/src/pages/cans/list/CanList.jsx | 8 +++++++- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/backend/ops_api/ops/schemas/can_funding_summary.py b/backend/ops_api/ops/schemas/can_funding_summary.py index 97828c6425..633cc0a34f 100644 --- a/backend/ops_api/ops/schemas/can_funding_summary.py +++ b/backend/ops_api/ops/schemas/can_funding_summary.py @@ -1,23 +1,7 @@ -from marshmallow import Schema, fields, validate +from marshmallow import Schema, fields from ops_api.ops.schemas.cans import BasicCANSchema -class FiscalYearBudgetSchema(Schema): - min_fy_budget = fields.Integer(allow_none=True) - max_fy_budget = fields.Integer(allow_none=True) - - -class GetCANFundingSummaryRequestSchema(Schema): - can_ids = fields.List(fields.Integer(), required=False) - fiscal_year = fields.String(allow_none=True) - active_period = fields.List(fields.Integer(), allow_none=True) - transfer = fields.List( - fields.String(validate=validate.OneOf(["DIRECT", "COST_SHARE", "IAA", "IDDA"])), allow_none=True - ) - portfolio = fields.List(fields.String(), allow_none=True) - fy_budget = fields.List(fields.Integer(), min_items=2, max_items=2, allow_none=True) - - class CANSFundingSourceSchema(Schema): can = fields.Nested(BasicCANSchema()) carry_forward_label = fields.String(allow_none=True) diff --git a/frontend/src/components/CANs/CANSummaryCards/CANSummaryCards.jsx b/frontend/src/components/CANs/CANSummaryCards/CANSummaryCards.jsx index 494ca09a76..e999b40c0c 100644 --- a/frontend/src/components/CANs/CANSummaryCards/CANSummaryCards.jsx +++ b/frontend/src/components/CANs/CANSummaryCards/CANSummaryCards.jsx @@ -12,11 +12,11 @@ import LineGraphWithLegendCard from "../../UI/Cards/LineGraphWithLegendCard"; * @param {CANSummaryCardsProps} props * @returns {JSX.Element} - The CANSummaryCards component. */ -const CANSummaryCards = ({ fiscalYear }) => { - const totalSpending = 42_650_000; - const totalBudget = 56_000_000; - const newFunding = 41_000_000; - const carryForward = 15_000_000; +const CANSummaryCards = ({ fiscalYear, totalSpending, totalBudget, newFunding, carryForward }) => { + // const totalSpending = 42_650_000; + // const totalBudget = 56_000_000; + // const newFunding = 41_000_000; + // const carryForward = 15_000_000; const data = [ { id: 1, diff --git a/frontend/src/pages/cans/list/CanList.jsx b/frontend/src/pages/cans/list/CanList.jsx index cdc8488990..eda1cfcd1d 100644 --- a/frontend/src/pages/cans/list/CanList.jsx +++ b/frontend/src/pages/cans/list/CanList.jsx @@ -105,7 +105,13 @@ const CanList = () => { fyBudgetRange={[minFYBudget, maxFYBudget]} /> } - SummaryCardsSection={} + SummaryCardsSection={} /> Date: Thu, 21 Nov 2024 01:31:19 -0600 Subject: [PATCH 13/15] chore: calc total spending --- .../src/components/CANs/CANSummaryCards/CANSummaryCards.jsx | 3 ++- frontend/src/pages/cans/list/CanList.jsx | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/CANs/CANSummaryCards/CANSummaryCards.jsx b/frontend/src/components/CANs/CANSummaryCards/CANSummaryCards.jsx index e999b40c0c..5d6a111b9e 100644 --- a/frontend/src/components/CANs/CANSummaryCards/CANSummaryCards.jsx +++ b/frontend/src/components/CANs/CANSummaryCards/CANSummaryCards.jsx @@ -12,11 +12,12 @@ import LineGraphWithLegendCard from "../../UI/Cards/LineGraphWithLegendCard"; * @param {CANSummaryCardsProps} props * @returns {JSX.Element} - The CANSummaryCards component. */ -const CANSummaryCards = ({ fiscalYear, totalSpending, totalBudget, newFunding, carryForward }) => { +const CANSummaryCards = ({ fiscalYear, totalBudget, newFunding, carryForward, plannedFunding, obligatedFunding, inExecutionFunding }) => { // const totalSpending = 42_650_000; // const totalBudget = 56_000_000; // const newFunding = 41_000_000; // const carryForward = 15_000_000; + const totalSpending = Number(plannedFunding) + Number(obligatedFunding) + Number(inExecutionFunding); const data = [ { id: 1, diff --git a/frontend/src/pages/cans/list/CanList.jsx b/frontend/src/pages/cans/list/CanList.jsx index eda1cfcd1d..5245d40653 100644 --- a/frontend/src/pages/cans/list/CanList.jsx +++ b/frontend/src/pages/cans/list/CanList.jsx @@ -108,9 +108,11 @@ const CanList = () => { SummaryCardsSection={} /> Date: Thu, 21 Nov 2024 02:40:38 -0600 Subject: [PATCH 14/15] fix: correct id param and endpoint response usage --- frontend/src/api/getCanFundingSummary.js | 2 +- frontend/src/components/CANs/CANFundingCard/CANFundingCard.jsx | 2 +- frontend/src/pages/cans/detail/Can.hooks.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/getCanFundingSummary.js b/frontend/src/api/getCanFundingSummary.js index 9a7630408d..93e9e5baf0 100644 --- a/frontend/src/api/getCanFundingSummary.js +++ b/frontend/src/api/getCanFundingSummary.js @@ -5,7 +5,7 @@ export const getPortfolioCansFundingDetails = async (item) => { const api_version = ApplicationContext.get().helpers().backEndConfig.apiVersion; const responseData = await ApplicationContext.get() .helpers() - .callBackend(`/api/${api_version}/can-funding-summary?can_id=${item.id}?fiscal_year=${item.fiscalYear}`, "get"); + .callBackend(`/api/${api_version}/can-funding-summary?can_ids=${item.id}?fiscal_year=${item.fiscalYear}`, "get"); return responseData; } return {}; diff --git a/frontend/src/components/CANs/CANFundingCard/CANFundingCard.jsx b/frontend/src/components/CANs/CANFundingCard/CANFundingCard.jsx index f57d106ac8..caf1588d67 100644 --- a/frontend/src/components/CANs/CANFundingCard/CANFundingCard.jsx +++ b/frontend/src/components/CANs/CANFundingCard/CANFundingCard.jsx @@ -21,7 +21,7 @@ import LineGraph from "../../UI/DataViz/LineGraph"; const CANFundingCard = ({ can, pendingAmount, afterApproval }) => { const adjustAmount = afterApproval ? pendingAmount : 0; const canId = can?.id; - const { data, error, isLoading } = useGetCanFundingSummaryQuery({ id: canId }); + const { data, error, isLoading } = useGetCanFundingSummaryQuery({ ids: [canId] }); if (isLoading) { return
Loading...
; diff --git a/frontend/src/pages/cans/detail/Can.hooks.js b/frontend/src/pages/cans/detail/Can.hooks.js index 30566d0eaf..197e3f3c9f 100644 --- a/frontend/src/pages/cans/detail/Can.hooks.js +++ b/frontend/src/pages/cans/detail/Can.hooks.js @@ -17,7 +17,7 @@ export default function useCan() { /** @type {{data?: CAN | undefined, isLoading: boolean}} */ const { data: can, isLoading } = useGetCanByIdQuery(canId); const { data: CANFunding, isLoading: CANFundingLoading } = useGetCanFundingSummaryQuery({ - id: canId, + ids: [canId], fiscalYear: fiscalYear }); From a633d3b48d24be254a2eb8c0598e8376b56b7e62 Mon Sep 17 00:00:00 2001 From: maiyerlee Date: Thu, 21 Nov 2024 09:37:35 -0600 Subject: [PATCH 15/15] chore: remove commented out code --- .../src/components/CANs/CANSummaryCards/CANSummaryCards.jsx | 4 ---- frontend/src/pages/cans/list/CanList.jsx | 1 - 2 files changed, 5 deletions(-) diff --git a/frontend/src/components/CANs/CANSummaryCards/CANSummaryCards.jsx b/frontend/src/components/CANs/CANSummaryCards/CANSummaryCards.jsx index 5d6a111b9e..7d6a1a8768 100644 --- a/frontend/src/components/CANs/CANSummaryCards/CANSummaryCards.jsx +++ b/frontend/src/components/CANs/CANSummaryCards/CANSummaryCards.jsx @@ -13,10 +13,6 @@ import LineGraphWithLegendCard from "../../UI/Cards/LineGraphWithLegendCard"; * @returns {JSX.Element} - The CANSummaryCards component. */ const CANSummaryCards = ({ fiscalYear, totalBudget, newFunding, carryForward, plannedFunding, obligatedFunding, inExecutionFunding }) => { - // const totalSpending = 42_650_000; - // const totalBudget = 56_000_000; - // const newFunding = 41_000_000; - // const carryForward = 15_000_000; const totalSpending = Number(plannedFunding) + Number(obligatedFunding) + Number(inExecutionFunding); const data = [ { diff --git a/frontend/src/pages/cans/list/CanList.jsx b/frontend/src/pages/cans/list/CanList.jsx index 5245d40653..440088ac4e 100644 --- a/frontend/src/pages/cans/list/CanList.jsx +++ b/frontend/src/pages/cans/list/CanList.jsx @@ -53,7 +53,6 @@ const CanList = () => { const [minFYBudget, maxFYBudget] = [sortedFYBudgets[0], sortedFYBudgets[sortedFYBudgets.length - 1]]; if (isLoading || fundingSummaryisLoading) { - console.log({ fundingSummaryData }) return (

Loading...