Skip to content

Commit 35f8d77

Browse files
authored
Add IT Dashboard investments endpoints (#17)
1 parent edd4158 commit 35f8d77

24 files changed

Lines changed: 2849 additions & 3 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.5.0] - 2026-04-08
11+
12+
### Added
13+
- IT Dashboard investments: `list_itdashboard_investments`, `get_itdashboard_investment` (`/api/itdashboard/`) with shaping and filter params (`search`, `agency_code`, `agency_name`, `type_of_investment`, `updated_time_after`, `updated_time_before`, `cio_rating`, `cio_rating_max`, `performance_risk`). Tier-gated by the API: free tier gets `search`, pro adds structured filters, business+ adds CIO/performance analytics. New `ITDashboardInvestment` model and `ShapeConfig.ITDASHBOARD_INVESTMENTS_MINIMAL` / `ITDASHBOARD_INVESTMENTS_COMPREHENSIVE` defaults.
14+
15+
## [0.4.4] - 2026-03-25
16+
17+
### Added
18+
- `parent_piid` filter parameter on `list_contracts` for filtering orders under a specific parent IDV PIID.
19+
- `user_agent` and `extra_headers` parameters on `TangoClient` for custom request headers.
20+
- `TangoClient.last_response_headers` property for accessing full HTTP headers from the most recent API response.
21+
1022
## [0.4.3] - 2026-03-21
1123

1224
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "tango-python"
7-
version = "0.4.3"
7+
version = "0.5.0"
88
description = "Python SDK for the Tango API"
99
readme = "README.md"
1010
requires-python = ">=3.12"

scripts/check_filter_shape_conformance.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"agencies": "list_agencies",
5151
"naics": "list_naics",
5252
"gsa_elibrary_contracts": "list_gsa_elibrary_contracts",
53+
"itdashboard": "list_itdashboard_investments",
5354
# Resources not yet implemented in SDK
5455
"offices": None,
5556
}
@@ -66,6 +67,7 @@ def get_shape_config_entries() -> list[tuple[str, str, type[Any]]]:
6667
Forecast,
6768
Grant,
6869
GsaElibraryContract,
70+
ITDashboardInvestment,
6971
Notice,
7072
Opportunity,
7173
Organization,
@@ -98,6 +100,16 @@ def get_shape_config_entries() -> list[tuple[str, str, type[Any]]]:
98100
ShapeConfig.GSA_ELIBRARY_CONTRACTS_MINIMAL,
99101
GsaElibraryContract,
100102
),
103+
(
104+
"ITDASHBOARD_INVESTMENTS_MINIMAL",
105+
ShapeConfig.ITDASHBOARD_INVESTMENTS_MINIMAL,
106+
ITDashboardInvestment,
107+
),
108+
(
109+
"ITDASHBOARD_INVESTMENTS_COMPREHENSIVE",
110+
ShapeConfig.ITDASHBOARD_INVESTMENTS_COMPREHENSIVE,
111+
ITDashboardInvestment,
112+
),
101113
]
102114
for name, shape_str, model_cls in configs:
103115
entries.append((name, shape_str, model_cls))

tango/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
)
1111
from .models import (
1212
GsaElibraryContract,
13+
ITDashboardInvestment,
1314
PaginatedResponse,
1415
RateLimitInfo,
1516
SearchFilters,
@@ -28,7 +29,7 @@
2829
TypeGenerator,
2930
)
3031

31-
__version__ = "0.4.3"
32+
__version__ = "0.5.0"
3233
__all__ = [
3334
"TangoClient",
3435
"TangoAPIError",
@@ -38,6 +39,7 @@
3839
"TangoRateLimitError",
3940
"RateLimitInfo",
4041
"GsaElibraryContract",
42+
"ITDashboardInvestment",
4143
"PaginatedResponse",
4244
"SearchFilters",
4345
"ShapeConfig",

tango/client.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
Forecast,
2727
Grant,
2828
GsaElibraryContract,
29+
ITDashboardInvestment,
2930
Location,
3031
Notice,
3132
Opportunity,
@@ -59,6 +60,8 @@ def __init__(
5960
self,
6061
api_key: str | None = None,
6162
base_url: str = "https://tango.makegov.com",
63+
user_agent: str | None = None,
64+
extra_headers: dict[str, str] | None = None,
6265
):
6366
"""
6467
Initialize the Tango API client
@@ -67,6 +70,8 @@ def __init__(
6770
api_key: API key for authentication. If not provided, will attempt to load from
6871
TANGO_API_KEY environment variable.
6972
base_url: Base URL for the API
73+
user_agent: Custom User-Agent header value.
74+
extra_headers: Additional headers to include in every request.
7075
"""
7176
# Load API key from environment if not provided
7277
self.api_key = api_key or os.getenv("TANGO_API_KEY")
@@ -76,9 +81,14 @@ def __init__(
7681
headers = {}
7782
if self.api_key:
7883
headers["X-API-KEY"] = self.api_key
84+
if user_agent:
85+
headers["User-Agent"] = user_agent
86+
if extra_headers:
87+
headers.update(extra_headers)
7988

8089
self.client = httpx.Client(headers=headers, timeout=30.0)
8190
self._last_rate_limit_info: RateLimitInfo | None = None
91+
self._last_response_headers: httpx.Headers | None = None
8292

8393
# Use hardcoded sensible defaults
8494
cache_size = 100
@@ -105,6 +115,11 @@ def rate_limit_info(self) -> RateLimitInfo | None:
105115
"""Rate limit info from the most recent API response."""
106116
return self._last_rate_limit_info
107117

118+
@property
119+
def last_response_headers(self) -> httpx.Headers | None:
120+
"""Full HTTP headers from the most recent API response."""
121+
return self._last_response_headers
122+
108123
@staticmethod
109124
def _parse_rate_limit_headers(headers: httpx.Headers) -> RateLimitInfo:
110125
"""Extract rate limit info from response headers."""
@@ -140,6 +155,7 @@ def _request(
140155

141156
try:
142157
response = self.client.request(method=method, url=url, params=params, json=json_data)
158+
self._last_response_headers = response.headers
143159
self._last_rate_limit_info = self._parse_rate_limit_headers(response.headers)
144160

145161
if response.status_code == 401:
@@ -1321,6 +1337,112 @@ def get_gsa_elibrary_contract(
13211337
data, shape, GsaElibraryContract, flat, flat_lists, joiner=joiner
13221338
)
13231339

1340+
# ============================================================================
1341+
# IT Dashboard Investments
1342+
# ============================================================================
1343+
1344+
def list_itdashboard_investments(
1345+
self,
1346+
page: int = 1,
1347+
limit: int = 25,
1348+
shape: str | None = None,
1349+
flat: bool = False,
1350+
flat_lists: bool = False,
1351+
joiner: str = ".",
1352+
search: str | None = None,
1353+
agency_code: int | None = None,
1354+
agency_name: str | None = None,
1355+
type_of_investment: str | None = None,
1356+
updated_time_after: str | date | datetime | None = None,
1357+
updated_time_before: str | date | datetime | None = None,
1358+
cio_rating: int | None = None,
1359+
cio_rating_max: int | None = None,
1360+
performance_risk: bool | None = None,
1361+
) -> PaginatedResponse:
1362+
"""List federal IT investments from the IT Dashboard (`/api/itdashboard/`).
1363+
1364+
Filters are tier-gated by the API:
1365+
1366+
- **Free**: ``search`` (full-text across UII, title, description, agency, bureau)
1367+
- **Pro**: ``agency_code``, ``type_of_investment``,
1368+
``updated_time_after`` / ``updated_time_before``
1369+
- **Business+**: ``agency_name`` (text), ``cio_rating``,
1370+
``cio_rating_max``, ``performance_risk``
1371+
1372+
Hitting a gated filter on a lower tier returns a 403 with upgrade info.
1373+
1374+
CIO ratings: 1=High Risk, 2=Moderately High, 3=Medium, 4=Moderately Low, 5=Low.
1375+
``performance_risk=True`` returns investments with at least one NOT MET metric.
1376+
"""
1377+
params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
1378+
if shape is None:
1379+
shape = ShapeConfig.ITDASHBOARD_INVESTMENTS_MINIMAL
1380+
if shape:
1381+
params["shape"] = shape
1382+
if flat:
1383+
params["flat"] = "true"
1384+
if joiner:
1385+
params["joiner"] = joiner
1386+
if flat_lists:
1387+
params["flat_lists"] = "true"
1388+
for k, val in (
1389+
("search", search),
1390+
("agency_code", agency_code),
1391+
("agency_name", agency_name),
1392+
("type_of_investment", type_of_investment),
1393+
("updated_time_after", updated_time_after),
1394+
("updated_time_before", updated_time_before),
1395+
("cio_rating", cio_rating),
1396+
("cio_rating_max", cio_rating_max),
1397+
("performance_risk", performance_risk),
1398+
):
1399+
if val is None:
1400+
continue
1401+
if isinstance(val, bool):
1402+
params[k] = "true" if val else "false"
1403+
elif isinstance(val, (date, datetime)):
1404+
params[k] = val.isoformat()
1405+
else:
1406+
params[k] = val
1407+
data = self._get("/api/itdashboard/", params)
1408+
results = [
1409+
self._parse_response_with_shape(
1410+
obj, shape, ITDashboardInvestment, flat, flat_lists, joiner=joiner
1411+
)
1412+
for obj in data.get("results", [])
1413+
]
1414+
return PaginatedResponse(
1415+
count=data.get("count", 0),
1416+
next=data.get("next"),
1417+
previous=data.get("previous"),
1418+
results=results,
1419+
)
1420+
1421+
def get_itdashboard_investment(
1422+
self,
1423+
uii: str,
1424+
shape: str | None = None,
1425+
flat: bool = False,
1426+
flat_lists: bool = False,
1427+
joiner: str = ".",
1428+
) -> Any:
1429+
"""Get a single IT Dashboard investment by UII (`/api/itdashboard/{uii}/`)."""
1430+
params: dict[str, Any] = {}
1431+
if shape is None:
1432+
shape = ShapeConfig.ITDASHBOARD_INVESTMENTS_COMPREHENSIVE
1433+
if shape:
1434+
params["shape"] = shape
1435+
if flat:
1436+
params["flat"] = "true"
1437+
if joiner:
1438+
params["joiner"] = joiner
1439+
if flat_lists:
1440+
params["flat_lists"] = "true"
1441+
data = self._get(f"/api/itdashboard/{uii}/", params)
1442+
return self._parse_response_with_shape(
1443+
data, shape, ITDashboardInvestment, flat, flat_lists, joiner=joiner
1444+
)
1445+
13241446
# ============================================================================
13251447
# Vehicles (Awards)
13261448
# ============================================================================

tango/models.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,45 @@ class GsaElibraryContract:
367367
sins: list[str] | None = None
368368

369369

370+
@dataclass
371+
class ITDashboardInvestment:
372+
"""Schema definition for IT Dashboard Investment (not used for instances)
373+
374+
Federal IT investment from itdashboard.gov, exposed at /api/itdashboard/.
375+
Identified by ``uii`` (Unique Investment Identifier).
376+
377+
Tier-gated shape expansions:
378+
Free base fields only
379+
Pro+ ``funding`` and ``details`` expansions
380+
Business+ nested sub-tables (``cio_evaluation``, ``contracts``,
381+
``projects``, ``cost_pools_towers``, ``funding_sources``,
382+
``performance_metrics``, ``performance_actual``,
383+
``operational_analysis``) and ``business_case_html``
384+
"""
385+
386+
uii: str
387+
agency_code: int | None = None
388+
agency_name: str | None = None
389+
bureau_code: int | None = None
390+
bureau_name: str | None = None
391+
investment_title: str | None = None
392+
type_of_investment: str | None = None
393+
part_of_it_portfolio: str | None = None
394+
updated_time: datetime | None = None
395+
url: str | None = None
396+
business_case_html: str | None = None
397+
funding: dict[str, Any] | None = None
398+
details: dict[str, Any] | None = None
399+
cio_evaluation: list[dict[str, Any]] | None = None
400+
contracts: list[dict[str, Any]] | None = None
401+
projects: list[dict[str, Any]] | None = None
402+
cost_pools_towers: list[dict[str, Any]] | None = None
403+
funding_sources: list[dict[str, Any]] | None = None
404+
performance_metrics: list[dict[str, Any]] | None = None
405+
performance_actual: list[dict[str, Any]] | None = None
406+
operational_analysis: list[dict[str, Any]] | None = None
407+
408+
370409
@dataclass
371410
class Vehicle:
372411
"""Schema definition for Vehicle (not used for instances)"""
@@ -687,3 +726,18 @@ class ShapeConfig:
687726
GSA_ELIBRARY_CONTRACTS_MINIMAL: Final = (
688727
"uuid,contract_number,schedule,recipient(display_name,uei),idv(key,award_date)"
689728
)
729+
730+
# Default for list_itdashboard_investments()
731+
# Free-tier safe: matches the API's INVESTMENT_LIST_DEFAULT_SHAPE.
732+
ITDASHBOARD_INVESTMENTS_MINIMAL: Final = (
733+
"uii,agency_name,bureau_name,investment_title,"
734+
"type_of_investment,part_of_it_portfolio,updated_time,url"
735+
)
736+
737+
# Default for get_itdashboard_investment()
738+
# Free-tier safe: matches the API's INVESTMENT_RETRIEVE_DEFAULT_SHAPE.
739+
ITDASHBOARD_INVESTMENTS_COMPREHENSIVE: Final = (
740+
"uii,agency_code,agency_name,bureau_code,bureau_name,"
741+
"investment_title,type_of_investment,part_of_it_portfolio,"
742+
"updated_time,url"
743+
)

0 commit comments

Comments
 (0)