diff --git a/.gitignore b/.gitignore index fd11622..250b9e6 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,6 @@ dmypy.json # OS .DS_Store Thumbs.db + +# other +/yoni \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f00873c..0523483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Vehicles endpoints: `list_vehicles`, `get_vehicle`, and `list_vehicle_awardees` (supports shaping + flattening). (refs `makegov/tango#1328`) +- IDV endpoints: `list_idvs`, `get_idv`, `list_idv_awards`, `list_idv_child_idvs`, `list_idv_transactions`, `get_idv_summary`, `list_idv_summary_awards`. (refs `makegov/tango#1328`) + +### Changed +- Expanded explicit schemas to support common IDV shaping expansions (award offices, officers, period of performance, etc.). + ## [0.2.0] - 2025-11-16 -- Entirely refactored SDK \ No newline at end of file +- Entirely refactored SDK diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 6c31764..84aa8e6 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -7,6 +7,8 @@ Complete reference for all Tango Python SDK methods and functionality. - [Client Initialization](#client-initialization) - [Agencies](#agencies) - [Contracts](#contracts) +- [IDVs](#idvs) +- [Vehicles](#vehicles) - [Entities](#entities) - [Forecasts](#forecasts) - [Opportunities](#opportunities) @@ -271,6 +273,119 @@ contracts = client.list_contracts( --- +## Vehicles + +Vehicles provide a solicitation-centric way to discover groups of related IDVs and (optionally) expand into the underlying awards via shaping. + +### list_vehicles() + +List vehicles with optional vehicle-level full-text search. + +```python +vehicles = client.list_vehicles( + page=1, + limit=25, + search="GSA schedule", + shape=ShapeConfig.VEHICLES_MINIMAL, + flat=False, + flat_lists=False, +) +``` + +**Parameters:** +- `page` (int): Page number (default: 1) +- `limit` (int): Results per page (default: 25, max: 100) +- `search` (str, optional): Vehicle-level search term +- `shape` (str, optional): Shape string (defaults to `ShapeConfig.VEHICLES_MINIMAL`) +- `flat` (bool): Flatten nested objects in shaped response +- `flat_lists` (bool): Flatten arrays using indexed keys +- `joiner` (str): Joiner used when `flat=True` (default: `"."`) + +**Returns:** [PaginatedResponse](#paginatedresponse) with vehicle dictionaries + +### get_vehicle() + +Get a single vehicle by UUID. + +```python +vehicle = client.get_vehicle( + uuid="00000000-0000-0000-0000-000000000001", + shape=ShapeConfig.VEHICLES_COMPREHENSIVE, +) +``` + +**Notes:** +- On the vehicle detail endpoint, `search` filters **expanded awardees** when your `shape` includes `awardees(...)` (it does not filter the vehicle itself). + +### list_vehicle_awardees() + +List the IDV awardees for a vehicle. + +```python +awardees = client.list_vehicle_awardees( + uuid="00000000-0000-0000-0000-000000000001", + shape=ShapeConfig.VEHICLE_AWARDEES_MINIMAL, +) +``` + +--- + +## IDVs + +IDVs (indefinite delivery vehicles) are the parent “vehicle award” records that can have child awards/orders under them. + +### list_idvs() + +```python +idvs = client.list_idvs( + limit=25, + cursor=None, + shape=ShapeConfig.IDVS_MINIMAL, + awarding_agency="4700", +) +``` + +Notes: + +- This endpoint uses **keyset pagination** (`cursor` + `limit`) rather than page numbers. + +### get_idv() + +```python +idv = client.get_idv("SOME_IDV_KEY", shape=ShapeConfig.IDVS_COMPREHENSIVE) +``` + +### list_idv_awards() + +Lists child awards (contracts) under an IDV. + +```python +awards = client.list_idv_awards("SOME_IDV_KEY", limit=25) +``` + +### list_idv_child_idvs() + +Lists child IDVs under an IDV. + +```python +children = client.list_idv_child_idvs("SOME_IDV_KEY", limit=25) +``` + +### list_idv_transactions() + +```python +tx = client.list_idv_transactions("SOME_IDV_KEY", limit=100) +``` + +### get_idv_summary() / list_idv_summary_awards() + +```python +summary = client.get_idv_summary("SOLICITATION_IDENTIFIER") +awards = client.list_idv_summary_awards("SOLICITATION_IDENTIFIER", limit=25) +``` + +--- + ## Entities Vendors, recipients, and organizations doing business with the government. diff --git a/tango/client.py b/tango/client.py index cd0efac..080b516 100644 --- a/tango/client.py +++ b/tango/client.py @@ -16,6 +16,7 @@ TangoValidationError, ) from tango.models import ( + IDV, Agency, BusinessType, Contract, @@ -28,6 +29,7 @@ PaginatedResponse, SearchFilters, ShapeConfig, + Vehicle, ) from tango.shapes import ( ModelFactory, @@ -152,6 +154,7 @@ def _parse_response_with_shape( base_model: type, flat: bool = False, flat_lists: bool = False, + joiner: str = ".", ) -> Any: """ Parse API response using dynamic model generation @@ -189,7 +192,7 @@ def _parse_response_with_shape( # Unflatten if necessary if flat: - data = self._unflatten_response(data) + data = self._unflatten_response(data, joiner=joiner) # Create typed instance return self._model_factory.create_instance( @@ -562,6 +565,363 @@ def list_contracts( results=results, ) + # ============================================================================ + # IDVs (Awards) + # ============================================================================ + + def list_idvs( + self, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + **filters, + ) -> PaginatedResponse: + """ + List IDVs (indefinite delivery vehicles) with keyset pagination. + + This mirrors `/api/idvs/` and supports the same filter parameters as the API, + plus shaping via `shape`. + """ + params: dict[str, Any] = {"limit": min(limit, 100)} + if cursor: + params["cursor"] = cursor + + if shape is None: + shape = ShapeConfig.IDVS_MINIMAL + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if joiner: + params["joiner"] = joiner + if flat_lists: + params["flat_lists"] = "true" + + params.update({k: v for k, v in filters.items() if v is not None}) + + data = self._get("/api/idvs/", params) + + raw_results = data.get("results") or [] + results = [ + self._parse_response_with_shape(obj, shape, IDV, flat, flat_lists, joiner=joiner) + for obj in raw_results + ] + + return PaginatedResponse( + count=int(data.get("count") or len(results)), + next=data.get("next"), + previous=data.get("previous"), + results=results, + page_metadata=data.get("page_metadata"), + ) + + def get_idv( + self, + key: str, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ) -> Any: + """Get a single IDV by award key (`/api/idvs/{key}/`).""" + params: dict[str, Any] = {} + if shape is None: + shape = ShapeConfig.IDVS_COMPREHENSIVE + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if joiner: + params["joiner"] = joiner + if flat_lists: + params["flat_lists"] = "true" + + data = self._get(f"/api/idvs/{key}/", params) + return self._parse_response_with_shape(data, shape, IDV, flat, flat_lists, joiner=joiner) + + def list_idv_awards( + self, + key: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + filters: SearchFilters | dict[str, Any] | None = None, + **kwargs, + ) -> PaginatedResponse: + """ + List child awards (contracts) under an IDV (`/api/idvs/{key}/awards/`). + + This endpoint behaves like `/api/contracts/`, but scoped to a specific IDV. + """ + # Reuse list_contracts mapping and behavior by calling the endpoint directly. + params: dict[str, Any] = {"limit": min(limit, 100)} + if cursor: + params["cursor"] = cursor + + if shape is None: + shape = ShapeConfig.CONTRACTS_MINIMAL + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if joiner: + params["joiner"] = joiner + if flat_lists: + params["flat_lists"] = "true" + + filter_dict: dict[str, Any] = {} + if filters is not None: + filter_dict = filters.to_dict() if hasattr(filters, "to_dict") else filters + filter_params = {**filter_dict, **kwargs} + for param in {"shape", "flat", "flat_lists", "joiner", "limit", "cursor"}: + filter_params.pop(param, None) + + # Same mapping used by list_contracts() + api_param_mapping = { + "naics_code": "naics", + "keyword": "search", + "psc_code": "psc", + "recipient_name": "recipient", + "recipient_uei": "uei", + "set_aside_type": "set_aside", + } + sort_field = filter_params.pop("sort", None) + sort_order = filter_params.pop("order", None) + if sort_field: + prefix = "-" if sort_order == "desc" else "" + filter_params["ordering"] = f"{prefix}{sort_field}" + + api_params: dict[str, Any] = {} + for k, v in filter_params.items(): + if v is None: + continue + api_params[api_param_mapping.get(k, k)] = v + params.update(api_params) + + data = self._get(f"/api/idvs/{key}/awards/", params) + raw_results = data.get("results") or [] + results = [ + self._parse_response_with_shape(obj, shape, Contract, flat, flat_lists, joiner=joiner) + for obj in raw_results + ] + + return PaginatedResponse( + count=int(data.get("count") or len(results)), + next=data.get("next"), + previous=data.get("previous"), + results=results, + page_metadata=data.get("page_metadata"), + ) + + def list_idv_child_idvs( + self, + key: str, + limit: int = 25, + cursor: str | None = None, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + **filters, + ) -> PaginatedResponse: + """List child IDVs under an IDV (`/api/idvs/{key}/idvs/`).""" + params: dict[str, Any] = {"limit": min(limit, 100)} + if cursor: + params["cursor"] = cursor + + if shape is None: + shape = ShapeConfig.IDVS_MINIMAL + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if joiner: + params["joiner"] = joiner + if flat_lists: + params["flat_lists"] = "true" + + params.update({k: v for k, v in filters.items() if v is not None}) + + data = self._get(f"/api/idvs/{key}/idvs/", params) + raw_results = data.get("results") or [] + results = [ + self._parse_response_with_shape(obj, shape, IDV, flat, flat_lists, joiner=joiner) + for obj in raw_results + ] + + return PaginatedResponse( + count=int(data.get("count") or len(results)), + next=data.get("next"), + previous=data.get("previous"), + results=results, + page_metadata=data.get("page_metadata"), + ) + + def list_idv_transactions( + self, key: str, limit: int = 100, cursor: str | None = None + ) -> PaginatedResponse: + """List transactions for an IDV (`/api/idvs/{key}/transactions/`).""" + params: dict[str, Any] = {"limit": min(limit, 500)} + if cursor: + params["cursor"] = cursor + data = self._get(f"/api/idvs/{key}/transactions/", params) + return PaginatedResponse( + count=int(data.get("count") or len(data.get("results") or [])), + next=data.get("next"), + previous=data.get("previous"), + results=data.get("results") or [], + page_metadata=data.get("page_metadata"), + ) + + def get_idv_summary(self, identifier: str) -> dict[str, Any]: + """Get a summary for an IDV solicitation identifier (`/api/idvs/{identifier}/summary/`).""" + return self._get(f"/api/idvs/{identifier}/summary/") + + def list_idv_summary_awards( + self, + identifier: str, + limit: int = 25, + cursor: str | None = None, + ordering: str | None = None, + ) -> PaginatedResponse: + """List awards under an IDV summary (`/api/idvs/{identifier}/summary/awards/`).""" + params: dict[str, Any] = {"limit": min(limit, 100)} + if cursor: + params["cursor"] = cursor + if ordering: + params["ordering"] = ordering + data = self._get(f"/api/idvs/{identifier}/summary/awards/", params) + return PaginatedResponse( + count=int(data.get("count") or len(data.get("results") or [])), + next=data.get("next"), + previous=data.get("previous"), + results=data.get("results") or [], + page_metadata=data.get("page_metadata"), + ) + + # ============================================================================ + # Vehicles (Awards) + # ============================================================================ + + def list_vehicles( + self, + page: int = 1, + limit: int = 25, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + search: str | None = None, + ) -> PaginatedResponse: + """List Vehicles (solicitation-centric groupings of IDVs).""" + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + + if shape is None: + shape = ShapeConfig.VEHICLES_MINIMAL + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if joiner: + params["joiner"] = joiner + if flat_lists: + params["flat_lists"] = "true" + + if search: + params["search"] = search + + data = self._get("/api/vehicles/", params) + + results = [ + self._parse_response_with_shape( + vehicle, shape, Vehicle, flat, flat_lists, joiner=joiner + ) + for vehicle in data["results"] + ] + + return PaginatedResponse( + count=data["count"], + next=data.get("next"), + previous=data.get("previous"), + results=results, + ) + + def get_vehicle( + self, + uuid: str, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + search: str | None = None, + ) -> Any: + """Get a Vehicle by UUID.""" + params: dict[str, Any] = {} + + if shape is None: + shape = ShapeConfig.VEHICLES_COMPREHENSIVE + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if joiner: + params["joiner"] = joiner + if flat_lists: + params["flat_lists"] = "true" + + # On vehicle detail, `search` filters expanded awardees when shaping includes `awardees(...)`. + if search: + params["search"] = search + + data = self._get(f"/api/vehicles/{uuid}/", params) + return self._parse_response_with_shape( + data, shape, Vehicle, flat, flat_lists, joiner=joiner + ) + + def list_vehicle_awardees( + self, + uuid: str, + page: int = 1, + limit: int = 25, + shape: str | None = None, + flat: bool = False, + flat_lists: bool = False, + joiner: str = ".", + ) -> PaginatedResponse: + """List the IDV awardees for a Vehicle (`/api/vehicles/{uuid}/awardees/`).""" + params: dict[str, Any] = {"page": page, "limit": min(limit, 100)} + + if shape is None: + shape = ShapeConfig.VEHICLE_AWARDEES_MINIMAL + if shape: + params["shape"] = shape + if flat: + params["flat"] = "true" + if joiner: + params["joiner"] = joiner + if flat_lists: + params["flat_lists"] = "true" + + data = self._get(f"/api/vehicles/{uuid}/awardees/", params) + + results = [ + self._parse_response_with_shape(awardee, shape, IDV, flat, flat_lists, joiner=joiner) + for awardee in data["results"] + ] + + return PaginatedResponse( + count=data["count"], + next=data.get("next"), + previous=data.get("previous"), + results=results, + ) + # Business Types endpoints def list_business_types(self, page: int = 1, limit: int = 25) -> PaginatedResponse: """List business types""" diff --git a/tango/models.py b/tango/models.py index 800e4d0..2c509ea 100644 --- a/tango/models.py +++ b/tango/models.py @@ -280,6 +280,32 @@ class Contract: place_of_performance: Location | None = None +@dataclass +class IDV: + """Schema definition for IDV (used by Vehicles awardees shaping)""" + + uuid: str + key: str + piid: str | None = None + award_date: date | None = None + description: str | None = None + recipient: RecipientProfile | None = None + + +@dataclass +class Vehicle: + """Schema definition for Vehicle (not used for instances)""" + + uuid: str + solicitation_identifier: str + agency_id: str + solicitation_title: str | None = None + solicitation_date: date | None = None + award_date: date | None = None + last_date_to_order: date | None = None + fiscal_year: int | None = None + + @dataclass class Entity: """Schema definition for Entity (not used for instances)""" @@ -445,3 +471,32 @@ class ShapeConfig: # Default for list_grants() GRANTS_MINIMAL: Final = "grant_id,opportunity_number,title,status(*),agency_code" + + # Default for list_idvs() + IDVS_MINIMAL: Final = "key,piid,award_date,recipient(display_name,uei),description,total_contract_value,obligated,idv_type" + + # Default for get_idv() + IDVS_COMPREHENSIVE: Final = ( + "key,piid,award_date,description,fiscal_year,total_contract_value,base_and_exercised_options_value,obligated," + "idv_type,multiple_or_single_award_idv,type_of_idc,period_of_performance(start_date,last_date_to_order)," + "recipient(display_name,legal_business_name,uei,cage_code)," + "awarding_office(*),funding_office(*),place_of_performance(*),parent_award(key,piid)," + "competition(*),legislative_mandates(*),transactions(*),subawards_summary(*)" + ) + + # Default for list_vehicles() + VEHICLES_MINIMAL: Final = ( + "uuid,solicitation_identifier,organization_id,awardee_count,order_count," + "vehicle_obligations,vehicle_contracts_value,solicitation_title,solicitation_date" + ) + + # Default for get_vehicle() + VEHICLES_COMPREHENSIVE: Final = ( + "uuid,solicitation_identifier,agency_id,organization_id,vehicle_type,who_can_use," + "solicitation_title,solicitation_description,solicitation_date,naics_code,psc_code,set_aside," + "fiscal_year,award_date,last_date_to_order,awardee_count,order_count,vehicle_obligations,vehicle_contracts_value," + "type_of_idc,contract_type,competition_details(*)" + ) + + # Default for list_vehicle_awardees() + VEHICLE_AWARDEES_MINIMAL: Final = "uuid,key,piid,award_date,title,order_count,idv_obligations,idv_contracts_value,recipient(display_name,uei)" diff --git a/tango/shapes/explicit_schemas.py b/tango/shapes/explicit_schemas.py index 3694397..8836ec6 100644 --- a/tango/shapes/explicit_schemas.py +++ b/tango/shapes/explicit_schemas.py @@ -23,6 +23,60 @@ "name": FieldSchema(name="name", type=str, is_optional=True, is_list=False), } +# Awards endpoints often return a richer office object (awarding/funding office). +AWARD_OFFICE_SCHEMA: dict[str, FieldSchema] = { + "office_code": FieldSchema(name="office_code", type=str, is_optional=True, is_list=False), + "office_name": FieldSchema(name="office_name", type=str, is_optional=True, is_list=False), + "agency_code": FieldSchema(name="agency_code", type=str, is_optional=True, is_list=False), + "agency_name": FieldSchema(name="agency_name", type=str, is_optional=True, is_list=False), + "department_code": FieldSchema( + name="department_code", type=str, is_optional=True, is_list=False + ), + "department_name": FieldSchema( + name="department_name", type=str, is_optional=True, is_list=False + ), +} + +PERIOD_OF_PERFORMANCE_IDV_SCHEMA: dict[str, FieldSchema] = { + "start_date": FieldSchema(name="start_date", type=date, is_optional=True, is_list=False), + "last_date_to_order": FieldSchema( + name="last_date_to_order", type=date, is_optional=True, is_list=False + ), +} + +OFFICERS_SCHEMA: dict[str, FieldSchema] = { + "highly_compensated_officer_1_name": FieldSchema( + name="highly_compensated_officer_1_name", type=str, is_optional=True, is_list=False + ), + "highly_compensated_officer_1_amount": FieldSchema( + name="highly_compensated_officer_1_amount", type=Decimal, is_optional=True, is_list=False + ), + "highly_compensated_officer_2_name": FieldSchema( + name="highly_compensated_officer_2_name", type=str, is_optional=True, is_list=False + ), + "highly_compensated_officer_2_amount": FieldSchema( + name="highly_compensated_officer_2_amount", type=Decimal, is_optional=True, is_list=False + ), + "highly_compensated_officer_3_name": FieldSchema( + name="highly_compensated_officer_3_name", type=str, is_optional=True, is_list=False + ), + "highly_compensated_officer_3_amount": FieldSchema( + name="highly_compensated_officer_3_amount", type=Decimal, is_optional=True, is_list=False + ), + "highly_compensated_officer_4_name": FieldSchema( + name="highly_compensated_officer_4_name", type=str, is_optional=True, is_list=False + ), + "highly_compensated_officer_4_amount": FieldSchema( + name="highly_compensated_officer_4_amount", type=Decimal, is_optional=True, is_list=False + ), + "highly_compensated_officer_5_name": FieldSchema( + name="highly_compensated_officer_5_name", type=str, is_optional=True, is_list=False + ), + "highly_compensated_officer_5_amount": FieldSchema( + name="highly_compensated_officer_5_amount", type=Decimal, is_optional=True, is_list=False + ), +} + LOCATION_SCHEMA: dict[str, FieldSchema] = { "city_name": FieldSchema(name="city_name", type=str, is_optional=True, is_list=False), @@ -153,6 +207,9 @@ "location": FieldSchema( name="location", type=dict, is_optional=True, is_list=False, nested_model="Location" ), + # Award endpoints may expose these legacy identifiers/fields. + "cage": FieldSchema(name="cage", type=str, is_optional=True, is_list=False), + "duns": FieldSchema(name="duns", type=str, is_optional=True, is_list=False), } @@ -680,12 +737,251 @@ } +# ============================================================================ +# VEHICLES (Awards) RESOURCE SCHEMAS +# ============================================================================ + +# Vehicles expose a "competition_details(...)" expansion that shapes an underlying JSON object. +# We model that as a nested schema to allow field selection (including wildcard use). +VEHICLE_COMPETITION_DETAILS_SCHEMA: dict[str, FieldSchema] = { + "commercial_item_acquisition_procedures": FieldSchema( + name="commercial_item_acquisition_procedures", type=dict, is_optional=True, is_list=False + ), + "evaluated_preference": FieldSchema( + name="evaluated_preference", type=dict, is_optional=True, is_list=False + ), + "extent_competed": FieldSchema( + name="extent_competed", type=dict, is_optional=True, is_list=False + ), + "most_recent_solicitation_date": FieldSchema( + name="most_recent_solicitation_date", type=date, is_optional=True, is_list=False + ), + "number_of_offers_received": FieldSchema( + name="number_of_offers_received", type=int, is_optional=True, is_list=False + ), + "original_solicitation_date": FieldSchema( + name="original_solicitation_date", type=date, is_optional=True, is_list=False + ), + "other_than_full_and_open_competition": FieldSchema( + name="other_than_full_and_open_competition", type=dict, is_optional=True, is_list=False + ), + "set_aside": FieldSchema(name="set_aside", type=dict, is_optional=True, is_list=False), + "simplified_procedures_for_certain_commercial_items": FieldSchema( + name="simplified_procedures_for_certain_commercial_items", + type=dict, + is_optional=True, + is_list=False, + ), + "small_business_competitiveness_demonstration_program": FieldSchema( + name="small_business_competitiveness_demonstration_program", + type=dict, + is_optional=True, + is_list=False, + ), + "solicitation_identifier": FieldSchema( + name="solicitation_identifier", type=str, is_optional=True, is_list=False + ), + "solicitation_procedures": FieldSchema( + name="solicitation_procedures", type=dict, is_optional=True, is_list=False + ), +} + + +# IDV schema (used for `/api/idvs/`, and also by Vehicles awardees shaping). +IDV_SCHEMA: dict[str, FieldSchema] = { + # Identifiers + "uuid": FieldSchema(name="uuid", type=str, is_optional=True, is_list=False), + "key": FieldSchema(name="key", type=str, is_optional=False, is_list=False), + "piid": FieldSchema(name="piid", type=str, is_optional=True, is_list=False), + # Core fields + "award_date": FieldSchema(name="award_date", type=date, is_optional=True, is_list=False), + "naics_code": FieldSchema(name="naics_code", type=int, is_optional=True, is_list=False), + "psc_code": FieldSchema(name="psc_code", type=str, is_optional=True, is_list=False), + "total_contract_value": FieldSchema( + name="total_contract_value", type=Decimal, is_optional=True, is_list=False + ), + "description": FieldSchema(name="description", type=str, is_optional=True, is_list=False), + "base_and_exercised_options_value": FieldSchema( + name="base_and_exercised_options_value", type=Decimal, is_optional=True, is_list=False + ), + "fiscal_year": FieldSchema(name="fiscal_year", type=int, is_optional=True, is_list=False), + "obligated": FieldSchema(name="obligated", type=Decimal, is_optional=True, is_list=False), + "idv_type": FieldSchema(name="idv_type", type=dict, is_optional=True, is_list=False), + "multiple_or_single_award_idv": FieldSchema( + name="multiple_or_single_award_idv", type=dict, is_optional=True, is_list=False + ), + "type_of_idc": FieldSchema(name="type_of_idc", type=dict, is_optional=True, is_list=False), + # Expansions / nested objects + "recipient": FieldSchema( + name="recipient", + type=dict, + is_optional=True, + is_list=False, + nested_model="RecipientProfile", + ), + "place_of_performance": FieldSchema( + name="place_of_performance", + type=dict, + is_optional=True, + is_list=False, + nested_model="PlaceOfPerformance", + ), + "officers": FieldSchema( + name="officers", type=dict, is_optional=True, is_list=False, nested_model="Officers" + ), + "parent_award": FieldSchema( + name="parent_award", + type=dict, + is_optional=True, + is_list=False, + nested_model="ParentAward", + ), + "awarding_office": FieldSchema( + name="awarding_office", + type=dict, + is_optional=True, + is_list=False, + nested_model="AwardOffice", + ), + "funding_office": FieldSchema( + name="funding_office", + type=dict, + is_optional=True, + is_list=False, + nested_model="AwardOffice", + ), + "legislative_mandates": FieldSchema( + name="legislative_mandates", + type=dict, + is_optional=True, + is_list=False, + nested_model="LegislativeMandates", + ), + "set_aside": FieldSchema( + name="set_aside", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription" + ), + "period_of_performance": FieldSchema( + name="period_of_performance", + type=dict, + is_optional=True, + is_list=False, + nested_model="IDVPeriodOfPerformance", + ), + "transactions": FieldSchema( + name="transactions", + type=dict, + is_optional=True, + is_list=True, + nested_model="Transaction", + ), + "subawards_summary": FieldSchema( + name="subawards_summary", + type=dict, + is_optional=True, + is_list=False, + nested_model="SubawardsSummary", + ), + "competition": FieldSchema( + name="competition", + type=dict, + is_optional=True, + is_list=False, + nested_model="ContractOrIDVCompetition", + ), + "awards": FieldSchema( + name="awards", type=dict, is_optional=True, is_list=True, nested_model="Contract" + ), + # Alias expansion used in vehicle shaping: orders(...) == IDV child awards/contracts. + "orders": FieldSchema( + name="orders", type=dict, is_optional=True, is_list=True, nested_model="Contract" + ), + # Shapes that request naics(...) / psc(...) + "naics": FieldSchema( + name="naics", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription" + ), + "psc": FieldSchema( + name="psc", type=dict, is_optional=True, is_list=False, nested_model="CodeDescription" + ), + # Vehicle membership rollups (present on `/api/vehicles/{uuid}/awardees/`). + "title": FieldSchema(name="title", type=str, is_optional=True, is_list=False), + "order_count": FieldSchema(name="order_count", type=int, is_optional=True, is_list=False), + "idv_obligations": FieldSchema( + name="idv_obligations", type=Decimal, is_optional=True, is_list=False + ), + "idv_contracts_value": FieldSchema( + name="idv_contracts_value", type=Decimal, is_optional=True, is_list=False + ), +} + + +VEHICLE_SCHEMA: dict[str, FieldSchema] = { + "uuid": FieldSchema(name="uuid", type=str, is_optional=False, is_list=False), + "solicitation_identifier": FieldSchema( + name="solicitation_identifier", type=str, is_optional=False, is_list=False + ), + "agency_id": FieldSchema(name="agency_id", type=str, is_optional=False, is_list=False), + "organization_id": FieldSchema( + name="organization_id", type=str, is_optional=True, is_list=False + ), + # Choice fields are returned as {code, description} objects. + "vehicle_type": FieldSchema(name="vehicle_type", type=dict, is_optional=True, is_list=False), + "who_can_use": FieldSchema(name="who_can_use", type=dict, is_optional=True, is_list=False), + "type_of_idc": FieldSchema(name="type_of_idc", type=dict, is_optional=True, is_list=False), + "contract_type": FieldSchema(name="contract_type", type=dict, is_optional=True, is_list=False), + "agency_details": FieldSchema( + name="agency_details", type=dict, is_optional=True, is_list=False + ), + "descriptions": FieldSchema(name="descriptions", type=str, is_optional=True, is_list=True), + "fiscal_year": FieldSchema(name="fiscal_year", type=int, is_optional=True, is_list=False), + "award_date": FieldSchema(name="award_date", type=date, is_optional=True, is_list=False), + "last_date_to_order": FieldSchema( + name="last_date_to_order", type=date, is_optional=True, is_list=False + ), + "awardee_count": FieldSchema(name="awardee_count", type=int, is_optional=True, is_list=False), + "order_count": FieldSchema(name="order_count", type=int, is_optional=True, is_list=False), + "vehicle_obligations": FieldSchema( + name="vehicle_obligations", type=Decimal, is_optional=True, is_list=False + ), + "vehicle_contracts_value": FieldSchema( + name="vehicle_contracts_value", type=Decimal, is_optional=True, is_list=False + ), + # Opportunity-derived fields (SAM.gov) + "solicitation_title": FieldSchema( + name="solicitation_title", type=str, is_optional=True, is_list=False + ), + "solicitation_description": FieldSchema( + name="solicitation_description", type=str, is_optional=True, is_list=False + ), + "solicitation_date": FieldSchema( + name="solicitation_date", type=date, is_optional=True, is_list=False + ), + "naics_code": FieldSchema(name="naics_code", type=int, is_optional=True, is_list=False), + "psc_code": FieldSchema(name="psc_code", type=str, is_optional=True, is_list=False), + "set_aside": FieldSchema(name="set_aside", type=str, is_optional=True, is_list=False), + # Shaping expansions + "awardees": FieldSchema( + name="awardees", type=dict, is_optional=True, is_list=True, nested_model="IDV" + ), + "opportunity": FieldSchema( + name="opportunity", type=dict, is_optional=True, is_list=False, nested_model="Opportunity" + ), + "competition_details": FieldSchema( + name="competition_details", + type=dict, + is_optional=True, + is_list=False, + nested_model="VehicleCompetitionDetails", + ), +} + + # ============================================================================ # SCHEMA REGISTRY MAPPING # ============================================================================ EXPLICIT_SCHEMAS: dict[str, dict[str, FieldSchema]] = { "Office": OFFICE_SCHEMA, + "AwardOffice": AWARD_OFFICE_SCHEMA, "Location": LOCATION_SCHEMA, "PlaceOfPerformance": PLACE_OF_PERFORMANCE_SCHEMA, "Competition": COMPETITION_SCHEMA, @@ -697,12 +993,18 @@ "Contact": CONTACT_SCHEMA, "RecipientProfile": RECIPIENT_PROFILE_SCHEMA, "Contract": CONTRACT_SCHEMA, + "IDVPeriodOfPerformance": PERIOD_OF_PERFORMANCE_IDV_SCHEMA, + "Officers": OFFICERS_SCHEMA, "Entity": ENTITY_SCHEMA, "Forecast": FORECAST_SCHEMA, "Opportunity": OPPORTUNITY_SCHEMA, "Notice": NOTICE_SCHEMA, "Agency": AGENCY_SCHEMA, "Grant": GRANT_SCHEMA, + # Vehicles (Awards) + "Vehicle": VEHICLE_SCHEMA, + "IDV": IDV_SCHEMA, + "VehicleCompetitionDetails": VEHICLE_COMPETITION_DETAILS_SCHEMA, # Nested schemas for Grant fields "CFDANumber": CFDA_NUMBER_SCHEMA, "CodeDescription": CODE_DESCRIPTION_SCHEMA, diff --git a/tests/test_client.py b/tests/test_client.py index 5f35396..183b46d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1041,6 +1041,183 @@ def test_list_grants(self, mock_request): assert grants.results[0].opportunity_number == "OPP-123" +# ============================================================================ +# Vehicles (Awards) Endpoint Tests +# ============================================================================ + + +class TestVehiclesEndpoints: + """Test Vehicles endpoints""" + + @patch("tango.client.httpx.Client.request") + def test_list_vehicles_uses_default_shape_and_search(self, mock_request): + mock_response = Mock() + mock_response.is_success = True + mock_response.content = b'{"count": 1}' + mock_response.json.return_value = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "uuid": "00000000-0000-0000-0000-000000000001", + "solicitation_identifier": "47QSWA20D0001", + "organization_id": "00000000-0000-0000-0000-000000000099", + "awardee_count": 12, + "order_count": 345, + "vehicle_obligations": "123.45", + "vehicle_contracts_value": "999.99", + "solicitation_title": "GSA MAS", + "solicitation_date": "2024-01-15", + } + ], + } + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + vehicles = client.list_vehicles(search="GSA", limit=10) + + call_args = mock_request.call_args + params = call_args[1]["params"] + assert params["shape"] == ShapeConfig.VEHICLES_MINIMAL + assert params["search"] == "GSA" + + assert vehicles.count == 1 + v = vehicles.results[0] + assert v["solicitation_identifier"] == "47QSWA20D0001" + assert v["vehicle_obligations"] == Decimal("123.45") + assert isinstance(v["solicitation_date"], date) + + @patch("tango.client.httpx.Client.request") + def test_get_vehicle_supports_joiner_and_flat_lists(self, mock_request): + mock_response = Mock() + mock_response.is_success = True + mock_response.content = b'{"uuid": "00000000-0000-0000-0000-000000000001"}' + mock_response.json.return_value = { + "uuid": "00000000-0000-0000-0000-000000000001", + "opportunity__title": "Test Opportunity", + } + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + vehicle = client.get_vehicle( + "00000000-0000-0000-0000-000000000001", + shape="uuid,opportunity(title)", + flat=True, + flat_lists=True, + joiner="__", + ) + + call_args = mock_request.call_args + params = call_args[1]["params"] + assert params["shape"] == "uuid,opportunity(title)" + assert params["flat"] == "true" + assert params["flat_lists"] == "true" + assert params["joiner"] == "__" + + assert vehicle["uuid"] == "00000000-0000-0000-0000-000000000001" + assert vehicle["opportunity"]["title"] == "Test Opportunity" + + @patch("tango.client.httpx.Client.request") + def test_list_vehicle_awardees_uses_default_shape(self, mock_request): + mock_response = Mock() + mock_response.is_success = True + mock_response.content = b'{"count": 1}' + mock_response.json.return_value = { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "uuid": "00000000-0000-0000-0000-000000000002", + "key": "IDV-KEY", + "piid": "47QSWA20D0001", + "award_date": "2024-01-01", + "title": "Acme Corp", + "order_count": 10, + "idv_obligations": "100.00", + "idv_contracts_value": "250.50", + "recipient": {"display_name": "Acme Corp", "uei": "UEI123"}, + } + ], + } + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + awardees = client.list_vehicle_awardees("00000000-0000-0000-0000-000000000001", limit=10) + + call_args = mock_request.call_args + params = call_args[1]["params"] + assert params["shape"] == ShapeConfig.VEHICLE_AWARDEES_MINIMAL + + assert awardees.count == 1 + a = awardees.results[0] + assert a["key"] == "IDV-KEY" + assert a["idv_obligations"] == Decimal("100.00") + assert isinstance(a["award_date"], date) + assert a["recipient"]["display_name"] == "Acme Corp" + + +class TestIDVEndpoints: + """Test IDV endpoints wiring""" + + @patch("tango.client.httpx.Client.request") + def test_list_idvs_uses_default_shape_and_keyset_params(self, mock_request): + mock_response = Mock() + mock_response.is_success = True + mock_response.content = b'{"count": 1}' + mock_response.json.return_value = { + "count": 1, + "next": "https://example.test/api/idvs/?cursor=next", + "previous": None, + "results": [ + { + "key": "IDV-KEY", + "piid": "47QSWA20D0001", + "award_date": "2024-01-01", + "recipient": {"display_name": "Acme Corp", "uei": "UEI123"}, + "description": "Test IDV", + "total_contract_value": "1000.00", + "obligated": "10.00", + "idv_type": {"code": "A", "description": "GWAC"}, + } + ], + } + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + resp = client.list_idvs(limit=10, cursor="abc", awarding_agency="4700") + + call_args = mock_request.call_args + params = call_args[1]["params"] + assert params["shape"] == ShapeConfig.IDVS_MINIMAL + assert params["limit"] == 10 + assert params["cursor"] == "abc" + assert params["awarding_agency"] == "4700" + + assert resp.count == 1 + item = resp.results[0] + assert item["key"] == "IDV-KEY" + assert isinstance(item["award_date"], date) + assert item["obligated"] == Decimal("10.00") + + @patch("tango.client.httpx.Client.request") + def test_get_idv_uses_default_shape(self, mock_request): + mock_response = Mock() + mock_response.is_success = True + mock_response.content = b'{"key": "IDV-KEY"}' + mock_response.json.return_value = {"key": "IDV-KEY", "piid": "47QSWA20D0001"} + mock_request.return_value = mock_response + + client = TangoClient(api_key="test-key") + idv = client.get_idv("IDV-KEY") + + call_args = mock_request.call_args + params = call_args[1]["params"] + assert params["shape"] == ShapeConfig.IDVS_COMPREHENSIVE + assert idv["key"] == "IDV-KEY" + + # ============================================================================ # Parser Tests # ============================================================================