diff --git a/usaspending_api/spending_explorer/tests/integration/test_spending_explorer.py b/usaspending_api/spending_explorer/tests/integration/test_spending_explorer.py index d7dcebe49c..a347ac5c05 100644 --- a/usaspending_api/spending_explorer/tests/integration/test_spending_explorer.py +++ b/usaspending_api/spending_explorer/tests/integration/test_spending_explorer.py @@ -1,17 +1,18 @@ import copy import json -import pytest - from datetime import datetime, timezone +from unittest.mock import patch + +import pytest from model_bakery import baker from rest_framework import status +from usaspending_api.accounts.models import FederalAccount, TreasuryAppropriationAccount from usaspending_api.awards.models import FinancialAccountsByAwards from usaspending_api.financial_activities.models import FinancialAccountsByProgramActivityObjectClass -from usaspending_api.accounts.models import FederalAccount, TreasuryAppropriationAccount -from usaspending_api.references.models import Agency, GTASSF133Balances, ToptierAgency, ObjectClass +from usaspending_api.references.models import Agency, GTASSF133Balances, ObjectClass, ToptierAgency +from usaspending_api.search.models import AwardSearch, TransactionSearch from usaspending_api.submissions.models import DABSSubmissionWindowSchedule, SubmissionAttributes -from usaspending_api.search.models import TransactionSearch, AwardSearch ENDPOINT_URL = "/api/v2/spending/" CONTENT_TYPE = "application/json" @@ -187,7 +188,6 @@ def setup_only_dabs_window(): @pytest.mark.django_db def test_unreported_data_actual_value_file_b(client): - models = copy.deepcopy(GLOBAL_MOCK_DICT) for entry in models: baker.make(entry.pop("model"), **entry) @@ -408,7 +408,6 @@ def test_federal_account_linkage(client): @pytest.mark.django_db def test_budget_function_filter_success(setup_only_dabs_window, client): - # Test for Budget Function Results resp = client.post( "/api/v2/spending/", @@ -747,7 +746,6 @@ def test_agency_failure(client): @pytest.mark.django_db def test_object_budget_match(client): - models = copy.deepcopy(GLOBAL_MOCK_DICT) for entry in models: baker.make(entry.pop("model"), **entry) @@ -779,7 +777,6 @@ def test_object_budget_match(client): @pytest.mark.django_db def test_period(setup_only_dabs_window, client): - # Test for Object Class Results resp = client.post( "/api/v2/spending/", @@ -1198,3 +1195,193 @@ def test_unreported_file_c(client): assert response["total"] != response2["total"] assert response["total"] == -12 assert response2["total"] == -15 + + +@pytest.mark.django_db +def test_award_type_respects_limit(client): + """Test that the award type endpoint respects the SPENDING_EXPLORER_LIMIT when patched to a lower value.""" + + # Setup test data + baker.make( + DABSSubmissionWindowSchedule, + submission_fiscal_year=2020, + submission_fiscal_quarter=1, + submission_fiscal_month=3, + is_quarter=True, + submission_reveal_date=datetime(2020, 1, 15, tzinfo=timezone.utc), + period_end_date=datetime(2019, 12, 31, tzinfo=timezone.utc), + ) + + submission = baker.make( + SubmissionAttributes, + reporting_fiscal_year=2020, + reporting_fiscal_period=3, + reporting_fiscal_quarter=1, + ) + + toptier = baker.make(ToptierAgency, toptier_code="001", name="Test Agency") + baker.make(Agency, toptier_agency=toptier, toptier_flag=True) + treasury_account = baker.make( + TreasuryAppropriationAccount, + funding_toptier_agency=toptier, + ) + + # Create 5 awards (more than our test limit of 2) + awards = [] + for i in range(5): + award = baker.make( + AwardSearch, + award_id=i + 1, + piid=f"PIID-{i + 1}", + recipient_name=f"Recipient {i + 1}", + ) + awards.append(award) + + baker.make( + FinancialAccountsByAwards, + submission=submission, + award=award, + treasury_account=treasury_account, + transaction_obligated_amount=-(i + 1) * 1000, # Different amounts for sorting + ) + + # Patch the limit to 2 + with patch("usaspending_api.spending_explorer.v2.views.spending_explorer.SPENDING_EXPLORER_LIMIT", 2): + json_request = {"type": "award", "filters": {"fy": "2020", "quarter": "1"}} + + response = client.post(path=ENDPOINT_URL, content_type=CONTENT_TYPE, data=json.dumps(json_request)) + + assert response.status_code == status.HTTP_200_OK + + json_response = response.json() + + # Validate that exactly 2 results are returned + assert len(json_response["results"]) == 2, f"Expected 2 results due to limit, got {len(json_response['results'])}" + + # Validate that the total still reflects all matching records (not just the limited results) + # This ensures the limit only affects the results array, not the total calculation + assert json_response["total"] < 0, "Total should be negative (sum of all obligations)" + + # Validate that results are sorted by amount (descending) + amounts = [result["amount"] for result in json_response["results"]] + assert amounts == sorted(amounts, reverse=True), "Results should be sorted by amount descending" + + +@pytest.mark.django_db +def test_non_award_type_ignores_limit(client): + """Test that non-award types (e.g., agency) do NOT apply the limit.""" + + # Setup test data + baker.make( + DABSSubmissionWindowSchedule, + submission_fiscal_year=2020, + submission_fiscal_quarter=1, + submission_fiscal_month=3, + is_quarter=True, + submission_reveal_date=datetime(2020, 1, 15, tzinfo=timezone.utc), + period_end_date=datetime(2019, 12, 31, tzinfo=timezone.utc), + ) + + submission = baker.make( + SubmissionAttributes, + reporting_fiscal_year=2020, + reporting_fiscal_period=3, + reporting_fiscal_quarter=1, + ) + + # Create 5 agencies + for i in range(5): + toptier = baker.make(ToptierAgency, toptier_code=f"00{i}", name=f"Agency {i + 1}") + baker.make(Agency, toptier_agency=toptier, toptier_flag=True) + + treasury_account = baker.make( + TreasuryAppropriationAccount, + funding_toptier_agency=toptier, + ) + + # Create some financial data for each agency + from usaspending_api.financial_activities.models import FinancialAccountsByProgramActivityObjectClass + + baker.make( + FinancialAccountsByProgramActivityObjectClass, + submission=submission, + treasury_account=treasury_account, + obligations_incurred_by_program_object_class_cpe=-(i + 1) * 1000, + ) + + # Patch the limit to 2 + with patch("usaspending_api.spending_explorer.v2.views.spending_explorer.SPENDING_EXPLORER_LIMIT", 2): + json_request = {"type": "agency", "filters": {"fy": "2020", "quarter": "1"}} + + response = client.post(path=ENDPOINT_URL, content_type=CONTENT_TYPE, data=json.dumps(json_request)) + + assert response.status_code == status.HTTP_200_OK + + json_response = response.json() + + # Validate that ALL results are returned (limit not applied for non-award types) + assert len(json_response["results"]) == 5, ( + f"Expected 5 results (no limit for agency type), got {len(json_response['results'])}" + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("award_type", ["award", "award_category", "recipient"]) +def test_award_types_with_limit(client, award_type): + """Test that all award-related types handle the limit appropriately.""" + + # Setup minimal test data + baker.make( + DABSSubmissionWindowSchedule, + submission_fiscal_year=2020, + submission_fiscal_quarter=1, + submission_fiscal_month=3, + is_quarter=True, + submission_reveal_date=datetime(2020, 1, 15, tzinfo=timezone.utc), + period_end_date=datetime(2019, 12, 31, tzinfo=timezone.utc), + ) + + submission = baker.make( + SubmissionAttributes, + reporting_fiscal_year=2020, + reporting_fiscal_period=3, + ) + + toptier = baker.make(ToptierAgency, toptier_code="001") + baker.make(Agency, toptier_agency=toptier, toptier_flag=True) + treasury_account = baker.make(TreasuryAppropriationAccount, funding_toptier_agency=toptier) + + # Create 3 awards + for i in range(3): + award = baker.make( + AwardSearch, + award_id=i + 1, + piid=f"PIID-{i + 1}", + recipient_name=f"Recipient {i + 1}", + category="contract", + ) + + baker.make( + FinancialAccountsByAwards, + submission=submission, + award=award, + treasury_account=treasury_account, + transaction_obligated_amount=-1000 * (i + 1), + ) + + with patch("usaspending_api.spending_explorer.v2.views.spending_explorer.SPENDING_EXPLORER_LIMIT", 2): + json_request = {"type": award_type, "filters": {"fy": "2020", "quarter": "1"}} + + response = client.post(path=ENDPOINT_URL, content_type=CONTENT_TYPE, data=json.dumps(json_request)) + + assert response.status_code == status.HTTP_200_OK + json_response = response.json() + + # Only "award" type applies limit + if award_type == "award": + assert len(json_response["results"]) == 2, ( + f"Award type should respect limit of 2, got {len(json_response['results'])}" + ) + else: + # award_category and recipient don't apply limit + assert len(json_response["results"]) >= 2, f"{award_type} type should not apply limit" diff --git a/usaspending_api/spending_explorer/v2/filters/explorer.py b/usaspending_api/spending_explorer/v2/filters/explorer.py index da3d7a593f..58a6d89464 100644 --- a/usaspending_api/spending_explorer/v2/filters/explorer.py +++ b/usaspending_api/spending_explorer/v2/filters/explorer.py @@ -1,20 +1,29 @@ -from django.db.models import Exists, F, OuterRef, Sum, TextField, Value +from django.db.models import Exists, F, OuterRef, QuerySet, Sum, TextField, Value from django_cte import With from usaspending_api.common.calculations.file_b import FileBCalculations from usaspending_api.references.models import Agency from usaspending_api.submissions.models import SubmissionAttributes +# Default maximum records returned by spending explorer (also used by the view) +SPENDING_EXPLORER_LIMIT = 1000 -class Explorer(object): +class Explorer: file_b_calculations = FileBCalculations() - def __init__(self, alt_set, queryset): + def __init__(self, alt_set: QuerySet, queryset: QuerySet, limit: int | None = SPENDING_EXPLORER_LIMIT) -> None: self.alt_set = alt_set self.queryset = queryset + self.limit = limit - def budget_function(self): + def _apply_limit(self, queryset: QuerySet) -> QuerySet: + """Apply the explorer limit to a queryset before it is evaluated""" + if self.limit is not None: + return queryset[: self.limit] + return queryset + + def budget_function(self) -> QuerySet: # Budget Function Queryset queryset = ( self.queryset.annotate( @@ -28,9 +37,9 @@ def budget_function(self): .order_by("-total") ) - return queryset + return self._apply_limit(queryset) - def budget_subfunction(self): + def budget_subfunction(self) -> QuerySet: # Budget Sub Function Queryset queryset = ( self.queryset.annotate( @@ -44,9 +53,9 @@ def budget_subfunction(self): .order_by("-total") ) - return queryset + return self._apply_limit(queryset) - def federal_account(self): + def federal_account(self) -> QuerySet: # Federal Account Queryset queryset = ( self.queryset.annotate( @@ -61,9 +70,9 @@ def federal_account(self): .order_by("-total") ) - return queryset + return self._apply_limit(queryset) - def program_activity(self): + def program_activity(self) -> QuerySet: # Program Activity Queryset queryset = ( self.queryset.annotate( @@ -77,9 +86,9 @@ def program_activity(self): .order_by("-total") ) - return queryset + return self._apply_limit(queryset) - def object_class(self): + def object_class(self) -> QuerySet: # Object Classes Queryset queryset = ( self.queryset.annotate( @@ -93,9 +102,9 @@ def object_class(self): .order_by("-total") ) - return queryset + return self._apply_limit(queryset) - def recipient(self): + def recipient(self) -> QuerySet: # Recipients Queryset alt_set = ( self.alt_set.filter(transaction_obligated_amount__isnull=False) @@ -110,9 +119,9 @@ def recipient(self): .order_by("-total") ) - return alt_set + return self._apply_limit(alt_set) - def agency(self): + def agency(self) -> QuerySet: # Funding Top Tier Agencies Querysets agency_cte = With( Agency.objects.filter(toptier_flag=True) @@ -142,9 +151,9 @@ def agency(self): ) ) - return queryset + return self._apply_limit(queryset) - def award_category(self): + def award_category(self) -> QuerySet: # Award Category Queryset alt_set = ( self.alt_set.annotate( @@ -157,9 +166,9 @@ def award_category(self): .order_by("-total") ) - return alt_set + return self._apply_limit(alt_set) - def award(self): + def award(self) -> QuerySet: # Awards Queryset alt_set = ( self.alt_set.annotate( @@ -172,4 +181,4 @@ def award(self): .order_by("-total") ) - return alt_set + return self._apply_limit(alt_set) diff --git a/usaspending_api/spending_explorer/v2/filters/type_filter.py b/usaspending_api/spending_explorer/v2/filters/type_filter.py index 9d86221300..083205fc98 100644 --- a/usaspending_api/spending_explorer/v2/filters/type_filter.py +++ b/usaspending_api/spending_explorer/v2/filters/type_filter.py @@ -1,6 +1,8 @@ -from datetime import datetime, timezone +from dataclasses import dataclass +from datetime import date, datetime, timezone +from typing import Any -from django.db.models import Sum +from django.db.models import QuerySet, Sum from usaspending_api.awards.models import FinancialAccountsByAwards from usaspending_api.common.calculations.file_b import FileBCalculations @@ -11,19 +13,28 @@ from usaspending_api.spending_explorer.v2.filters.spending_filter import spending_filter from usaspending_api.submissions.models import DABSSubmissionWindowSchedule - UNREPORTED_DATA_NAME = "Unreported Data" VALID_UNREPORTED_DATA_TYPES = ["agency", "budget_function", "object_class"] VALID_UNREPORTED_FILTERS = ["fy", "quarter", "period"] - - -def get_unreported_data_obj( - queryset, filters, limit, spending_type, actual_total, fiscal_year, fiscal_period -) -> (list, float): - """Returns the modified list of result objects including the object corresponding to the unreported amount, only - if applicable. If the unreported amount does not fit within the limit of results provided, it will not be added. - - Args: +AWARD_TYPES = {"award", "award_category", "recipient"} + +valid_types = [ + "agency", + "award", + "award_category", + "budget_function", + "budget_subfunction", + "federal_account", + "object_class", + "program_activity", + "recipient", +] + + +@dataclass +class UnreportedDataParams: + """ + Parameters for building the unreported-data response object: queryset: Django queryset with all necessary filters, etc already applied filters: filters provided in POST request to endpoint limit: number of results to limit to @@ -31,64 +42,85 @@ def get_unreported_data_obj( actual_total: total calculated based on results in `queryset` fiscal_year: fiscal year from request fiscal_period: final fiscal period for fiscal quarter requested + """ + + queryset: QuerySet + filters: dict[str, str | int] + limit: int | None + spending_type: str + actual_total: float | None + fiscal_year: int + fiscal_period: int + + +def get_unreported_data_obj(params: UnreportedDataParams) -> tuple[list[dict[str, Any]], float | None]: + """Returns the modified list of result objects including the object corresponding to the unreported amount, only + if applicable. If the unreported amount does not fit within the limit of results provided, it will not be added. + + Args: + params: UnreportedDataParams Returns: result_set: modified (if applicable) result set as a list expected_total: total calculated from GTAS """ - queryset = queryset[:limit] if spending_type == "award" else queryset + queryset = params.queryset[: params.limit] if params.spending_type == "award" else params.queryset result_keys = ["id", "code", "type", "name", "amount"] - if spending_type == "agency": + if params.spending_type == "agency": result_keys.append("link") - if spending_type == "federal_account": + if params.spending_type == "federal_account": result_keys.append("account_number") + result_set = [ {k: (v if k != "id" else str(v)) for k, v in entry.items()} for entry in queryset.values(*result_keys) ] + gtas = ( - GTASSF133Balances.objects.filter(fiscal_year=fiscal_year, fiscal_period=fiscal_period) + GTASSF133Balances.objects.filter(fiscal_year=params.fiscal_year, fiscal_period=params.fiscal_period) .values("fiscal_year", "fiscal_period") .annotate(Sum("obligations_incurred_total_cpe")) .values("obligations_incurred_total_cpe__sum") ) + expected_total = gtas[0]["obligations_incurred_total_cpe__sum"] if gtas else None - if spending_type in VALID_UNREPORTED_DATA_TYPES and set(filters.keys()).issubset(set(VALID_UNREPORTED_FILTERS)): - unreported_obj = {"id": None, "code": None, "type": spending_type, "name": UNREPORTED_DATA_NAME, "amount": None} + + if params.spending_type in VALID_UNREPORTED_DATA_TYPES and set(params.filters.keys()).issubset( + set(VALID_UNREPORTED_FILTERS) + ): + unreported_obj = { + "id": None, + "code": None, + "type": params.spending_type, + "name": UNREPORTED_DATA_NAME, + "amount": None, + } + # if both values are actually available, then calculate the amount, otherwise leave it as the default of None - if not (actual_total is None or expected_total is None): - unreported_obj["amount"] = expected_total - actual_total + if not (params.actual_total is None or expected_total is None): + unreported_obj["amount"] = expected_total - params.actual_total # Since the limit doesn't apply to anything except the awards category, always append the unreported object result_set.append(unreported_obj) result_set = sorted(result_set, key=lambda k: k["amount"], reverse=True) else: - expected_total = actual_total + expected_total = params.actual_total return result_set, expected_total -def type_filter(_type, filters, limit=None): - _types = [ - "agency", - "award", - "award_category", - "budget_function", - "budget_subfunction", - "federal_account", - "object_class", - "program_activity", - "recipient", - ] +def _validate_request(_type: str | None, filters: dict | None) -> tuple[str, dict, int, str, int]: + """ + Validate type / filters and return type, filters, fiscal_year, time_unit, fiscal_unit + """ - # Validate explorer _type if _type is None: raise InvalidParameterException('Missing Required Request Parameter, "type": "type"') - elif _type not in _types: - raise InvalidParameterException("Type does not have a valid value. " f"Valid Types: {_types}") + if _type not in valid_types: + raise InvalidParameterException(f"Type does not have a valid value. Valid Types: {valid_types}") if filters is None: raise InvalidParameterException('Missing Required Request Parameter, "filters": { "filter_options" }') @@ -105,8 +137,8 @@ def type_filter(_type, filters, limit=None): fiscal_year = int(filters["fy"]) if fiscal_year < 1000 or fiscal_year > 9999: raise InvalidParameterException('Incorrect Fiscal Year Parameter, "fy": "YYYY"') - except ValueError: - raise InvalidParameterException('Incorrect or Missing Fiscal Year Parameter, "fy": "YYYY"') + except ValueError as exc: + raise InvalidParameterException('Incorrect or Missing Fiscal Year Parameter, "fy": "YYYY"') from exc if time_unit == "quarter" and filters["quarter"] not in ("1", "2", "3", "4", 1, 2, 3, 4): raise InvalidParameterException("Incorrect value provided for quarter parameter. Must be between 1 and 4") @@ -114,8 +146,11 @@ def type_filter(_type, filters, limit=None): if time_unit == "period" and int(filters["period"]) not in range(1, 13): raise InvalidParameterException("Incorrect value provided for period parameter. Must be between 1 and 12") - fiscal_unit = int(filters[time_unit]) + return _type, filters, fiscal_year, time_unit, int(filters[time_unit]) + +def _get_submission_window(fiscal_year: int, time_unit: str, fiscal_unit: int) -> DABSSubmissionWindowSchedule: + """Get and validate submission window.""" if time_unit == "quarter": submission_window = DABSSubmissionWindowSchedule.objects.filter( submission_fiscal_year=fiscal_year, @@ -129,13 +164,15 @@ def type_filter(_type, filters, limit=None): submission_fiscal_month=fiscal_unit, submission_reveal_date__lte=datetime.now(timezone.utc), ).first() + if submission_window is None: raise InvalidParameterException("Fiscal parameters provided do not belong to a current submission period") - fiscal_date = submission_window.period_end_date - fiscal_period = submission_window.submission_fiscal_month + return submission_window + - # transaction_obligated_amount is summed across all periods in the year up to and including the requested quarter. +def _get_base_querysets(fiscal_year: int, fiscal_period: int) -> tuple[QuerySet, QuerySet]: + """Get base querysets for alt_set and queryset.""" alt_set = FinancialAccountsByAwards.objects.filter( submission__reporting_fiscal_year=fiscal_year, submission__reporting_fiscal_period__lte=fiscal_period ).annotate(amount=Sum("transaction_obligated_amount")) @@ -146,77 +183,132 @@ def type_filter(_type, filters, limit=None): submission__reporting_fiscal_year=fiscal_year, submission__reporting_fiscal_period=fiscal_period ).annotate(amount=Sum(file_b_calculations.get_obligations())) - # Apply filters to queryset results - alt_set, queryset = spending_filter(alt_set, queryset, filters, _type) + return alt_set, queryset + + +def _normalize_award_row(award: dict, _type: str) -> None: + """Mutate an award / award_category / recipient result row im place""" + award["id"] = str(award["id"]) + if _type in ["award", "award_category"]: + code = None + for code_type in ("piid", "fain", "uri"): + if award[code_type]: + code = award[code_type] + break + for code_type in ("piid", "fain", "uri"): + del award[code_type] + award["code"] = code + if _type == "award": + award["name"] = code + if award["amount"] is None: + award["amount"] = 0 + if award["name"] is None: + award["name"] = "Blank {}".format(_type.capitalize().replace("_", " ")) + + +def _process_award_types( + _type: str, alt_set: QuerySet, queryset: QuerySet, limit: int | None, fiscal_date: date +) -> dict[str, Any]: + """Process award, award_category, and recipient types. + + - Historically only 'award' applied 'limit' after materializing the full result set. Limit is now applied + on the Explorer queryset (SQL LIMIT) before evaluation for 'award' only. + 'total' remains the sum of all matching rows + """ + explorer_limit = limit if _type == "award" else None - if _type in {"award", "award_category", "recipient"}: - # Annotate and get explorer _type filtered results - exp = Explorer(alt_set, queryset) + if _type == "award": + # Cheap full-set total (single aggregate row) so response total matches prior behavior + actual_total = ( + Explorer(alt_set, queryset, limit=None).award().aggregate(amount_sum=Sum("total"))["amount_sum"] or 0 + ) + alt_set = Explorer(alt_set, queryset, limit=explorer_limit).award() + for award in alt_set: + _normalize_award_row(award, _type) + else: + exp = Explorer(alt_set, queryset, limit=None) if _type == "recipient": alt_set = exp.recipient() - if _type == "award": - alt_set = exp.award() - if _type == "award_category": + else: alt_set = exp.award_category() - # Total value of filtered results actual_total = 0 - for award in alt_set: - award["id"] = str(award["id"]) - if _type in ["award", "award_category"]: - code = None - for code_type in ("piid", "fain", "uri"): - if award[code_type]: - code = award[code_type] - break - for code_type in ("piid", "fain", "uri"): - del award[code_type] - award["code"] = code - if _type == "award": - award["name"] = code - if award["amount"] is None: - award["amount"] = 0 - if award["name"] is None: - award["name"] = "Blank {}".format(_type.capitalize().replace("_", " ")) + _normalize_award_row(award, _type) actual_total += award["total"] or 0 - result_set = list(alt_set) + result_set = list(alt_set) + result_set.sort(key=lambda k: k["amount"], reverse=True) - result_set.sort(key=lambda k: k["amount"], reverse=True) + return {"total": actual_total, "end_date": fiscal_date, "results": result_set} - result_set = result_set[:limit] if _type == "award" else result_set - results = {"total": actual_total, "end_date": fiscal_date, "results": result_set} +@dataclass +class NonAwardTypeParams: + _type: str + alt_set: QuerySet + queryset: QuerySet + filters: dict[str, str | int] + limit: int | None + fiscal_year: int + fiscal_period: int + fiscal_date: date - else: - # Annotate and get explorer _type filtered results - exp = Explorer(alt_set, queryset) - - if _type == "budget_function": - queryset = exp.budget_function() - if _type == "budget_subfunction": - queryset = exp.budget_subfunction() - if _type == "federal_account": - queryset = exp.federal_account() - if _type == "program_activity": - queryset = exp.program_activity() - if _type == "object_class": - queryset = exp.object_class() - if _type == "agency": - queryset = exp.agency() - # Actual total value of filtered results - actual_total = queryset.aggregate(total=Sum("amount"))["total"] or 0 - result_set, expected_total = get_unreported_data_obj( + +def _process_non_award_types(params: NonAwardTypeParams) -> dict[str, Any]: + """Process non-award types without applying a limit + + Non-award types never limited results historically; limiting before aggregation would + also break unreported-amount math (expected_total - actual_total). + """ + # explicit limit = None: Explorer sets defaults to SPENDING_EXPLORER_LIMIT + exp = Explorer(params.alt_set, params.queryset, limit=None) + type_methods = { + "budget_function": exp.budget_function, + "budget_subfunction": exp.budget_subfunction, + "federal_account": exp.federal_account, + "program_activity": exp.program_activity, + "object_class": exp.object_class, + "agency": exp.agency, + } + queryset = type_methods[params._type]() + + # actual_total value of filtered results (full set) + actual_total = queryset.aggregate(total=Sum("amount"))["total"] or 0 + result_set, expected_total = get_unreported_data_obj( + UnreportedDataParams( + queryset=queryset, + filters=params.filters, + limit=params.limit, + spending_type=params._type, + actual_total=actual_total, + fiscal_year=params.fiscal_year, + fiscal_period=params.fiscal_period, + ) + ) + return {"total": expected_total, "end_date": params.fiscal_date, "results": result_set} + + +def type_filter(_type: str | None, filters: dict[str, str | int] | None, limit: int | None = None) -> dict[str, Any]: + _type, filters, fiscal_year, time_unit, fiscal_unit = _validate_request(_type, filters) + submission_window = _get_submission_window(fiscal_year, time_unit, fiscal_unit) + fiscal_date = submission_window.period_end_date + fiscal_period = submission_window.submission_fiscal_month + + alt_set, queryset = _get_base_querysets(fiscal_year, fiscal_period) + alt_set, queryset = spending_filter(alt_set, queryset, filters, _type) + + if _type in AWARD_TYPES: + return _process_award_types(_type, alt_set, queryset, limit, fiscal_date) + return _process_non_award_types( + NonAwardTypeParams( + _type=_type, + alt_set=alt_set, queryset=queryset, filters=filters, limit=limit, - spending_type=_type, - actual_total=actual_total, fiscal_year=fiscal_year, fiscal_period=fiscal_period, + fiscal_date=fiscal_date, ) - - results = {"total": expected_total, "end_date": fiscal_date, "results": result_set} - - return results + ) diff --git a/usaspending_api/spending_explorer/v2/views/spending_explorer.py b/usaspending_api/spending_explorer/v2/views/spending_explorer.py index ed419f6042..12238b50d4 100644 --- a/usaspending_api/spending_explorer/v2/views/spending_explorer.py +++ b/usaspending_api/spending_explorer/v2/views/spending_explorer.py @@ -1,11 +1,11 @@ +from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView + from usaspending_api.common.cache_decorator import cache_response +from usaspending_api.spending_explorer.v2.filters.explorer import SPENDING_EXPLORER_LIMIT from usaspending_api.spending_explorer.v2.filters.type_filter import type_filter -# Limits the amount of results the spending explorer returns -SPENDING_EXPLORER_LIMIT = 1000 - class SpendingExplorerViewSet(APIView): """ @@ -15,8 +15,7 @@ class SpendingExplorerViewSet(APIView): endpoint_doc = "usaspending_api/api_contracts/contracts/v2/spending.md" @cache_response() - def post(self, request): - + def post(self, request: Request) -> Response: json_request = request.data _type = json_request.get("type") filters = json_request.get("filters", None)