usaspending-orm 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- usaspending/__init__.py +144 -0
- usaspending/cli/__init__.py +2 -0
- usaspending/cli/download_award.py +113 -0
- usaspending/client.py +550 -0
- usaspending/config.py +119 -0
- usaspending/download/__init__.py +8 -0
- usaspending/download/job.py +219 -0
- usaspending/download/manager.py +140 -0
- usaspending/exceptions.py +71 -0
- usaspending/logging_config.py +310 -0
- usaspending/models/__init__.py +86 -0
- usaspending/models/agency.py +631 -0
- usaspending/models/award.py +933 -0
- usaspending/models/award_factory.py +72 -0
- usaspending/models/award_types.py +114 -0
- usaspending/models/base_model.py +214 -0
- usaspending/models/contract.py +147 -0
- usaspending/models/district_spending.py +70 -0
- usaspending/models/download.py +74 -0
- usaspending/models/funding.py +182 -0
- usaspending/models/grant.py +147 -0
- usaspending/models/idv.py +165 -0
- usaspending/models/lazy_record.py +82 -0
- usaspending/models/loan.py +85 -0
- usaspending/models/location.py +125 -0
- usaspending/models/period_of_performance.py +43 -0
- usaspending/models/recipient.py +204 -0
- usaspending/models/recipient_business_categories.py +364 -0
- usaspending/models/recipient_spending.py +58 -0
- usaspending/models/spending.py +70 -0
- usaspending/models/state_spending.py +43 -0
- usaspending/models/subaward.py +270 -0
- usaspending/models/subtier_agency.py +98 -0
- usaspending/models/transaction.py +89 -0
- usaspending/queries/__init__.py +87 -0
- usaspending/queries/agencies_search.py +196 -0
- usaspending/queries/agency_award_summary.py +134 -0
- usaspending/queries/agency_query.py +102 -0
- usaspending/queries/award_query.py +40 -0
- usaspending/queries/awarding_agencies_search.py +16 -0
- usaspending/queries/awards_search.py +602 -0
- usaspending/queries/filters.py +437 -0
- usaspending/queries/funding_agencies_search.py +20 -0
- usaspending/queries/funding_search.py +180 -0
- usaspending/queries/query_builder.py +1430 -0
- usaspending/queries/recipient_query.py +66 -0
- usaspending/queries/recipients_search.py +167 -0
- usaspending/queries/single_resource_base.py +83 -0
- usaspending/queries/spending_search.py +261 -0
- usaspending/queries/sub_agency_query.py +177 -0
- usaspending/queries/subawards_search.py +196 -0
- usaspending/queries/transactions_search.py +213 -0
- usaspending/resources/__init__.py +17 -0
- usaspending/resources/agency_resource.py +105 -0
- usaspending/resources/award_resource.py +119 -0
- usaspending/resources/base_resource.py +31 -0
- usaspending/resources/download_resource.py +102 -0
- usaspending/resources/funding_resource.py +41 -0
- usaspending/resources/recipients_resource.py +104 -0
- usaspending/resources/spending_resource.py +45 -0
- usaspending/resources/subawards_resource.py +68 -0
- usaspending/resources/transactions_resource.py +39 -0
- usaspending/utils/__init__.py +6 -0
- usaspending/utils/formatter.py +550 -0
- usaspending/utils/rate_limit.py +135 -0
- usaspending/utils/retry.py +257 -0
- usaspending/utils/special_cases.yaml +155 -0
- usaspending_orm-0.6.0.dist-info/LICENSE +18 -0
- usaspending_orm-0.6.0.dist-info/METADATA +324 -0
- usaspending_orm-0.6.0.dist-info/RECORD +71 -0
- usaspending_orm-0.6.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Sub-agency query implementation for retrieving agency sub-agencies with pagination."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Optional, Dict, Any, List
|
|
4
|
+
from .single_resource_base import SingleResourceBase
|
|
5
|
+
from ..exceptions import ValidationError
|
|
6
|
+
from ..client import USASpendingClient
|
|
7
|
+
from ..logging_config import USASpendingLogger
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SubAgencyQuery(SingleResourceBase):
|
|
16
|
+
"""Retrieve sub-agency data from the USAspending API.
|
|
17
|
+
|
|
18
|
+
This query class handles fetching sub-agency information including
|
|
19
|
+
transaction counts and obligations filtered by fiscal year, agency type,
|
|
20
|
+
and award type codes.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, client: USASpendingClient):
|
|
24
|
+
"""Initialize SubAgencyQuery with client.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
client: USASpendingClient client instance
|
|
28
|
+
"""
|
|
29
|
+
super().__init__(client)
|
|
30
|
+
logger.debug("SubAgencyQuery initialized with client: %s", client)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def _endpoint(self) -> str:
|
|
34
|
+
"""Base endpoint for sub-agency retrieval."""
|
|
35
|
+
return "/agency/{toptier_code}/sub_agency/"
|
|
36
|
+
|
|
37
|
+
def _construct_endpoint(self, resource_id: str) -> str:
|
|
38
|
+
"""Construct the full endpoint URL for agency sub-agency.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
resource_id: The toptier_code for the agency
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Full endpoint path including /sub_agency/
|
|
45
|
+
"""
|
|
46
|
+
endpoint = self._endpoint.replace("{toptier_code}", resource_id)
|
|
47
|
+
return endpoint
|
|
48
|
+
|
|
49
|
+
def find_by_id(self, toptier_code: str) -> Dict[str, Any]:
|
|
50
|
+
"""Not used for sub-agency - use get_subagencies instead.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
NotImplementedError: This method should not be used directly
|
|
54
|
+
"""
|
|
55
|
+
raise NotImplementedError(
|
|
56
|
+
"Use get_subagencies() method instead for sub-agency data"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def get_subagencies(
|
|
60
|
+
self,
|
|
61
|
+
toptier_code: str,
|
|
62
|
+
fiscal_year: Optional[int] = None,
|
|
63
|
+
agency_type: str = "awarding",
|
|
64
|
+
award_type_codes: Optional[List[str]] = None,
|
|
65
|
+
order: str = "desc",
|
|
66
|
+
sort: str = "total_obligations",
|
|
67
|
+
page: int = 1,
|
|
68
|
+
limit: int = 100,
|
|
69
|
+
) -> Dict[str, Any]:
|
|
70
|
+
"""Retrieve agency sub-agencies with optional filters and pagination.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
toptier_code: The toptier code of an agency (3-4 digit string)
|
|
74
|
+
fiscal_year: Optional fiscal year for the data (defaults to current)
|
|
75
|
+
agency_type: "awarding" or "funding" (defaults to "awarding")
|
|
76
|
+
award_type_codes: Optional list of award type codes to filter by
|
|
77
|
+
order: Sort direction "asc" or "desc" (defaults to "desc")
|
|
78
|
+
sort: Sort field - "name", "total_obligations", "transaction_count", "new_award_count"
|
|
79
|
+
page: Page number (defaults to 1)
|
|
80
|
+
limit: Items per page, max 100 (defaults to 100)
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Dictionary containing:
|
|
84
|
+
- toptier_code: Agency toptier code
|
|
85
|
+
- fiscal_year: Fiscal year of data
|
|
86
|
+
- page_metadata: Pagination metadata
|
|
87
|
+
- results: List of sub-agency data
|
|
88
|
+
- messages: Any API messages
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValidationError: If toptier_code is invalid or other parameters are invalid
|
|
92
|
+
APIError: If API request fails
|
|
93
|
+
"""
|
|
94
|
+
# Validate toptier_code
|
|
95
|
+
if not toptier_code:
|
|
96
|
+
raise ValidationError("toptier_code is required")
|
|
97
|
+
|
|
98
|
+
toptier_code = str(toptier_code).strip()
|
|
99
|
+
if not toptier_code.isdigit() or len(toptier_code) not in [3, 4]:
|
|
100
|
+
raise ValidationError(
|
|
101
|
+
f"Invalid toptier_code: {toptier_code}. "
|
|
102
|
+
"Must be a 3-4 digit numeric string"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Validate agency_type
|
|
106
|
+
if agency_type not in ["awarding", "funding"]:
|
|
107
|
+
raise ValidationError(
|
|
108
|
+
f"Invalid agency_type: {agency_type}. Must be 'awarding' or 'funding'"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Validate order
|
|
112
|
+
if order not in ["asc", "desc"]:
|
|
113
|
+
raise ValidationError(f"Invalid order: {order}. Must be 'asc' or 'desc'")
|
|
114
|
+
|
|
115
|
+
# Validate sort
|
|
116
|
+
valid_sorts = [
|
|
117
|
+
"name",
|
|
118
|
+
"total_obligations",
|
|
119
|
+
"transaction_count",
|
|
120
|
+
"new_award_count",
|
|
121
|
+
]
|
|
122
|
+
if sort not in valid_sorts:
|
|
123
|
+
raise ValidationError(
|
|
124
|
+
f"Invalid sort: {sort}. Must be one of: {', '.join(valid_sorts)}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Validate page
|
|
128
|
+
if page < 1:
|
|
129
|
+
raise ValidationError("page must be >= 1")
|
|
130
|
+
|
|
131
|
+
# Validate limit
|
|
132
|
+
if limit < 1 or limit > 100:
|
|
133
|
+
raise ValidationError("limit must be between 1 and 100")
|
|
134
|
+
|
|
135
|
+
logger.debug(
|
|
136
|
+
"Fetching sub-agencies for toptier_code: %s, fiscal_year: %s, "
|
|
137
|
+
"agency_type: %s, award_type_codes: %s, order: %s, sort: %s, "
|
|
138
|
+
"page: %s, limit: %s",
|
|
139
|
+
toptier_code,
|
|
140
|
+
fiscal_year,
|
|
141
|
+
agency_type,
|
|
142
|
+
award_type_codes,
|
|
143
|
+
order,
|
|
144
|
+
sort,
|
|
145
|
+
page,
|
|
146
|
+
limit,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Build params
|
|
150
|
+
params = {
|
|
151
|
+
"agency_type": agency_type,
|
|
152
|
+
"order": order,
|
|
153
|
+
"sort": sort,
|
|
154
|
+
"page": page,
|
|
155
|
+
"limit": limit,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if fiscal_year is not None:
|
|
159
|
+
params["fiscal_year"] = fiscal_year
|
|
160
|
+
|
|
161
|
+
if award_type_codes:
|
|
162
|
+
# Convert to list if needed and filter out None/empty values
|
|
163
|
+
if isinstance(award_type_codes, (set, frozenset)):
|
|
164
|
+
award_type_codes = list(award_type_codes)
|
|
165
|
+
award_type_codes = [code for code in award_type_codes if code]
|
|
166
|
+
|
|
167
|
+
if award_type_codes:
|
|
168
|
+
# API expects award_type_codes as array parameter
|
|
169
|
+
params["award_type_codes"] = award_type_codes
|
|
170
|
+
|
|
171
|
+
# Construct endpoint
|
|
172
|
+
endpoint = self._construct_endpoint(toptier_code)
|
|
173
|
+
|
|
174
|
+
# Make API request with params
|
|
175
|
+
response = self._client._make_request("GET", endpoint, params=params)
|
|
176
|
+
|
|
177
|
+
return response
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Subawards search query builder for USASpending data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, TYPE_CHECKING, Optional
|
|
6
|
+
|
|
7
|
+
from ..exceptions import ValidationError
|
|
8
|
+
from ..models.subaward import SubAward
|
|
9
|
+
from .awards_search import AwardsSearch
|
|
10
|
+
from ..logging_config import USASpendingLogger
|
|
11
|
+
from ..models.award_types import get_category_for_code
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..client import USASpendingClient
|
|
15
|
+
|
|
16
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SubAwardsSearch(AwardsSearch):
|
|
20
|
+
"""
|
|
21
|
+
Builds and executes a subawards search query, allowing for complex
|
|
22
|
+
filtering on subaward data. This class extends AwardsSearch to reuse
|
|
23
|
+
filter logic while specializing for subawards.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, client: "USASpendingClient"):
|
|
27
|
+
"""
|
|
28
|
+
Initializes the SubAwardsSearch query builder.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
client: The USASpending client instance.
|
|
32
|
+
"""
|
|
33
|
+
super().__init__(client)
|
|
34
|
+
self._award_id: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
def _clone(self) -> SubAwardsSearch:
|
|
37
|
+
"""Creates an immutable copy of the query builder."""
|
|
38
|
+
clone = SubAwardsSearch(self._client)
|
|
39
|
+
clone._filter_objects = self._filter_objects.copy()
|
|
40
|
+
clone._page_size = self._page_size
|
|
41
|
+
clone._total_limit = self._total_limit
|
|
42
|
+
clone._max_pages = self._max_pages
|
|
43
|
+
clone._order_by = self._order_by
|
|
44
|
+
clone._order_direction = self._order_direction
|
|
45
|
+
clone._award_id = self._award_id
|
|
46
|
+
return clone
|
|
47
|
+
|
|
48
|
+
def _build_payload(self, page: int) -> Dict[str, Any]:
|
|
49
|
+
"""
|
|
50
|
+
Constructs the final API request payload for subawards.
|
|
51
|
+
|
|
52
|
+
Overrides parent to always include subawards=true and spending_level=subawards.
|
|
53
|
+
"""
|
|
54
|
+
payload = super()._build_payload(page)
|
|
55
|
+
|
|
56
|
+
# Always search for subawards
|
|
57
|
+
payload["subawards"] = True
|
|
58
|
+
payload["spending_level"] = "subawards"
|
|
59
|
+
|
|
60
|
+
# If filtering by specific award, add to filters
|
|
61
|
+
if self._award_id:
|
|
62
|
+
if "filters" not in payload:
|
|
63
|
+
payload["filters"] = {}
|
|
64
|
+
payload["filters"]["award_unique_id"] = self._award_id
|
|
65
|
+
|
|
66
|
+
return payload
|
|
67
|
+
|
|
68
|
+
def _transform_result(self, result: Dict[str, Any]) -> SubAward:
|
|
69
|
+
"""Transforms a single API result item into a SubAward model."""
|
|
70
|
+
return SubAward(result, self._client)
|
|
71
|
+
|
|
72
|
+
def _get_fields(self) -> list[str]:
|
|
73
|
+
"""
|
|
74
|
+
Determines the list of fields to request based on award type filters.
|
|
75
|
+
|
|
76
|
+
Returns different field sets depending on the award type codes:
|
|
77
|
+
- Contracts: Contract subaward fields
|
|
78
|
+
- Grants/Assistance: Grant subaward fields
|
|
79
|
+
"""
|
|
80
|
+
# Get award type codes from filters
|
|
81
|
+
award_types = self._get_award_type_codes()
|
|
82
|
+
|
|
83
|
+
# Determine if we're dealing with contracts or grants
|
|
84
|
+
is_contract = False
|
|
85
|
+
is_grant = False
|
|
86
|
+
|
|
87
|
+
# Check each award type code to determine categories
|
|
88
|
+
for award_type in award_types:
|
|
89
|
+
category = get_category_for_code(award_type)
|
|
90
|
+
if category == "contracts":
|
|
91
|
+
is_contract = True
|
|
92
|
+
elif category == "grants":
|
|
93
|
+
is_grant = True
|
|
94
|
+
|
|
95
|
+
# Return appropriate field set
|
|
96
|
+
if is_contract and not is_grant:
|
|
97
|
+
return SubAward.CONTRACT_SUBAWARD_FIELDS.copy()
|
|
98
|
+
elif is_grant and not is_contract:
|
|
99
|
+
return SubAward.GRANT_SUBAWARD_FIELDS.copy()
|
|
100
|
+
else:
|
|
101
|
+
# If both or neither, return union of both field sets
|
|
102
|
+
fields = set(SubAward.CONTRACT_SUBAWARD_FIELDS)
|
|
103
|
+
fields.update(SubAward.GRANT_SUBAWARD_FIELDS)
|
|
104
|
+
return list(fields)
|
|
105
|
+
|
|
106
|
+
def count(self) -> int:
|
|
107
|
+
"""
|
|
108
|
+
Get the total count of subawards.
|
|
109
|
+
|
|
110
|
+
If filtering by a specific award, uses the efficient count endpoint.
|
|
111
|
+
Otherwise falls back to parent implementation.
|
|
112
|
+
"""
|
|
113
|
+
logger.debug(f"{self.__class__.__name__}.count() called")
|
|
114
|
+
|
|
115
|
+
# If we have an award_id filter, use the efficient count endpoint
|
|
116
|
+
if self._award_id:
|
|
117
|
+
endpoint = f"/awards/count/subaward/{self._award_id}/"
|
|
118
|
+
|
|
119
|
+
from ..logging_config import log_query_execution
|
|
120
|
+
|
|
121
|
+
log_query_execution(logger, "SubAwardsSearch.count", [], endpoint)
|
|
122
|
+
|
|
123
|
+
# Send the request to the count endpoint
|
|
124
|
+
response = self._client._make_request("GET", endpoint)
|
|
125
|
+
|
|
126
|
+
# Extract count from response
|
|
127
|
+
total = response.get("subawards", 0)
|
|
128
|
+
|
|
129
|
+
logger.info(
|
|
130
|
+
f"{self.__class__.__name__}.count() = {total} subawards for award {self._award_id}"
|
|
131
|
+
)
|
|
132
|
+
return total
|
|
133
|
+
|
|
134
|
+
# Fall back to parent implementation for general subaward counting
|
|
135
|
+
# This is inefficient, but it's the only way to get the count
|
|
136
|
+
# without a dedicated endpoint for subaward searches.
|
|
137
|
+
# The parent's count() method will iterate through all pages.
|
|
138
|
+
# return super().count()
|
|
139
|
+
# For now, let's just iterate and count
|
|
140
|
+
count = 0
|
|
141
|
+
for _ in self:
|
|
142
|
+
count += 1
|
|
143
|
+
return count
|
|
144
|
+
|
|
145
|
+
def count_awards_by_type(self) -> Dict[str, int]:
|
|
146
|
+
"""
|
|
147
|
+
Override parent method to use subawards-specific count endpoint.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
A dictionary mapping award type categories to their subaward counts.
|
|
151
|
+
"""
|
|
152
|
+
endpoint = "/search/spending_by_award_count/"
|
|
153
|
+
final_filters = self._aggregate_filters()
|
|
154
|
+
|
|
155
|
+
payload = {
|
|
156
|
+
"filters": final_filters,
|
|
157
|
+
"subawards": True, # Always count subawards
|
|
158
|
+
"spending_level": "subawards",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
from ..logging_config import log_query_execution
|
|
162
|
+
|
|
163
|
+
log_query_execution(
|
|
164
|
+
logger,
|
|
165
|
+
"SubAwardsSearch.count_awards_by_type",
|
|
166
|
+
self._filter_objects,
|
|
167
|
+
endpoint,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Send the request to the count endpoint
|
|
171
|
+
response = self._client._make_request("POST", endpoint, json=payload)
|
|
172
|
+
|
|
173
|
+
# Extract and return aggregations
|
|
174
|
+
return response.get("aggregations", {})
|
|
175
|
+
|
|
176
|
+
def award_id(self, award_id: str) -> SubAwardsSearch:
|
|
177
|
+
"""
|
|
178
|
+
Filter subawards for a specific prime award.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
award_id: The unique generated award identifier.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
A new SubAwardsSearch instance with the award filter applied.
|
|
185
|
+
|
|
186
|
+
Example:
|
|
187
|
+
>>> subawards = client.subawards.award_id("CONT_AWD_123...")
|
|
188
|
+
>>> for sub in subawards:
|
|
189
|
+
... print(f"{sub.sub_awardee_name}: ${sub.sub_award_amount:,.2f}")
|
|
190
|
+
"""
|
|
191
|
+
if not award_id:
|
|
192
|
+
raise ValidationError("award_id cannot be empty")
|
|
193
|
+
|
|
194
|
+
clone = self._clone()
|
|
195
|
+
clone._award_id = str(award_id).strip()
|
|
196
|
+
return clone
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Transactions search query builder for USASpending data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, TYPE_CHECKING, Iterator
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from ..exceptions import ValidationError
|
|
9
|
+
from ..models.transaction import Transaction
|
|
10
|
+
from .query_builder import QueryBuilder
|
|
11
|
+
from ..logging_config import USASpendingLogger
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ..client import USASpendingClient
|
|
15
|
+
|
|
16
|
+
logger = USASpendingLogger.get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TransactionsSearch(QueryBuilder["Transaction"]):
|
|
20
|
+
"""
|
|
21
|
+
Builds and executes a transactions search query, allowing for filtering
|
|
22
|
+
on transaction data. This class follows a fluent interface pattern.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, client: "USASpendingClient"):
|
|
26
|
+
"""
|
|
27
|
+
Initializes the TransactionsSearch query builder.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
client: The USASpending client instance.
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(client)
|
|
33
|
+
self._award_id: str = None
|
|
34
|
+
# Client-side filters (not supported by API)
|
|
35
|
+
self._client_filters = {}
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def _endpoint(self) -> str:
|
|
39
|
+
"""The API endpoint for this query."""
|
|
40
|
+
return "/transactions/"
|
|
41
|
+
|
|
42
|
+
def _clone(self) -> TransactionsSearch:
|
|
43
|
+
"""Creates an immutable copy of the query builder."""
|
|
44
|
+
clone = super()._clone()
|
|
45
|
+
clone._filter_objects = self._filter_objects.copy()
|
|
46
|
+
clone._award_id = self._award_id
|
|
47
|
+
clone._client_filters = self._client_filters.copy()
|
|
48
|
+
return clone
|
|
49
|
+
|
|
50
|
+
def _build_payload(self, page: int) -> Dict[str, Any]:
|
|
51
|
+
"""Constructs the final API request payload from the filter objects."""
|
|
52
|
+
|
|
53
|
+
if not self._award_id:
|
|
54
|
+
raise ValidationError(
|
|
55
|
+
"An award_id is required. Use the .award_id() method."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
payload = {
|
|
59
|
+
"award_id": self._award_id,
|
|
60
|
+
"limit": self._get_effective_page_size(),
|
|
61
|
+
"page": page,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Add any additional filters if they exist
|
|
65
|
+
final_filters = self._aggregate_filters()
|
|
66
|
+
if final_filters:
|
|
67
|
+
payload.update(final_filters)
|
|
68
|
+
|
|
69
|
+
return payload
|
|
70
|
+
|
|
71
|
+
def _transform_result(self, result: Dict[str, Any]) -> Transaction:
|
|
72
|
+
"""Transforms a single API result item into a Transaction model."""
|
|
73
|
+
return Transaction(result)
|
|
74
|
+
|
|
75
|
+
def count(self) -> int:
|
|
76
|
+
"""Counts the number of transactions per a given award id."""
|
|
77
|
+
logger.debug(f"{self.__class__.__name__}.count() called")
|
|
78
|
+
|
|
79
|
+
# If we have client-side filters, we need to fetch all results and count
|
|
80
|
+
if self._client_filters:
|
|
81
|
+
logger.debug(
|
|
82
|
+
"Client-side filters present, counting by iterating all results"
|
|
83
|
+
)
|
|
84
|
+
count = 0
|
|
85
|
+
for _ in self:
|
|
86
|
+
count += 1
|
|
87
|
+
return count
|
|
88
|
+
|
|
89
|
+
# No client-side filters, use the efficient API count endpoint
|
|
90
|
+
endpoint = f"/awards/count/transaction/{self._award_id}/"
|
|
91
|
+
|
|
92
|
+
from ..logging_config import log_query_execution
|
|
93
|
+
|
|
94
|
+
log_query_execution(logger, "TransactionsSearch.count", [], endpoint)
|
|
95
|
+
|
|
96
|
+
# Send the request to the count endpoint
|
|
97
|
+
response = self._client._make_request("GET", endpoint)
|
|
98
|
+
|
|
99
|
+
# Extract count from the appropriate category
|
|
100
|
+
total = response.get("transactions", 0)
|
|
101
|
+
|
|
102
|
+
logger.info(
|
|
103
|
+
f"{self.__class__.__name__}.count() = {total} transactions for award {self._award_id}"
|
|
104
|
+
)
|
|
105
|
+
return total
|
|
106
|
+
|
|
107
|
+
# ==========================================================================
|
|
108
|
+
# Filter Methods
|
|
109
|
+
# ==========================================================================
|
|
110
|
+
|
|
111
|
+
def award_id(self, award_id: str) -> TransactionsSearch:
|
|
112
|
+
"""
|
|
113
|
+
Filter transactions for a specific award.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
award_id: The unique award identifier.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A new `TransactionsSearch` instance with the award filter applied.
|
|
120
|
+
"""
|
|
121
|
+
if not award_id:
|
|
122
|
+
raise ValidationError("award_id cannot be empty")
|
|
123
|
+
|
|
124
|
+
clone = self._clone()
|
|
125
|
+
clone._award_id = str(award_id).strip()
|
|
126
|
+
return clone
|
|
127
|
+
|
|
128
|
+
def since(self, date: str) -> "TransactionsSearch":
|
|
129
|
+
"""
|
|
130
|
+
Filter transactions to those on or after the specified date.
|
|
131
|
+
|
|
132
|
+
Note: This filter is applied client-side as the API endpoint
|
|
133
|
+
doesn't support date filtering for transactions.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
date: Date string in YYYY-MM-DD format
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
A new TransactionsSearch instance with the date filter applied
|
|
140
|
+
|
|
141
|
+
Example:
|
|
142
|
+
>>> transactions = award.transactions.since("2024-01-01").all()
|
|
143
|
+
"""
|
|
144
|
+
# Validate date format
|
|
145
|
+
try:
|
|
146
|
+
datetime.strptime(date, "%Y-%m-%d")
|
|
147
|
+
except ValueError:
|
|
148
|
+
raise ValidationError("Date must be in YYYY-MM-DD format")
|
|
149
|
+
|
|
150
|
+
clone = self._clone()
|
|
151
|
+
clone._client_filters["since_date"] = date
|
|
152
|
+
return clone
|
|
153
|
+
|
|
154
|
+
def until(self, date: str) -> "TransactionsSearch":
|
|
155
|
+
"""
|
|
156
|
+
Filter transactions to those on or before the specified date.
|
|
157
|
+
|
|
158
|
+
Note: This filter is applied client-side as the API endpoint
|
|
159
|
+
doesn't support date filtering for transactions.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
date: Date string in YYYY-MM-DD format
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
A new TransactionsSearch instance with the date filter applied
|
|
166
|
+
|
|
167
|
+
Example:
|
|
168
|
+
>>> transactions = award.transactions.until("2024-12-31").all()
|
|
169
|
+
"""
|
|
170
|
+
# Validate date format
|
|
171
|
+
try:
|
|
172
|
+
datetime.strptime(date, "%Y-%m-%d")
|
|
173
|
+
except ValueError:
|
|
174
|
+
raise ValidationError("Date must be in YYYY-MM-DD format")
|
|
175
|
+
|
|
176
|
+
clone = self._clone()
|
|
177
|
+
clone._client_filters["until_date"] = date
|
|
178
|
+
return clone
|
|
179
|
+
|
|
180
|
+
def _apply_client_filters(self, transaction: Transaction) -> bool:
|
|
181
|
+
"""
|
|
182
|
+
Apply client-side filters to a transaction.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
transaction: The transaction to filter
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
True if transaction passes all filters, False otherwise
|
|
189
|
+
"""
|
|
190
|
+
# Apply date filters
|
|
191
|
+
if "since_date" in self._client_filters:
|
|
192
|
+
since_date = datetime.strptime(
|
|
193
|
+
self._client_filters["since_date"], "%Y-%m-%d"
|
|
194
|
+
).date()
|
|
195
|
+
if transaction.action_date and transaction.action_date.date() < since_date:
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
if "until_date" in self._client_filters:
|
|
199
|
+
until_date = datetime.strptime(
|
|
200
|
+
self._client_filters["until_date"], "%Y-%m-%d"
|
|
201
|
+
).date()
|
|
202
|
+
if transaction.action_date and transaction.action_date.date() > until_date:
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
def __iter__(self) -> Iterator[Transaction]:
|
|
208
|
+
"""
|
|
209
|
+
Override iteration to apply client-side filters.
|
|
210
|
+
"""
|
|
211
|
+
for transaction in super().__iter__():
|
|
212
|
+
if self._apply_client_filters(transaction):
|
|
213
|
+
yield transaction
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .award_resource import AwardResource
|
|
2
|
+
from .agency_resource import AgencyResource
|
|
3
|
+
from .recipients_resource import RecipientsResource
|
|
4
|
+
from .transactions_resource import TransactionsResource
|
|
5
|
+
from .spending_resource import SpendingResource
|
|
6
|
+
from .funding_resource import FundingResource
|
|
7
|
+
from .download_resource import DownloadResource
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AwardResource",
|
|
11
|
+
"AgencyResource",
|
|
12
|
+
"RecipientsResource",
|
|
13
|
+
"TransactionsResource",
|
|
14
|
+
"SpendingResource",
|
|
15
|
+
"FundingResource",
|
|
16
|
+
"DownloadResource",
|
|
17
|
+
]
|