qodev-apollo-api 0.3.2__tar.gz → 0.4.0__tar.gz
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.
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/CHANGELOG.md +11 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/PKG-INFO +1 -1
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/pyproject.toml +1 -1
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/src/qodev_apollo_api/client.py +75 -2
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/test_client.py +94 -2
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/.github/workflows/ci.yml +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/.github/workflows/publish.yml +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/.gitignore +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/.pre-commit-config.yaml +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/CLAUDE.md +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/LICENSE +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/Makefile +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/README.md +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/src/qodev_apollo_api/__init__.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/src/qodev_apollo_api/exceptions.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/src/qodev_apollo_api/models.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/src/qodev_apollo_api/py.typed +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/src/qodev_apollo_api/utils.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/__init__.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/integration/__init__.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/integration/validate_all_models.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/integration/validate_email_task_flow.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/test_exceptions.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/test_models.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/test_utils.py +0 -0
- {qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/uv.lock +0 -0
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-07-09
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `create_deal(name, **fields)` creates a deal/opportunity via `POST /opportunities`. `name` is the only required field; optional `owner_id`, `account_id`, `amount`, `opportunity_stage_id`, `closed_date` are forwarded as-is. Requires a **master** API key (non-master keys return 403). Live-verified against the real API.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `update_opportunity_roles(...)` now sends Apollo's expected **nested** role shape — `{"contact_id": …, "is_primary": …, "role": [{"opportunity_contact_role_type_id": …, "is_primary": …}]}` — instead of the flat `opportunity_contact_role_type_id` on the entry. The flat shape made Apollo 422 with `undefined method 'map' for nil`, so setting a contact's role on a deal failed every time. The public `RoleAssignment` interface is unchanged (callers still pass flat entries).
|
|
19
|
+
- `search_accounts(**filters)` now validates filter keys against an allowlist (`q_organization_name`, `account_stage_ids`, `account_label_ids`, `sort_by_field`, `sort_ascending`) and raises `ValueError` on unknown keys. Apollo silently ignores unrecognised keys and returns an unfiltered default page that looks like a real match (e.g. `query="…"` returned ~28k accounts, "Google" first) — fail-loud now prevents wrong-account attribution.
|
|
20
|
+
|
|
10
21
|
## [0.3.2] - 2026-07-08
|
|
11
22
|
|
|
12
23
|
### Fixed
|
|
@@ -46,6 +46,20 @@ from .utils import markdown_to_prosemirror, normalize_linkedin_url, prosemirror_
|
|
|
46
46
|
|
|
47
47
|
logger = logging.getLogger(__name__)
|
|
48
48
|
|
|
49
|
+
# Filters accepted by POST /accounts/search (besides page/per_page, which are
|
|
50
|
+
# passed explicitly). Apollo *silently drops* unrecognised keys and returns an
|
|
51
|
+
# unfiltered default page, so we validate against this allowlist and raise
|
|
52
|
+
# instead of returning wrong data. See ``ApolloClient.search_accounts``.
|
|
53
|
+
ACCOUNT_SEARCH_FILTERS = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"q_organization_name",
|
|
56
|
+
"account_stage_ids",
|
|
57
|
+
"account_label_ids",
|
|
58
|
+
"sort_by_field",
|
|
59
|
+
"sort_ascending",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
49
63
|
|
|
50
64
|
class ApolloClient:
|
|
51
65
|
"""Async Apollo.io API client with context manager support."""
|
|
@@ -350,11 +364,32 @@ class ApolloClient:
|
|
|
350
364
|
Args:
|
|
351
365
|
page: Page number (default 1)
|
|
352
366
|
limit: Results per page (default 100, max 100)
|
|
353
|
-
**filters:
|
|
367
|
+
**filters: Search filters. Must be keys Apollo actually supports
|
|
368
|
+
(see ``ACCOUNT_SEARCH_FILTERS``): ``q_organization_name``,
|
|
369
|
+
``account_stage_ids``, ``account_label_ids``, ``sort_by_field``,
|
|
370
|
+
``sort_ascending``.
|
|
354
371
|
|
|
355
372
|
Returns:
|
|
356
373
|
Paginated response with Account items
|
|
374
|
+
|
|
375
|
+
Raises:
|
|
376
|
+
ValueError: If an unrecognised filter key is passed. Apollo silently
|
|
377
|
+
ignores unknown keys and returns an unfiltered default page (which
|
|
378
|
+
looks like a real match), so we fail loudly instead of returning the
|
|
379
|
+
wrong accounts. Note ``query=`` is **not** a valid filter — use
|
|
380
|
+
``q_organization_name=`` to search by name.
|
|
357
381
|
"""
|
|
382
|
+
unknown = set(filters) - ACCOUNT_SEARCH_FILTERS
|
|
383
|
+
if unknown:
|
|
384
|
+
raise ValueError(
|
|
385
|
+
"Unknown account search filter(s): "
|
|
386
|
+
+ ", ".join(sorted(unknown))
|
|
387
|
+
+ ". Apollo silently ignores unrecognised keys and returns an unfiltered "
|
|
388
|
+
"default page. Supported filters: "
|
|
389
|
+
+ ", ".join(sorted(ACCOUNT_SEARCH_FILTERS))
|
|
390
|
+
+ " (to search by name use q_organization_name=)."
|
|
391
|
+
)
|
|
392
|
+
|
|
358
393
|
data = {"page": page, "per_page": min(limit, 100), **filters}
|
|
359
394
|
result = await self._post("/accounts/search", data)
|
|
360
395
|
|
|
@@ -420,6 +455,25 @@ class ApolloClient:
|
|
|
420
455
|
result = await self._get(f"/opportunities/{deal_id}")
|
|
421
456
|
return Deal.model_validate(result.get("opportunity", {}))
|
|
422
457
|
|
|
458
|
+
async def create_deal(self, name: str, **fields) -> Deal:
|
|
459
|
+
"""Create a new deal/opportunity.
|
|
460
|
+
|
|
461
|
+
Note:
|
|
462
|
+
Apollo requires a **master** API key for this endpoint; a non-master
|
|
463
|
+
key returns 403. ``name`` is the only required field.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
name: Human-readable deal name (required).
|
|
467
|
+
**fields: Additional fields (owner_id, account_id, amount,
|
|
468
|
+
opportunity_stage_id, closed_date [YYYY-MM-DD], etc.).
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
The created Deal model.
|
|
472
|
+
"""
|
|
473
|
+
data = {"name": name, **fields}
|
|
474
|
+
result = await self._post("/opportunities", data)
|
|
475
|
+
return Deal.model_validate(result.get("opportunity", result))
|
|
476
|
+
|
|
423
477
|
# ========================================================================
|
|
424
478
|
# PIPELINES & STAGES
|
|
425
479
|
# ========================================================================
|
|
@@ -545,7 +599,26 @@ class ApolloClient:
|
|
|
545
599
|
Returns:
|
|
546
600
|
The updated Deal.
|
|
547
601
|
"""
|
|
548
|
-
|
|
602
|
+
# Apollo's endpoint expects each entry's role type *nested* under a ``role``
|
|
603
|
+
# array — sending ``opportunity_contact_role_type_id`` flat on the entry (with
|
|
604
|
+
# no ``role`` key) makes the server call ``.map`` on nil and 422 with
|
|
605
|
+
# "undefined method 'map' for nil". Reshape the flat RoleAssignment entries
|
|
606
|
+
# into the wire format the server actually accepts.
|
|
607
|
+
wire_roles: list[dict] = []
|
|
608
|
+
for entry in roles:
|
|
609
|
+
# RoleAssignment types is_primary as a bool; default to False when omitted.
|
|
610
|
+
# Avoid bool(...) coercion, which would turn a stray truthy non-bool (e.g.
|
|
611
|
+
# the string "false") into True.
|
|
612
|
+
is_primary = entry.get("is_primary", False)
|
|
613
|
+
role_obj: dict[str, Any] = {"is_primary": is_primary}
|
|
614
|
+
role_type_id = entry.get("opportunity_contact_role_type_id")
|
|
615
|
+
if role_type_id:
|
|
616
|
+
role_obj["opportunity_contact_role_type_id"] = role_type_id
|
|
617
|
+
wire_roles.append(
|
|
618
|
+
{"contact_id": entry["contact_id"], "is_primary": is_primary, "role": [role_obj]}
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
data = {"opportunity_id": opportunity_id, "roles": wire_roles}
|
|
549
622
|
result = await self._post("/opportunities/update_roles", data)
|
|
550
623
|
return Deal.model_validate(result.get("opportunity", result))
|
|
551
624
|
|
|
@@ -251,6 +251,45 @@ async def test_search_accounts(client: ApolloClient):
|
|
|
251
251
|
assert result.items[0].name == "Acme Corp"
|
|
252
252
|
|
|
253
253
|
|
|
254
|
+
async def test_search_accounts_rejects_unknown_filter(client: ApolloClient):
|
|
255
|
+
"""An unrecognised filter raises instead of returning a wrong default page.
|
|
256
|
+
|
|
257
|
+
Regression: Apollo silently drops unknown keys (e.g. ``query=``) and returns
|
|
258
|
+
an unfiltered default list that looks like a real match.
|
|
259
|
+
"""
|
|
260
|
+
with pytest.raises(ValueError, match="Unknown account search filter"):
|
|
261
|
+
await client.search_accounts(query="Red and Bundle")
|
|
262
|
+
|
|
263
|
+
# The bad request must never reach Apollo.
|
|
264
|
+
client._client.request.assert_not_called()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def test_search_accounts_error_names_the_bad_key_and_suggests_fix(client: ApolloClient):
|
|
268
|
+
"""The message names the offending key and points to q_organization_name."""
|
|
269
|
+
with pytest.raises(ValueError, match=r"query.*q_organization_name"):
|
|
270
|
+
await client.search_accounts(query="x")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def test_search_accounts_allows_documented_filters(client: ApolloClient):
|
|
274
|
+
"""All allowlisted keys pass validation and reach the request body."""
|
|
275
|
+
client._client.request.return_value = _make_response(
|
|
276
|
+
{"accounts": [], "pagination": {"total_entries": 0}}
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
await client.search_accounts(
|
|
280
|
+
q_organization_name="Acme",
|
|
281
|
+
account_stage_ids=["s1"],
|
|
282
|
+
account_label_ids=["l1"],
|
|
283
|
+
sort_by_field="account_created_at",
|
|
284
|
+
sort_ascending=True,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
body = client._client.request.call_args[1]["json"]
|
|
288
|
+
assert body["q_organization_name"] == "Acme"
|
|
289
|
+
assert body["account_stage_ids"] == ["s1"]
|
|
290
|
+
assert body["sort_by_field"] == "account_created_at"
|
|
291
|
+
|
|
292
|
+
|
|
254
293
|
async def test_search_deals(client: ApolloClient):
|
|
255
294
|
"""Test POST /opportunities/search returns PaginatedResponse[Deal]."""
|
|
256
295
|
client._client.request.return_value = _make_response(
|
|
@@ -533,6 +572,43 @@ async def test_get_deal(client: ApolloClient):
|
|
|
533
572
|
client._client.request.assert_called_once_with("GET", "/opportunities/d1")
|
|
534
573
|
|
|
535
574
|
|
|
575
|
+
async def test_create_deal(client: ApolloClient):
|
|
576
|
+
"""Test POST /opportunities returns the created Deal with name + extra fields in the body."""
|
|
577
|
+
client._client.request.return_value = _make_response(
|
|
578
|
+
{"opportunity": {"id": "d9", "name": "New Deal", "amount": "1000"}}
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
result = await client.create_deal(
|
|
582
|
+
"New Deal", owner_id="o1", account_id="a1", amount=1000, opportunity_stage_id="st1"
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
assert isinstance(result, Deal)
|
|
586
|
+
assert result.id == "d9"
|
|
587
|
+
|
|
588
|
+
call_args = client._client.request.call_args
|
|
589
|
+
assert call_args[0] == ("POST", "/opportunities")
|
|
590
|
+
assert call_args[1]["json"] == {
|
|
591
|
+
"name": "New Deal",
|
|
592
|
+
"owner_id": "o1",
|
|
593
|
+
"account_id": "a1",
|
|
594
|
+
"amount": 1000,
|
|
595
|
+
"opportunity_stage_id": "st1",
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
async def test_create_deal_name_only(client: ApolloClient):
|
|
600
|
+
"""Only ``name`` is required; the body carries nothing else."""
|
|
601
|
+
client._client.request.return_value = _make_response(
|
|
602
|
+
{"opportunity": {"id": "d10", "name": "Minimal"}}
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
result = await client.create_deal("Minimal")
|
|
606
|
+
|
|
607
|
+
assert isinstance(result, Deal)
|
|
608
|
+
assert result.id == "d10"
|
|
609
|
+
assert client._client.request.call_args[1]["json"] == {"name": "Minimal"}
|
|
610
|
+
|
|
611
|
+
|
|
536
612
|
async def test_get_pipeline(client: ApolloClient):
|
|
537
613
|
"""Test GET /opportunity_pipelines/{id} returns Pipeline."""
|
|
538
614
|
client._client.request.return_value = _make_response(
|
|
@@ -643,7 +719,11 @@ async def test_list_opportunity_contact_role_types(client: ApolloClient):
|
|
|
643
719
|
|
|
644
720
|
|
|
645
721
|
async def test_update_opportunity_roles(client: ApolloClient):
|
|
646
|
-
"""
|
|
722
|
+
"""The flat RoleAssignment entries are reshaped into Apollo's nested ``role`` wire format.
|
|
723
|
+
|
|
724
|
+
Regression: sending ``opportunity_contact_role_type_id`` flat on the entry (no
|
|
725
|
+
``role`` key) makes Apollo 422 with "undefined method 'map' for nil".
|
|
726
|
+
"""
|
|
647
727
|
client._client.request.return_value = _make_response(
|
|
648
728
|
{"opportunity": {"id": "d1", "name": "Big Deal"}}
|
|
649
729
|
)
|
|
@@ -659,7 +739,19 @@ async def test_update_opportunity_roles(client: ApolloClient):
|
|
|
659
739
|
|
|
660
740
|
call_args = client._client.request.call_args
|
|
661
741
|
assert call_args[0] == ("POST", "/opportunities/update_roles")
|
|
662
|
-
assert call_args[1]["json"] == {
|
|
742
|
+
assert call_args[1]["json"] == {
|
|
743
|
+
"opportunity_id": "d1",
|
|
744
|
+
"roles": [
|
|
745
|
+
{
|
|
746
|
+
"contact_id": "c1",
|
|
747
|
+
"is_primary": True,
|
|
748
|
+
"role": [{"is_primary": True, "opportunity_contact_role_type_id": "rt1"}],
|
|
749
|
+
},
|
|
750
|
+
# No role type → the nested role object carries only is_primary (never a
|
|
751
|
+
# flat/absent role type, which is what triggered the nil.map crash).
|
|
752
|
+
{"contact_id": "c2", "is_primary": False, "role": [{"is_primary": False}]},
|
|
753
|
+
],
|
|
754
|
+
}
|
|
663
755
|
|
|
664
756
|
|
|
665
757
|
async def test_list_custom_fields(client: ApolloClient):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qodev_apollo_api-0.3.2 → qodev_apollo_api-0.4.0}/tests/integration/validate_email_task_flow.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|