Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **SBA OHA support on the protests endpoint.** The Tango API now serves SBA OHA (Office of Hearings and Appeals) decisions as a third `source_system`, alongside GAO and the Court of Federal Claims. `list_protests()` gains a `naics_code` filter, and `Protest` / `PROTEST_SCHEMA` / `PROTEST_DOCKET_SCHEMA` gain the five SBA OHA fields: `challenged_party`, `naics_code`, `size_standard`, `outcome_reason`, and `judge`. They are opt-in via `shape=` (null on GAO and CoFC rows, so they are absent from the default shape) and ungated by tier. Note that `naics_code` is sent to `/api/protests/` verbatim — unlike `list_contracts()` and `list_opportunities()`, which remap the same kwarg onto the API's `naics` param.
- `protests` is now mapped in the conformance checker's `RESOURCE_TO_METHOD`. It had no entry, so the resource fell through to "no SDK method implemented" and its filters were never checked against the contract at all — which is how the `naics_code` gap went unnoticed. With the mapping in place, a protests filter that exists in the API but not in `list_protests()` is now a hard conformance error.

- **Contract-first conformance system.** The canonical API filter/shape
contract is now vendored at `contracts/filter_shape_contract.json` (refresh
with the new `scripts/refresh_contract.py`), so the conformance check runs
Expand Down
21 changes: 15 additions & 6 deletions contracts/filter_shape_contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@
],
"pagination": {
"class": "KeysetPagination",
"max_page_size": null
"max_page_size": 100
},
"shape": {
"expands": {
Expand Down Expand Up @@ -2039,7 +2039,7 @@
],
"pagination": {
"class": "KeysetPagination",
"max_page_size": null
"max_page_size": 100
},
"shape": {
"expands": {
Expand Down Expand Up @@ -2860,7 +2860,7 @@
"type": "date"
},
"search": {
"filter_class": "BaseSmartFilter",
"filter_class": "NoticeSearchFilter",
"type": "string"
},
"set_aside": {
Expand Down Expand Up @@ -3063,7 +3063,7 @@
"type": "string"
},
"place_of_performance": {
"filter_class": "BaseSmartFilter",
"filter_class": "OpportunityPlaceOfPerformanceFilter",
"type": "string"
},
"psc": {
Expand Down Expand Up @@ -3111,6 +3111,7 @@
},
"swagger_has_key": true,
"swagger_params": [
"active",
"agency",
"first_notice_date_after",
"first_notice_date_before",
Expand Down Expand Up @@ -3543,7 +3544,7 @@
],
"pagination": {
"class": "KeysetPagination",
"max_page_size": null
"max_page_size": 100
},
"shape": {
"expands": {
Expand Down Expand Up @@ -3891,7 +3892,7 @@
],
"pagination": {
"class": "KeysetPagination",
"max_page_size": null
"max_page_size": 100
},
"shape": {
"expands": {
Expand Down Expand Up @@ -4094,6 +4095,7 @@
"decision_date_before",
"filed_date_after",
"filed_date_before",
"naics_code",
"outcome",
"protester",
"search",
Expand Down Expand Up @@ -4133,6 +4135,11 @@
"lookup": "lte",
"type": "date"
},
"naics_code": {
"filter_class": "CharFilter",
"lookup": "iexact",
"type": "string"
},
"outcome": {
"filter_class": "BaseSmartFilter",
"type": "string"
Expand Down Expand Up @@ -5474,6 +5481,7 @@
"last_date_to_order",
"latest_award_date",
"naics_code",
"name",
"opportunity_id",
"order_count",
"organization",
Expand Down Expand Up @@ -5900,6 +5908,7 @@
"metrics.top_recipient_share",
"metrics.using_agency_count",
"naics_code",
"name",
"opportunity",
"opportunity.active",
"opportunity.agency",
Expand Down
1 change: 1 addition & 0 deletions scripts/check_filter_shape_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"gsa_elibrary_contracts": "list_gsa_elibrary_contracts",
"itdashboard": "list_itdashboard_investments",
"budget_accounts": "list_budget_accounts",
"protests": "list_protests",
# Resources not yet implemented in SDK
"offices": None,
}
Expand Down
10 changes: 8 additions & 2 deletions tango/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2529,6 +2529,7 @@ def list_protests(
agency: str | None = None,
case_number: str | None = None,
solicitation_number: str | None = None,
naics_code: str | None = None,
protester: str | None = None,
filed_date_after: str | None = None,
filed_date_before: str | None = None,
Expand All @@ -2552,12 +2553,16 @@ def list_protests(
shape: Response shape string (defaults to minimal shape)
flat: If True, flatten nested objects in shaped response
flat_lists: If True, flatten arrays using indexed keys
source_system: Filter by source system (e.g. gao)
outcome: Filter by outcome (e.g. Denied, Dismissed, Withdrawn, Sustained)
source_system: Filter by source system (gao, cofc, sba_oha)
outcome: Filter by outcome. GAO/CoFC use Denied, Dismissed,
Withdrawn, Sustained; SBA OHA adds Granted, Remanded,
Reversed, Vacated
case_type: Filter by case type
agency: Filter by protested agency text
case_number: Filter by case number (e.g. b-423274)
solicitation_number: Filter by exact solicitation number
naics_code: Filter by the NAICS code at issue (e.g. 541512).
Populated only on SBA OHA size and NAICS appeals
protester: Filter by protester name text
filed_date_after: Filed date on or after
filed_date_before: Filed date on or before
Expand All @@ -2583,6 +2588,7 @@ def list_protests(
("agency", agency),
("case_number", case_number),
("solicitation_number", solicitation_number),
("naics_code", naics_code),
("protester", protester),
("filed_date_after", filed_date_after),
("filed_date_before", filed_date_before),
Expand Down
6 changes: 6 additions & 0 deletions tango/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,12 @@ class Protest:
docket_url: str | None = None
decision_url: str | None = None
digest: str | None = None
# SBA OHA only; null on gao/cofc rows. Opt-in via shape=, like digest.
challenged_party: str | None = None
naics_code: str | None = None
size_standard: str | None = None
outcome_reason: str | None = None
judge: str | None = None
dockets: list[dict[str, Any]] | None = None


Expand Down
16 changes: 16 additions & 0 deletions tango/shapes/explicit_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,14 @@
"docket_url": FieldSchema(name="docket_url", type=str, is_optional=True, is_list=False),
"decision_url": FieldSchema(name="decision_url", type=str, is_optional=True, is_list=False),
"digest": FieldSchema(name="digest", type=str, is_optional=True, is_list=False),
# SBA OHA only; null on gao/cofc rows. Opt-in via shape=, like digest.
"challenged_party": FieldSchema(
name="challenged_party", type=str, is_optional=True, is_list=False
),
"naics_code": FieldSchema(name="naics_code", type=str, is_optional=True, is_list=False),
"size_standard": FieldSchema(name="size_standard", type=str, is_optional=True, is_list=False),
"outcome_reason": FieldSchema(name="outcome_reason", type=str, is_optional=True, is_list=False),
"judge": FieldSchema(name="judge", type=str, is_optional=True, is_list=False),
}

PROTEST_SCHEMA: dict[str, FieldSchema] = {
Expand All @@ -750,6 +758,14 @@
"docket_url": FieldSchema(name="docket_url", type=str, is_optional=True, is_list=False),
"decision_url": FieldSchema(name="decision_url", type=str, is_optional=True, is_list=False),
"digest": FieldSchema(name="digest", type=str, is_optional=True, is_list=False),
# SBA OHA only; null on gao/cofc rows. Opt-in via shape=, like digest.
"challenged_party": FieldSchema(
name="challenged_party", type=str, is_optional=True, is_list=False
),
"naics_code": FieldSchema(name="naics_code", type=str, is_optional=True, is_list=False),
"size_standard": FieldSchema(name="size_standard", type=str, is_optional=True, is_list=False),
"outcome_reason": FieldSchema(name="outcome_reason", type=str, is_optional=True, is_list=False),
"judge": FieldSchema(name="judge", type=str, is_optional=True, is_list=False),
"dockets": FieldSchema(
name="dockets", type=dict, is_optional=True, is_list=True, nested_model="ProtestDocket"
),
Expand Down
79 changes: 79 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,85 @@ def test_list_budget_accounts_no_filters_sends_only_pagination_and_shape(self, m
assert leaked == [], f"unexpected filter keys sent: {leaked}"


class TestListProtestsNaicsCodeFilter:
"""`naics_code` on protests is a literal query param, not the `naics` remap."""

@patch("tango.client.httpx.Client.request")
def test_naics_code_is_sent_verbatim(self, mock_request):
mock_response = Mock()
mock_response.is_success = True
mock_response.json.return_value = {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
mock_response.content = b'{"count": 0}'
mock_request.return_value = mock_response

TangoClient(api_key="test-key").list_protests(naics_code="541512")

params = mock_request.call_args[1]["params"]
# Contracts and opportunities remap naics_code -> `naics`; protests does not.
assert params["naics_code"] == "541512"
assert "naics" not in params

@patch("tango.client.httpx.Client.request")
def test_omitted_naics_code_sends_no_key(self, mock_request):
mock_response = Mock()
mock_response.is_success = True
mock_response.json.return_value = {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
mock_response.content = b'{"count": 0}'
mock_request.return_value = mock_response

TangoClient(api_key="test-key").list_protests(source_system="sba_oha")

params = mock_request.call_args[1]["params"]
assert params["source_system"] == "sba_oha"
assert "naics_code" not in params


class TestProtestOhaShapeFields:
"""The five SBA OHA fields round-trip through the shape parser."""

@patch("tango.client.httpx.Client.request")
def test_oha_fields_parse_off_a_shaped_response(self, mock_request):
shape = "case_id,challenged_party,naics_code,size_standard,outcome_reason,judge"
mock_response = Mock()
mock_response.is_success = True
mock_response.json.return_value = {
"count": 1,
"next": None,
"previous": None,
"results": [
{
"case_id": "0b6a1c9e-1f4d-4a2b-8c3d-5e6f7a8b9c0d",
"challenged_party": "Acme Federal LLC",
"naics_code": "541512",
"size_standard": "$34.0 million",
"outcome_reason": "untimely",
"judge": "Christopher Holleman",
}
],
}
mock_response.content = b'{"count": 1}'
mock_request.return_value = mock_response

result = TangoClient(api_key="test-key").list_protests(shape=shape)

(protest,) = result.results
assert protest.challenged_party == "Acme Federal LLC"
assert protest.naics_code == "541512"
assert protest.size_standard == "$34.0 million"
assert protest.outcome_reason == "untimely"
assert protest.judge == "Christopher Holleman"


class TestShapeConfig:
"""Test ShapeConfig class"""

Expand Down
Loading