Skip to content

Commit 23984c0

Browse files
committed
Added protests endpoint
1 parent c44ab9a commit 23984c0

12 files changed

Lines changed: 1139 additions & 1 deletion

tango/client.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
Opportunity,
3232
Organization,
3333
PaginatedResponse,
34+
Protest,
3435
SearchFilters,
3536
ShapeConfig,
3637
Subaward,
@@ -1859,6 +1860,129 @@ def list_notices(
18591860
results=results,
18601861
)
18611862

1863+
# Protest endpoints
1864+
# See https://tango.makegov.com/docs/api-reference/protests.md
1865+
# Note: Protests API does not support ordering (returns 400 if provided).
1866+
# Use shape=...,dockets(...) to request nested dockets.
1867+
def list_protests(
1868+
self,
1869+
page: int = 1,
1870+
limit: int = 25,
1871+
shape: str | None = None,
1872+
flat: bool = False,
1873+
flat_lists: bool = False,
1874+
source_system: str | None = None,
1875+
outcome: str | None = None,
1876+
case_type: str | None = None,
1877+
agency: str | None = None,
1878+
case_number: str | None = None,
1879+
solicitation_number: str | None = None,
1880+
protester: str | None = None,
1881+
filed_date_after: str | None = None,
1882+
filed_date_before: str | None = None,
1883+
decision_date_after: str | None = None,
1884+
decision_date_before: str | None = None,
1885+
search: str | None = None,
1886+
) -> PaginatedResponse:
1887+
"""
1888+
List bid protests.
1889+
1890+
Returns case-level protest records. Use shape=...,dockets(...) to include
1891+
nested dockets. API reference: https://tango.makegov.com/docs/api-reference/protests.md
1892+
1893+
Args:
1894+
page: Page number
1895+
limit: Results per page (max 100)
1896+
shape: Response shape string (defaults to minimal shape)
1897+
flat: If True, flatten nested objects in shaped response
1898+
flat_lists: If True, flatten arrays using indexed keys
1899+
source_system: Filter by source system (e.g. gao)
1900+
outcome: Filter by outcome (e.g. Denied, Dismissed, Withdrawn, Sustained)
1901+
case_type: Filter by case type
1902+
agency: Filter by protested agency text
1903+
case_number: Filter by case number (e.g. b-423274)
1904+
solicitation_number: Filter by exact solicitation number
1905+
protester: Filter by protester name text
1906+
filed_date_after: Filed date on or after
1907+
filed_date_before: Filed date on or before
1908+
decision_date_after: Decision date on or after
1909+
decision_date_before: Decision date on or before
1910+
search: Full-text search over protest searchable fields
1911+
"""
1912+
params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
1913+
1914+
if shape is None:
1915+
shape = ShapeConfig.PROTESTS_MINIMAL
1916+
if shape:
1917+
params["shape"] = shape
1918+
if flat:
1919+
params["flat"] = "true"
1920+
if flat_lists:
1921+
params["flat_lists"] = "true"
1922+
1923+
for key, val in (
1924+
("source_system", source_system),
1925+
("outcome", outcome),
1926+
("case_type", case_type),
1927+
("agency", agency),
1928+
("case_number", case_number),
1929+
("solicitation_number", solicitation_number),
1930+
("protester", protester),
1931+
("filed_date_after", filed_date_after),
1932+
("filed_date_before", filed_date_before),
1933+
("decision_date_after", decision_date_after),
1934+
("decision_date_before", decision_date_before),
1935+
("search", search),
1936+
):
1937+
if val is not None:
1938+
params[key] = val
1939+
1940+
data = self._get("/api/protests/", params)
1941+
1942+
results = [
1943+
self._parse_response_with_shape(item, shape, Protest, flat, flat_lists)
1944+
for item in data["results"]
1945+
]
1946+
1947+
return PaginatedResponse(
1948+
count=data["count"],
1949+
next=data.get("next"),
1950+
previous=data.get("previous"),
1951+
results=results,
1952+
)
1953+
1954+
def get_protest(
1955+
self,
1956+
case_id: str,
1957+
shape: str | None = None,
1958+
flat: bool = False,
1959+
flat_lists: bool = False,
1960+
) -> Any:
1961+
"""
1962+
Get a single protest by case_id (RFC 4122 UUID).
1963+
1964+
Use shape=...,dockets(...) to include nested dockets.
1965+
API reference: https://tango.makegov.com/docs/api-reference/protests.md
1966+
1967+
Args:
1968+
case_id: Deterministic case UUID (from source_system + base_case_number)
1969+
shape: Response shape string (defaults to minimal shape)
1970+
flat: If True, flatten nested objects in shaped response
1971+
flat_lists: If True, flatten arrays using indexed keys
1972+
"""
1973+
params: dict[str, Any] = {}
1974+
if shape is None:
1975+
shape = ShapeConfig.PROTESTS_MINIMAL
1976+
if shape:
1977+
params["shape"] = shape
1978+
if flat:
1979+
params["flat"] = "true"
1980+
if flat_lists:
1981+
params["flat_lists"] = "true"
1982+
1983+
data = self._get(f"/api/protests/{case_id}/", params)
1984+
return self._parse_response_with_shape(data, shape, Protest, flat, flat_lists)
1985+
18621986
# Grant endpoints
18631987
def list_grants(
18641988
self,

tango/models.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,33 @@ class Notice:
433433
naics_code: str | None = None
434434

435435

436+
@dataclass
437+
class Protest:
438+
"""Schema definition for Protest (not used for instances)
439+
440+
Bid protest records at /api/protests/. Case-level object identified by case_id (UUID).
441+
See https://tango.makegov.com/docs/api-reference/protests.md.
442+
"""
443+
444+
case_id: str
445+
case_number: str | None = None
446+
title: str | None = None
447+
source_system: str | None = None
448+
outcome: str | None = None
449+
agency: str | None = None
450+
protester: str | None = None
451+
solicitation_number: str | None = None
452+
case_type: str | None = None
453+
filed_date: datetime | None = None
454+
posted_date: datetime | None = None
455+
decision_date: datetime | None = None
456+
due_date: datetime | None = None
457+
docket_url: str | None = None
458+
decision_url: str | None = None
459+
digest: str | None = None
460+
dockets: list[dict[str, Any]] | None = None
461+
462+
436463
@dataclass
437464
class AssistanceListing:
438465
"""Schema definition for Assistance Listing (not used for instances)"""
@@ -589,6 +616,9 @@ class ShapeConfig:
589616
# Default for list_notices()
590617
NOTICES_MINIMAL: Final = "notice_id,title,solicitation_number,posted_date"
591618

619+
# Default for list_protests()
620+
PROTESTS_MINIMAL: Final = "case_id,case_number,title,source_system,outcome,filed_date"
621+
592622
# Default for list_grants()
593623
GRANTS_MINIMAL: Final = "grant_id,opportunity_number,title,status(*),agency_code"
594624

tango/shapes/explicit_schemas.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,57 @@
637637
}
638638

639639

640+
# Docket-level fields for Protest dockets expansion: dockets(docket_number, filed_date, ...)
641+
PROTEST_DOCKET_SCHEMA: dict[str, FieldSchema] = {
642+
"source_system": FieldSchema(name="source_system", type=str, is_optional=True, is_list=False),
643+
"case_number": FieldSchema(name="case_number", type=str, is_optional=True, is_list=False),
644+
"docket_number": FieldSchema(name="docket_number", type=str, is_optional=True, is_list=False),
645+
"title": FieldSchema(name="title", type=str, is_optional=True, is_list=False),
646+
"protester": FieldSchema(name="protester", type=str, is_optional=True, is_list=False),
647+
"agency": FieldSchema(name="agency", type=str, is_optional=True, is_list=False),
648+
"solicitation_number": FieldSchema(
649+
name="solicitation_number", type=str, is_optional=True, is_list=False
650+
),
651+
"case_type": FieldSchema(name="case_type", type=str, is_optional=True, is_list=False),
652+
"outcome": FieldSchema(name="outcome", type=str, is_optional=True, is_list=False),
653+
"filed_date": FieldSchema(name="filed_date", type=datetime, is_optional=True, is_list=False),
654+
"posted_date": FieldSchema(name="posted_date", type=datetime, is_optional=True, is_list=False),
655+
"decision_date": FieldSchema(
656+
name="decision_date", type=datetime, is_optional=True, is_list=False
657+
),
658+
"due_date": FieldSchema(name="due_date", type=datetime, is_optional=True, is_list=False),
659+
"docket_url": FieldSchema(name="docket_url", type=str, is_optional=True, is_list=False),
660+
"decision_url": FieldSchema(name="decision_url", type=str, is_optional=True, is_list=False),
661+
"digest": FieldSchema(name="digest", type=str, is_optional=True, is_list=False),
662+
}
663+
664+
PROTEST_SCHEMA: dict[str, FieldSchema] = {
665+
"case_id": FieldSchema(name="case_id", type=str, is_optional=False, is_list=False),
666+
"case_number": FieldSchema(name="case_number", type=str, is_optional=True, is_list=False),
667+
"title": FieldSchema(name="title", type=str, is_optional=True, is_list=False),
668+
"source_system": FieldSchema(name="source_system", type=str, is_optional=True, is_list=False),
669+
"outcome": FieldSchema(name="outcome", type=str, is_optional=True, is_list=False),
670+
"agency": FieldSchema(name="agency", type=str, is_optional=True, is_list=False),
671+
"protester": FieldSchema(name="protester", type=str, is_optional=True, is_list=False),
672+
"solicitation_number": FieldSchema(
673+
name="solicitation_number", type=str, is_optional=True, is_list=False
674+
),
675+
"case_type": FieldSchema(name="case_type", type=str, is_optional=True, is_list=False),
676+
"filed_date": FieldSchema(name="filed_date", type=datetime, is_optional=True, is_list=False),
677+
"posted_date": FieldSchema(name="posted_date", type=datetime, is_optional=True, is_list=False),
678+
"decision_date": FieldSchema(
679+
name="decision_date", type=datetime, is_optional=True, is_list=False
680+
),
681+
"due_date": FieldSchema(name="due_date", type=datetime, is_optional=True, is_list=False),
682+
"docket_url": FieldSchema(name="docket_url", type=str, is_optional=True, is_list=False),
683+
"decision_url": FieldSchema(name="decision_url", type=str, is_optional=True, is_list=False),
684+
"digest": FieldSchema(name="digest", type=str, is_optional=True, is_list=False),
685+
"dockets": FieldSchema(
686+
name="dockets", type=dict, is_optional=True, is_list=True, nested_model="ProtestDocket"
687+
),
688+
}
689+
690+
640691
AGENCY_SCHEMA: dict[str, FieldSchema] = {
641692
"abbreviation": FieldSchema(name="abbreviation", type=str, is_optional=True, is_list=False),
642693
"code": FieldSchema(name="code", type=str, is_optional=False, is_list=False),
@@ -1105,6 +1156,8 @@
11051156
"Forecast": FORECAST_SCHEMA,
11061157
"Opportunity": OPPORTUNITY_SCHEMA,
11071158
"Notice": NOTICE_SCHEMA,
1159+
"Protest": PROTEST_SCHEMA,
1160+
"ProtestDocket": PROTEST_DOCKET_SCHEMA,
11081161
"Agency": AGENCY_SCHEMA,
11091162
"Grant": GRANT_SCHEMA,
11101163
# Vehicles (Awards)

0 commit comments

Comments
 (0)